Initial deploy commit
This commit is contained in:
commit
ad32686290
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Server
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3011
|
||||||
|
API_PREFIX=/api/v1
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=erp_generic
|
||||||
|
DB_USER=erp_admin
|
||||||
|
DB_PASSWORD=erp_secret_2024
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=http://localhost:3010,http://localhost:5173
|
||||||
51
.env.production
Normal file
51
.env.production
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# ERP-CORE Backend - Production Environment
|
||||||
|
# =============================================================================
|
||||||
|
# Servidor: 72.60.226.4
|
||||||
|
# Dominio: api.erp.isem.dev
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3011
|
||||||
|
API_PREFIX=api
|
||||||
|
API_VERSION=v1
|
||||||
|
|
||||||
|
# URLs
|
||||||
|
SERVER_URL=https://api.erp.isem.dev
|
||||||
|
FRONTEND_URL=https://erp.isem.dev
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=${DB_HOST:-localhost}
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=erp_generic
|
||||||
|
DB_USER=erp_admin
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
DB_SSL=true
|
||||||
|
DB_SYNCHRONIZE=false
|
||||||
|
DB_LOGGING=false
|
||||||
|
DB_POOL_MAX=20
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=${REDIS_HOST:-localhost}
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
JWT_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=https://erp.isem.dev
|
||||||
|
|
||||||
|
# Security
|
||||||
|
ENABLE_SWAGGER=false
|
||||||
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
RATE_LIMIT_MAX=100
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=warn
|
||||||
|
LOG_TO_FILE=true
|
||||||
|
LOG_FILE_PATH=/var/log/erp-core/app.log
|
||||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# ERP-CORE Backend - Dockerfile
|
||||||
|
# =============================================================================
|
||||||
|
# Multi-stage build for production
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies needed for native modules
|
||||||
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Stage 2: Builder
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3: Production
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nestjs
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/package*.json ./
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
RUN mkdir -p /var/log/erp-core && chown -R nestjs:nodejs /var/log/erp-core
|
||||||
|
|
||||||
|
USER nestjs
|
||||||
|
|
||||||
|
EXPOSE 3011
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3011/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
7778
package-lock.json
generated
Normal file
7778
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
package.json
Normal file
55
package.json
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "@erp-generic/backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "ERP Generic Backend API",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node": "^20.10.4",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"tsx": "^4.6.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/app.ts
Normal file
107
src/app.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import express, { Application, Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import compression from 'compression';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import { config } from './config/index.js';
|
||||||
|
import { logger } from './shared/utils/logger.js';
|
||||||
|
import { AppError, ApiResponse } from './shared/types/index.js';
|
||||||
|
import { setupSwagger } from './config/swagger.config.js';
|
||||||
|
import authRoutes from './modules/auth/auth.routes.js';
|
||||||
|
import apiKeysRoutes from './modules/auth/apiKeys.routes.js';
|
||||||
|
import usersRoutes from './modules/users/users.routes.js';
|
||||||
|
import companiesRoutes from './modules/companies/companies.routes.js';
|
||||||
|
import coreRoutes from './modules/core/core.routes.js';
|
||||||
|
import partnersRoutes from './modules/partners/partners.routes.js';
|
||||||
|
import inventoryRoutes from './modules/inventory/inventory.routes.js';
|
||||||
|
import financialRoutes from './modules/financial/financial.routes.js';
|
||||||
|
import purchasesRoutes from './modules/purchases/purchases.routes.js';
|
||||||
|
import salesRoutes from './modules/sales/sales.routes.js';
|
||||||
|
import projectsRoutes from './modules/projects/projects.routes.js';
|
||||||
|
import systemRoutes from './modules/system/system.routes.js';
|
||||||
|
import crmRoutes from './modules/crm/crm.routes.js';
|
||||||
|
import hrRoutes from './modules/hr/hr.routes.js';
|
||||||
|
import reportsRoutes from './modules/reports/reports.routes.js';
|
||||||
|
|
||||||
|
const app: Application = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({
|
||||||
|
origin: config.cors.origin,
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Request parsing
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(compression());
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
const morganFormat = config.env === 'production' ? 'combined' : 'dev';
|
||||||
|
app.use(morgan(morganFormat, {
|
||||||
|
stream: { write: (message) => logger.http(message.trim()) }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Swagger documentation
|
||||||
|
const apiPrefix = config.apiPrefix;
|
||||||
|
setupSwagger(app, apiPrefix);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (_req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use(`${apiPrefix}/auth`, authRoutes);
|
||||||
|
app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes);
|
||||||
|
app.use(`${apiPrefix}/users`, usersRoutes);
|
||||||
|
app.use(`${apiPrefix}/companies`, companiesRoutes);
|
||||||
|
app.use(`${apiPrefix}/core`, coreRoutes);
|
||||||
|
app.use(`${apiPrefix}/partners`, partnersRoutes);
|
||||||
|
app.use(`${apiPrefix}/inventory`, inventoryRoutes);
|
||||||
|
app.use(`${apiPrefix}/financial`, financialRoutes);
|
||||||
|
app.use(`${apiPrefix}/purchases`, purchasesRoutes);
|
||||||
|
app.use(`${apiPrefix}/sales`, salesRoutes);
|
||||||
|
app.use(`${apiPrefix}/projects`, projectsRoutes);
|
||||||
|
app.use(`${apiPrefix}/system`, systemRoutes);
|
||||||
|
app.use(`${apiPrefix}/crm`, crmRoutes);
|
||||||
|
app.use(`${apiPrefix}/hr`, hrRoutes);
|
||||||
|
app.use(`${apiPrefix}/reports`, reportsRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((_req: Request, res: Response) => {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Endpoint no encontrado'
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
logger.error('Unhandled error', {
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
name: err.name
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
return res.status(err.statusCode).json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: config.env === 'production'
|
||||||
|
? 'Error interno del servidor'
|
||||||
|
: err.message,
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
69
src/config/database.ts
Normal file
69
src/config/database.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Pool, PoolConfig, PoolClient } from 'pg';
|
||||||
|
|
||||||
|
// Re-export PoolClient for use in services
|
||||||
|
export type { PoolClient };
|
||||||
|
import { config } from './index.js';
|
||||||
|
import { logger } from '../shared/utils/logger.js';
|
||||||
|
|
||||||
|
const poolConfig: PoolConfig = {
|
||||||
|
host: config.database.host,
|
||||||
|
port: config.database.port,
|
||||||
|
database: config.database.name,
|
||||||
|
user: config.database.user,
|
||||||
|
password: config.database.password,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pool = new Pool(poolConfig);
|
||||||
|
|
||||||
|
pool.on('connect', () => {
|
||||||
|
logger.debug('New database connection established');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
logger.error('Unexpected database error', { error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const result = await client.query('SELECT NOW()');
|
||||||
|
client.release();
|
||||||
|
logger.info('Database connection successful', { timestamp: result.rows[0].now });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Database connection failed', { error: (error as Error).message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await pool.query(text, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
logger.debug('Query executed', {
|
||||||
|
text: text.substring(0, 100),
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
rows: result.rowCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryOne<T = any>(text: string, params?: any[]): Promise<T | null> {
|
||||||
|
const rows = await query<T>(text, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClient() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
logger.info('Database pool closed');
|
||||||
|
}
|
||||||
35
src/config/index.ts
Normal file
35
src/config/index.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Load .env file
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
env: process.env.NODE_ENV || 'development',
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
apiPrefix: process.env.API_PREFIX || '/api/v1',
|
||||||
|
|
||||||
|
database: {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
name: process.env.DB_NAME || 'erp_generic',
|
||||||
|
user: process.env.DB_USER || 'erp_admin',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
},
|
||||||
|
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET || 'change-this-secret',
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||||
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||||
|
},
|
||||||
|
|
||||||
|
logging: {
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
},
|
||||||
|
|
||||||
|
cors: {
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Config = typeof config;
|
||||||
200
src/config/swagger.config.ts
Normal file
200
src/config/swagger.config.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Swagger/OpenAPI Configuration for ERP Generic Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
import swaggerJSDoc from 'swagger-jsdoc';
|
||||||
|
import { Express } from 'express';
|
||||||
|
import swaggerUi from 'swagger-ui-express';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Swagger definition
|
||||||
|
const swaggerDefinition = {
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: {
|
||||||
|
title: 'ERP Generic - Core API',
|
||||||
|
version: '0.1.0',
|
||||||
|
description: `
|
||||||
|
API para el sistema ERP genérico multitenant.
|
||||||
|
|
||||||
|
## Características principales
|
||||||
|
- Autenticación JWT y gestión de sesiones
|
||||||
|
- Multi-tenant con aislamiento de datos por empresa
|
||||||
|
- Gestión financiera y contable completa
|
||||||
|
- Control de inventario y almacenes
|
||||||
|
- Módulos de compras y ventas
|
||||||
|
- CRM y gestión de partners (clientes, proveedores)
|
||||||
|
- Proyectos y recursos humanos
|
||||||
|
- Sistema de permisos granular mediante API Keys
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
Todos los endpoints requieren autenticación mediante Bearer Token (JWT).
|
||||||
|
El token debe incluirse en el header Authorization: Bearer <token>
|
||||||
|
|
||||||
|
## Multi-tenant
|
||||||
|
El sistema identifica automáticamente la empresa (tenant) del usuario autenticado
|
||||||
|
y filtra todos los datos según el contexto de la empresa.
|
||||||
|
`,
|
||||||
|
contact: {
|
||||||
|
name: 'ERP Generic Support',
|
||||||
|
email: 'support@erpgeneric.com',
|
||||||
|
},
|
||||||
|
license: {
|
||||||
|
name: 'Proprietary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: 'http://localhost:3003/api/v1',
|
||||||
|
description: 'Desarrollo local',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://api.erpgeneric.com/api/v1',
|
||||||
|
description: 'Producción',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'Auth', description: 'Autenticación y autorización (JWT)' },
|
||||||
|
{ name: 'Users', description: 'Gestión de usuarios y perfiles' },
|
||||||
|
{ name: 'Companies', description: 'Gestión de empresas (multi-tenant)' },
|
||||||
|
{ name: 'Core', description: 'Configuración central y parámetros del sistema' },
|
||||||
|
{ name: 'Partners', description: 'Gestión de partners (clientes, proveedores, contactos)' },
|
||||||
|
{ name: 'Inventory', description: 'Control de inventario, productos y almacenes' },
|
||||||
|
{ name: 'Financial', description: 'Gestión financiera, contable y movimientos' },
|
||||||
|
{ name: 'Purchases', description: 'Módulo de compras y órdenes de compra' },
|
||||||
|
{ name: 'Sales', description: 'Módulo de ventas, cotizaciones y pedidos' },
|
||||||
|
{ name: 'Projects', description: 'Gestión de proyectos y tareas' },
|
||||||
|
{ name: 'System', description: 'Configuración del sistema, logs y auditoría' },
|
||||||
|
{ name: 'CRM', description: 'CRM, oportunidades y seguimiento comercial' },
|
||||||
|
{ name: 'HR', description: 'Recursos humanos, empleados y nómina' },
|
||||||
|
{ name: 'Reports', description: 'Reportes y analíticas del sistema' },
|
||||||
|
{ name: 'Health', description: 'Health checks y monitoreo' },
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
BearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
description: 'Token JWT obtenido del endpoint de login',
|
||||||
|
},
|
||||||
|
ApiKeyAuth: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'X-API-Key',
|
||||||
|
description: 'API Key para operaciones administrativas específicas',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
ApiResponse: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PaginatedResponse: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
page: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 1,
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 20,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 100,
|
||||||
|
},
|
||||||
|
totalPages: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
BearerAuth: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Options for swagger-jsdoc
|
||||||
|
const options: swaggerJSDoc.Options = {
|
||||||
|
definition: swaggerDefinition,
|
||||||
|
// Path to the API routes for JSDoc comments
|
||||||
|
apis: [
|
||||||
|
path.join(__dirname, '../modules/**/*.routes.ts'),
|
||||||
|
path.join(__dirname, '../modules/**/*.routes.js'),
|
||||||
|
path.join(__dirname, '../docs/openapi.yaml'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize swagger-jsdoc
|
||||||
|
const swaggerSpec = swaggerJSDoc(options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup Swagger documentation for Express app
|
||||||
|
*/
|
||||||
|
export function setupSwagger(app: Express, prefix: string = '/api/v1') {
|
||||||
|
// Swagger UI options
|
||||||
|
const swaggerUiOptions = {
|
||||||
|
customCss: `
|
||||||
|
.swagger-ui .topbar { display: none }
|
||||||
|
.swagger-ui .info { margin: 50px 0; }
|
||||||
|
.swagger-ui .info .title { font-size: 36px; }
|
||||||
|
`,
|
||||||
|
customSiteTitle: 'ERP Generic - API Documentation',
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
filter: true,
|
||||||
|
tagsSorter: 'alpha',
|
||||||
|
operationsSorter: 'alpha',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serve Swagger UI
|
||||||
|
app.use(`${prefix}/docs`, swaggerUi.serve);
|
||||||
|
app.get(`${prefix}/docs`, swaggerUi.setup(swaggerSpec, swaggerUiOptions));
|
||||||
|
|
||||||
|
// Serve OpenAPI spec as JSON
|
||||||
|
app.get(`${prefix}/docs.json`, (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.send(swaggerSpec);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📚 Swagger docs available at: http://localhost:${process.env.PORT || 3003}${prefix}/docs`);
|
||||||
|
console.log(`📄 OpenAPI spec JSON at: http://localhost:${process.env.PORT || 3003}${prefix}/docs.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { swaggerSpec };
|
||||||
138
src/docs/openapi.yaml
Normal file
138
src/docs/openapi.yaml
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: ERP Generic - Core API
|
||||||
|
description: |
|
||||||
|
API para el sistema ERP genérico multitenant.
|
||||||
|
|
||||||
|
## Características principales
|
||||||
|
- Autenticación JWT y gestión de sesiones
|
||||||
|
- Multi-tenant con aislamiento de datos
|
||||||
|
- Gestión financiera y contable
|
||||||
|
- Control de inventario y almacenes
|
||||||
|
- Compras y ventas
|
||||||
|
- CRM y gestión de partners
|
||||||
|
- Proyectos y recursos humanos
|
||||||
|
- Sistema de permisos granular (API Keys)
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
Todos los endpoints requieren autenticación mediante Bearer Token (JWT).
|
||||||
|
Algunos endpoints administrativos pueden requerir API Key específica.
|
||||||
|
|
||||||
|
version: 0.1.0
|
||||||
|
contact:
|
||||||
|
name: ERP Generic Support
|
||||||
|
email: support@erpgeneric.com
|
||||||
|
license:
|
||||||
|
name: Proprietary
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:3003/api/v1
|
||||||
|
description: Desarrollo local
|
||||||
|
- url: https://api.erpgeneric.com/api/v1
|
||||||
|
description: Producción
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Auth
|
||||||
|
description: Autenticación y autorización
|
||||||
|
- name: Users
|
||||||
|
description: Gestión de usuarios
|
||||||
|
- name: Companies
|
||||||
|
description: Gestión de empresas (tenants)
|
||||||
|
- name: Core
|
||||||
|
description: Configuración central y parámetros
|
||||||
|
- name: Partners
|
||||||
|
description: Gestión de partners (clientes, proveedores, contactos)
|
||||||
|
- name: Inventory
|
||||||
|
description: Control de inventario y productos
|
||||||
|
- name: Financial
|
||||||
|
description: Gestión financiera y contable
|
||||||
|
- name: Purchases
|
||||||
|
description: Compras y órdenes de compra
|
||||||
|
- name: Sales
|
||||||
|
description: Ventas, cotizaciones y pedidos
|
||||||
|
- name: Projects
|
||||||
|
description: Gestión de proyectos y tareas
|
||||||
|
- name: System
|
||||||
|
description: Configuración del sistema y logs
|
||||||
|
- name: CRM
|
||||||
|
description: CRM y gestión de oportunidades
|
||||||
|
- name: HR
|
||||||
|
description: Recursos humanos y empleados
|
||||||
|
- name: Reports
|
||||||
|
description: Reportes y analíticas
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
BearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: Token JWT obtenido del endpoint de login
|
||||||
|
|
||||||
|
ApiKeyAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-API-Key
|
||||||
|
description: API Key para operaciones específicas
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
ApiResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
PaginatedResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
pagination:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
example: 20
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
example: 100
|
||||||
|
totalPages:
|
||||||
|
type: integer
|
||||||
|
example: 5
|
||||||
|
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Health
|
||||||
|
summary: Health check del servidor
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Servidor funcionando correctamente
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
51
src/index.ts
Normal file
51
src/index.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import app from './app.js';
|
||||||
|
import { config } from './config/index.js';
|
||||||
|
import { testConnection, closePool } from './config/database.js';
|
||||||
|
import { logger } from './shared/utils/logger.js';
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
logger.info('Starting ERP Generic Backend...', {
|
||||||
|
env: config.env,
|
||||||
|
port: config.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test database connection
|
||||||
|
const dbConnected = await testConnection();
|
||||||
|
if (!dbConnected) {
|
||||||
|
logger.error('Failed to connect to database. Exiting...');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const server = app.listen(config.port, () => {
|
||||||
|
logger.info(`Server running on port ${config.port}`);
|
||||||
|
logger.info(`API available at http://localhost:${config.port}${config.apiPrefix}`);
|
||||||
|
logger.info(`Health check at http://localhost:${config.port}/health`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
logger.info(`Received ${signal}. Starting graceful shutdown...`);
|
||||||
|
|
||||||
|
server.close(async () => {
|
||||||
|
logger.info('HTTP server closed');
|
||||||
|
await closePool();
|
||||||
|
logger.info('Shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force shutdown after 10s
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.error('Forced shutdown after timeout');
|
||||||
|
process.exit(1);
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((error) => {
|
||||||
|
logger.error('Failed to start server', { error: error.message });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
331
src/modules/auth/apiKeys.controller.ts
Normal file
331
src/modules/auth/apiKeys.controller.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { apiKeysService, CreateApiKeyDto, UpdateApiKeyDto, ApiKeyFilters } from './apiKeys.service.js';
|
||||||
|
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const createApiKeySchema = z.object({
|
||||||
|
name: z.string().min(1, 'Nombre requerido').max(255),
|
||||||
|
scope: z.string().max(100).optional(),
|
||||||
|
allowed_ips: z.array(z.string().ip()).optional(),
|
||||||
|
expiration_days: z.number().int().positive().max(365).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateApiKeySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
scope: z.string().max(100).nullable().optional(),
|
||||||
|
allowed_ips: z.array(z.string().ip()).nullable().optional(),
|
||||||
|
expiration_date: z.string().datetime().nullable().optional(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listApiKeysSchema = z.object({
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
is_active: z.enum(['true', 'false']).optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTROLLER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ApiKeysController {
|
||||||
|
/**
|
||||||
|
* Create a new API key
|
||||||
|
* POST /api/auth/api-keys
|
||||||
|
*/
|
||||||
|
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = createApiKeySchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateApiKeyDto = {
|
||||||
|
...validation.data,
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
tenant_id: req.user!.tenantId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiKeysService.create(dto);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'API key creada exitosamente. Guarde la clave, no podrá verla de nuevo.',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List API keys for the current user
|
||||||
|
* GET /api/auth/api-keys
|
||||||
|
*/
|
||||||
|
async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = listApiKeysSchema.safeParse(req.query);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Parámetros inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: ApiKeyFilters = {
|
||||||
|
tenant_id: req.user!.tenantId,
|
||||||
|
// By default, only show user's own keys unless admin
|
||||||
|
user_id: validation.data.user_id || req.user!.userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admins can view all keys in tenant
|
||||||
|
if (validation.data.user_id && req.user!.roles.includes('admin')) {
|
||||||
|
filters.user_id = validation.data.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.data.is_active !== undefined) {
|
||||||
|
filters.is_active = validation.data.is_active === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.data.scope) {
|
||||||
|
filters.scope = validation.data.scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeys = await apiKeysService.findAll(filters);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: apiKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific API key
|
||||||
|
* GET /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const apiKey = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership (unless admin)
|
||||||
|
if (apiKey.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para ver esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an API key
|
||||||
|
* PATCH /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const validation = updateApiKeySchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership first
|
||||||
|
const existing = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para modificar esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateApiKeyDto = {
|
||||||
|
...validation.data,
|
||||||
|
expiration_date: validation.data.expiration_date
|
||||||
|
? new Date(validation.data.expiration_date)
|
||||||
|
: validation.data.expiration_date === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = await apiKeysService.update(id, req.user!.tenantId, dto);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: updated,
|
||||||
|
message: 'API key actualizada',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key (soft delete)
|
||||||
|
* POST /api/auth/api-keys/:id/revoke
|
||||||
|
*/
|
||||||
|
async revoke(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check ownership first
|
||||||
|
const existing = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para revocar esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiKeysService.revoke(id, req.user!.tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'API key revocada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key permanently
|
||||||
|
* DELETE /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check ownership first
|
||||||
|
const existing = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para eliminar esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiKeysService.delete(id, req.user!.tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'API key eliminada permanentemente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an API key (invalidates old key, creates new)
|
||||||
|
* POST /api/auth/api-keys/:id/regenerate
|
||||||
|
*/
|
||||||
|
async regenerate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check ownership first
|
||||||
|
const existing = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para regenerar esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeysService.regenerate(id, req.user!.tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'API key regenerada. Guarde la nueva clave, no podrá verla de nuevo.',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiKeysController = new ApiKeysController();
|
||||||
56
src/modules/auth/apiKeys.routes.ts
Normal file
56
src/modules/auth/apiKeys.routes.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { apiKeysController } from './apiKeys.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API KEY MANAGEMENT ROUTES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API key
|
||||||
|
* POST /api/auth/api-keys
|
||||||
|
*/
|
||||||
|
router.post('/', (req, res, next) => apiKeysController.create(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List API keys (user's own, or all for admins)
|
||||||
|
* GET /api/auth/api-keys
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res, next) => apiKeysController.list(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific API key
|
||||||
|
* GET /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
router.get('/:id', (req, res, next) => apiKeysController.getById(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an API key
|
||||||
|
* PATCH /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
router.patch('/:id', (req, res, next) => apiKeysController.update(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key (soft delete)
|
||||||
|
* POST /api/auth/api-keys/:id/revoke
|
||||||
|
*/
|
||||||
|
router.post('/:id/revoke', (req, res, next) => apiKeysController.revoke(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key permanently
|
||||||
|
* DELETE /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
router.delete('/:id', (req, res, next) => apiKeysController.delete(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an API key
|
||||||
|
* POST /api/auth/api-keys/:id/regenerate
|
||||||
|
*/
|
||||||
|
router.post('/:id/regenerate', (req, res, next) => apiKeysController.regenerate(req, res, next));
|
||||||
|
|
||||||
|
export default router;
|
||||||
491
src/modules/auth/apiKeys.service.ts
Normal file
491
src/modules/auth/apiKeys.service.ts
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
key_index: string;
|
||||||
|
key_hash: string;
|
||||||
|
scope: string | null;
|
||||||
|
allowed_ips: string[] | null;
|
||||||
|
expiration_date: Date | null;
|
||||||
|
last_used_at: Date | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyDto {
|
||||||
|
user_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
scope?: string;
|
||||||
|
allowed_ips?: string[];
|
||||||
|
expiration_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateApiKeyDto {
|
||||||
|
name?: string;
|
||||||
|
scope?: string;
|
||||||
|
allowed_ips?: string[];
|
||||||
|
expiration_date?: Date | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyWithPlainKey {
|
||||||
|
apiKey: Omit<ApiKey, 'key_hash'>;
|
||||||
|
plainKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
apiKey?: ApiKey;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyFilters {
|
||||||
|
user_id?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const API_KEY_PREFIX = 'mgn_';
|
||||||
|
const KEY_LENGTH = 32; // 32 bytes = 256 bits
|
||||||
|
const HASH_ITERATIONS = 100000;
|
||||||
|
const HASH_KEYLEN = 64;
|
||||||
|
const HASH_DIGEST = 'sha512';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ApiKeysService {
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure API key
|
||||||
|
*/
|
||||||
|
private generatePlainKey(): string {
|
||||||
|
const randomBytes = crypto.randomBytes(KEY_LENGTH);
|
||||||
|
const key = randomBytes.toString('base64url');
|
||||||
|
return `${API_KEY_PREFIX}${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the key index (first 16 chars after prefix) for lookup
|
||||||
|
*/
|
||||||
|
private getKeyIndex(plainKey: string): string {
|
||||||
|
const keyWithoutPrefix = plainKey.replace(API_KEY_PREFIX, '');
|
||||||
|
return keyWithoutPrefix.substring(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash the API key using PBKDF2
|
||||||
|
*/
|
||||||
|
private async hashKey(plainKey: string): Promise<string> {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(
|
||||||
|
plainKey,
|
||||||
|
salt,
|
||||||
|
HASH_ITERATIONS,
|
||||||
|
HASH_KEYLEN,
|
||||||
|
HASH_DIGEST,
|
||||||
|
(err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(`${salt}:${derivedKey.toString('hex')}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a plain key against a stored hash
|
||||||
|
*/
|
||||||
|
private async verifyKey(plainKey: string, storedHash: string): Promise<boolean> {
|
||||||
|
const [salt, hash] = storedHash.split(':');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(
|
||||||
|
plainKey,
|
||||||
|
salt,
|
||||||
|
HASH_ITERATIONS,
|
||||||
|
HASH_KEYLEN,
|
||||||
|
HASH_DIGEST,
|
||||||
|
(err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(derivedKey.toString('hex') === hash);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API key
|
||||||
|
* Returns the plain key only once - it cannot be retrieved later
|
||||||
|
*/
|
||||||
|
async create(dto: CreateApiKeyDto): Promise<ApiKeyWithPlainKey> {
|
||||||
|
// Validate user exists
|
||||||
|
const user = await queryOne<{ id: string }>(
|
||||||
|
'SELECT id FROM auth.users WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[dto.user_id, dto.tenant_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ValidationError('Usuario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name
|
||||||
|
const existing = await queryOne<{ id: string }>(
|
||||||
|
'SELECT id FROM auth.api_keys WHERE user_id = $1 AND name = $2',
|
||||||
|
[dto.user_id, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError('Ya existe una API key con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate key
|
||||||
|
const plainKey = this.generatePlainKey();
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
const keyHash = await this.hashKey(plainKey);
|
||||||
|
|
||||||
|
// Calculate expiration date
|
||||||
|
let expirationDate: Date | null = null;
|
||||||
|
if (dto.expiration_days) {
|
||||||
|
expirationDate = new Date();
|
||||||
|
expirationDate.setDate(expirationDate.getDate() + dto.expiration_days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert API key
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`INSERT INTO auth.api_keys (
|
||||||
|
user_id, tenant_id, name, key_index, key_hash,
|
||||||
|
scope, allowed_ips, expiration_date, is_active
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, is_active, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
dto.user_id,
|
||||||
|
dto.tenant_id,
|
||||||
|
dto.name,
|
||||||
|
keyIndex,
|
||||||
|
keyHash,
|
||||||
|
dto.scope || null,
|
||||||
|
dto.allowed_ips || null,
|
||||||
|
expirationDate,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('Error al crear API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key created', {
|
||||||
|
apiKeyId: apiKey.id,
|
||||||
|
userId: dto.user_id,
|
||||||
|
name: dto.name
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
plainKey, // Only returned once!
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all API keys for a user/tenant
|
||||||
|
*/
|
||||||
|
async findAll(filters: ApiKeyFilters): Promise<Omit<ApiKey, 'key_hash'>[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.user_id) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.tenant_id) {
|
||||||
|
conditions.push(`tenant_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.tenant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.is_active !== undefined) {
|
||||||
|
conditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(filters.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.scope) {
|
||||||
|
conditions.push(`scope = $${paramIndex++}`);
|
||||||
|
params.push(filters.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0
|
||||||
|
? `WHERE ${conditions.join(' AND ')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const apiKeys = await query<ApiKey>(
|
||||||
|
`SELECT id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM auth.api_keys
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return apiKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific API key by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string, tenantId: string): Promise<Omit<ApiKey, 'key_hash'> | null> {
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`SELECT id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM auth.api_keys
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an API key
|
||||||
|
*/
|
||||||
|
async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise<Omit<ApiKey, 'key_hash'>> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = ['updated_at = NOW()'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updates.push(`name = $${paramIndex++}`);
|
||||||
|
params.push(dto.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.scope !== undefined) {
|
||||||
|
updates.push(`scope = $${paramIndex++}`);
|
||||||
|
params.push(dto.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.allowed_ips !== undefined) {
|
||||||
|
updates.push(`allowed_ips = $${paramIndex++}`);
|
||||||
|
params.push(dto.allowed_ips);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.expiration_date !== undefined) {
|
||||||
|
updates.push(`expiration_date = $${paramIndex++}`);
|
||||||
|
params.push(dto.expiration_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.is_active !== undefined) {
|
||||||
|
updates.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(dto.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
params.push(tenantId);
|
||||||
|
|
||||||
|
const updated = await queryOne<ApiKey>(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Error al actualizar API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key updated', { apiKeyId: id });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (soft delete) an API key
|
||||||
|
*/
|
||||||
|
async revoke(id: string, tenantId: string): Promise<void> {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET is_active = false, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key revoked', { apiKeyId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key permanently
|
||||||
|
*/
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const result = await query(
|
||||||
|
'DELETE FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('API key deleted', { apiKeyId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key and return the associated user info
|
||||||
|
* This is the main method used by the authentication middleware
|
||||||
|
*/
|
||||||
|
async validate(plainKey: string, clientIp?: string): Promise<ApiKeyValidationResult> {
|
||||||
|
// Check prefix
|
||||||
|
if (!plainKey.startsWith(API_KEY_PREFIX)) {
|
||||||
|
return { valid: false, error: 'Formato de API key inválido' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key index for lookup
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
|
||||||
|
// Find API key by index
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`SELECT * FROM auth.api_keys
|
||||||
|
WHERE key_index = $1 AND is_active = true`,
|
||||||
|
[keyIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return { valid: false, error: 'API key no encontrada o inactiva' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hash
|
||||||
|
const isValid = await this.verifyKey(plainKey, apiKey.key_hash);
|
||||||
|
if (!isValid) {
|
||||||
|
return { valid: false, error: 'API key inválida' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (apiKey.expiration_date && new Date(apiKey.expiration_date) < new Date()) {
|
||||||
|
return { valid: false, error: 'API key expirada' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP whitelist
|
||||||
|
if (apiKey.allowed_ips && apiKey.allowed_ips.length > 0 && clientIp) {
|
||||||
|
if (!apiKey.allowed_ips.includes(clientIp)) {
|
||||||
|
logger.warn('API key IP not allowed', {
|
||||||
|
apiKeyId: apiKey.id,
|
||||||
|
clientIp,
|
||||||
|
allowedIps: apiKey.allowed_ips
|
||||||
|
});
|
||||||
|
return { valid: false, error: 'IP no autorizada' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info with roles
|
||||||
|
const user = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
role_codes: string[];
|
||||||
|
}>(
|
||||||
|
`SELECT u.id, u.tenant_id, u.email, array_agg(r.code) as role_codes
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN auth.user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN auth.roles r ON ur.role_id = r.id
|
||||||
|
WHERE u.id = $1 AND u.status = 'active'
|
||||||
|
GROUP BY u.id`,
|
||||||
|
[apiKey.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { valid: false, error: 'Usuario asociado no encontrado o inactivo' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp (async, don't wait)
|
||||||
|
query(
|
||||||
|
'UPDATE auth.api_keys SET last_used_at = NOW() WHERE id = $1',
|
||||||
|
[apiKey.id]
|
||||||
|
).catch(err => logger.error('Error updating last_used_at', { error: err }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
apiKey,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
tenant_id: user.tenant_id,
|
||||||
|
email: user.email,
|
||||||
|
roles: user.role_codes?.filter(Boolean) || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an API key (creates new key, invalidates old)
|
||||||
|
*/
|
||||||
|
async regenerate(id: string, tenantId: string): Promise<ApiKeyWithPlainKey> {
|
||||||
|
const existing = await queryOne<ApiKey>(
|
||||||
|
'SELECT * FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new key
|
||||||
|
const plainKey = this.generatePlainKey();
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
const keyHash = await this.hashKey(plainKey);
|
||||||
|
|
||||||
|
// Update with new key
|
||||||
|
const updated = await queryOne<ApiKey>(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET key_index = $1, key_hash = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3 AND tenant_id = $4
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, is_active, created_at, updated_at`,
|
||||||
|
[keyIndex, keyHash, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Error al regenerar API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key regenerated', { apiKeyId: id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: updated,
|
||||||
|
plainKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiKeysService = new ApiKeysService();
|
||||||
152
src/modules/auth/auth.controller.ts
Normal file
152
src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authService } from './auth.service.js';
|
||||||
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||||
|
// Soporta ambos formatos: full_name (legacy) o firstName+lastName (frontend)
|
||||||
|
full_name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(),
|
||||||
|
firstName: z.string().min(2, 'Nombre debe tener al menos 2 caracteres').optional(),
|
||||||
|
lastName: z.string().min(2, 'Apellido debe tener al menos 2 caracteres').optional(),
|
||||||
|
tenant_id: z.string().uuid('Tenant ID inválido').optional(),
|
||||||
|
companyName: z.string().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => data.full_name || (data.firstName && data.lastName),
|
||||||
|
{ message: 'Se requiere full_name o firstName y lastName', path: ['full_name'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
current_password: z.string().min(1, 'Contraseña actual requerida'),
|
||||||
|
new_password: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshTokenSchema = z.object({
|
||||||
|
refresh_token: z.string().min(1, 'Refresh token requerido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export class AuthController {
|
||||||
|
async login(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = loginSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.login(validation.data);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Inicio de sesión exitoso',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = registerSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.register(validation.data);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Usuario registrado exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = refreshTokenSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await authService.refreshToken(validation.data.refresh_token);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: { tokens },
|
||||||
|
message: 'Token renovado exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = changePasswordSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
await authService.changePassword(
|
||||||
|
userId,
|
||||||
|
validation.data.current_password,
|
||||||
|
validation.data.new_password
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'Contraseña actualizada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const profile = await authService.getProfile(userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: profile,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(_req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
// For JWT, logout is handled client-side by removing the token
|
||||||
|
// Here we could add token to a blacklist if needed
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'Sesión cerrada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authController = new AuthController();
|
||||||
17
src/modules/auth/auth.routes.ts
Normal file
17
src/modules/auth/auth.routes.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authController } from './auth.controller.js';
|
||||||
|
import { authenticate } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
router.post('/login', (req, res, next) => authController.login(req, res, next));
|
||||||
|
router.post('/register', (req, res, next) => authController.register(req, res, next));
|
||||||
|
router.post('/refresh', (req, res, next) => authController.refreshToken(req, res, next));
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
router.get('/profile', authenticate, (req, res, next) => authController.getProfile(req, res, next));
|
||||||
|
router.post('/change-password', authenticate, (req, res, next) => authController.changePassword(req, res, next));
|
||||||
|
router.post('/logout', authenticate, (req, res, next) => authController.logout(req, res));
|
||||||
|
|
||||||
|
export default router;
|
||||||
251
src/modules/auth/auth.service.ts
Normal file
251
src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { config } from '../../config/index.js';
|
||||||
|
import { User, JwtPayload, UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export interface LoginDto {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterDto {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
// Soporta ambos formatos para compatibilidad frontend/backend
|
||||||
|
full_name?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
companyName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforma full_name a firstName/lastName para respuesta al frontend
|
||||||
|
*/
|
||||||
|
export function splitFullName(fullName: string): { firstName: string; lastName: string } {
|
||||||
|
const parts = fullName.trim().split(/\s+/);
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { firstName: parts[0], lastName: '' };
|
||||||
|
}
|
||||||
|
const firstName = parts[0];
|
||||||
|
const lastName = parts.slice(1).join(' ');
|
||||||
|
return { firstName, lastName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforma firstName/lastName a full_name para almacenar en BD
|
||||||
|
*/
|
||||||
|
export function buildFullName(firstName?: string, lastName?: string, fullName?: string): string {
|
||||||
|
if (fullName) return fullName.trim();
|
||||||
|
return `${firstName || ''} ${lastName || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthTokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
user: Omit<User, 'password_hash'>;
|
||||||
|
tokens: AuthTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
async login(dto: LoginDto): Promise<LoginResponse> {
|
||||||
|
// Find user by email
|
||||||
|
const user = await queryOne<User>(
|
||||||
|
`SELECT u.*, array_agg(r.code) as role_codes
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN auth.user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN auth.roles r ON ur.role_id = r.id
|
||||||
|
WHERE u.email = $1 AND u.status = 'active'
|
||||||
|
GROUP BY u.id`,
|
||||||
|
[dto.email.toLowerCase()]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedError('Credenciales inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await bcrypt.compare(dto.password, user.password_hash || '');
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new UnauthorizedError('Credenciales inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await query(
|
||||||
|
'UPDATE auth.users SET last_login_at = NOW() WHERE id = $1',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = this.generateTokens(user);
|
||||||
|
|
||||||
|
// Transformar full_name a firstName/lastName para respuesta al frontend
|
||||||
|
const { firstName, lastName } = splitFullName(user.full_name);
|
||||||
|
|
||||||
|
// Remove password_hash from response and add firstName/lastName
|
||||||
|
const { password_hash, full_name: _, ...userWithoutPassword } = user;
|
||||||
|
const userResponse = {
|
||||||
|
...userWithoutPassword,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('User logged in', { userId: user.id, email: user.email });
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userResponse as unknown as Omit<User, 'password_hash'>,
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto): Promise<LoginResponse> {
|
||||||
|
// Check if email already exists
|
||||||
|
const existingUser = await queryOne<User>(
|
||||||
|
'SELECT id FROM auth.users WHERE email = $1',
|
||||||
|
[dto.email.toLowerCase()]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ValidationError('El email ya está registrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformar firstName/lastName a full_name para almacenar en BD
|
||||||
|
const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name);
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const password_hash = await bcrypt.hash(dto.password, 10);
|
||||||
|
|
||||||
|
// Generar tenant_id si no viene (nuevo registro de empresa)
|
||||||
|
const tenantId = dto.tenant_id || crypto.randomUUID();
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const newUser = await queryOne<User>(
|
||||||
|
`INSERT INTO auth.users (tenant_id, email, password_hash, full_name, status, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'active', NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.email.toLowerCase(), password_hash, fullName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newUser) {
|
||||||
|
throw new Error('Error al crear usuario');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = this.generateTokens(newUser);
|
||||||
|
|
||||||
|
// Transformar full_name a firstName/lastName para respuesta al frontend
|
||||||
|
const { firstName, lastName } = splitFullName(newUser.full_name);
|
||||||
|
|
||||||
|
// Remove password_hash from response and add firstName/lastName
|
||||||
|
const { password_hash: _, full_name: __, ...userWithoutPassword } = newUser;
|
||||||
|
const userResponse = {
|
||||||
|
...userWithoutPassword,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('User registered', { userId: newUser.id, email: newUser.email });
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userResponse as unknown as Omit<User, 'password_hash'>,
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(refreshToken: string): Promise<AuthTokens> {
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(refreshToken, config.jwt.secret) as JwtPayload;
|
||||||
|
|
||||||
|
// Verify user still exists and is active
|
||||||
|
const user = await queryOne<User>(
|
||||||
|
'SELECT * FROM auth.users WHERE id = $1 AND status = $2',
|
||||||
|
[payload.userId, 'active']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedError('Usuario no encontrado o inactivo');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateTokens(user);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
throw new UnauthorizedError('Refresh token expirado');
|
||||||
|
}
|
||||||
|
throw new UnauthorizedError('Refresh token inválido');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
const user = await queryOne<User>(
|
||||||
|
'SELECT * FROM auth.users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Usuario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash || '');
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new UnauthorizedError('Contraseña actual incorrecta');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await query(
|
||||||
|
'UPDATE auth.users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
|
||||||
|
[newPasswordHash, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Password changed', { userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(userId: string): Promise<Omit<User, 'password_hash'>> {
|
||||||
|
const user = await queryOne<User>(
|
||||||
|
`SELECT u.*, array_agg(r.code) as role_codes
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN auth.user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN auth.roles r ON ur.role_id = r.id
|
||||||
|
WHERE u.id = $1
|
||||||
|
GROUP BY u.id`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Usuario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password_hash, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateTokens(user: User): AuthTokens {
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: user.tenant_id,
|
||||||
|
email: user.email,
|
||||||
|
roles: (user as any).role_codes || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = jwt.sign(payload, config.jwt.secret, {
|
||||||
|
expiresIn: config.jwt.expiresIn,
|
||||||
|
} as SignOptions);
|
||||||
|
|
||||||
|
const refreshToken = jwt.sign(payload, config.jwt.secret, {
|
||||||
|
expiresIn: config.jwt.refreshExpiresIn,
|
||||||
|
} as SignOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresIn: config.jwt.expiresIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService();
|
||||||
8
src/modules/auth/index.ts
Normal file
8
src/modules/auth/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from './auth.service.js';
|
||||||
|
export * from './auth.controller.js';
|
||||||
|
export { default as authRoutes } from './auth.routes.js';
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
export * from './apiKeys.service.js';
|
||||||
|
export * from './apiKeys.controller.js';
|
||||||
|
export { default as apiKeysRoutes } from './apiKeys.routes.js';
|
||||||
142
src/modules/companies/companies.controller.ts
Normal file
142
src/modules/companies/companies.controller.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { companiesService, CreateCompanyDto, UpdateCompanyDto, CompanyFilters } from './companies.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
const createCompanySchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
legal_name: z.string().max(255).optional(),
|
||||||
|
tax_id: z.string().max(50).optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
parent_company_id: z.string().uuid().optional(),
|
||||||
|
settings: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCompanySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
legal_name: z.string().max(255).optional().nullable(),
|
||||||
|
tax_id: z.string().max(50).optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
parent_company_id: z.string().uuid().optional().nullable(),
|
||||||
|
settings: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
parent_company_id: z.string().uuid().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class CompaniesController {
|
||||||
|
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: CompanyFilters = queryResult.data;
|
||||||
|
const result = await companiesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const company = await companiesService.findById(id, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: company,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createCompanySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de empresa inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateCompanyDto = parseResult.data;
|
||||||
|
const company = await companiesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: company,
|
||||||
|
message: 'Empresa creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const parseResult = updateCompanySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de empresa inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateCompanyDto = parseResult.data;
|
||||||
|
const company = await companiesService.update(id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: company,
|
||||||
|
message: 'Empresa actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await companiesService.delete(id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Empresa eliminada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const users = await companiesService.getUsers(id, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: users,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const companiesController = new CompaniesController();
|
||||||
40
src/modules/companies/companies.routes.ts
Normal file
40
src/modules/companies/companies.routes.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { companiesController } from './companies.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// List companies (admin, manager)
|
||||||
|
router.get('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.findAll(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get company by ID
|
||||||
|
router.get('/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.findById(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create company (admin only)
|
||||||
|
router.post('/', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.create(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update company (admin only)
|
||||||
|
router.put('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.update(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete company (admin only)
|
||||||
|
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.delete(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get users assigned to company
|
||||||
|
router.get('/:id/users', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.getUsers(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
266
src/modules/companies/companies.service.ts
Normal file
266
src/modules/companies/companies.service.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
legal_name?: string;
|
||||||
|
tax_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
parent_company_id?: string;
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
created_at: Date;
|
||||||
|
created_by?: string;
|
||||||
|
updated_at?: Date;
|
||||||
|
updated_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCompanyDto {
|
||||||
|
name: string;
|
||||||
|
legal_name?: string;
|
||||||
|
tax_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
parent_company_id?: string;
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCompanyDto {
|
||||||
|
name?: string;
|
||||||
|
legal_name?: string | null;
|
||||||
|
tax_id?: string | null;
|
||||||
|
currency_id?: string | null;
|
||||||
|
parent_company_id?: string | null;
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyFilters {
|
||||||
|
search?: string;
|
||||||
|
parent_company_id?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompaniesService {
|
||||||
|
async findAll(tenantId: string, filters: CompanyFilters = {}): Promise<{ data: Company[]; total: number }> {
|
||||||
|
const { search, parent_company_id, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE c.tenant_id = $1 AND c.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (c.name ILIKE $${paramIndex} OR c.legal_name ILIKE $${paramIndex} OR c.tax_id ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent_company_id) {
|
||||||
|
whereClause += ` AND c.parent_company_id = $${paramIndex}`;
|
||||||
|
params.push(parent_company_id);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM auth.companies c ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Company>(
|
||||||
|
`SELECT c.*,
|
||||||
|
cur.code as currency_code,
|
||||||
|
pc.name as parent_company_name
|
||||||
|
FROM auth.companies c
|
||||||
|
LEFT JOIN core.currencies cur ON c.currency_id = cur.id
|
||||||
|
LEFT JOIN auth.companies pc ON c.parent_company_id = pc.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY c.name ASC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Company> {
|
||||||
|
const company = await queryOne<Company>(
|
||||||
|
`SELECT c.*,
|
||||||
|
cur.code as currency_code,
|
||||||
|
pc.name as parent_company_name
|
||||||
|
FROM auth.companies c
|
||||||
|
LEFT JOIN core.currencies cur ON c.currency_id = cur.id
|
||||||
|
LEFT JOIN auth.companies pc ON c.parent_company_id = pc.id
|
||||||
|
WHERE c.id = $1 AND c.tenant_id = $2 AND c.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
throw new NotFoundError('Empresa no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return company;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCompanyDto, tenantId: string, userId: string): Promise<Company> {
|
||||||
|
// Validate unique tax_id within tenant
|
||||||
|
if (dto.tax_id) {
|
||||||
|
const existing = await queryOne<Company>(
|
||||||
|
`SELECT id FROM auth.companies
|
||||||
|
WHERE tenant_id = $1 AND tax_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[tenantId, dto.tax_id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una empresa con este RFC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent company exists
|
||||||
|
if (dto.parent_company_id) {
|
||||||
|
const parent = await queryOne<Company>(
|
||||||
|
`SELECT id FROM auth.companies
|
||||||
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.parent_company_id, tenantId]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Empresa matriz no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await queryOne<Company>(
|
||||||
|
`INSERT INTO auth.companies (tenant_id, name, legal_name, tax_id, currency_id, parent_company_id, settings, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.name,
|
||||||
|
dto.legal_name,
|
||||||
|
dto.tax_id,
|
||||||
|
dto.currency_id,
|
||||||
|
dto.parent_company_id,
|
||||||
|
JSON.stringify(dto.settings || {}),
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return company!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCompanyDto, tenantId: string, userId: string): Promise<Company> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate unique tax_id
|
||||||
|
if (dto.tax_id && dto.tax_id !== existing.tax_id) {
|
||||||
|
const duplicate = await queryOne<Company>(
|
||||||
|
`SELECT id FROM auth.companies
|
||||||
|
WHERE tenant_id = $1 AND tax_id = $2 AND id != $3 AND deleted_at IS NULL`,
|
||||||
|
[tenantId, dto.tax_id, id]
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
throw new ConflictError('Ya existe una empresa con este RFC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent company (prevent self-reference and cycles)
|
||||||
|
if (dto.parent_company_id) {
|
||||||
|
if (dto.parent_company_id === id) {
|
||||||
|
throw new ConflictError('Una empresa no puede ser su propia matriz');
|
||||||
|
}
|
||||||
|
const parent = await queryOne<Company>(
|
||||||
|
`SELECT id FROM auth.companies
|
||||||
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.parent_company_id, tenantId]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Empresa matriz no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.legal_name !== undefined) {
|
||||||
|
updateFields.push(`legal_name = $${paramIndex++}`);
|
||||||
|
values.push(dto.legal_name);
|
||||||
|
}
|
||||||
|
if (dto.tax_id !== undefined) {
|
||||||
|
updateFields.push(`tax_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_id);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.parent_company_id !== undefined) {
|
||||||
|
updateFields.push(`parent_company_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.parent_company_id);
|
||||||
|
}
|
||||||
|
if (dto.settings !== undefined) {
|
||||||
|
updateFields.push(`settings = $${paramIndex++}`);
|
||||||
|
values.push(JSON.stringify(dto.settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const company = await queryOne<Company>(
|
||||||
|
`UPDATE auth.companies
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return company!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if company has child companies
|
||||||
|
const children = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM auth.companies
|
||||||
|
WHERE parent_company_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(children?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una empresa que tiene empresas subsidiarias');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE auth.companies
|
||||||
|
SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers(companyId: string, tenantId: string): Promise<any[]> {
|
||||||
|
await this.findById(companyId, tenantId);
|
||||||
|
|
||||||
|
return query(
|
||||||
|
`SELECT u.id, u.email, u.full_name, u.status, uc.is_default, uc.assigned_at
|
||||||
|
FROM auth.users u
|
||||||
|
INNER JOIN auth.user_companies uc ON u.id = uc.user_id
|
||||||
|
WHERE uc.company_id = $1 AND u.tenant_id = $2 AND u.deleted_at IS NULL
|
||||||
|
ORDER BY u.full_name`,
|
||||||
|
[companyId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const companiesService = new CompaniesService();
|
||||||
3
src/modules/companies/index.ts
Normal file
3
src/modules/companies/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './companies.service.js';
|
||||||
|
export * from './companies.controller.js';
|
||||||
|
export { default as companiesRoutes } from './companies.routes.js';
|
||||||
247
src/modules/core/core.controller.ts
Normal file
247
src/modules/core/core.controller.ts
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js';
|
||||||
|
import { countriesService } from './countries.service.js';
|
||||||
|
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
|
||||||
|
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
const createCurrencySchema = z.object({
|
||||||
|
code: z.string().length(3, 'El código debe tener 3 caracteres').toUpperCase(),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||||
|
symbol: z.string().min(1).max(10),
|
||||||
|
decimal_places: z.number().int().min(0).max(6).default(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCurrencySchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
symbol: z.string().min(1).max(10).optional(),
|
||||||
|
decimal_places: z.number().int().min(0).max(6).optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUomSchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||||
|
code: z.string().min(1).max(20),
|
||||||
|
category_id: z.string().uuid(),
|
||||||
|
uom_type: z.enum(['reference', 'bigger', 'smaller']).default('reference'),
|
||||||
|
ratio: z.number().positive().default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUomSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
ratio: z.number().positive().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCategorySchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCategorySchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
class CoreController {
|
||||||
|
// ========== CURRENCIES ==========
|
||||||
|
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const currencies = await currenciesService.findAll(activeOnly);
|
||||||
|
res.json({ success: true, data: currencies });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const currency = await currenciesService.findById(req.params.id);
|
||||||
|
res.json({ success: true, data: currency });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createCurrencySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de moneda inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateCurrencyDto = parseResult.data;
|
||||||
|
const currency = await currenciesService.create(dto);
|
||||||
|
res.status(201).json({ success: true, data: currency, message: 'Moneda creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateCurrencySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de moneda inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateCurrencyDto = parseResult.data;
|
||||||
|
const currency = await currenciesService.update(req.params.id, dto);
|
||||||
|
res.json({ success: true, data: currency, message: 'Moneda actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== COUNTRIES ==========
|
||||||
|
async getCountries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const countries = await countriesService.findAll();
|
||||||
|
res.json({ success: true, data: countries });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCountry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const country = await countriesService.findById(req.params.id);
|
||||||
|
res.json({ success: true, data: country });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== UOM CATEGORIES ==========
|
||||||
|
async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const categories = await uomService.findAllCategories(activeOnly);
|
||||||
|
res.json({ success: true, data: categories });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUomCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const category = await uomService.findCategoryById(req.params.id);
|
||||||
|
res.json({ success: true, data: category });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== UOM ==========
|
||||||
|
async getUoms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const categoryId = req.query.category_id as string | undefined;
|
||||||
|
const uoms = await uomService.findAll(categoryId, activeOnly);
|
||||||
|
res.json({ success: true, data: uoms });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const uom = await uomService.findById(req.params.id);
|
||||||
|
res.json({ success: true, data: uom });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createUomSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de UdM inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateUomDto = parseResult.data;
|
||||||
|
const uom = await uomService.create(dto);
|
||||||
|
res.status(201).json({ success: true, data: uom, message: 'Unidad de medida creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateUomSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de UdM inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateUomDto = parseResult.data;
|
||||||
|
const uom = await uomService.update(req.params.id, dto);
|
||||||
|
res.json({ success: true, data: uom, message: 'Unidad de medida actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PRODUCT CATEGORIES ==========
|
||||||
|
async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const parentId = req.query.parent_id as string | undefined;
|
||||||
|
const categories = await productCategoriesService.findAll(req.tenantId!, parentId, activeOnly);
|
||||||
|
res.json({ success: true, data: categories });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const category = await productCategoriesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: category });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createCategorySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de categoría inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateProductCategoryDto = parseResult.data;
|
||||||
|
const category = await productCategoriesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: category, message: 'Categoría creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateCategorySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de categoría inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateProductCategoryDto = parseResult.data;
|
||||||
|
const category = await productCategoriesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: category, message: 'Categoría actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await productCategoriesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Categoría eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const coreController = new CoreController();
|
||||||
51
src/modules/core/core.routes.ts
Normal file
51
src/modules/core/core.routes.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { coreController } from './core.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== CURRENCIES ==========
|
||||||
|
router.get('/currencies', (req, res, next) => coreController.getCurrencies(req, res, next));
|
||||||
|
router.get('/currencies/:id', (req, res, next) => coreController.getCurrency(req, res, next));
|
||||||
|
router.post('/currencies', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createCurrency(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/currencies/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updateCurrency(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== COUNTRIES ==========
|
||||||
|
router.get('/countries', (req, res, next) => coreController.getCountries(req, res, next));
|
||||||
|
router.get('/countries/:id', (req, res, next) => coreController.getCountry(req, res, next));
|
||||||
|
|
||||||
|
// ========== UOM CATEGORIES ==========
|
||||||
|
router.get('/uom-categories', (req, res, next) => coreController.getUomCategories(req, res, next));
|
||||||
|
router.get('/uom-categories/:id', (req, res, next) => coreController.getUomCategory(req, res, next));
|
||||||
|
|
||||||
|
// ========== UOM ==========
|
||||||
|
router.get('/uom', (req, res, next) => coreController.getUoms(req, res, next));
|
||||||
|
router.get('/uom/:id', (req, res, next) => coreController.getUom(req, res, next));
|
||||||
|
router.post('/uom', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createUom(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/uom/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updateUom(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PRODUCT CATEGORIES ==========
|
||||||
|
router.get('/product-categories', (req, res, next) => coreController.getProductCategories(req, res, next));
|
||||||
|
router.get('/product-categories/:id', (req, res, next) => coreController.getProductCategory(req, res, next));
|
||||||
|
router.post('/product-categories', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createProductCategory(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/product-categories/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updateProductCategory(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/product-categories/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.deleteProductCategory(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
39
src/modules/core/countries.service.ts
Normal file
39
src/modules/core/countries.service.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Country {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
phone_code?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CountriesService {
|
||||||
|
async findAll(): Promise<Country[]> {
|
||||||
|
return query<Country>(
|
||||||
|
`SELECT * FROM core.countries ORDER BY name`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Country> {
|
||||||
|
const country = await queryOne<Country>(
|
||||||
|
`SELECT * FROM core.countries WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!country) {
|
||||||
|
throw new NotFoundError('País no encontrado');
|
||||||
|
}
|
||||||
|
return country;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<Country | null> {
|
||||||
|
return queryOne<Country>(
|
||||||
|
`SELECT * FROM core.countries WHERE code = $1`,
|
||||||
|
[code.toUpperCase()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const countriesService = new CountriesService();
|
||||||
105
src/modules/core/currencies.service.ts
Normal file
105
src/modules/core/currencies.service.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Currency {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimal_places: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCurrencyDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimal_places?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCurrencyDto {
|
||||||
|
name?: string;
|
||||||
|
symbol?: string;
|
||||||
|
decimal_places?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CurrenciesService {
|
||||||
|
async findAll(activeOnly: boolean = false): Promise<Currency[]> {
|
||||||
|
const whereClause = activeOnly ? 'WHERE active = true' : '';
|
||||||
|
return query<Currency>(
|
||||||
|
`SELECT * FROM core.currencies ${whereClause} ORDER BY code`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Currency> {
|
||||||
|
const currency = await queryOne<Currency>(
|
||||||
|
`SELECT * FROM core.currencies WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!currency) {
|
||||||
|
throw new NotFoundError('Moneda no encontrada');
|
||||||
|
}
|
||||||
|
return currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<Currency | null> {
|
||||||
|
return queryOne<Currency>(
|
||||||
|
`SELECT * FROM core.currencies WHERE code = $1`,
|
||||||
|
[code.toUpperCase()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCurrencyDto): Promise<Currency> {
|
||||||
|
const existing = await this.findByCode(dto.code);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe una moneda con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = await queryOne<Currency>(
|
||||||
|
`INSERT INTO core.currencies (code, name, symbol, decimal_places)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[dto.code.toUpperCase(), dto.name, dto.symbol, dto.decimal_places || 2]
|
||||||
|
);
|
||||||
|
return currency!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCurrencyDto): Promise<Currency> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.symbol !== undefined) {
|
||||||
|
updateFields.push(`symbol = $${paramIndex++}`);
|
||||||
|
values.push(dto.symbol);
|
||||||
|
}
|
||||||
|
if (dto.decimal_places !== undefined) {
|
||||||
|
updateFields.push(`decimal_places = $${paramIndex++}`);
|
||||||
|
values.push(dto.decimal_places);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const currency = await queryOne<Currency>(
|
||||||
|
`UPDATE core.currencies SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return currency!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currenciesService = new CurrenciesService();
|
||||||
6
src/modules/core/index.ts
Normal file
6
src/modules/core/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from './currencies.service.js';
|
||||||
|
export * from './countries.service.js';
|
||||||
|
export * from './uom.service.js';
|
||||||
|
export * from './product-categories.service.js';
|
||||||
|
export * from './core.controller.js';
|
||||||
|
export { default as coreRoutes } from './core.routes.js';
|
||||||
181
src/modules/core/product-categories.service.ts
Normal file
181
src/modules/core/product-categories.service.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface ProductCategory {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
full_path?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductCategoryDto {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
parent_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductCategoryDto {
|
||||||
|
name?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductCategoriesService {
|
||||||
|
async findAll(tenantId: string, parentId?: string, activeOnly: boolean = false): Promise<ProductCategory[]> {
|
||||||
|
let whereClause = 'WHERE pc.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
if (parentId === null || parentId === 'null') {
|
||||||
|
whereClause += ' AND pc.parent_id IS NULL';
|
||||||
|
} else {
|
||||||
|
whereClause += ` AND pc.parent_id = $${paramIndex++}`;
|
||||||
|
params.push(parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeOnly) {
|
||||||
|
whereClause += ' AND pc.active = true';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<ProductCategory>(
|
||||||
|
`SELECT pc.*, pcp.name as parent_name
|
||||||
|
FROM core.product_categories pc
|
||||||
|
LEFT JOIN core.product_categories pcp ON pc.parent_id = pcp.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY pc.name`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<ProductCategory> {
|
||||||
|
const category = await queryOne<ProductCategory>(
|
||||||
|
`SELECT pc.*, pcp.name as parent_name
|
||||||
|
FROM core.product_categories pc
|
||||||
|
LEFT JOIN core.product_categories pcp ON pc.parent_id = pcp.id
|
||||||
|
WHERE pc.id = $1 AND pc.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundError('Categoría de producto no encontrada');
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateProductCategoryDto, tenantId: string, userId: string): Promise<ProductCategory> {
|
||||||
|
// Check unique code within tenant
|
||||||
|
const existing = await queryOne<ProductCategory>(
|
||||||
|
`SELECT id FROM core.product_categories WHERE tenant_id = $1 AND code = $2`,
|
||||||
|
[tenantId, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe una categoría con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent if specified
|
||||||
|
if (dto.parent_id) {
|
||||||
|
const parent = await queryOne<ProductCategory>(
|
||||||
|
`SELECT id FROM core.product_categories WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[dto.parent_id, tenantId]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Categoría padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await queryOne<ProductCategory>(
|
||||||
|
`INSERT INTO core.product_categories (tenant_id, name, code, parent_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.code, dto.parent_id, userId]
|
||||||
|
);
|
||||||
|
return category!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateProductCategoryDto, tenantId: string, userId: string): Promise<ProductCategory> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate parent (prevent self-reference)
|
||||||
|
if (dto.parent_id) {
|
||||||
|
if (dto.parent_id === id) {
|
||||||
|
throw new ConflictError('Una categoría no puede ser su propio padre');
|
||||||
|
}
|
||||||
|
const parent = await queryOne<ProductCategory>(
|
||||||
|
`SELECT id FROM core.product_categories WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[dto.parent_id, tenantId]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Categoría padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.parent_id !== undefined) {
|
||||||
|
updateFields.push(`parent_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.parent_id);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
const category = await queryOne<ProductCategory>(
|
||||||
|
`UPDATE core.product_categories SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return category!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if has children
|
||||||
|
const children = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM core.product_categories WHERE parent_id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
if (parseInt(children?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una categoría que tiene subcategorías');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if has products
|
||||||
|
const products = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.products WHERE category_id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
if (parseInt(products?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una categoría que tiene productos asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM core.product_categories WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const productCategoriesService = new ProductCategoriesService();
|
||||||
371
src/modules/core/sequences.service.ts
Normal file
371
src/modules/core/sequences.service.ts
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Sequence {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string | null;
|
||||||
|
suffix: string | null;
|
||||||
|
next_number: number;
|
||||||
|
padding: number;
|
||||||
|
reset_period: 'none' | 'year' | 'month' | null;
|
||||||
|
last_reset_date: Date | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSequenceDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
start_number?: number;
|
||||||
|
padding?: number;
|
||||||
|
reset_period?: 'none' | 'year' | 'month';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSequenceDto {
|
||||||
|
name?: string;
|
||||||
|
prefix?: string | null;
|
||||||
|
suffix?: string | null;
|
||||||
|
padding?: number;
|
||||||
|
reset_period?: 'none' | 'year' | 'month';
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PREDEFINED SEQUENCE CODES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SEQUENCE_CODES = {
|
||||||
|
// Sales
|
||||||
|
SALES_ORDER: 'SO',
|
||||||
|
QUOTATION: 'QT',
|
||||||
|
|
||||||
|
// Purchases
|
||||||
|
PURCHASE_ORDER: 'PO',
|
||||||
|
RFQ: 'RFQ',
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
PICKING_IN: 'WH/IN',
|
||||||
|
PICKING_OUT: 'WH/OUT',
|
||||||
|
PICKING_INT: 'WH/INT',
|
||||||
|
INVENTORY_ADJ: 'INV/ADJ',
|
||||||
|
|
||||||
|
// Financial
|
||||||
|
INVOICE_CUSTOMER: 'INV',
|
||||||
|
INVOICE_SUPPLIER: 'BILL',
|
||||||
|
PAYMENT: 'PAY',
|
||||||
|
JOURNAL_ENTRY: 'JE',
|
||||||
|
|
||||||
|
// CRM
|
||||||
|
LEAD: 'LEAD',
|
||||||
|
OPPORTUNITY: 'OPP',
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
PROJECT: 'PRJ',
|
||||||
|
TASK: 'TASK',
|
||||||
|
|
||||||
|
// HR
|
||||||
|
EMPLOYEE: 'EMP',
|
||||||
|
CONTRACT: 'CTR',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class SequencesService {
|
||||||
|
/**
|
||||||
|
* Get the next number in a sequence using the database function
|
||||||
|
* This is atomic and handles concurrent requests safely
|
||||||
|
*/
|
||||||
|
async getNextNumber(
|
||||||
|
sequenceCode: string,
|
||||||
|
tenantId: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<string> {
|
||||||
|
const executeQuery = client
|
||||||
|
? async (sql: string, params: any[]) => {
|
||||||
|
const result = await client.query(sql, params);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
: queryOne;
|
||||||
|
|
||||||
|
// Use the database function for atomic sequence generation
|
||||||
|
const result = await executeQuery(
|
||||||
|
`SELECT core.generate_next_sequence($1, $2) as sequence_number`,
|
||||||
|
[sequenceCode, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.sequence_number) {
|
||||||
|
// Sequence doesn't exist, try to create it with default settings
|
||||||
|
logger.warn('Sequence not found, creating default', { sequenceCode, tenantId });
|
||||||
|
|
||||||
|
await this.ensureSequenceExists(sequenceCode, tenantId, client);
|
||||||
|
|
||||||
|
// Try again
|
||||||
|
const retryResult = await executeQuery(
|
||||||
|
`SELECT core.generate_next_sequence($1, $2) as sequence_number`,
|
||||||
|
[sequenceCode, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!retryResult?.sequence_number) {
|
||||||
|
throw new NotFoundError(`Secuencia ${sequenceCode} no encontrada`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return retryResult.sequence_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Generated sequence number', {
|
||||||
|
sequenceCode,
|
||||||
|
number: result.sequence_number,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.sequence_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a sequence exists, creating it with defaults if not
|
||||||
|
*/
|
||||||
|
async ensureSequenceExists(
|
||||||
|
sequenceCode: string,
|
||||||
|
tenantId: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<void> {
|
||||||
|
const executeQuery = client
|
||||||
|
? async (sql: string, params: any[]) => {
|
||||||
|
const result = await client.query(sql, params);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
: queryOne;
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
const existing = await executeQuery(
|
||||||
|
`SELECT id FROM core.sequences WHERE code = $1 AND tenant_id = $2`,
|
||||||
|
[sequenceCode, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
// Create with defaults based on code
|
||||||
|
const defaults = this.getDefaultsForCode(sequenceCode);
|
||||||
|
|
||||||
|
const insertQuery = client
|
||||||
|
? async (sql: string, params: any[]) => client.query(sql, params)
|
||||||
|
: query;
|
||||||
|
|
||||||
|
await insertQuery(
|
||||||
|
`INSERT INTO core.sequences (tenant_id, code, name, prefix, padding, next_number)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 1)
|
||||||
|
ON CONFLICT (tenant_id, code) DO NOTHING`,
|
||||||
|
[tenantId, sequenceCode, defaults.name, defaults.prefix, defaults.padding]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Created default sequence', { sequenceCode, tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default settings for a sequence code
|
||||||
|
*/
|
||||||
|
private getDefaultsForCode(code: string): { name: string; prefix: string; padding: number } {
|
||||||
|
const defaults: Record<string, { name: string; prefix: string; padding: number }> = {
|
||||||
|
[SEQUENCE_CODES.SALES_ORDER]: { name: 'Órdenes de Venta', prefix: 'SO-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.QUOTATION]: { name: 'Cotizaciones', prefix: 'QT-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.PURCHASE_ORDER]: { name: 'Órdenes de Compra', prefix: 'PO-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.RFQ]: { name: 'Solicitudes de Cotización', prefix: 'RFQ-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.PICKING_IN]: { name: 'Recepciones', prefix: 'WH/IN/', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.PICKING_OUT]: { name: 'Entregas', prefix: 'WH/OUT/', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.PICKING_INT]: { name: 'Transferencias', prefix: 'WH/INT/', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.INVENTORY_ADJ]: { name: 'Ajustes de Inventario', prefix: 'ADJ/', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.INVOICE_CUSTOMER]: { name: 'Facturas de Cliente', prefix: 'INV/', padding: 6 },
|
||||||
|
[SEQUENCE_CODES.INVOICE_SUPPLIER]: { name: 'Facturas de Proveedor', prefix: 'BILL/', padding: 6 },
|
||||||
|
[SEQUENCE_CODES.PAYMENT]: { name: 'Pagos', prefix: 'PAY/', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.JOURNAL_ENTRY]: { name: 'Asientos Contables', prefix: 'JE/', padding: 6 },
|
||||||
|
[SEQUENCE_CODES.LEAD]: { name: 'Prospectos', prefix: 'LEAD-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.OPPORTUNITY]: { name: 'Oportunidades', prefix: 'OPP-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.PROJECT]: { name: 'Proyectos', prefix: 'PRJ-', padding: 4 },
|
||||||
|
[SEQUENCE_CODES.TASK]: { name: 'Tareas', prefix: 'TASK-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.EMPLOYEE]: { name: 'Empleados', prefix: 'EMP-', padding: 4 },
|
||||||
|
[SEQUENCE_CODES.CONTRACT]: { name: 'Contratos', prefix: 'CTR-', padding: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return defaults[code] || { name: code, prefix: `${code}-`, padding: 5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sequences for a tenant
|
||||||
|
*/
|
||||||
|
async findAll(tenantId: string): Promise<Sequence[]> {
|
||||||
|
return query<Sequence>(
|
||||||
|
`SELECT * FROM core.sequences
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY code`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific sequence by code
|
||||||
|
*/
|
||||||
|
async findByCode(code: string, tenantId: string): Promise<Sequence | null> {
|
||||||
|
return queryOne<Sequence>(
|
||||||
|
`SELECT * FROM core.sequences
|
||||||
|
WHERE code = $1 AND tenant_id = $2`,
|
||||||
|
[code, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new sequence
|
||||||
|
*/
|
||||||
|
async create(dto: CreateSequenceDto, tenantId: string): Promise<Sequence> {
|
||||||
|
// Check for existing
|
||||||
|
const existing = await this.findByCode(dto.code, tenantId);
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError(`Ya existe una secuencia con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequence = await queryOne<Sequence>(
|
||||||
|
`INSERT INTO core.sequences (
|
||||||
|
tenant_id, code, name, prefix, suffix, next_number, padding, reset_period
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.code,
|
||||||
|
dto.name,
|
||||||
|
dto.prefix || null,
|
||||||
|
dto.suffix || null,
|
||||||
|
dto.start_number || 1,
|
||||||
|
dto.padding || 5,
|
||||||
|
dto.reset_period || 'none',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Sequence created', { code: dto.code, tenantId });
|
||||||
|
|
||||||
|
return sequence!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a sequence
|
||||||
|
*/
|
||||||
|
async update(code: string, dto: UpdateSequenceDto, tenantId: string): Promise<Sequence> {
|
||||||
|
const existing = await this.findByCode(code, tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = ['updated_at = NOW()'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updates.push(`name = $${idx++}`);
|
||||||
|
params.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.prefix !== undefined) {
|
||||||
|
updates.push(`prefix = $${idx++}`);
|
||||||
|
params.push(dto.prefix);
|
||||||
|
}
|
||||||
|
if (dto.suffix !== undefined) {
|
||||||
|
updates.push(`suffix = $${idx++}`);
|
||||||
|
params.push(dto.suffix);
|
||||||
|
}
|
||||||
|
if (dto.padding !== undefined) {
|
||||||
|
updates.push(`padding = $${idx++}`);
|
||||||
|
params.push(dto.padding);
|
||||||
|
}
|
||||||
|
if (dto.reset_period !== undefined) {
|
||||||
|
updates.push(`reset_period = $${idx++}`);
|
||||||
|
params.push(dto.reset_period);
|
||||||
|
}
|
||||||
|
if (dto.is_active !== undefined) {
|
||||||
|
updates.push(`is_active = $${idx++}`);
|
||||||
|
params.push(dto.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(code, tenantId);
|
||||||
|
|
||||||
|
const updated = await queryOne<Sequence>(
|
||||||
|
`UPDATE core.sequences
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE code = $${idx++} AND tenant_id = $${idx}
|
||||||
|
RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset a sequence to a specific number
|
||||||
|
*/
|
||||||
|
async reset(code: string, tenantId: string, newNumber: number = 1): Promise<Sequence> {
|
||||||
|
const updated = await queryOne<Sequence>(
|
||||||
|
`UPDATE core.sequences
|
||||||
|
SET next_number = $1, last_reset_date = NOW(), updated_at = NOW()
|
||||||
|
WHERE code = $2 AND tenant_id = $3
|
||||||
|
RETURNING *`,
|
||||||
|
[newNumber, code, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Sequence reset', { code, tenantId, newNumber });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview what the next number would be (without incrementing)
|
||||||
|
*/
|
||||||
|
async preview(code: string, tenantId: string): Promise<string> {
|
||||||
|
const sequence = await this.findByCode(code, tenantId);
|
||||||
|
if (!sequence) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddedNumber = String(sequence.next_number).padStart(sequence.padding, '0');
|
||||||
|
const prefix = sequence.prefix || '';
|
||||||
|
const suffix = sequence.suffix || '';
|
||||||
|
|
||||||
|
return `${prefix}${paddedNumber}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all standard sequences for a new tenant
|
||||||
|
*/
|
||||||
|
async initializeForTenant(tenantId: string): Promise<void> {
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
for (const [key, code] of Object.entries(SEQUENCE_CODES)) {
|
||||||
|
await this.ensureSequenceExists(code, tenantId, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
logger.info('Initialized sequences for tenant', { tenantId, count: Object.keys(SEQUENCE_CODES).length });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sequencesService = new SequencesService();
|
||||||
152
src/modules/core/uom.service.ts
Normal file
152
src/modules/core/uom.service.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface UomCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Uom {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
category_id: string;
|
||||||
|
category_name?: string;
|
||||||
|
uom_type: 'reference' | 'bigger' | 'smaller';
|
||||||
|
ratio: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUomDto {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
category_id: string;
|
||||||
|
uom_type?: 'reference' | 'bigger' | 'smaller';
|
||||||
|
ratio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUomDto {
|
||||||
|
name?: string;
|
||||||
|
ratio?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UomService {
|
||||||
|
// Categories
|
||||||
|
async findAllCategories(activeOnly: boolean = false): Promise<UomCategory[]> {
|
||||||
|
const whereClause = activeOnly ? 'WHERE active = true' : '';
|
||||||
|
return query<UomCategory>(
|
||||||
|
`SELECT * FROM core.uom_categories ${whereClause} ORDER BY name`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCategoryById(id: string): Promise<UomCategory> {
|
||||||
|
const category = await queryOne<UomCategory>(
|
||||||
|
`SELECT * FROM core.uom_categories WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundError('Categoría de UdM no encontrada');
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UoM
|
||||||
|
async findAll(categoryId?: string, activeOnly: boolean = false): Promise<Uom[]> {
|
||||||
|
let whereClause = '';
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (categoryId || activeOnly) {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (categoryId) {
|
||||||
|
conditions.push(`u.category_id = $${paramIndex++}`);
|
||||||
|
params.push(categoryId);
|
||||||
|
}
|
||||||
|
if (activeOnly) {
|
||||||
|
conditions.push('u.active = true');
|
||||||
|
}
|
||||||
|
whereClause = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<Uom>(
|
||||||
|
`SELECT u.*, uc.name as category_name
|
||||||
|
FROM core.uom u
|
||||||
|
LEFT JOIN core.uom_categories uc ON u.category_id = uc.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY uc.name, u.uom_type, u.name`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Uom> {
|
||||||
|
const uom = await queryOne<Uom>(
|
||||||
|
`SELECT u.*, uc.name as category_name
|
||||||
|
FROM core.uom u
|
||||||
|
LEFT JOIN core.uom_categories uc ON u.category_id = uc.id
|
||||||
|
WHERE u.id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!uom) {
|
||||||
|
throw new NotFoundError('Unidad de medida no encontrada');
|
||||||
|
}
|
||||||
|
return uom;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateUomDto): Promise<Uom> {
|
||||||
|
// Validate category exists
|
||||||
|
await this.findCategoryById(dto.category_id);
|
||||||
|
|
||||||
|
// Check unique code
|
||||||
|
const existing = await queryOne<Uom>(
|
||||||
|
`SELECT id FROM core.uom WHERE code = $1`,
|
||||||
|
[dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe una UdM con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uom = await queryOne<Uom>(
|
||||||
|
`INSERT INTO core.uom (name, code, category_id, uom_type, ratio)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[dto.name, dto.code, dto.category_id, dto.uom_type || 'reference', dto.ratio || 1]
|
||||||
|
);
|
||||||
|
return uom!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateUomDto): Promise<Uom> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.ratio !== undefined) {
|
||||||
|
updateFields.push(`ratio = $${paramIndex++}`);
|
||||||
|
values.push(dto.ratio);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const uom = await queryOne<Uom>(
|
||||||
|
`UPDATE core.uom SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return uom!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uomService = new UomService();
|
||||||
682
src/modules/crm/crm.controller.ts
Normal file
682
src/modules/crm/crm.controller.ts
Normal file
@ -0,0 +1,682 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { leadsService, CreateLeadDto, UpdateLeadDto, LeadFilters } from './leads.service.js';
|
||||||
|
import { opportunitiesService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from './opportunities.service.js';
|
||||||
|
import { stagesService, CreateLeadStageDto, UpdateLeadStageDto, CreateOpportunityStageDto, UpdateOpportunityStageDto, CreateLostReasonDto, UpdateLostReasonDto } from './stages.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Lead schemas
|
||||||
|
const createLeadSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
contact_name: z.string().max(255).optional(),
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
mobile: z.string().max(50).optional(),
|
||||||
|
website: z.string().url().max(255).optional(),
|
||||||
|
company_prospect_name: z.string().max(255).optional(),
|
||||||
|
job_position: z.string().max(100).optional(),
|
||||||
|
industry: z.string().max(100).optional(),
|
||||||
|
employee_count: z.string().max(50).optional(),
|
||||||
|
annual_revenue: z.number().min(0).optional(),
|
||||||
|
street: z.string().max(255).optional(),
|
||||||
|
city: z.string().max(100).optional(),
|
||||||
|
state: z.string().max(100).optional(),
|
||||||
|
zip: z.string().max(20).optional(),
|
||||||
|
country: z.string().max(100).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLeadSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
contact_name: z.string().max(255).optional().nullable(),
|
||||||
|
email: z.string().email().max(255).optional().nullable(),
|
||||||
|
phone: z.string().max(50).optional().nullable(),
|
||||||
|
mobile: z.string().max(50).optional().nullable(),
|
||||||
|
website: z.string().url().max(255).optional().nullable(),
|
||||||
|
company_prospect_name: z.string().max(255).optional().nullable(),
|
||||||
|
job_position: z.string().max(100).optional().nullable(),
|
||||||
|
industry: z.string().max(100).optional().nullable(),
|
||||||
|
employee_count: z.string().max(50).optional().nullable(),
|
||||||
|
annual_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
street: z.string().max(255).optional().nullable(),
|
||||||
|
city: z.string().max(100).optional().nullable(),
|
||||||
|
state: z.string().max(100).optional().nullable(),
|
||||||
|
zip: z.string().max(20).optional().nullable(),
|
||||||
|
country: z.string().max(100).optional().nullable(),
|
||||||
|
stage_id: z.string().uuid().optional().nullable(),
|
||||||
|
user_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional().nullable(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
tags: z.array(z.string()).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const leadQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['new', 'contacted', 'qualified', 'converted', 'lost']).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
priority: z.coerce.number().int().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lostSchema = z.object({
|
||||||
|
lost_reason_id: z.string().uuid(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveStageSchema = z.object({
|
||||||
|
stage_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opportunity schemas
|
||||||
|
const createOpportunitySchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
partner_id: z.string().uuid(),
|
||||||
|
contact_name: z.string().max(255).optional(),
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional(),
|
||||||
|
recurring_revenue: z.number().min(0).optional(),
|
||||||
|
recurring_plan: z.string().max(50).optional(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOpportunitySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
contact_name: z.string().max(255).optional().nullable(),
|
||||||
|
email: z.string().email().max(255).optional().nullable(),
|
||||||
|
phone: z.string().max(50).optional().nullable(),
|
||||||
|
stage_id: z.string().uuid().optional().nullable(),
|
||||||
|
user_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
recurring_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
recurring_plan: z.string().max(50).optional().nullable(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
tags: z.array(z.string()).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const opportunityQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['open', 'won', 'lost']).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
priority: z.coerce.number().int().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stage schemas
|
||||||
|
const createStageSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
sequence: z.number().int().optional(),
|
||||||
|
is_won: z.boolean().optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
requirements: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStageSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
sequence: z.number().int().optional(),
|
||||||
|
is_won: z.boolean().optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
requirements: z.string().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lost reason schemas
|
||||||
|
const createLostReasonSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLostReasonSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
class CrmController {
|
||||||
|
// ========== LEADS ==========
|
||||||
|
|
||||||
|
async getLeads(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = leadQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LeadFilters = queryResult.data;
|
||||||
|
const result = await leadsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: lead });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLeadSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lead invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeadDto = parseResult.data;
|
||||||
|
const lead = await leadsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLeadSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lead invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeadDto = parseResult.data;
|
||||||
|
const lead = await leadsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = moveStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = await leadsService.moveStage(req.params.id, parseResult.data.stage_id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead movido a nueva etapa',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await leadsService.convert(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.lead,
|
||||||
|
opportunity_id: result.opportunity_id,
|
||||||
|
message: 'Lead convertido a oportunidad exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLeadLost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = lostSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = await leadsService.markLost(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.lost_reason_id,
|
||||||
|
parseResult.data.notes,
|
||||||
|
req.tenantId!,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead marcado como perdido',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await leadsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Lead eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITIES ==========
|
||||||
|
|
||||||
|
async getOpportunities(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = opportunityQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: OpportunityFilters = queryResult.data;
|
||||||
|
const result = await opportunitiesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const opportunity = await opportunitiesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: opportunity });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createOpportunitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de oportunidad invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateOpportunityDto = parseResult.data;
|
||||||
|
const opportunity = await opportunitiesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateOpportunitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de oportunidad invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateOpportunityDto = parseResult.data;
|
||||||
|
const opportunity = await opportunitiesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = moveStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opportunity = await opportunitiesService.moveStage(req.params.id, parseResult.data.stage_id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad movida a nueva etapa',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markOpportunityWon(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const opportunity = await opportunitiesService.markWon(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad marcada como ganada',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markOpportunityLost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = lostSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opportunity = await opportunitiesService.markLost(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.lost_reason_id,
|
||||||
|
parseResult.data.notes,
|
||||||
|
req.tenantId!,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad marcada como perdida',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await opportunitiesService.createQuotation(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result.opportunity,
|
||||||
|
quotation_id: result.quotation_id,
|
||||||
|
message: 'Cotizacion creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await opportunitiesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Oportunidad eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPipeline(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyId = req.query.company_id as string | undefined;
|
||||||
|
const pipeline = await opportunitiesService.getPipeline(req.tenantId!, companyId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: pipeline,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
async getLeadStages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const stages = await stagesService.getLeadStages(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: stages });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeadStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.createLeadStage(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de lead creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeadStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.updateLeadStage(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de lead actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteLeadStage(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Etapa de lead eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
async getOpportunityStages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const stages = await stagesService.getOpportunityStages(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: stages });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateOpportunityStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.createOpportunityStage(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de oportunidad creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateOpportunityStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.updateOpportunityStage(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de oportunidad actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteOpportunityStage(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Etapa de oportunidad eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
async getLostReasons(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const reasons = await stagesService.getLostReasons(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: reasons });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLostReasonSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de razon invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLostReasonDto = parseResult.data;
|
||||||
|
const reason = await stagesService.createLostReason(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: reason,
|
||||||
|
message: 'Razon de perdida creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLostReasonSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de razon invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLostReasonDto = parseResult.data;
|
||||||
|
const reason = await stagesService.updateLostReason(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: reason,
|
||||||
|
message: 'Razon de perdida actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteLostReason(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Razon de perdida eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const crmController = new CrmController();
|
||||||
126
src/modules/crm/crm.routes.ts
Normal file
126
src/modules/crm/crm.routes.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { crmController } from './crm.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== LEADS ==========
|
||||||
|
|
||||||
|
router.get('/leads', (req, res, next) => crmController.getLeads(req, res, next));
|
||||||
|
|
||||||
|
router.get('/leads/:id', (req, res, next) => crmController.getLead(req, res, next));
|
||||||
|
|
||||||
|
router.post('/leads', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/leads/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/move', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.moveLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/convert', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.convertLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/lost', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markLeadLost(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/leads/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== OPPORTUNITIES ==========
|
||||||
|
|
||||||
|
router.get('/opportunities', (req, res, next) => crmController.getOpportunities(req, res, next));
|
||||||
|
|
||||||
|
router.get('/opportunities/:id', (req, res, next) => crmController.getOpportunity(req, res, next));
|
||||||
|
|
||||||
|
router.post('/opportunities', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/opportunities/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/move', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.moveOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/won', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markOpportunityWon(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/lost', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markOpportunityLost(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/quote', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunityQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/opportunities/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PIPELINE ==========
|
||||||
|
|
||||||
|
router.get('/pipeline', (req, res, next) => crmController.getPipeline(req, res, next));
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
router.get('/lead-stages', (req, res, next) => crmController.getLeadStages(req, res, next));
|
||||||
|
|
||||||
|
router.post('/lead-stages', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/lead-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/lead-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
router.get('/opportunity-stages', (req, res, next) => crmController.getOpportunityStages(req, res, next));
|
||||||
|
|
||||||
|
router.post('/opportunity-stages', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/opportunity-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/opportunity-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
router.get('/lost-reasons', (req, res, next) => crmController.getLostReasons(req, res, next));
|
||||||
|
|
||||||
|
router.post('/lost-reasons', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/lost-reasons/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/lost-reasons/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
5
src/modules/crm/index.ts
Normal file
5
src/modules/crm/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './leads.service.js';
|
||||||
|
export * from './opportunities.service.js';
|
||||||
|
export * from './stages.service.js';
|
||||||
|
export * from './crm.controller.js';
|
||||||
|
export { default as crmRoutes } from './crm.routes.js';
|
||||||
449
src/modules/crm/leads.service.ts
Normal file
449
src/modules/crm/leads.service.ts
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
|
||||||
|
export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other';
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
company_prospect_name?: string;
|
||||||
|
job_position?: string;
|
||||||
|
industry?: string;
|
||||||
|
employee_count?: string;
|
||||||
|
annual_revenue?: number;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
stage_name?: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority: number;
|
||||||
|
probability: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
date_open?: Date;
|
||||||
|
date_closed?: Date;
|
||||||
|
date_deadline?: Date;
|
||||||
|
date_last_activity?: Date;
|
||||||
|
partner_id?: string;
|
||||||
|
opportunity_id?: string;
|
||||||
|
lost_reason_id?: string;
|
||||||
|
lost_reason_name?: string;
|
||||||
|
lost_notes?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeadDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
company_prospect_name?: string;
|
||||||
|
job_position?: string;
|
||||||
|
industry?: string;
|
||||||
|
employee_count?: string;
|
||||||
|
annual_revenue?: number;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
date_deadline?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeadDto {
|
||||||
|
name?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
contact_name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
company_prospect_name?: string | null;
|
||||||
|
job_position?: string | null;
|
||||||
|
industry?: string | null;
|
||||||
|
employee_count?: string | null;
|
||||||
|
annual_revenue?: number | null;
|
||||||
|
street?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
stage_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
source?: LeadSource | null;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number | null;
|
||||||
|
date_deadline?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadFilters {
|
||||||
|
company_id?: string;
|
||||||
|
status?: LeadStatus;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority?: number;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeadsService {
|
||||||
|
async findAll(tenantId: string, filters: LeadFilters = {}): Promise<{ data: Lead[]; total: number }> {
|
||||||
|
const { company_id, status, stage_id, user_id, source, priority, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND l.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND l.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage_id) {
|
||||||
|
whereClause += ` AND l.stage_id = $${paramIndex++}`;
|
||||||
|
params.push(stage_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND l.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
whereClause += ` AND l.source = $${paramIndex++}`;
|
||||||
|
params.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority !== undefined) {
|
||||||
|
whereClause += ` AND l.priority = $${paramIndex++}`;
|
||||||
|
params.push(priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.contact_name ILIKE $${paramIndex} OR l.email ILIKE $${paramIndex} OR l.company_name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads l ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Lead>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
ls.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.leads l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id
|
||||||
|
LEFT JOIN auth.users u ON l.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.priority DESC, l.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Lead> {
|
||||||
|
const lead = await queryOne<Lead>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
ls.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.leads l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id
|
||||||
|
LEFT JOIN auth.users u ON l.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lead) {
|
||||||
|
throw new NotFoundError('Lead no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lead;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLeadDto, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await queryOne<Lead>(
|
||||||
|
`INSERT INTO crm.leads (
|
||||||
|
tenant_id, company_id, name, ref, contact_name, email, phone, mobile, website,
|
||||||
|
company_name, job_position, industry, employee_count, annual_revenue,
|
||||||
|
street, city, state, zip, country, stage_id, user_id, sales_team_id, source,
|
||||||
|
priority, probability, expected_revenue, date_deadline, description, notes, tags,
|
||||||
|
date_open, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
|
||||||
|
$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, CURRENT_TIMESTAMP, $31)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.ref, dto.contact_name, dto.email, dto.phone,
|
||||||
|
dto.mobile, dto.website, dto.company_prospect_name, dto.job_position, dto.industry,
|
||||||
|
dto.employee_count, dto.annual_revenue, dto.street, dto.city, dto.state, dto.zip,
|
||||||
|
dto.country, dto.stage_id, dto.user_id, dto.sales_team_id, dto.source,
|
||||||
|
dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.date_deadline,
|
||||||
|
dto.description, dto.notes, dto.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(lead!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLeadDto, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status === 'converted' || existing.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede editar un lead convertido o perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'name', 'ref', 'contact_name', 'email', 'phone', 'mobile', 'website',
|
||||||
|
'company_prospect_name', 'job_position', 'industry', 'employee_count', 'annual_revenue',
|
||||||
|
'street', 'city', 'state', 'zip', 'country', 'stage_id', 'user_id', 'sales_team_id',
|
||||||
|
'source', 'priority', 'probability', 'expected_revenue', 'date_deadline',
|
||||||
|
'description', 'notes', 'tags'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
const key = field === 'company_prospect_name' ? 'company_name' : field;
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${key} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`);
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted' || lead.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede mover un lead convertido o perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
stage_id = $1,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[stageId, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async convert(id: string, tenantId: string, userId: string): Promise<{ lead: Lead; opportunity_id: string }> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted') {
|
||||||
|
throw new ValidationError('El lead ya fue convertido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lead.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede convertir un lead perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Create or get partner
|
||||||
|
let partnerId = lead.partner_id;
|
||||||
|
|
||||||
|
if (!partnerId && lead.email) {
|
||||||
|
// Check if partner exists with same email
|
||||||
|
const existingPartner = await client.query(
|
||||||
|
`SELECT id FROM core.partners WHERE email = $1 AND tenant_id = $2`,
|
||||||
|
[lead.email, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingPartner.rows.length > 0) {
|
||||||
|
partnerId = existingPartner.rows[0].id;
|
||||||
|
} else {
|
||||||
|
// Create new partner
|
||||||
|
const partnerResult = await client.query(
|
||||||
|
`INSERT INTO core.partners (tenant_id, name, email, phone, mobile, is_customer, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, TRUE, $6)
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId, lead.contact_name || lead.name, lead.email, lead.phone, lead.mobile, userId]
|
||||||
|
);
|
||||||
|
partnerId = partnerResult.rows[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partnerId) {
|
||||||
|
throw new ValidationError('El lead debe tener un email o partner asociado para convertirse');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default opportunity stage
|
||||||
|
const stageResult = await client.query(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE tenant_id = $1 ORDER BY sequence LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stageId = stageResult.rows[0]?.id || null;
|
||||||
|
|
||||||
|
// Create opportunity
|
||||||
|
const opportunityResult = await client.query(
|
||||||
|
`INSERT INTO crm.opportunities (
|
||||||
|
tenant_id, company_id, name, partner_id, contact_name, email, phone,
|
||||||
|
stage_id, user_id, sales_team_id, source, priority, probability,
|
||||||
|
expected_revenue, lead_id, description, notes, tags, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
tenantId, lead.company_id, lead.name, partnerId, lead.contact_name, lead.email,
|
||||||
|
lead.phone, stageId, lead.user_id, lead.sales_team_id, lead.source, lead.priority,
|
||||||
|
lead.probability, lead.expected_revenue, id, lead.description, lead.notes, lead.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const opportunityId = opportunityResult.rows[0].id;
|
||||||
|
|
||||||
|
// Update lead
|
||||||
|
await client.query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
status = 'converted',
|
||||||
|
partner_id = $1,
|
||||||
|
opportunity_id = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4`,
|
||||||
|
[partnerId, opportunityId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const updatedLead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
return { lead: updatedLead, opportunity_id: opportunityId };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted') {
|
||||||
|
throw new ValidationError('No se puede marcar como perdido un lead convertido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lead.status === 'lost') {
|
||||||
|
throw new ValidationError('El lead ya esta marcado como perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
status = 'lost',
|
||||||
|
lost_reason_id = $1,
|
||||||
|
lost_notes = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[lostReasonId, notes, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.opportunity_id) {
|
||||||
|
throw new ConflictError('No se puede eliminar un lead que tiene una oportunidad asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.leads WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const leadsService = new LeadsService();
|
||||||
503
src/modules/crm/opportunities.service.ts
Normal file
503
src/modules/crm/opportunities.service.ts
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { LeadSource } from './leads.service.js';
|
||||||
|
|
||||||
|
export type OpportunityStatus = 'open' | 'won' | 'lost';
|
||||||
|
|
||||||
|
export interface Opportunity {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
stage_name?: string;
|
||||||
|
status: OpportunityStatus;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
priority: number;
|
||||||
|
probability: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
recurring_revenue?: number;
|
||||||
|
recurring_plan?: string;
|
||||||
|
date_deadline?: Date;
|
||||||
|
date_closed?: Date;
|
||||||
|
date_last_activity?: Date;
|
||||||
|
lead_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
lost_reason_id?: string;
|
||||||
|
lost_reason_name?: string;
|
||||||
|
lost_notes?: string;
|
||||||
|
quotation_id?: string;
|
||||||
|
order_id?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOpportunityDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
recurring_revenue?: number;
|
||||||
|
recurring_plan?: string;
|
||||||
|
date_deadline?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOpportunityDto {
|
||||||
|
name?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
partner_id?: string;
|
||||||
|
contact_name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
stage_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number | null;
|
||||||
|
recurring_revenue?: number | null;
|
||||||
|
recurring_plan?: string | null;
|
||||||
|
date_deadline?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityFilters {
|
||||||
|
company_id?: string;
|
||||||
|
status?: OpportunityStatus;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
priority?: number;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpportunitiesService {
|
||||||
|
async findAll(tenantId: string, filters: OpportunityFilters = {}): Promise<{ data: Opportunity[]; total: number }> {
|
||||||
|
const { company_id, status, stage_id, user_id, partner_id, priority, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE o.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND o.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND o.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage_id) {
|
||||||
|
whereClause += ` AND o.stage_id = $${paramIndex++}`;
|
||||||
|
params.push(stage_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND o.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND o.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority !== undefined) {
|
||||||
|
whereClause += ` AND o.priority = $${paramIndex++}`;
|
||||||
|
params.push(priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (o.name ILIKE $${paramIndex} OR o.contact_name ILIKE $${paramIndex} OR o.email ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities o
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Opportunity>(
|
||||||
|
`SELECT o.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
os.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN auth.companies c ON o.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
LEFT JOIN crm.opportunity_stages os ON o.stage_id = os.id
|
||||||
|
LEFT JOIN auth.users u ON o.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON o.lost_reason_id = lr.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY o.priority DESC, o.expected_revenue DESC NULLS LAST, o.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await queryOne<Opportunity>(
|
||||||
|
`SELECT o.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
os.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN auth.companies c ON o.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
LEFT JOIN crm.opportunity_stages os ON o.stage_id = os.id
|
||||||
|
LEFT JOIN auth.users u ON o.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON o.lost_reason_id = lr.id
|
||||||
|
WHERE o.id = $1 AND o.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!opportunity) {
|
||||||
|
throw new NotFoundError('Oportunidad no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return opportunity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateOpportunityDto, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await queryOne<Opportunity>(
|
||||||
|
`INSERT INTO crm.opportunities (
|
||||||
|
tenant_id, company_id, name, ref, partner_id, contact_name, email, phone,
|
||||||
|
stage_id, user_id, sales_team_id, priority, probability, expected_revenue,
|
||||||
|
recurring_revenue, recurring_plan, date_deadline, source, description, notes, tags, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.ref, dto.partner_id, dto.contact_name,
|
||||||
|
dto.email, dto.phone, dto.stage_id, dto.user_id, dto.sales_team_id,
|
||||||
|
dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.recurring_revenue,
|
||||||
|
dto.recurring_plan, dto.date_deadline, dto.source, dto.description, dto.notes, dto.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(opportunity!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateOpportunityDto, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden editar oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'name', 'ref', 'partner_id', 'contact_name', 'email', 'phone', 'stage_id',
|
||||||
|
'user_id', 'sales_team_id', 'priority', 'probability', 'expected_revenue',
|
||||||
|
'recurring_revenue', 'recurring_plan', 'date_deadline', 'description', 'notes', 'tags'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`);
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden mover oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stage probability
|
||||||
|
const stage = await queryOne<{ probability: number; is_won: boolean }>(
|
||||||
|
`SELECT probability, is_won FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[stageId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
stage_id = $1,
|
||||||
|
probability = $2,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[stageId, stage.probability, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markWon(id: string, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden marcar como ganadas oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
status = 'won',
|
||||||
|
probability = 100,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden marcar como perdidas oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
status = 'lost',
|
||||||
|
probability = 0,
|
||||||
|
lost_reason_id = $1,
|
||||||
|
lost_notes = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[lostReasonId, notes, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createQuotation(id: string, tenantId: string, userId: string): Promise<{ opportunity: Opportunity; quotation_id: string }> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden crear cotizaciones de oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opportunity.quotation_id) {
|
||||||
|
throw new ValidationError('Esta oportunidad ya tiene una cotizacion asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate quotation name
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 3) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM sales.quotations WHERE tenant_id = $1 AND name LIKE 'SO%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const nextNum = seqResult.rows[0]?.next_num || 1;
|
||||||
|
const quotationName = `SO${String(nextNum).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// Get default currency
|
||||||
|
const currencyResult = await client.query(
|
||||||
|
`SELECT id FROM core.currencies WHERE code = 'MXN' AND tenant_id = $1 LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const currencyId = currencyResult.rows[0]?.id;
|
||||||
|
|
||||||
|
if (!currencyId) {
|
||||||
|
throw new ValidationError('No se encontro una moneda configurada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create quotation
|
||||||
|
const quotationResult = await client.query(
|
||||||
|
`INSERT INTO sales.quotations (
|
||||||
|
tenant_id, company_id, name, partner_id, quotation_date, validity_date,
|
||||||
|
currency_id, user_id, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, CURRENT_DATE, CURRENT_DATE + INTERVAL '30 days', $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
tenantId, opportunity.company_id, quotationName, opportunity.partner_id,
|
||||||
|
currencyId, userId, opportunity.description, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const quotationId = quotationResult.rows[0].id;
|
||||||
|
|
||||||
|
// Update opportunity
|
||||||
|
await client.query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
quotation_id = $1,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3`,
|
||||||
|
[quotationId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const updatedOpportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
return { opportunity: updatedOpportunity, quotation_id: quotationId };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.quotation_id || opportunity.order_id) {
|
||||||
|
throw new ValidationError('No se puede eliminar una oportunidad con cotizacion u orden asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lead if exists
|
||||||
|
if (opportunity.lead_id) {
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET opportunity_id = NULL WHERE id = $1`,
|
||||||
|
[opportunity.lead_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.opportunities WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline view - grouped by stage
|
||||||
|
async getPipeline(tenantId: string, companyId?: string): Promise<{ stages: any[]; totals: any }> {
|
||||||
|
let whereClause = 'WHERE o.tenant_id = $1 AND o.status = $2';
|
||||||
|
const params: any[] = [tenantId, 'open'];
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
whereClause += ` AND o.company_id = $3`;
|
||||||
|
params.push(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = await query<{ id: string; name: string; sequence: number; probability: number }>(
|
||||||
|
`SELECT id, name, sequence, probability
|
||||||
|
FROM crm.opportunity_stages
|
||||||
|
WHERE tenant_id = $1 AND active = TRUE
|
||||||
|
ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const opportunities = await query<any>(
|
||||||
|
`SELECT o.id, o.name, o.partner_id, p.name as partner_name,
|
||||||
|
o.stage_id, o.expected_revenue, o.probability, o.priority,
|
||||||
|
o.date_deadline, o.user_id
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY o.priority DESC, o.expected_revenue DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group opportunities by stage
|
||||||
|
const pipelineStages = stages.map(stage => ({
|
||||||
|
...stage,
|
||||||
|
opportunities: opportunities.filter((opp: any) => opp.stage_id === stage.id),
|
||||||
|
count: opportunities.filter((opp: any) => opp.stage_id === stage.id).length,
|
||||||
|
total_revenue: opportunities
|
||||||
|
.filter((opp: any) => opp.stage_id === stage.id)
|
||||||
|
.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add "No stage" for opportunities without stage
|
||||||
|
const noStageOpps = opportunities.filter((opp: any) => !opp.stage_id);
|
||||||
|
if (noStageOpps.length > 0) {
|
||||||
|
pipelineStages.unshift({
|
||||||
|
id: null as unknown as string,
|
||||||
|
name: 'Sin etapa',
|
||||||
|
sequence: 0,
|
||||||
|
probability: 0,
|
||||||
|
opportunities: noStageOpps,
|
||||||
|
count: noStageOpps.length,
|
||||||
|
total_revenue: noStageOpps.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = {
|
||||||
|
total_opportunities: opportunities.length,
|
||||||
|
total_expected_revenue: opportunities.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0),
|
||||||
|
weighted_revenue: opportunities.reduce((sum: number, opp: any) => {
|
||||||
|
const revenue = parseFloat(opp.expected_revenue) || 0;
|
||||||
|
const probability = parseFloat(opp.probability) || 0;
|
||||||
|
return sum + (revenue * probability / 100);
|
||||||
|
}, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
return { stages: pipelineStages, totals };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opportunitiesService = new OpportunitiesService();
|
||||||
435
src/modules/crm/stages.service.ts
Normal file
435
src/modules/crm/stages.service.ts
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
export interface LeadStage {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
is_won: boolean;
|
||||||
|
probability: number;
|
||||||
|
requirements?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeadStageDto {
|
||||||
|
name: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeadStageDto {
|
||||||
|
name?: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
export interface OpportunityStage {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
is_won: boolean;
|
||||||
|
probability: number;
|
||||||
|
requirements?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOpportunityStageDto {
|
||||||
|
name: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOpportunityStageDto {
|
||||||
|
name?: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
export interface LostReason {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLostReasonDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLostReasonDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StagesService {
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
async getLeadStages(tenantId: string, includeInactive = false): Promise<LeadStage[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<LeadStage>(
|
||||||
|
`SELECT * FROM crm.lead_stages ${whereClause} ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeadStageById(id: string, tenantId: string): Promise<LeadStage> {
|
||||||
|
const stage = await queryOne<LeadStage>(
|
||||||
|
`SELECT * FROM crm.lead_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa de lead no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeadStage(dto: CreateLeadStageDto, tenantId: string): Promise<LeadStage> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lead_stages WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await queryOne<LeadStage>(
|
||||||
|
`INSERT INTO crm.lead_stages (tenant_id, name, sequence, is_won, probability, requirements)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.sequence || 10, dto.is_won || false, dto.probability || 0, dto.requirements]
|
||||||
|
);
|
||||||
|
|
||||||
|
return stage!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeadStage(id: string, dto: UpdateLeadStageDto, tenantId: string): Promise<LeadStage> {
|
||||||
|
await this.getLeadStageById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lead_stages WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.sequence !== undefined) {
|
||||||
|
updateFields.push(`sequence = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence);
|
||||||
|
}
|
||||||
|
if (dto.is_won !== undefined) {
|
||||||
|
updateFields.push(`is_won = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_won);
|
||||||
|
}
|
||||||
|
if (dto.probability !== undefined) {
|
||||||
|
updateFields.push(`probability = $${paramIndex++}`);
|
||||||
|
values.push(dto.probability);
|
||||||
|
}
|
||||||
|
if (dto.requirements !== undefined) {
|
||||||
|
updateFields.push(`requirements = $${paramIndex++}`);
|
||||||
|
values.push(dto.requirements);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getLeadStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.lead_stages SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getLeadStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeadStage(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getLeadStageById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if stage is in use
|
||||||
|
const inUse = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads WHERE stage_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUse?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una etapa que tiene leads asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.lead_stages WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
async getOpportunityStages(tenantId: string, includeInactive = false): Promise<OpportunityStage[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<OpportunityStage>(
|
||||||
|
`SELECT * FROM crm.opportunity_stages ${whereClause} ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpportunityStageById(id: string, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
const stage = await queryOne<OpportunityStage>(
|
||||||
|
`SELECT * FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa de oportunidad no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityStage(dto: CreateOpportunityStageDto, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await queryOne<OpportunityStage>(
|
||||||
|
`INSERT INTO crm.opportunity_stages (tenant_id, name, sequence, is_won, probability, requirements)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.sequence || 10, dto.is_won || false, dto.probability || 0, dto.requirements]
|
||||||
|
);
|
||||||
|
|
||||||
|
return stage!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunityStage(id: string, dto: UpdateOpportunityStageDto, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
await this.getOpportunityStageById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.sequence !== undefined) {
|
||||||
|
updateFields.push(`sequence = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence);
|
||||||
|
}
|
||||||
|
if (dto.is_won !== undefined) {
|
||||||
|
updateFields.push(`is_won = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_won);
|
||||||
|
}
|
||||||
|
if (dto.probability !== undefined) {
|
||||||
|
updateFields.push(`probability = $${paramIndex++}`);
|
||||||
|
values.push(dto.probability);
|
||||||
|
}
|
||||||
|
if (dto.requirements !== undefined) {
|
||||||
|
updateFields.push(`requirements = $${paramIndex++}`);
|
||||||
|
values.push(dto.requirements);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getOpportunityStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunity_stages SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getOpportunityStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunityStage(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getOpportunityStageById(id, tenantId);
|
||||||
|
|
||||||
|
const inUse = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities WHERE stage_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUse?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una etapa que tiene oportunidades asociadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
async getLostReasons(tenantId: string, includeInactive = false): Promise<LostReason[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<LostReason>(
|
||||||
|
`SELECT * FROM crm.lost_reasons ${whereClause} ORDER BY name`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLostReasonById(id: string, tenantId: string): Promise<LostReason> {
|
||||||
|
const reason = await queryOne<LostReason>(
|
||||||
|
`SELECT * FROM crm.lost_reasons WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reason) {
|
||||||
|
throw new NotFoundError('Razon de perdida no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLostReason(dto: CreateLostReasonDto, tenantId: string): Promise<LostReason> {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lost_reasons WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una razon con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = await queryOne<LostReason>(
|
||||||
|
`INSERT INTO crm.lost_reasons (tenant_id, name, description)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.description]
|
||||||
|
);
|
||||||
|
|
||||||
|
return reason!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLostReason(id: string, dto: UpdateLostReasonDto, tenantId: string): Promise<LostReason> {
|
||||||
|
await this.getLostReasonById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lost_reasons WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una razon con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getLostReasonById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.lost_reasons SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getLostReasonById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLostReason(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getLostReasonById(id, tenantId);
|
||||||
|
|
||||||
|
const inUseLeads = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads WHERE lost_reason_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inUseOpps = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities WHERE lost_reason_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUseLeads?.count || '0') > 0 || parseInt(inUseOpps?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una razon que esta en uso');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.lost_reasons WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stagesService = new StagesService();
|
||||||
330
src/modules/financial/accounts.service.ts
Normal file
330
src/modules/financial/accounts.service.ts
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type AccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense';
|
||||||
|
|
||||||
|
export interface AccountTypeEntity {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
account_type: AccountType;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
account_type_id: string;
|
||||||
|
account_type_name?: string;
|
||||||
|
account_type_code?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
is_reconcilable: boolean;
|
||||||
|
is_deprecated: boolean;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAccountDto {
|
||||||
|
company_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
account_type_id: string;
|
||||||
|
parent_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
is_reconcilable?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAccountDto {
|
||||||
|
name?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
currency_id?: string | null;
|
||||||
|
is_reconcilable?: boolean;
|
||||||
|
is_deprecated?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountFilters {
|
||||||
|
company_id?: string;
|
||||||
|
account_type_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
is_deprecated?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountsService {
|
||||||
|
// Account Types (catalog)
|
||||||
|
async findAllAccountTypes(): Promise<AccountTypeEntity[]> {
|
||||||
|
return query<AccountTypeEntity>(
|
||||||
|
`SELECT * FROM financial.account_types ORDER BY code`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAccountTypeById(id: string): Promise<AccountTypeEntity> {
|
||||||
|
const accountType = await queryOne<AccountTypeEntity>(
|
||||||
|
`SELECT * FROM financial.account_types WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!accountType) {
|
||||||
|
throw new NotFoundError('Tipo de cuenta no encontrado');
|
||||||
|
}
|
||||||
|
return accountType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
async findAll(tenantId: string, filters: AccountFilters = {}): Promise<{ data: Account[]; total: number }> {
|
||||||
|
const { company_id, account_type_id, parent_id, is_deprecated, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE a.tenant_id = $1 AND a.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND a.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account_type_id) {
|
||||||
|
whereClause += ` AND a.account_type_id = $${paramIndex++}`;
|
||||||
|
params.push(account_type_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent_id !== undefined) {
|
||||||
|
if (parent_id === null || parent_id === 'null') {
|
||||||
|
whereClause += ' AND a.parent_id IS NULL';
|
||||||
|
} else {
|
||||||
|
whereClause += ` AND a.parent_id = $${paramIndex++}`;
|
||||||
|
params.push(parent_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_deprecated !== undefined) {
|
||||||
|
whereClause += ` AND a.is_deprecated = $${paramIndex++}`;
|
||||||
|
params.push(is_deprecated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (a.code ILIKE $${paramIndex} OR a.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.accounts a ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Account>(
|
||||||
|
`SELECT a.*,
|
||||||
|
at.name as account_type_name,
|
||||||
|
at.code as account_type_code,
|
||||||
|
ap.name as parent_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.accounts a
|
||||||
|
LEFT JOIN financial.account_types at ON a.account_type_id = at.id
|
||||||
|
LEFT JOIN financial.accounts ap ON a.parent_id = ap.id
|
||||||
|
LEFT JOIN core.currencies cur ON a.currency_id = cur.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.code
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Account> {
|
||||||
|
const account = await queryOne<Account>(
|
||||||
|
`SELECT a.*,
|
||||||
|
at.name as account_type_name,
|
||||||
|
at.code as account_type_code,
|
||||||
|
ap.name as parent_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.accounts a
|
||||||
|
LEFT JOIN financial.account_types at ON a.account_type_id = at.id
|
||||||
|
LEFT JOIN financial.accounts ap ON a.parent_id = ap.id
|
||||||
|
LEFT JOIN core.currencies cur ON a.currency_id = cur.id
|
||||||
|
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new NotFoundError('Cuenta no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateAccountDto, tenantId: string, userId: string): Promise<Account> {
|
||||||
|
// Validate unique code within company
|
||||||
|
const existing = await queryOne<Account>(
|
||||||
|
`SELECT id FROM financial.accounts WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe una cuenta con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate account type exists
|
||||||
|
await this.findAccountTypeById(dto.account_type_id);
|
||||||
|
|
||||||
|
// Validate parent account if specified
|
||||||
|
if (dto.parent_id) {
|
||||||
|
const parent = await queryOne<Account>(
|
||||||
|
`SELECT id FROM financial.accounts WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.parent_id, dto.company_id]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Cuenta padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await queryOne<Account>(
|
||||||
|
`INSERT INTO financial.accounts (tenant_id, company_id, code, name, account_type_id, parent_id, currency_id, is_reconcilable, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.company_id,
|
||||||
|
dto.code,
|
||||||
|
dto.name,
|
||||||
|
dto.account_type_id,
|
||||||
|
dto.parent_id,
|
||||||
|
dto.currency_id,
|
||||||
|
dto.is_reconcilable || false,
|
||||||
|
dto.notes,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return account!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateAccountDto, tenantId: string, userId: string): Promise<Account> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate parent (prevent self-reference)
|
||||||
|
if (dto.parent_id) {
|
||||||
|
if (dto.parent_id === id) {
|
||||||
|
throw new ConflictError('Una cuenta no puede ser su propia cuenta padre');
|
||||||
|
}
|
||||||
|
const parent = await queryOne<Account>(
|
||||||
|
`SELECT id FROM financial.accounts WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.parent_id, existing.company_id]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Cuenta padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.parent_id !== undefined) {
|
||||||
|
updateFields.push(`parent_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.parent_id);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.is_reconcilable !== undefined) {
|
||||||
|
updateFields.push(`is_reconcilable = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_reconcilable);
|
||||||
|
}
|
||||||
|
if (dto.is_deprecated !== undefined) {
|
||||||
|
updateFields.push(`is_deprecated = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_deprecated);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const account = await queryOne<Account>(
|
||||||
|
`UPDATE financial.accounts
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return account!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if account has children
|
||||||
|
const children = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.accounts WHERE parent_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (parseInt(children?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una cuenta que tiene subcuentas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account has journal entry lines
|
||||||
|
const entries = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journal_entry_lines WHERE account_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (parseInt(entries?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una cuenta que tiene movimientos contables');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.accounts SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(accountId: string, tenantId: string): Promise<{ debit: number; credit: number; balance: number }> {
|
||||||
|
await this.findById(accountId, tenantId);
|
||||||
|
|
||||||
|
const result = await queryOne<{ total_debit: string; total_credit: string }>(
|
||||||
|
`SELECT COALESCE(SUM(jel.debit), 0) as total_debit,
|
||||||
|
COALESCE(SUM(jel.credit), 0) as total_credit
|
||||||
|
FROM financial.journal_entry_lines jel
|
||||||
|
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
|
||||||
|
WHERE jel.account_id = $1 AND je.status = 'posted'`,
|
||||||
|
[accountId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const debit = parseFloat(result?.total_debit || '0');
|
||||||
|
const credit = parseFloat(result?.total_credit || '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
debit,
|
||||||
|
credit,
|
||||||
|
balance: debit - credit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountsService = new AccountsService();
|
||||||
753
src/modules/financial/financial.controller.ts
Normal file
753
src/modules/financial/financial.controller.ts
Normal file
@ -0,0 +1,753 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { accountsService, CreateAccountDto, UpdateAccountDto, AccountFilters } from './accounts.service.js';
|
||||||
|
import { journalsService, CreateJournalDto, UpdateJournalDto, JournalFilters } from './journals.service.js';
|
||||||
|
import { journalEntriesService, CreateJournalEntryDto, UpdateJournalEntryDto, JournalEntryFilters } from './journal-entries.service.js';
|
||||||
|
import { invoicesService, CreateInvoiceDto, UpdateInvoiceDto, CreateInvoiceLineDto, UpdateInvoiceLineDto, InvoiceFilters } from './invoices.service.js';
|
||||||
|
import { paymentsService, CreatePaymentDto, UpdatePaymentDto, ReconcileDto, PaymentFilters } from './payments.service.js';
|
||||||
|
import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './taxes.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
const createAccountSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
account_type_id: z.string().uuid(),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
is_reconcilable: z.boolean().default(false),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAccountSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
is_reconcilable: z.boolean().optional(),
|
||||||
|
is_deprecated: z.boolean().optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
account_type_id: z.string().uuid().optional(),
|
||||||
|
parent_id: z.string().optional(),
|
||||||
|
is_deprecated: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createJournalSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
code: z.string().min(1).max(20),
|
||||||
|
journal_type: z.enum(['sale', 'purchase', 'cash', 'bank', 'general']),
|
||||||
|
default_account_id: z.string().uuid().optional(),
|
||||||
|
sequence_id: z.string().uuid().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateJournalSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
default_account_id: z.string().uuid().optional().nullable(),
|
||||||
|
sequence_id: z.string().uuid().optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const journalQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
journal_type: z.enum(['sale', 'purchase', 'cash', 'bank', 'general']).optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const journalEntryLineSchema = z.object({
|
||||||
|
account_id: z.string().uuid(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
debit: z.number().min(0).default(0),
|
||||||
|
credit: z.number().min(0).default(0),
|
||||||
|
description: z.string().optional(),
|
||||||
|
ref: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createJournalEntrySchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
journal_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
ref: z.string().max(255).optional(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
lines: z.array(journalEntryLineSchema).min(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateJournalEntrySchema = z.object({
|
||||||
|
ref: z.string().max(255).optional().nullable(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
lines: z.array(journalEntryLineSchema).min(2).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const journalEntryQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
journal_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'posted', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== INVOICE SCHEMAS ==========
|
||||||
|
const createInvoiceSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
partner_id: z.string().uuid(),
|
||||||
|
invoice_type: z.enum(['customer', 'supplier']),
|
||||||
|
currency_id: z.string().uuid(),
|
||||||
|
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
payment_term_id: z.string().uuid().optional(),
|
||||||
|
journal_id: z.string().uuid().optional(),
|
||||||
|
ref: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInvoiceSchema = z.object({
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
payment_term_id: z.string().uuid().optional().nullable(),
|
||||||
|
journal_id: z.string().uuid().optional().nullable(),
|
||||||
|
ref: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
invoice_type: z.enum(['customer', 'supplier']).optional(),
|
||||||
|
status: z.enum(['draft', 'open', 'paid', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createInvoiceLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().min(1),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
uom_id: z.string().uuid().optional(),
|
||||||
|
price_unit: z.number().min(0),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
account_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInvoiceLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional().nullable(),
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
uom_id: z.string().uuid().optional().nullable(),
|
||||||
|
price_unit: z.number().min(0).optional(),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
account_id: z.string().uuid().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== PAYMENT SCHEMAS ==========
|
||||||
|
const createPaymentSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
partner_id: z.string().uuid(),
|
||||||
|
payment_type: z.enum(['inbound', 'outbound']),
|
||||||
|
payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
currency_id: z.string().uuid(),
|
||||||
|
payment_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
ref: z.string().optional(),
|
||||||
|
journal_id: z.string().uuid(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePaymentSchema = z.object({
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']).optional(),
|
||||||
|
amount: z.number().positive().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
payment_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
ref: z.string().optional().nullable(),
|
||||||
|
journal_id: z.string().uuid().optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reconcilePaymentSchema = z.object({
|
||||||
|
invoices: z.array(z.object({
|
||||||
|
invoice_id: z.string().uuid(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
})).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
payment_type: z.enum(['inbound', 'outbound']).optional(),
|
||||||
|
payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']).optional(),
|
||||||
|
status: z.enum(['draft', 'posted', 'reconciled', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== TAX SCHEMAS ==========
|
||||||
|
const createTaxSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
code: z.string().min(1).max(20),
|
||||||
|
tax_type: z.enum(['sales', 'purchase', 'all']),
|
||||||
|
amount: z.number().min(0).max(100),
|
||||||
|
included_in_price: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaxSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
code: z.string().min(1).max(20).optional(),
|
||||||
|
tax_type: z.enum(['sales', 'purchase', 'all']).optional(),
|
||||||
|
amount: z.number().min(0).max(100).optional(),
|
||||||
|
included_in_price: z.boolean().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const taxQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
tax_type: z.enum(['sales', 'purchase', 'all']).optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class FinancialController {
|
||||||
|
// ========== ACCOUNT TYPES ==========
|
||||||
|
async getAccountTypes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const accountTypes = await accountsService.findAllAccountTypes();
|
||||||
|
res.json({ success: true, data: accountTypes });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ACCOUNTS ==========
|
||||||
|
async getAccounts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = accountQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: AccountFilters = queryResult.data;
|
||||||
|
const result = await accountsService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const account = await accountsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: account });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createAccountSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de cuenta inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateAccountDto = parseResult.data;
|
||||||
|
const account = await accountsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: account, message: 'Cuenta creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateAccountSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de cuenta inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateAccountDto = parseResult.data;
|
||||||
|
const account = await accountsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: account, message: 'Cuenta actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await accountsService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Cuenta eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountBalance(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const balance = await accountsService.getBalance(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: balance });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JOURNALS ==========
|
||||||
|
async getJournals(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = journalQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: JournalFilters = queryResult.data;
|
||||||
|
const result = await journalsService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const journal = await journalsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: journal });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createJournalSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de diario inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateJournalDto = parseResult.data;
|
||||||
|
const journal = await journalsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: journal, message: 'Diario creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateJournalSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de diario inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateJournalDto = parseResult.data;
|
||||||
|
const journal = await journalsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: journal, message: 'Diario actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await journalsService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Diario eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JOURNAL ENTRIES ==========
|
||||||
|
async getJournalEntries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = journalEntryQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: JournalEntryFilters = queryResult.data;
|
||||||
|
const result = await journalEntriesService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = await journalEntriesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: entry });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createJournalEntrySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de póliza inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateJournalEntryDto = parseResult.data;
|
||||||
|
const entry = await journalEntriesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: entry, message: 'Póliza creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateJournalEntrySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de póliza inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateJournalEntryDto = parseResult.data;
|
||||||
|
const entry = await journalEntriesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: entry, message: 'Póliza actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = await journalEntriesService.post(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: entry, message: 'Póliza publicada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = await journalEntriesService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: entry, message: 'Póliza cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await journalEntriesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Póliza eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== INVOICES ==========
|
||||||
|
async getInvoices(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = invoiceQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: InvoiceFilters = queryResult.data;
|
||||||
|
const result = await invoicesService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await invoicesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createInvoiceSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de factura inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateInvoiceDto = parseResult.data;
|
||||||
|
const invoice = await invoicesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: invoice, message: 'Factura creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateInvoiceSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de factura inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateInvoiceDto = parseResult.data;
|
||||||
|
const invoice = await invoicesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: invoice, message: 'Factura actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await invoicesService.validate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: invoice, message: 'Factura validada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await invoicesService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: invoice, message: 'Factura cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoicesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Factura eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== INVOICE LINES ==========
|
||||||
|
async addInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createInvoiceLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateInvoiceLineDto = parseResult.data;
|
||||||
|
const line = await invoicesService.addLine(req.params.id, dto, req.tenantId!);
|
||||||
|
res.status(201).json({ success: true, data: line, message: 'Línea agregada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateInvoiceLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateInvoiceLineDto = parseResult.data;
|
||||||
|
const line = await invoicesService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
res.json({ success: true, data: line, message: 'Línea actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoicesService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PAYMENTS ==========
|
||||||
|
async getPayments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = paymentQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: PaymentFilters = queryResult.data;
|
||||||
|
const result = await paymentsService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payment = await paymentsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: payment });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPaymentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de pago inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreatePaymentDto = parseResult.data;
|
||||||
|
const payment = await paymentsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: payment, message: 'Pago creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updatePaymentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de pago inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdatePaymentDto = parseResult.data;
|
||||||
|
const payment = await paymentsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: payment, message: 'Pago actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payment = await paymentsService.post(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: payment, message: 'Pago publicado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcilePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = reconcilePaymentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de conciliación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: ReconcileDto = parseResult.data;
|
||||||
|
const payment = await paymentsService.reconcile(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: payment, message: 'Pago conciliado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payment = await paymentsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: payment, message: 'Pago cancelado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await paymentsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Pago eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== TAXES ==========
|
||||||
|
async getTaxes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = taxQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: TaxFilters = queryResult.data;
|
||||||
|
const result = await taxesService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tax = await taxesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: tax });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createTaxSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de impuesto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateTaxDto = parseResult.data;
|
||||||
|
const tax = await taxesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: tax, message: 'Impuesto creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateTaxSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de impuesto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateTaxDto = parseResult.data;
|
||||||
|
const tax = await taxesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: tax, message: 'Impuesto actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await taxesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Impuesto eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const financialController = new FinancialController();
|
||||||
150
src/modules/financial/financial.routes.ts
Normal file
150
src/modules/financial/financial.routes.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { financialController } from './financial.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== ACCOUNT TYPES ==========
|
||||||
|
router.get('/account-types', (req, res, next) => financialController.getAccountTypes(req, res, next));
|
||||||
|
|
||||||
|
// ========== ACCOUNTS ==========
|
||||||
|
router.get('/accounts', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getAccounts(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/accounts/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getAccount(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/accounts/:id/balance', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getAccountBalance(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/accounts', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createAccount(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/accounts/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateAccount(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/accounts/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteAccount(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== JOURNALS ==========
|
||||||
|
router.get('/journals', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getJournals(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/journals/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getJournal(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/journals', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createJournal(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/journals/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateJournal(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/journals/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteJournal(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== JOURNAL ENTRIES ==========
|
||||||
|
router.get('/entries', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getJournalEntries(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/entries/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/entries', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/entries/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/entries/:id/post', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.postJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/entries/:id/cancel', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.cancelJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/entries/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== INVOICES ==========
|
||||||
|
router.get('/invoices', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getInvoices(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/invoices/:id', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/invoices', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/invoices/:id', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/invoices/:id/validate', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.validateInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/invoices/:id/cancel', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.cancelInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/invoices/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invoice lines
|
||||||
|
router.post('/invoices/:id/lines', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.addInvoiceLine(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/invoices/:id/lines/:lineId', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateInvoiceLine(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/invoices/:id/lines/:lineId', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.removeInvoiceLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PAYMENTS ==========
|
||||||
|
router.get('/payments', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getPayments(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/payments/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getPayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payments', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createPayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/payments/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updatePayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payments/:id/post', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.postPayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payments/:id/reconcile', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.reconcilePayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payments/:id/cancel', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.cancelPayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/payments/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deletePayment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== TAXES ==========
|
||||||
|
router.get('/taxes', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getTaxes(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/taxes/:id', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getTax(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/taxes', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createTax(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/taxes/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateTax(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/taxes/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteTax(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
369
src/modules/financial/fiscalPeriods.service.ts
Normal file
369
src/modules/financial/fiscalPeriods.service.ts
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type FiscalPeriodStatus = 'open' | 'closed';
|
||||||
|
|
||||||
|
export interface FiscalYear {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
date_from: Date;
|
||||||
|
date_to: Date;
|
||||||
|
status: FiscalPeriodStatus;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FiscalPeriod {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
fiscal_year_id: string;
|
||||||
|
fiscal_year_name?: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
date_from: Date;
|
||||||
|
date_to: Date;
|
||||||
|
status: FiscalPeriodStatus;
|
||||||
|
closed_at: Date | null;
|
||||||
|
closed_by: string | null;
|
||||||
|
closed_by_name?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFiscalYearDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFiscalPeriodDto {
|
||||||
|
fiscal_year_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FiscalPeriodFilters {
|
||||||
|
company_id?: string;
|
||||||
|
fiscal_year_id?: string;
|
||||||
|
status?: FiscalPeriodStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class FiscalPeriodsService {
|
||||||
|
// ==================== FISCAL YEARS ====================
|
||||||
|
|
||||||
|
async findAllYears(tenantId: string, companyId?: string): Promise<FiscalYear[]> {
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM financial.fiscal_years
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
sql += ` AND company_id = $2`;
|
||||||
|
params.push(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY date_from DESC`;
|
||||||
|
|
||||||
|
return query<FiscalYear>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findYearById(id: string, tenantId: string): Promise<FiscalYear> {
|
||||||
|
const year = await queryOne<FiscalYear>(
|
||||||
|
`SELECT * FROM financial.fiscal_years WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!year) {
|
||||||
|
throw new NotFoundError('Año fiscal no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createYear(dto: CreateFiscalYearDto, tenantId: string, userId: string): Promise<FiscalYear> {
|
||||||
|
// Check for overlapping years
|
||||||
|
const overlapping = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM financial.fiscal_years
|
||||||
|
WHERE tenant_id = $1 AND company_id = $2
|
||||||
|
AND (date_from, date_to) OVERLAPS ($3::date, $4::date)`,
|
||||||
|
[tenantId, dto.company_id, dto.date_from, dto.date_to]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlapping) {
|
||||||
|
throw new ConflictError('Ya existe un año fiscal que se superpone con estas fechas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = await queryOne<FiscalYear>(
|
||||||
|
`INSERT INTO financial.fiscal_years (
|
||||||
|
tenant_id, company_id, name, code, date_from, date_to, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.code, dto.date_from, dto.date_to, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Fiscal year created', { yearId: year?.id, name: dto.name });
|
||||||
|
|
||||||
|
return year!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== FISCAL PERIODS ====================
|
||||||
|
|
||||||
|
async findAllPeriods(tenantId: string, filters: FiscalPeriodFilters = {}): Promise<FiscalPeriod[]> {
|
||||||
|
const conditions: string[] = ['fp.tenant_id = $1'];
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (filters.fiscal_year_id) {
|
||||||
|
conditions.push(`fp.fiscal_year_id = $${idx++}`);
|
||||||
|
params.push(filters.fiscal_year_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.company_id) {
|
||||||
|
conditions.push(`fy.company_id = $${idx++}`);
|
||||||
|
params.push(filters.company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push(`fp.status = $${idx++}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.date_from) {
|
||||||
|
conditions.push(`fp.date_from >= $${idx++}`);
|
||||||
|
params.push(filters.date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.date_to) {
|
||||||
|
conditions.push(`fp.date_to <= $${idx++}`);
|
||||||
|
params.push(filters.date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<FiscalPeriod>(
|
||||||
|
`SELECT fp.*,
|
||||||
|
fy.name as fiscal_year_name,
|
||||||
|
u.full_name as closed_by_name
|
||||||
|
FROM financial.fiscal_periods fp
|
||||||
|
JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id
|
||||||
|
LEFT JOIN auth.users u ON fp.closed_by = u.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY fp.date_from DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPeriodById(id: string, tenantId: string): Promise<FiscalPeriod> {
|
||||||
|
const period = await queryOne<FiscalPeriod>(
|
||||||
|
`SELECT fp.*,
|
||||||
|
fy.name as fiscal_year_name,
|
||||||
|
u.full_name as closed_by_name
|
||||||
|
FROM financial.fiscal_periods fp
|
||||||
|
JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id
|
||||||
|
LEFT JOIN auth.users u ON fp.closed_by = u.id
|
||||||
|
WHERE fp.id = $1 AND fp.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!period) {
|
||||||
|
throw new NotFoundError('Período fiscal no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPeriodByDate(date: Date, companyId: string, tenantId: string): Promise<FiscalPeriod | null> {
|
||||||
|
return queryOne<FiscalPeriod>(
|
||||||
|
`SELECT fp.*
|
||||||
|
FROM financial.fiscal_periods fp
|
||||||
|
JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id
|
||||||
|
WHERE fp.tenant_id = $1
|
||||||
|
AND fy.company_id = $2
|
||||||
|
AND $3::date BETWEEN fp.date_from AND fp.date_to`,
|
||||||
|
[tenantId, companyId, date]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPeriod(dto: CreateFiscalPeriodDto, tenantId: string, userId: string): Promise<FiscalPeriod> {
|
||||||
|
// Verify fiscal year exists
|
||||||
|
await this.findYearById(dto.fiscal_year_id, tenantId);
|
||||||
|
|
||||||
|
// Check for overlapping periods in the same year
|
||||||
|
const overlapping = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM financial.fiscal_periods
|
||||||
|
WHERE tenant_id = $1 AND fiscal_year_id = $2
|
||||||
|
AND (date_from, date_to) OVERLAPS ($3::date, $4::date)`,
|
||||||
|
[tenantId, dto.fiscal_year_id, dto.date_from, dto.date_to]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlapping) {
|
||||||
|
throw new ConflictError('Ya existe un período que se superpone con estas fechas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = await queryOne<FiscalPeriod>(
|
||||||
|
`INSERT INTO financial.fiscal_periods (
|
||||||
|
tenant_id, fiscal_year_id, code, name, date_from, date_to, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.fiscal_year_id, dto.code, dto.name, dto.date_from, dto.date_to, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Fiscal period created', { periodId: period?.id, name: dto.name });
|
||||||
|
|
||||||
|
return period!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PERIOD OPERATIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a fiscal period
|
||||||
|
* Uses database function for validation
|
||||||
|
*/
|
||||||
|
async closePeriod(periodId: string, tenantId: string, userId: string): Promise<FiscalPeriod> {
|
||||||
|
// Verify period exists and belongs to tenant
|
||||||
|
await this.findPeriodById(periodId, tenantId);
|
||||||
|
|
||||||
|
// Use database function for atomic close with validations
|
||||||
|
const result = await queryOne<FiscalPeriod>(
|
||||||
|
`SELECT * FROM financial.close_fiscal_period($1, $2)`,
|
||||||
|
[periodId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Error al cerrar período');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Fiscal period closed', { periodId, userId });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reopen a fiscal period (admin only)
|
||||||
|
*/
|
||||||
|
async reopenPeriod(periodId: string, tenantId: string, userId: string, reason?: string): Promise<FiscalPeriod> {
|
||||||
|
// Verify period exists and belongs to tenant
|
||||||
|
await this.findPeriodById(periodId, tenantId);
|
||||||
|
|
||||||
|
// Use database function for atomic reopen with audit
|
||||||
|
const result = await queryOne<FiscalPeriod>(
|
||||||
|
`SELECT * FROM financial.reopen_fiscal_period($1, $2, $3)`,
|
||||||
|
[periodId, userId, reason]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Error al reabrir período');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Fiscal period reopened', { periodId, userId, reason });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics for a period
|
||||||
|
*/
|
||||||
|
async getPeriodStats(periodId: string, tenantId: string): Promise<{
|
||||||
|
total_entries: number;
|
||||||
|
draft_entries: number;
|
||||||
|
posted_entries: number;
|
||||||
|
total_debit: number;
|
||||||
|
total_credit: number;
|
||||||
|
}> {
|
||||||
|
const stats = await queryOne<{
|
||||||
|
total_entries: string;
|
||||||
|
draft_entries: string;
|
||||||
|
posted_entries: string;
|
||||||
|
total_debit: string;
|
||||||
|
total_credit: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total_entries,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'draft') as draft_entries,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'posted') as posted_entries,
|
||||||
|
COALESCE(SUM(total_debit), 0) as total_debit,
|
||||||
|
COALESCE(SUM(total_credit), 0) as total_credit
|
||||||
|
FROM financial.journal_entries
|
||||||
|
WHERE fiscal_period_id = $1 AND tenant_id = $2`,
|
||||||
|
[periodId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_entries: parseInt(stats?.total_entries || '0', 10),
|
||||||
|
draft_entries: parseInt(stats?.draft_entries || '0', 10),
|
||||||
|
posted_entries: parseInt(stats?.posted_entries || '0', 10),
|
||||||
|
total_debit: parseFloat(stats?.total_debit || '0'),
|
||||||
|
total_credit: parseFloat(stats?.total_credit || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate monthly periods for a fiscal year
|
||||||
|
*/
|
||||||
|
async generateMonthlyPeriods(fiscalYearId: string, tenantId: string, userId: string): Promise<FiscalPeriod[]> {
|
||||||
|
const year = await this.findYearById(fiscalYearId, tenantId);
|
||||||
|
|
||||||
|
const startDate = new Date(year.date_from);
|
||||||
|
const endDate = new Date(year.date_to);
|
||||||
|
const periods: FiscalPeriod[] = [];
|
||||||
|
|
||||||
|
let currentDate = new Date(startDate);
|
||||||
|
let periodNum = 1;
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
const periodStart = new Date(currentDate);
|
||||||
|
const periodEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
// Don't exceed the fiscal year end
|
||||||
|
if (periodEnd > endDate) {
|
||||||
|
periodEnd.setTime(endDate.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const period = await this.createPeriod({
|
||||||
|
fiscal_year_id: fiscalYearId,
|
||||||
|
code: String(periodNum).padStart(2, '0'),
|
||||||
|
name: `${monthNames[periodStart.getMonth()]} ${periodStart.getFullYear()}`,
|
||||||
|
date_from: periodStart.toISOString().split('T')[0],
|
||||||
|
date_to: periodEnd.toISOString().split('T')[0],
|
||||||
|
}, tenantId, userId);
|
||||||
|
|
||||||
|
periods.push(period);
|
||||||
|
} catch (error) {
|
||||||
|
// Skip if period already exists (overlapping check will fail)
|
||||||
|
logger.debug('Period creation skipped', { periodNum, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next month
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
currentDate.setDate(1);
|
||||||
|
periodNum++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Generated monthly periods', { fiscalYearId, count: periods.length });
|
||||||
|
|
||||||
|
return periods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fiscalPeriodsService = new FiscalPeriodsService();
|
||||||
8
src/modules/financial/index.ts
Normal file
8
src/modules/financial/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from './accounts.service.js';
|
||||||
|
export * from './journals.service.js';
|
||||||
|
export * from './journal-entries.service.js';
|
||||||
|
export * from './invoices.service.js';
|
||||||
|
export * from './payments.service.js';
|
||||||
|
export * from './taxes.service.js';
|
||||||
|
export * from './financial.controller.js';
|
||||||
|
export { default as financialRoutes } from './financial.routes.js';
|
||||||
547
src/modules/financial/invoices.service.ts
Normal file
547
src/modules/financial/invoices.service.ts
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { taxesService } from './taxes.service.js';
|
||||||
|
|
||||||
|
export interface InvoiceLine {
|
||||||
|
id: string;
|
||||||
|
invoice_id: string;
|
||||||
|
product_id?: string;
|
||||||
|
product_name?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id?: string;
|
||||||
|
uom_name?: string;
|
||||||
|
price_unit: number;
|
||||||
|
tax_ids: string[];
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
account_id?: string;
|
||||||
|
account_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
invoice_type: 'customer' | 'supplier';
|
||||||
|
number?: string;
|
||||||
|
ref?: string;
|
||||||
|
invoice_date: Date;
|
||||||
|
due_date?: Date;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
amount_paid: number;
|
||||||
|
amount_residual: number;
|
||||||
|
status: 'draft' | 'open' | 'paid' | 'cancelled';
|
||||||
|
payment_term_id?: string;
|
||||||
|
journal_id?: string;
|
||||||
|
journal_entry_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
lines?: InvoiceLine[];
|
||||||
|
created_at: Date;
|
||||||
|
validated_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
invoice_type: 'customer' | 'supplier';
|
||||||
|
ref?: string;
|
||||||
|
invoice_date?: string;
|
||||||
|
due_date?: string;
|
||||||
|
currency_id: string;
|
||||||
|
payment_term_id?: string;
|
||||||
|
journal_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInvoiceDto {
|
||||||
|
partner_id?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
invoice_date?: string;
|
||||||
|
due_date?: string | null;
|
||||||
|
currency_id?: string;
|
||||||
|
payment_term_id?: string | null;
|
||||||
|
journal_id?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceLineDto {
|
||||||
|
product_id?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id?: string;
|
||||||
|
price_unit: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInvoiceLineDto {
|
||||||
|
product_id?: string | null;
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
uom_id?: string | null;
|
||||||
|
price_unit?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
account_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
invoice_type?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvoicesService {
|
||||||
|
async findAll(tenantId: string, filters: InvoiceFilters = {}): Promise<{ data: Invoice[]; total: number }> {
|
||||||
|
const { company_id, partner_id, invoice_type, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE i.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND i.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND i.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice_type) {
|
||||||
|
whereClause += ` AND i.invoice_type = $${paramIndex++}`;
|
||||||
|
params.push(invoice_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND i.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND i.invoice_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND i.invoice_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (i.number ILIKE $${paramIndex} OR i.ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM financial.invoices i
|
||||||
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Invoice>(
|
||||||
|
`SELECT i.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM financial.invoices i
|
||||||
|
LEFT JOIN auth.companies c ON i.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON i.currency_id = cu.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY i.invoice_date DESC, i.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Invoice> {
|
||||||
|
const invoice = await queryOne<Invoice>(
|
||||||
|
`SELECT i.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM financial.invoices i
|
||||||
|
LEFT JOIN auth.companies c ON i.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON i.currency_id = cu.id
|
||||||
|
WHERE i.id = $1 AND i.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
throw new NotFoundError('Factura no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<InvoiceLine>(
|
||||||
|
`SELECT il.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
um.name as uom_name,
|
||||||
|
a.name as account_name
|
||||||
|
FROM financial.invoice_lines il
|
||||||
|
LEFT JOIN inventory.products pr ON il.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom um ON il.uom_id = um.id
|
||||||
|
LEFT JOIN financial.accounts a ON il.account_id = a.id
|
||||||
|
WHERE il.invoice_id = $1
|
||||||
|
ORDER BY il.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
invoice.lines = lines;
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateInvoiceDto, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
|
const invoiceDate = dto.invoice_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const invoice = await queryOne<Invoice>(
|
||||||
|
`INSERT INTO financial.invoices (
|
||||||
|
tenant_id, company_id, partner_id, invoice_type, ref, invoice_date,
|
||||||
|
due_date, currency_id, payment_term_id, journal_id, notes,
|
||||||
|
amount_untaxed, amount_tax, amount_total, amount_paid, amount_residual, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0, 0, 0, 0, 0, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.partner_id, dto.invoice_type, dto.ref,
|
||||||
|
invoiceDate, dto.due_date, dto.currency_id, dto.payment_term_id,
|
||||||
|
dto.journal_id, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return invoice!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateInvoiceDto, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.invoice_date !== undefined) {
|
||||||
|
updateFields.push(`invoice_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.invoice_date);
|
||||||
|
}
|
||||||
|
if (dto.due_date !== undefined) {
|
||||||
|
updateFields.push(`due_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.due_date);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_term_id !== undefined) {
|
||||||
|
updateFields.push(`payment_term_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_term_id);
|
||||||
|
}
|
||||||
|
if (dto.journal_id !== undefined) {
|
||||||
|
updateFields.push(`journal_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.journal_id);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoices SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.invoices WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(invoiceId: string, dto: CreateInvoiceLineDto, tenantId: string): Promise<InvoiceLine> {
|
||||||
|
const invoice = await this.findById(invoiceId, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate amounts with taxes using taxesService
|
||||||
|
// Determine transaction type based on invoice type
|
||||||
|
const transactionType = invoice.invoice_type === 'customer'
|
||||||
|
? 'sales'
|
||||||
|
: 'purchase';
|
||||||
|
|
||||||
|
const taxResult = await taxesService.calculateTaxes(
|
||||||
|
{
|
||||||
|
quantity: dto.quantity,
|
||||||
|
priceUnit: dto.price_unit,
|
||||||
|
discount: 0, // Invoices don't have line discounts by default
|
||||||
|
taxIds: dto.tax_ids || [],
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
transactionType
|
||||||
|
);
|
||||||
|
const amountUntaxed = taxResult.amountUntaxed;
|
||||||
|
const amountTax = taxResult.amountTax;
|
||||||
|
const amountTotal = taxResult.amountTotal;
|
||||||
|
|
||||||
|
const line = await queryOne<InvoiceLine>(
|
||||||
|
`INSERT INTO financial.invoice_lines (
|
||||||
|
invoice_id, tenant_id, product_id, description, quantity, uom_id,
|
||||||
|
price_unit, tax_ids, amount_untaxed, amount_tax, amount_total, account_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
invoiceId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id,
|
||||||
|
dto.price_unit, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.account_id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await this.updateTotals(invoiceId);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(invoiceId: string, lineId: string, dto: UpdateInvoiceLineDto, tenantId: string): Promise<InvoiceLine> {
|
||||||
|
const invoice = await this.findById(invoiceId, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas de facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = invoice.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea de factura no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const quantity = dto.quantity ?? existingLine.quantity;
|
||||||
|
const priceUnit = dto.price_unit ?? existingLine.price_unit;
|
||||||
|
|
||||||
|
if (dto.product_id !== undefined) {
|
||||||
|
updateFields.push(`product_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.product_id);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.quantity !== undefined) {
|
||||||
|
updateFields.push(`quantity = $${paramIndex++}`);
|
||||||
|
values.push(dto.quantity);
|
||||||
|
}
|
||||||
|
if (dto.uom_id !== undefined) {
|
||||||
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.uom_id);
|
||||||
|
}
|
||||||
|
if (dto.price_unit !== undefined) {
|
||||||
|
updateFields.push(`price_unit = $${paramIndex++}`);
|
||||||
|
values.push(dto.price_unit);
|
||||||
|
}
|
||||||
|
if (dto.tax_ids !== undefined) {
|
||||||
|
updateFields.push(`tax_ids = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_ids);
|
||||||
|
}
|
||||||
|
if (dto.account_id !== undefined) {
|
||||||
|
updateFields.push(`account_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.account_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate amounts
|
||||||
|
const amountUntaxed = quantity * priceUnit;
|
||||||
|
const amountTax = 0; // TODO: Calculate taxes
|
||||||
|
const amountTotal = amountUntaxed + amountTax;
|
||||||
|
|
||||||
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
||||||
|
values.push(amountUntaxed);
|
||||||
|
updateFields.push(`amount_tax = $${paramIndex++}`);
|
||||||
|
values.push(amountTax);
|
||||||
|
updateFields.push(`amount_total = $${paramIndex++}`);
|
||||||
|
values.push(amountTotal);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(lineId, invoiceId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoice_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND invoice_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await this.updateTotals(invoiceId);
|
||||||
|
|
||||||
|
const updated = await queryOne<InvoiceLine>(
|
||||||
|
`SELECT * FROM financial.invoice_lines WHERE id = $1`,
|
||||||
|
[lineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(invoiceId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const invoice = await this.findById(invoiceId, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas de facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.invoice_lines WHERE id = $1 AND invoice_id = $2`,
|
||||||
|
[lineId, invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await this.updateTotals(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(id: string, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden validar facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice.lines || invoice.lines.length === 0) {
|
||||||
|
throw new ValidationError('La factura debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate invoice number
|
||||||
|
const prefix = invoice.invoice_type === 'customer' ? 'INV' : 'BILL';
|
||||||
|
const seqResult = await queryOne<{ next_num: number }>(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(number FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM financial.invoices WHERE tenant_id = $1 AND number LIKE '${prefix}-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const invoiceNumber = `${prefix}-${String(seqResult?.next_num || 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
number = $1,
|
||||||
|
status = 'open',
|
||||||
|
amount_residual = amount_total,
|
||||||
|
validated_at = CURRENT_TIMESTAMP,
|
||||||
|
validated_by = $2,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[invoiceNumber, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status === 'paid') {
|
||||||
|
throw new ValidationError('No se pueden cancelar facturas pagadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La factura ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.amount_paid > 0) {
|
||||||
|
throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
status = 'cancelled',
|
||||||
|
cancelled_at = CURRENT_TIMESTAMP,
|
||||||
|
cancelled_by = $1,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTotals(invoiceId: string): Promise<void> {
|
||||||
|
const totals = await queryOne<{ amount_untaxed: number; amount_tax: number; amount_total: number }>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(amount_untaxed), 0) as amount_untaxed,
|
||||||
|
COALESCE(SUM(amount_tax), 0) as amount_tax,
|
||||||
|
COALESCE(SUM(amount_total), 0) as amount_total
|
||||||
|
FROM financial.invoice_lines WHERE invoice_id = $1`,
|
||||||
|
[invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
amount_untaxed = $1,
|
||||||
|
amount_tax = $2,
|
||||||
|
amount_total = $3,
|
||||||
|
amount_residual = $3 - amount_paid
|
||||||
|
WHERE id = $4`,
|
||||||
|
[totals?.amount_untaxed || 0, totals?.amount_tax || 0, totals?.amount_total || 0, invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invoicesService = new InvoicesService();
|
||||||
343
src/modules/financial/journal-entries.service.ts
Normal file
343
src/modules/financial/journal-entries.service.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type EntryStatus = 'draft' | 'posted' | 'cancelled';
|
||||||
|
|
||||||
|
export interface JournalEntryLine {
|
||||||
|
id?: string;
|
||||||
|
account_id: string;
|
||||||
|
account_name?: string;
|
||||||
|
account_code?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
partner_name?: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
description?: string;
|
||||||
|
ref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalEntry {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
journal_id: string;
|
||||||
|
journal_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
date: Date;
|
||||||
|
status: EntryStatus;
|
||||||
|
notes?: string;
|
||||||
|
lines?: JournalEntryLine[];
|
||||||
|
total_debit?: number;
|
||||||
|
total_credit?: number;
|
||||||
|
created_at: Date;
|
||||||
|
posted_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateJournalEntryDto {
|
||||||
|
company_id: string;
|
||||||
|
journal_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
date: string;
|
||||||
|
notes?: string;
|
||||||
|
lines: Omit<JournalEntryLine, 'id' | 'account_name' | 'account_code' | 'partner_name'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJournalEntryDto {
|
||||||
|
ref?: string | null;
|
||||||
|
date?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
lines?: Omit<JournalEntryLine, 'id' | 'account_name' | 'account_code' | 'partner_name'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalEntryFilters {
|
||||||
|
company_id?: string;
|
||||||
|
journal_id?: string;
|
||||||
|
status?: EntryStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class JournalEntriesService {
|
||||||
|
async findAll(tenantId: string, filters: JournalEntryFilters = {}): Promise<{ data: JournalEntry[]; total: number }> {
|
||||||
|
const { company_id, journal_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE je.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND je.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (journal_id) {
|
||||||
|
whereClause += ` AND je.journal_id = $${paramIndex++}`;
|
||||||
|
params.push(journal_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND je.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND je.date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND je.date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (je.name ILIKE $${paramIndex} OR je.ref ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journal_entries je ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<JournalEntry>(
|
||||||
|
`SELECT je.*,
|
||||||
|
c.name as company_name,
|
||||||
|
j.name as journal_name,
|
||||||
|
(SELECT COALESCE(SUM(debit), 0) FROM financial.journal_entry_lines WHERE entry_id = je.id) as total_debit,
|
||||||
|
(SELECT COALESCE(SUM(credit), 0) FROM financial.journal_entry_lines WHERE entry_id = je.id) as total_credit
|
||||||
|
FROM financial.journal_entries je
|
||||||
|
LEFT JOIN auth.companies c ON je.company_id = c.id
|
||||||
|
LEFT JOIN financial.journals j ON je.journal_id = j.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY je.date DESC, je.name DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<JournalEntry> {
|
||||||
|
const entry = await queryOne<JournalEntry>(
|
||||||
|
`SELECT je.*,
|
||||||
|
c.name as company_name,
|
||||||
|
j.name as journal_name
|
||||||
|
FROM financial.journal_entries je
|
||||||
|
LEFT JOIN auth.companies c ON je.company_id = c.id
|
||||||
|
LEFT JOIN financial.journals j ON je.journal_id = j.id
|
||||||
|
WHERE je.id = $1 AND je.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new NotFoundError('Póliza no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<JournalEntryLine>(
|
||||||
|
`SELECT jel.*,
|
||||||
|
a.name as account_name,
|
||||||
|
a.code as account_code,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM financial.journal_entry_lines jel
|
||||||
|
LEFT JOIN financial.accounts a ON jel.account_id = a.id
|
||||||
|
LEFT JOIN core.partners p ON jel.partner_id = p.id
|
||||||
|
WHERE jel.entry_id = $1
|
||||||
|
ORDER BY jel.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
entry.lines = lines;
|
||||||
|
entry.total_debit = lines.reduce((sum, l) => sum + Number(l.debit), 0);
|
||||||
|
entry.total_credit = lines.reduce((sum, l) => sum + Number(l.credit), 0);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateJournalEntryDto, tenantId: string, userId: string): Promise<JournalEntry> {
|
||||||
|
// Validate lines balance
|
||||||
|
const totalDebit = dto.lines.reduce((sum, l) => sum + l.debit, 0);
|
||||||
|
const totalCredit = dto.lines.reduce((sum, l) => sum + l.credit, 0);
|
||||||
|
|
||||||
|
if (Math.abs(totalDebit - totalCredit) > 0.01) {
|
||||||
|
throw new ValidationError('La póliza no está balanceada. Débitos y créditos deben ser iguales.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.lines.length < 2) {
|
||||||
|
throw new ValidationError('La póliza debe tener al menos 2 líneas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Create entry
|
||||||
|
const entryResult = await client.query(
|
||||||
|
`INSERT INTO financial.journal_entries (tenant_id, company_id, journal_id, name, ref, date, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.journal_id, dto.name, dto.ref, dto.date, dto.notes, userId]
|
||||||
|
);
|
||||||
|
const entry = entryResult.rows[0] as JournalEntry;
|
||||||
|
|
||||||
|
// Create lines (include tenant_id for multi-tenant security)
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO financial.journal_entry_lines (entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[entry.id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(entry.id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateJournalEntryDto, tenantId: string, userId: string): Promise<JournalEntry> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden modificar pólizas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Update entry header
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.date !== undefined) {
|
||||||
|
updateFields.push(`date = $${paramIndex++}`);
|
||||||
|
values.push(dto.date);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
if (updateFields.length > 2) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.journal_entries SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lines if provided
|
||||||
|
if (dto.lines) {
|
||||||
|
const totalDebit = dto.lines.reduce((sum, l) => sum + l.debit, 0);
|
||||||
|
const totalCredit = dto.lines.reduce((sum, l) => sum + l.credit, 0);
|
||||||
|
|
||||||
|
if (Math.abs(totalDebit - totalCredit) > 0.01) {
|
||||||
|
throw new ValidationError('La póliza no está balanceada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing lines
|
||||||
|
await client.query(`DELETE FROM financial.journal_entry_lines WHERE entry_id = $1`, [id]);
|
||||||
|
|
||||||
|
// Insert new lines (include tenant_id for multi-tenant security)
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO financial.journal_entry_lines (entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(id: string, tenantId: string, userId: string): Promise<JournalEntry> {
|
||||||
|
const entry = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (entry.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden publicar pólizas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate balance
|
||||||
|
if (Math.abs((entry.total_debit || 0) - (entry.total_credit || 0)) > 0.01) {
|
||||||
|
throw new ValidationError('La póliza no está balanceada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.journal_entries
|
||||||
|
SET status = 'posted', posted_at = CURRENT_TIMESTAMP, posted_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<JournalEntry> {
|
||||||
|
const entry = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (entry.status === 'cancelled') {
|
||||||
|
throw new ConflictError('La póliza ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.journal_entries
|
||||||
|
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const entry = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (entry.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden eliminar pólizas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM financial.journal_entries WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const journalEntriesService = new JournalEntriesService();
|
||||||
216
src/modules/financial/journals.service.ts
Normal file
216
src/modules/financial/journals.service.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
|
||||||
|
|
||||||
|
export interface Journal {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
journal_type: JournalType;
|
||||||
|
default_account_id?: string;
|
||||||
|
default_account_name?: string;
|
||||||
|
sequence_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateJournalDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
journal_type: JournalType;
|
||||||
|
default_account_id?: string;
|
||||||
|
sequence_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJournalDto {
|
||||||
|
name?: string;
|
||||||
|
default_account_id?: string | null;
|
||||||
|
sequence_id?: string | null;
|
||||||
|
currency_id?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalFilters {
|
||||||
|
company_id?: string;
|
||||||
|
journal_type?: JournalType;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class JournalsService {
|
||||||
|
async findAll(tenantId: string, filters: JournalFilters = {}): Promise<{ data: Journal[]; total: number }> {
|
||||||
|
const { company_id, journal_type, active, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE j.tenant_id = $1 AND j.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND j.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (journal_type) {
|
||||||
|
whereClause += ` AND j.journal_type = $${paramIndex++}`;
|
||||||
|
params.push(journal_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND j.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journals j ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Journal>(
|
||||||
|
`SELECT j.*,
|
||||||
|
c.name as company_name,
|
||||||
|
a.name as default_account_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.journals j
|
||||||
|
LEFT JOIN auth.companies c ON j.company_id = c.id
|
||||||
|
LEFT JOIN financial.accounts a ON j.default_account_id = a.id
|
||||||
|
LEFT JOIN core.currencies cur ON j.currency_id = cur.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY j.code
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Journal> {
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`SELECT j.*,
|
||||||
|
c.name as company_name,
|
||||||
|
a.name as default_account_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.journals j
|
||||||
|
LEFT JOIN auth.companies c ON j.company_id = c.id
|
||||||
|
LEFT JOIN financial.accounts a ON j.default_account_id = a.id
|
||||||
|
LEFT JOIN core.currencies cur ON j.currency_id = cur.id
|
||||||
|
WHERE j.id = $1 AND j.tenant_id = $2 AND j.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!journal) {
|
||||||
|
throw new NotFoundError('Diario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return journal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateJournalDto, tenantId: string, userId: string): Promise<Journal> {
|
||||||
|
// Validate unique code within company
|
||||||
|
const existing = await queryOne<Journal>(
|
||||||
|
`SELECT id FROM financial.journals WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe un diario con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`INSERT INTO financial.journals (tenant_id, company_id, name, code, journal_type, default_account_id, sequence_id, currency_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.company_id,
|
||||||
|
dto.name,
|
||||||
|
dto.code,
|
||||||
|
dto.journal_type,
|
||||||
|
dto.default_account_id,
|
||||||
|
dto.sequence_id,
|
||||||
|
dto.currency_id,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return journal!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateJournalDto, tenantId: string, userId: string): Promise<Journal> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.default_account_id !== undefined) {
|
||||||
|
updateFields.push(`default_account_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.default_account_id);
|
||||||
|
}
|
||||||
|
if (dto.sequence_id !== undefined) {
|
||||||
|
updateFields.push(`sequence_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence_id);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`UPDATE financial.journals
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return journal!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if journal has entries
|
||||||
|
const entries = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journal_entries WHERE journal_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (parseInt(entries?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un diario que tiene pólizas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.journals SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const journalsService = new JournalsService();
|
||||||
456
src/modules/financial/payments.service.ts
Normal file
456
src/modules/financial/payments.service.ts
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface PaymentInvoice {
|
||||||
|
invoice_id: string;
|
||||||
|
invoice_number?: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
payment_type: 'inbound' | 'outbound';
|
||||||
|
payment_method: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other';
|
||||||
|
amount: number;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
payment_date: Date;
|
||||||
|
ref?: string;
|
||||||
|
status: 'draft' | 'posted' | 'reconciled' | 'cancelled';
|
||||||
|
journal_id: string;
|
||||||
|
journal_name?: string;
|
||||||
|
journal_entry_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
invoices?: PaymentInvoice[];
|
||||||
|
created_at: Date;
|
||||||
|
posted_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePaymentDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
payment_type: 'inbound' | 'outbound';
|
||||||
|
payment_method: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other';
|
||||||
|
amount: number;
|
||||||
|
currency_id: string;
|
||||||
|
payment_date?: string;
|
||||||
|
ref?: string;
|
||||||
|
journal_id: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePaymentDto {
|
||||||
|
partner_id?: string;
|
||||||
|
payment_method?: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other';
|
||||||
|
amount?: number;
|
||||||
|
currency_id?: string;
|
||||||
|
payment_date?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
journal_id?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconcileDto {
|
||||||
|
invoices: { invoice_id: string; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
payment_type?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentsService {
|
||||||
|
async findAll(tenantId: string, filters: PaymentFilters = {}): Promise<{ data: Payment[]; total: number }> {
|
||||||
|
const { company_id, partner_id, payment_type, payment_method, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND p.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND p.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment_type) {
|
||||||
|
whereClause += ` AND p.payment_type = $${paramIndex++}`;
|
||||||
|
params.push(payment_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment_method) {
|
||||||
|
whereClause += ` AND p.payment_method = $${paramIndex++}`;
|
||||||
|
params.push(payment_method);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND p.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND p.payment_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND p.payment_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (p.ref ILIKE $${paramIndex} OR pr.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM financial.payments p
|
||||||
|
LEFT JOIN core.partners pr ON p.partner_id = pr.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Payment>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
pr.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
j.name as journal_name
|
||||||
|
FROM financial.payments p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.partners pr ON p.partner_id = pr.id
|
||||||
|
LEFT JOIN core.currencies cu ON p.currency_id = cu.id
|
||||||
|
LEFT JOIN financial.journals j ON p.journal_id = j.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.payment_date DESC, p.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Payment> {
|
||||||
|
const payment = await queryOne<Payment>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
pr.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
j.name as journal_name
|
||||||
|
FROM financial.payments p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.partners pr ON p.partner_id = pr.id
|
||||||
|
LEFT JOIN core.currencies cu ON p.currency_id = cu.id
|
||||||
|
LEFT JOIN financial.journals j ON p.journal_id = j.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
throw new NotFoundError('Pago no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reconciled invoices
|
||||||
|
const invoices = await query<PaymentInvoice>(
|
||||||
|
`SELECT pi.invoice_id, pi.amount, i.number as invoice_number
|
||||||
|
FROM financial.payment_invoice pi
|
||||||
|
LEFT JOIN financial.invoices i ON pi.invoice_id = i.id
|
||||||
|
WHERE pi.payment_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
payment.invoices = invoices;
|
||||||
|
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePaymentDto, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
if (dto.amount <= 0) {
|
||||||
|
throw new ValidationError('El monto debe ser mayor a 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentDate = dto.payment_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const payment = await queryOne<Payment>(
|
||||||
|
`INSERT INTO financial.payments (
|
||||||
|
tenant_id, company_id, partner_id, payment_type, payment_method,
|
||||||
|
amount, currency_id, payment_date, ref, journal_id, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.partner_id, dto.payment_type, dto.payment_method,
|
||||||
|
dto.amount, dto.currency_id, paymentDate, dto.ref, dto.journal_id, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return payment!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePaymentDto, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar pagos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_method !== undefined) {
|
||||||
|
updateFields.push(`payment_method = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_method);
|
||||||
|
}
|
||||||
|
if (dto.amount !== undefined) {
|
||||||
|
if (dto.amount <= 0) {
|
||||||
|
throw new ValidationError('El monto debe ser mayor a 0');
|
||||||
|
}
|
||||||
|
updateFields.push(`amount = $${paramIndex++}`);
|
||||||
|
values.push(dto.amount);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_date !== undefined) {
|
||||||
|
updateFields.push(`payment_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_date);
|
||||||
|
}
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.journal_id !== undefined) {
|
||||||
|
updateFields.push(`journal_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.journal_id);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.payments SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar pagos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.payments WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(id: string, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
const payment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (payment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden publicar pagos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.payments SET
|
||||||
|
status = 'posted',
|
||||||
|
posted_at = CURRENT_TIMESTAMP,
|
||||||
|
posted_by = $1,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcile(id: string, dto: ReconcileDto, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
const payment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (payment.status === 'draft') {
|
||||||
|
throw new ValidationError('Debe publicar el pago antes de conciliar');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.status === 'cancelled') {
|
||||||
|
throw new ValidationError('No se puede conciliar un pago cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate total amount matches
|
||||||
|
const totalReconciled = dto.invoices.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
if (totalReconciled > payment.amount) {
|
||||||
|
throw new ValidationError('El monto total conciliado excede el monto del pago');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Remove existing reconciliations
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM financial.payment_invoice WHERE payment_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new reconciliations
|
||||||
|
for (const inv of dto.invoices) {
|
||||||
|
// Validate invoice exists and belongs to same partner
|
||||||
|
const invoice = await client.query(
|
||||||
|
`SELECT id, partner_id, amount_residual, status FROM financial.invoices
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[inv.invoice_id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invoice.rows.length === 0) {
|
||||||
|
throw new ValidationError(`Factura ${inv.invoice_id} no encontrada`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.rows[0].partner_id !== payment.partner_id) {
|
||||||
|
throw new ValidationError('La factura debe pertenecer al mismo cliente/proveedor');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.rows[0].status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden conciliar facturas abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inv.amount > invoice.rows[0].amount_residual) {
|
||||||
|
throw new ValidationError(`El monto excede el saldo pendiente de la factura`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO financial.payment_invoice (payment_id, invoice_id, amount)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[id, inv.invoice_id, inv.amount]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update invoice amounts
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
amount_paid = amount_paid + $1,
|
||||||
|
amount_residual = amount_residual - $1,
|
||||||
|
status = CASE WHEN amount_residual - $1 <= 0 THEN 'paid'::financial.invoice_status ELSE status END
|
||||||
|
WHERE id = $2`,
|
||||||
|
[inv.amount, inv.invoice_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update payment status
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.payments SET
|
||||||
|
status = 'reconciled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
const payment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (payment.status === 'cancelled') {
|
||||||
|
throw new ValidationError('El pago ya está cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Reverse reconciliations if any
|
||||||
|
if (payment.invoices && payment.invoices.length > 0) {
|
||||||
|
for (const inv of payment.invoices) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
amount_paid = amount_paid - $1,
|
||||||
|
amount_residual = amount_residual + $1,
|
||||||
|
status = 'open'::financial.invoice_status
|
||||||
|
WHERE id = $2`,
|
||||||
|
[inv.amount, inv.invoice_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM financial.payment_invoice WHERE payment_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel payment
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.payments SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentsService = new PaymentsService();
|
||||||
382
src/modules/financial/taxes.service.ts
Normal file
382
src/modules/financial/taxes.service.ts
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Tax {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tax_type: 'sales' | 'purchase' | 'all';
|
||||||
|
amount: number;
|
||||||
|
included_in_price: boolean;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaxDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tax_type: 'sales' | 'purchase' | 'all';
|
||||||
|
amount: number;
|
||||||
|
included_in_price?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaxDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
tax_type?: 'sales' | 'purchase' | 'all';
|
||||||
|
amount?: number;
|
||||||
|
included_in_price?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxFilters {
|
||||||
|
company_id?: string;
|
||||||
|
tax_type?: string;
|
||||||
|
active?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaxesService {
|
||||||
|
async findAll(tenantId: string, filters: TaxFilters = {}): Promise<{ data: Tax[]; total: number }> {
|
||||||
|
const { company_id, tax_type, active, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE t.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND t.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tax_type) {
|
||||||
|
whereClause += ` AND t.tax_type = $${paramIndex++}`;
|
||||||
|
params.push(tax_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND t.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (t.name ILIKE $${paramIndex} OR t.code ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.taxes t ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Tax>(
|
||||||
|
`SELECT t.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM financial.taxes t
|
||||||
|
LEFT JOIN auth.companies c ON t.company_id = c.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Tax> {
|
||||||
|
const tax = await queryOne<Tax>(
|
||||||
|
`SELECT t.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM financial.taxes t
|
||||||
|
LEFT JOIN auth.companies c ON t.company_id = c.id
|
||||||
|
WHERE t.id = $1 AND t.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tax) {
|
||||||
|
throw new NotFoundError('Impuesto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tax;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateTaxDto, tenantId: string, userId: string): Promise<Tax> {
|
||||||
|
// Check unique code
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2`,
|
||||||
|
[tenantId, dto.code]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un impuesto con ese código');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tax = await queryOne<Tax>(
|
||||||
|
`INSERT INTO financial.taxes (
|
||||||
|
tenant_id, company_id, name, code, tax_type, amount, included_in_price, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.code, dto.tax_type,
|
||||||
|
dto.amount, dto.included_in_price ?? false, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return tax!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTaxDto, tenantId: string, userId: string): Promise<Tax> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.code !== undefined) {
|
||||||
|
// Check unique code
|
||||||
|
const existingCode = await queryOne(
|
||||||
|
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2 AND id != $3`,
|
||||||
|
[tenantId, dto.code, id]
|
||||||
|
);
|
||||||
|
if (existingCode) {
|
||||||
|
throw new ConflictError('Ya existe un impuesto con ese código');
|
||||||
|
}
|
||||||
|
updateFields.push(`code = $${paramIndex++}`);
|
||||||
|
values.push(dto.code);
|
||||||
|
}
|
||||||
|
if (dto.tax_type !== undefined) {
|
||||||
|
updateFields.push(`tax_type = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_type);
|
||||||
|
}
|
||||||
|
if (dto.amount !== undefined) {
|
||||||
|
updateFields.push(`amount = $${paramIndex++}`);
|
||||||
|
values.push(dto.amount);
|
||||||
|
}
|
||||||
|
if (dto.included_in_price !== undefined) {
|
||||||
|
updateFields.push(`included_in_price = $${paramIndex++}`);
|
||||||
|
values.push(dto.included_in_price);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.taxes SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if tax is used in any invoice lines
|
||||||
|
const usageCheck = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.invoice_lines
|
||||||
|
WHERE $1 = ANY(tax_ids)`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(usageCheck?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar: el impuesto está siendo usado en facturas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.taxes WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula impuestos para una linea de documento
|
||||||
|
* Sigue la logica de Odoo para calculos de IVA
|
||||||
|
*/
|
||||||
|
async calculateTaxes(
|
||||||
|
lineData: TaxCalculationInput,
|
||||||
|
tenantId: string,
|
||||||
|
transactionType: 'sales' | 'purchase' = 'sales'
|
||||||
|
): Promise<TaxCalculationResult> {
|
||||||
|
// Validar inputs
|
||||||
|
if (lineData.quantity <= 0 || lineData.priceUnit < 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed: 0,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: 0,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular subtotal antes de impuestos
|
||||||
|
const subtotal = lineData.quantity * lineData.priceUnit;
|
||||||
|
const discountAmount = subtotal * (lineData.discount || 0) / 100;
|
||||||
|
const amountUntaxed = subtotal - discountAmount;
|
||||||
|
|
||||||
|
// Si no hay impuestos, retornar solo el monto sin impuestos
|
||||||
|
if (!lineData.taxIds || lineData.taxIds.length === 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: amountUntaxed,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener impuestos de la BD
|
||||||
|
const taxResults = await query<Tax>(
|
||||||
|
`SELECT * FROM financial.taxes
|
||||||
|
WHERE id = ANY($1) AND tenant_id = $2 AND active = true
|
||||||
|
AND (tax_type = $3 OR tax_type = 'all')`,
|
||||||
|
[lineData.taxIds, tenantId, transactionType]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taxResults.length === 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: amountUntaxed,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular impuestos
|
||||||
|
const taxBreakdown: TaxBreakdownItem[] = [];
|
||||||
|
let totalTax = 0;
|
||||||
|
|
||||||
|
for (const tax of taxResults) {
|
||||||
|
let taxBase = amountUntaxed;
|
||||||
|
let taxAmount: number;
|
||||||
|
|
||||||
|
if (tax.included_in_price) {
|
||||||
|
// Precio incluye impuesto (IVA incluido)
|
||||||
|
// Base = Precio / (1 + tasa)
|
||||||
|
// Impuesto = Precio - Base
|
||||||
|
taxBase = amountUntaxed / (1 + tax.amount / 100);
|
||||||
|
taxAmount = amountUntaxed - taxBase;
|
||||||
|
} else {
|
||||||
|
// Precio sin impuesto (IVA añadido)
|
||||||
|
// Impuesto = Base * tasa
|
||||||
|
taxAmount = amountUntaxed * tax.amount / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
taxBreakdown.push({
|
||||||
|
taxId: tax.id,
|
||||||
|
taxName: tax.name,
|
||||||
|
taxCode: tax.code,
|
||||||
|
taxRate: tax.amount,
|
||||||
|
includedInPrice: tax.included_in_price,
|
||||||
|
base: Math.round(taxBase * 100) / 100,
|
||||||
|
taxAmount: Math.round(taxAmount * 100) / 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalTax += taxAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redondear a 2 decimales
|
||||||
|
const finalAmountTax = Math.round(totalTax * 100) / 100;
|
||||||
|
const finalAmountUntaxed = Math.round(amountUntaxed * 100) / 100;
|
||||||
|
const finalAmountTotal = Math.round((amountUntaxed + finalAmountTax) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountUntaxed: finalAmountUntaxed,
|
||||||
|
amountTax: finalAmountTax,
|
||||||
|
amountTotal: finalAmountTotal,
|
||||||
|
taxBreakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula impuestos para multiples lineas (ej: para totales de documento)
|
||||||
|
*/
|
||||||
|
async calculateDocumentTaxes(
|
||||||
|
lines: TaxCalculationInput[],
|
||||||
|
tenantId: string,
|
||||||
|
transactionType: 'sales' | 'purchase' = 'sales'
|
||||||
|
): Promise<TaxCalculationResult> {
|
||||||
|
let totalUntaxed = 0;
|
||||||
|
let totalTax = 0;
|
||||||
|
const allBreakdown: TaxBreakdownItem[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const result = await this.calculateTaxes(line, tenantId, transactionType);
|
||||||
|
totalUntaxed += result.amountUntaxed;
|
||||||
|
totalTax += result.amountTax;
|
||||||
|
allBreakdown.push(...result.taxBreakdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidar breakdown por impuesto
|
||||||
|
const consolidatedBreakdown = new Map<string, TaxBreakdownItem>();
|
||||||
|
for (const item of allBreakdown) {
|
||||||
|
const existing = consolidatedBreakdown.get(item.taxId);
|
||||||
|
if (existing) {
|
||||||
|
existing.base += item.base;
|
||||||
|
existing.taxAmount += item.taxAmount;
|
||||||
|
} else {
|
||||||
|
consolidatedBreakdown.set(item.taxId, { ...item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountUntaxed: Math.round(totalUntaxed * 100) / 100,
|
||||||
|
amountTax: Math.round(totalTax * 100) / 100,
|
||||||
|
amountTotal: Math.round((totalUntaxed + totalTax) * 100) / 100,
|
||||||
|
taxBreakdown: Array.from(consolidatedBreakdown.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces para calculo de impuestos
|
||||||
|
export interface TaxCalculationInput {
|
||||||
|
quantity: number;
|
||||||
|
priceUnit: number;
|
||||||
|
discount: number;
|
||||||
|
taxIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxBreakdownItem {
|
||||||
|
taxId: string;
|
||||||
|
taxName: string;
|
||||||
|
taxCode: string;
|
||||||
|
taxRate: number;
|
||||||
|
includedInPrice: boolean;
|
||||||
|
base: number;
|
||||||
|
taxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxCalculationResult {
|
||||||
|
amountUntaxed: number;
|
||||||
|
amountTax: number;
|
||||||
|
amountTotal: number;
|
||||||
|
taxBreakdown: TaxBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taxesService = new TaxesService();
|
||||||
346
src/modules/hr/contracts.service.ts
Normal file
346
src/modules/hr/contracts.service.ts
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled';
|
||||||
|
export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time';
|
||||||
|
|
||||||
|
export interface Contract {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
employee_id: string;
|
||||||
|
employee_name?: string;
|
||||||
|
employee_number?: string;
|
||||||
|
name: string;
|
||||||
|
reference?: string;
|
||||||
|
contract_type: ContractType;
|
||||||
|
status: ContractStatus;
|
||||||
|
job_position_id?: string;
|
||||||
|
job_position_name?: string;
|
||||||
|
department_id?: string;
|
||||||
|
department_name?: string;
|
||||||
|
date_start: Date;
|
||||||
|
date_end?: Date;
|
||||||
|
trial_date_end?: Date;
|
||||||
|
wage: number;
|
||||||
|
wage_type: string;
|
||||||
|
currency_id?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
hours_per_week: number;
|
||||||
|
vacation_days: number;
|
||||||
|
christmas_bonus_days: number;
|
||||||
|
document_url?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateContractDto {
|
||||||
|
company_id: string;
|
||||||
|
employee_id: string;
|
||||||
|
name: string;
|
||||||
|
reference?: string;
|
||||||
|
contract_type: ContractType;
|
||||||
|
job_position_id?: string;
|
||||||
|
department_id?: string;
|
||||||
|
date_start: string;
|
||||||
|
date_end?: string;
|
||||||
|
trial_date_end?: string;
|
||||||
|
wage: number;
|
||||||
|
wage_type?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
hours_per_week?: number;
|
||||||
|
vacation_days?: number;
|
||||||
|
christmas_bonus_days?: number;
|
||||||
|
document_url?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateContractDto {
|
||||||
|
reference?: string | null;
|
||||||
|
job_position_id?: string | null;
|
||||||
|
department_id?: string | null;
|
||||||
|
date_end?: string | null;
|
||||||
|
trial_date_end?: string | null;
|
||||||
|
wage?: number;
|
||||||
|
wage_type?: string;
|
||||||
|
currency_id?: string | null;
|
||||||
|
hours_per_week?: number;
|
||||||
|
vacation_days?: number;
|
||||||
|
christmas_bonus_days?: number;
|
||||||
|
document_url?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractFilters {
|
||||||
|
company_id?: string;
|
||||||
|
employee_id?: string;
|
||||||
|
status?: ContractStatus;
|
||||||
|
contract_type?: ContractType;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractsService {
|
||||||
|
async findAll(tenantId: string, filters: ContractFilters = {}): Promise<{ data: Contract[]; total: number }> {
|
||||||
|
const { company_id, employee_id, status, contract_type, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE c.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND c.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee_id) {
|
||||||
|
whereClause += ` AND c.employee_id = $${paramIndex++}`;
|
||||||
|
params.push(employee_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND c.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract_type) {
|
||||||
|
whereClause += ` AND c.contract_type = $${paramIndex++}`;
|
||||||
|
params.push(contract_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (c.name ILIKE $${paramIndex} OR c.reference ILIKE $${paramIndex} OR e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM hr.contracts c
|
||||||
|
LEFT JOIN hr.employees e ON c.employee_id = e.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Contract>(
|
||||||
|
`SELECT c.*,
|
||||||
|
co.name as company_name,
|
||||||
|
CONCAT(e.first_name, ' ', e.last_name) as employee_name,
|
||||||
|
e.employee_number,
|
||||||
|
j.name as job_position_name,
|
||||||
|
d.name as department_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM hr.contracts c
|
||||||
|
LEFT JOIN auth.companies co ON c.company_id = co.id
|
||||||
|
LEFT JOIN hr.employees e ON c.employee_id = e.id
|
||||||
|
LEFT JOIN hr.job_positions j ON c.job_position_id = j.id
|
||||||
|
LEFT JOIN hr.departments d ON c.department_id = d.id
|
||||||
|
LEFT JOIN core.currencies cu ON c.currency_id = cu.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY c.date_start DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Contract> {
|
||||||
|
const contract = await queryOne<Contract>(
|
||||||
|
`SELECT c.*,
|
||||||
|
co.name as company_name,
|
||||||
|
CONCAT(e.first_name, ' ', e.last_name) as employee_name,
|
||||||
|
e.employee_number,
|
||||||
|
j.name as job_position_name,
|
||||||
|
d.name as department_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM hr.contracts c
|
||||||
|
LEFT JOIN auth.companies co ON c.company_id = co.id
|
||||||
|
LEFT JOIN hr.employees e ON c.employee_id = e.id
|
||||||
|
LEFT JOIN hr.job_positions j ON c.job_position_id = j.id
|
||||||
|
LEFT JOIN hr.departments d ON c.department_id = d.id
|
||||||
|
LEFT JOIN core.currencies cu ON c.currency_id = cu.id
|
||||||
|
WHERE c.id = $1 AND c.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!contract) {
|
||||||
|
throw new NotFoundError('Contrato no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateContractDto, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
// Check if employee has an active contract
|
||||||
|
const activeContract = await queryOne(
|
||||||
|
`SELECT id FROM hr.contracts WHERE employee_id = $1 AND status = 'active'`,
|
||||||
|
[dto.employee_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeContract) {
|
||||||
|
throw new ValidationError('El empleado ya tiene un contrato activo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await queryOne<Contract>(
|
||||||
|
`INSERT INTO hr.contracts (
|
||||||
|
tenant_id, company_id, employee_id, name, reference, contract_type,
|
||||||
|
job_position_id, department_id, date_start, date_end, trial_date_end,
|
||||||
|
wage, wage_type, currency_id, hours_per_week, vacation_days, christmas_bonus_days,
|
||||||
|
document_url, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.employee_id, dto.name, dto.reference, dto.contract_type,
|
||||||
|
dto.job_position_id, dto.department_id, dto.date_start, dto.date_end, dto.trial_date_end,
|
||||||
|
dto.wage, dto.wage_type || 'monthly', dto.currency_id, dto.hours_per_week || 40,
|
||||||
|
dto.vacation_days || 6, dto.christmas_bonus_days || 15, dto.document_url, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(contract!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateContractDto, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar contratos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'reference', 'job_position_id', 'department_id', 'date_end', 'trial_date_end',
|
||||||
|
'wage', 'wage_type', 'currency_id', 'hours_per_week', 'vacation_days',
|
||||||
|
'christmas_bonus_days', 'document_url', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(id: string, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
const contract = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (contract.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden activar contratos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if employee has another active contract
|
||||||
|
const activeContract = await queryOne(
|
||||||
|
`SELECT id FROM hr.contracts WHERE employee_id = $1 AND status = 'active' AND id != $2`,
|
||||||
|
[contract.employee_id, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeContract) {
|
||||||
|
throw new ValidationError('El empleado ya tiene otro contrato activo');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET
|
||||||
|
status = 'active',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update employee department and position if specified
|
||||||
|
if (contract.department_id || contract.job_position_id) {
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET
|
||||||
|
department_id = COALESCE($1, department_id),
|
||||||
|
job_position_id = COALESCE($2, job_position_id),
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4`,
|
||||||
|
[contract.department_id, contract.job_position_id, userId, contract.employee_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(id: string, terminationDate: string, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
const contract = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (contract.status !== 'active') {
|
||||||
|
throw new ValidationError('Solo se pueden terminar contratos activos');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET
|
||||||
|
status = 'terminated',
|
||||||
|
date_end = $1,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[terminationDate, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
const contract = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (contract.status === 'active' || contract.status === 'terminated') {
|
||||||
|
throw new ValidationError('No se puede cancelar un contrato activo o terminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const contract = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (contract.status !== 'draft' && contract.status !== 'cancelled') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar contratos en borrador o cancelados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.contracts WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contractsService = new ContractsService();
|
||||||
393
src/modules/hr/departments.service.ts
Normal file
393
src/modules/hr/departments.service.ts
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
manager_name?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
active: boolean;
|
||||||
|
employee_count?: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDepartmentDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDepartmentDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
manager_id?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepartmentFilters {
|
||||||
|
company_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job Position interfaces
|
||||||
|
export interface JobPosition {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
department_id?: string;
|
||||||
|
department_name?: string;
|
||||||
|
description?: string;
|
||||||
|
requirements?: string;
|
||||||
|
responsibilities?: string;
|
||||||
|
min_salary?: number;
|
||||||
|
max_salary?: number;
|
||||||
|
active: boolean;
|
||||||
|
employee_count?: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateJobPositionDto {
|
||||||
|
name: string;
|
||||||
|
department_id?: string;
|
||||||
|
description?: string;
|
||||||
|
requirements?: string;
|
||||||
|
responsibilities?: string;
|
||||||
|
min_salary?: number;
|
||||||
|
max_salary?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJobPositionDto {
|
||||||
|
name?: string;
|
||||||
|
department_id?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
requirements?: string | null;
|
||||||
|
responsibilities?: string | null;
|
||||||
|
min_salary?: number | null;
|
||||||
|
max_salary?: number | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DepartmentsService {
|
||||||
|
// ========== DEPARTMENTS ==========
|
||||||
|
|
||||||
|
async findAll(tenantId: string, filters: DepartmentFilters = {}): Promise<{ data: Department[]; total: number }> {
|
||||||
|
const { company_id, active, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE d.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND d.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND d.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (d.name ILIKE $${paramIndex} OR d.code ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.departments d ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Department>(
|
||||||
|
`SELECT d.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as parent_name,
|
||||||
|
CONCAT(m.first_name, ' ', m.last_name) as manager_name,
|
||||||
|
COALESCE(ec.employee_count, 0) as employee_count
|
||||||
|
FROM hr.departments d
|
||||||
|
LEFT JOIN auth.companies c ON d.company_id = c.id
|
||||||
|
LEFT JOIN hr.departments p ON d.parent_id = p.id
|
||||||
|
LEFT JOIN hr.employees m ON d.manager_id = m.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT department_id, COUNT(*) as employee_count
|
||||||
|
FROM hr.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY department_id
|
||||||
|
) ec ON d.id = ec.department_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY d.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Department> {
|
||||||
|
const department = await queryOne<Department>(
|
||||||
|
`SELECT d.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as parent_name,
|
||||||
|
CONCAT(m.first_name, ' ', m.last_name) as manager_name,
|
||||||
|
COALESCE(ec.employee_count, 0) as employee_count
|
||||||
|
FROM hr.departments d
|
||||||
|
LEFT JOIN auth.companies c ON d.company_id = c.id
|
||||||
|
LEFT JOIN hr.departments p ON d.parent_id = p.id
|
||||||
|
LEFT JOIN hr.employees m ON d.manager_id = m.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT department_id, COUNT(*) as employee_count
|
||||||
|
FROM hr.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY department_id
|
||||||
|
) ec ON d.id = ec.department_id
|
||||||
|
WHERE d.id = $1 AND d.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!department) {
|
||||||
|
throw new NotFoundError('Departamento no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return department;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateDepartmentDto, tenantId: string, userId: string): Promise<Department> {
|
||||||
|
// Check unique name within company
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM hr.departments WHERE name = $1 AND company_id = $2 AND tenant_id = $3`,
|
||||||
|
[dto.name, dto.company_id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un departamento con ese nombre en esta empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
const department = await queryOne<Department>(
|
||||||
|
`INSERT INTO hr.departments (tenant_id, company_id, name, code, parent_id, manager_id, description, color, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.code, dto.parent_id, dto.manager_id, dto.description, dto.color, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(department!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateDepartmentDto, tenantId: string): Promise<Department> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check unique name if changing
|
||||||
|
if (dto.name && dto.name !== existing.name) {
|
||||||
|
const nameExists = await queryOne(
|
||||||
|
`SELECT id FROM hr.departments WHERE name = $1 AND company_id = $2 AND tenant_id = $3 AND id != $4`,
|
||||||
|
[dto.name, existing.company_id, tenantId, id]
|
||||||
|
);
|
||||||
|
if (nameExists) {
|
||||||
|
throw new ConflictError('Ya existe un departamento con ese nombre en esta empresa');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = ['name', 'code', 'parent_id', 'manager_id', 'description', 'color', 'active'];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.departments SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if department has employees
|
||||||
|
const hasEmployees = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.employees WHERE department_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasEmployees?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un departamento con empleados asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if department has children
|
||||||
|
const hasChildren = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.departments WHERE parent_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasChildren?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un departamento con subdepartamentos');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.departments WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JOB POSITIONS ==========
|
||||||
|
|
||||||
|
async getJobPositions(tenantId: string, includeInactive = false): Promise<JobPosition[]> {
|
||||||
|
let whereClause = 'WHERE j.tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND j.active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<JobPosition>(
|
||||||
|
`SELECT j.*,
|
||||||
|
d.name as department_name,
|
||||||
|
COALESCE(ec.employee_count, 0) as employee_count
|
||||||
|
FROM hr.job_positions j
|
||||||
|
LEFT JOIN hr.departments d ON j.department_id = d.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT job_position_id, COUNT(*) as employee_count
|
||||||
|
FROM hr.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY job_position_id
|
||||||
|
) ec ON j.id = ec.job_position_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY j.name`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJobPositionById(id: string, tenantId: string): Promise<JobPosition> {
|
||||||
|
const position = await queryOne<JobPosition>(
|
||||||
|
`SELECT j.*,
|
||||||
|
d.name as department_name,
|
||||||
|
COALESCE(ec.employee_count, 0) as employee_count
|
||||||
|
FROM hr.job_positions j
|
||||||
|
LEFT JOIN hr.departments d ON j.department_id = d.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT job_position_id, COUNT(*) as employee_count
|
||||||
|
FROM hr.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY job_position_id
|
||||||
|
) ec ON j.id = ec.job_position_id
|
||||||
|
WHERE j.id = $1 AND j.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
throw new NotFoundError('Puesto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJobPosition(dto: CreateJobPositionDto, tenantId: string): Promise<JobPosition> {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM hr.job_positions WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un puesto con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = await queryOne<JobPosition>(
|
||||||
|
`INSERT INTO hr.job_positions (tenant_id, name, department_id, description, requirements, responsibilities, min_salary, max_salary)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.department_id, dto.description, dto.requirements, dto.responsibilities, dto.min_salary, dto.max_salary]
|
||||||
|
);
|
||||||
|
|
||||||
|
return position!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJobPosition(id: string, dto: UpdateJobPositionDto, tenantId: string): Promise<JobPosition> {
|
||||||
|
const existing = await this.getJobPositionById(id, tenantId);
|
||||||
|
|
||||||
|
if (dto.name && dto.name !== existing.name) {
|
||||||
|
const nameExists = await queryOne(
|
||||||
|
`SELECT id FROM hr.job_positions WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (nameExists) {
|
||||||
|
throw new ConflictError('Ya existe un puesto con ese nombre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = ['name', 'department_id', 'description', 'requirements', 'responsibilities', 'min_salary', 'max_salary', 'active'];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.job_positions SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getJobPositionById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJobPosition(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getJobPositionById(id, tenantId);
|
||||||
|
|
||||||
|
const hasEmployees = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.employees WHERE job_position_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasEmployees?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un puesto con empleados asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.job_positions WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const departmentsService = new DepartmentsService();
|
||||||
402
src/modules/hr/employees.service.ts
Normal file
402
src/modules/hr/employees.service.ts
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type EmployeeStatus = 'active' | 'inactive' | 'on_leave' | 'terminated';
|
||||||
|
|
||||||
|
export interface Employee {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
employee_number: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
middle_name?: string;
|
||||||
|
full_name?: string;
|
||||||
|
user_id?: string;
|
||||||
|
birth_date?: Date;
|
||||||
|
gender?: string;
|
||||||
|
marital_status?: string;
|
||||||
|
nationality?: string;
|
||||||
|
identification_id?: string;
|
||||||
|
identification_type?: string;
|
||||||
|
social_security_number?: string;
|
||||||
|
tax_id?: string;
|
||||||
|
email?: string;
|
||||||
|
work_email?: string;
|
||||||
|
phone?: string;
|
||||||
|
work_phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
emergency_contact?: string;
|
||||||
|
emergency_phone?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
department_id?: string;
|
||||||
|
department_name?: string;
|
||||||
|
job_position_id?: string;
|
||||||
|
job_position_name?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
manager_name?: string;
|
||||||
|
hire_date: Date;
|
||||||
|
termination_date?: Date;
|
||||||
|
status: EmployeeStatus;
|
||||||
|
bank_name?: string;
|
||||||
|
bank_account?: string;
|
||||||
|
bank_clabe?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmployeeDto {
|
||||||
|
company_id: string;
|
||||||
|
employee_number: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
middle_name?: string;
|
||||||
|
user_id?: string;
|
||||||
|
birth_date?: string;
|
||||||
|
gender?: string;
|
||||||
|
marital_status?: string;
|
||||||
|
nationality?: string;
|
||||||
|
identification_id?: string;
|
||||||
|
identification_type?: string;
|
||||||
|
social_security_number?: string;
|
||||||
|
tax_id?: string;
|
||||||
|
email?: string;
|
||||||
|
work_email?: string;
|
||||||
|
phone?: string;
|
||||||
|
work_phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
emergency_contact?: string;
|
||||||
|
emergency_phone?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
department_id?: string;
|
||||||
|
job_position_id?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
hire_date: string;
|
||||||
|
bank_name?: string;
|
||||||
|
bank_account?: string;
|
||||||
|
bank_clabe?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmployeeDto {
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
middle_name?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
birth_date?: string | null;
|
||||||
|
gender?: string | null;
|
||||||
|
marital_status?: string | null;
|
||||||
|
nationality?: string | null;
|
||||||
|
identification_id?: string | null;
|
||||||
|
identification_type?: string | null;
|
||||||
|
social_security_number?: string | null;
|
||||||
|
tax_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
work_email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
work_phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
emergency_contact?: string | null;
|
||||||
|
emergency_phone?: string | null;
|
||||||
|
street?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
department_id?: string | null;
|
||||||
|
job_position_id?: string | null;
|
||||||
|
manager_id?: string | null;
|
||||||
|
bank_name?: string | null;
|
||||||
|
bank_account?: string | null;
|
||||||
|
bank_clabe?: string | null;
|
||||||
|
photo_url?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeFilters {
|
||||||
|
company_id?: string;
|
||||||
|
department_id?: string;
|
||||||
|
status?: EmployeeStatus;
|
||||||
|
manager_id?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmployeesService {
|
||||||
|
async findAll(tenantId: string, filters: EmployeeFilters = {}): Promise<{ data: Employee[]; total: number }> {
|
||||||
|
const { company_id, department_id, status, manager_id, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE e.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND e.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (department_id) {
|
||||||
|
whereClause += ` AND e.department_id = $${paramIndex++}`;
|
||||||
|
params.push(department_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND e.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manager_id) {
|
||||||
|
whereClause += ` AND e.manager_id = $${paramIndex++}`;
|
||||||
|
params.push(manager_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex} OR e.employee_number ILIKE $${paramIndex} OR e.email ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.employees e ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Employee>(
|
||||||
|
`SELECT e.*,
|
||||||
|
CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name,
|
||||||
|
c.name as company_name,
|
||||||
|
d.name as department_name,
|
||||||
|
j.name as job_position_name,
|
||||||
|
CONCAT(m.first_name, ' ', m.last_name) as manager_name
|
||||||
|
FROM hr.employees e
|
||||||
|
LEFT JOIN auth.companies c ON e.company_id = c.id
|
||||||
|
LEFT JOIN hr.departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN hr.job_positions j ON e.job_position_id = j.id
|
||||||
|
LEFT JOIN hr.employees m ON e.manager_id = m.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY e.last_name, e.first_name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Employee> {
|
||||||
|
const employee = await queryOne<Employee>(
|
||||||
|
`SELECT e.*,
|
||||||
|
CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name,
|
||||||
|
c.name as company_name,
|
||||||
|
d.name as department_name,
|
||||||
|
j.name as job_position_name,
|
||||||
|
CONCAT(m.first_name, ' ', m.last_name) as manager_name
|
||||||
|
FROM hr.employees e
|
||||||
|
LEFT JOIN auth.companies c ON e.company_id = c.id
|
||||||
|
LEFT JOIN hr.departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN hr.job_positions j ON e.job_position_id = j.id
|
||||||
|
LEFT JOIN hr.employees m ON e.manager_id = m.id
|
||||||
|
WHERE e.id = $1 AND e.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
throw new NotFoundError('Empleado no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateEmployeeDto, tenantId: string, userId: string): Promise<Employee> {
|
||||||
|
// Check unique employee number
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM hr.employees WHERE employee_number = $1 AND tenant_id = $2`,
|
||||||
|
[dto.employee_number, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un empleado con ese numero');
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await queryOne<Employee>(
|
||||||
|
`INSERT INTO hr.employees (
|
||||||
|
tenant_id, company_id, employee_number, first_name, last_name, middle_name,
|
||||||
|
user_id, birth_date, gender, marital_status, nationality, identification_id,
|
||||||
|
identification_type, social_security_number, tax_id, email, work_email,
|
||||||
|
phone, work_phone, mobile, emergency_contact, emergency_phone, street, city,
|
||||||
|
state, zip, country, department_id, job_position_id, manager_id, hire_date,
|
||||||
|
bank_name, bank_account, bank_clabe, photo_url, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
|
||||||
|
$17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
|
||||||
|
$31, $32, $33, $34, $35, $36, $37)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.employee_number, dto.first_name, dto.last_name,
|
||||||
|
dto.middle_name, dto.user_id, dto.birth_date, dto.gender, dto.marital_status,
|
||||||
|
dto.nationality, dto.identification_id, dto.identification_type,
|
||||||
|
dto.social_security_number, dto.tax_id, dto.email, dto.work_email, dto.phone,
|
||||||
|
dto.work_phone, dto.mobile, dto.emergency_contact, dto.emergency_phone,
|
||||||
|
dto.street, dto.city, dto.state, dto.zip, dto.country, dto.department_id,
|
||||||
|
dto.job_position_id, dto.manager_id, dto.hire_date, dto.bank_name,
|
||||||
|
dto.bank_account, dto.bank_clabe, dto.photo_url, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(employee!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateEmployeeDto, tenantId: string, userId: string): Promise<Employee> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'first_name', 'last_name', 'middle_name', 'user_id', 'birth_date', 'gender',
|
||||||
|
'marital_status', 'nationality', 'identification_id', 'identification_type',
|
||||||
|
'social_security_number', 'tax_id', 'email', 'work_email', 'phone', 'work_phone',
|
||||||
|
'mobile', 'emergency_contact', 'emergency_phone', 'street', 'city', 'state',
|
||||||
|
'zip', 'country', 'department_id', 'job_position_id', 'manager_id',
|
||||||
|
'bank_name', 'bank_account', 'bank_clabe', 'photo_url', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(id: string, terminationDate: string, tenantId: string, userId: string): Promise<Employee> {
|
||||||
|
const employee = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (employee.status === 'terminated') {
|
||||||
|
throw new ValidationError('El empleado ya esta dado de baja');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET
|
||||||
|
status = 'terminated',
|
||||||
|
termination_date = $1,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[terminationDate, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also terminate active contracts
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET
|
||||||
|
status = 'terminated',
|
||||||
|
date_end = $1,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE employee_id = $3 AND status = 'active'`,
|
||||||
|
[terminationDate, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivate(id: string, tenantId: string, userId: string): Promise<Employee> {
|
||||||
|
const employee = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (employee.status !== 'terminated' && employee.status !== 'inactive') {
|
||||||
|
throw new ValidationError('Solo se pueden reactivar empleados inactivos o dados de baja');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET
|
||||||
|
status = 'active',
|
||||||
|
termination_date = NULL,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const employee = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if employee has contracts
|
||||||
|
const hasContracts = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.contracts WHERE employee_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasContracts?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un empleado con contratos asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if employee is a manager
|
||||||
|
const isManager = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.employees WHERE manager_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(isManager?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un empleado que es manager de otros');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.employees WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subordinates
|
||||||
|
async getSubordinates(id: string, tenantId: string): Promise<Employee[]> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
return query<Employee>(
|
||||||
|
`SELECT e.*,
|
||||||
|
CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name,
|
||||||
|
d.name as department_name,
|
||||||
|
j.name as job_position_name
|
||||||
|
FROM hr.employees e
|
||||||
|
LEFT JOIN hr.departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN hr.job_positions j ON e.job_position_id = j.id
|
||||||
|
WHERE e.manager_id = $1 AND e.tenant_id = $2
|
||||||
|
ORDER BY e.last_name, e.first_name`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const employeesService = new EmployeesService();
|
||||||
721
src/modules/hr/hr.controller.ts
Normal file
721
src/modules/hr/hr.controller.ts
Normal file
@ -0,0 +1,721 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { employeesService, CreateEmployeeDto, UpdateEmployeeDto, EmployeeFilters } from './employees.service.js';
|
||||||
|
import { departmentsService, CreateDepartmentDto, UpdateDepartmentDto, DepartmentFilters, CreateJobPositionDto, UpdateJobPositionDto } from './departments.service.js';
|
||||||
|
import { contractsService, CreateContractDto, UpdateContractDto, ContractFilters } from './contracts.service.js';
|
||||||
|
import { leavesService, CreateLeaveDto, UpdateLeaveDto, LeaveFilters, CreateLeaveTypeDto, UpdateLeaveTypeDto } from './leaves.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Employee schemas
|
||||||
|
const createEmployeeSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
employee_number: z.string().min(1).max(50),
|
||||||
|
first_name: z.string().min(1).max(100),
|
||||||
|
last_name: z.string().min(1).max(100),
|
||||||
|
middle_name: z.string().max(100).optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
birth_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
gender: z.string().max(20).optional(),
|
||||||
|
marital_status: z.string().max(20).optional(),
|
||||||
|
nationality: z.string().max(100).optional(),
|
||||||
|
identification_id: z.string().max(50).optional(),
|
||||||
|
identification_type: z.string().max(50).optional(),
|
||||||
|
social_security_number: z.string().max(50).optional(),
|
||||||
|
tax_id: z.string().max(50).optional(),
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
work_email: z.string().email().max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
work_phone: z.string().max(50).optional(),
|
||||||
|
mobile: z.string().max(50).optional(),
|
||||||
|
emergency_contact: z.string().max(255).optional(),
|
||||||
|
emergency_phone: z.string().max(50).optional(),
|
||||||
|
street: z.string().max(255).optional(),
|
||||||
|
city: z.string().max(100).optional(),
|
||||||
|
state: z.string().max(100).optional(),
|
||||||
|
zip: z.string().max(20).optional(),
|
||||||
|
country: z.string().max(100).optional(),
|
||||||
|
department_id: z.string().uuid().optional(),
|
||||||
|
job_position_id: z.string().uuid().optional(),
|
||||||
|
manager_id: z.string().uuid().optional(),
|
||||||
|
hire_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
bank_name: z.string().max(100).optional(),
|
||||||
|
bank_account: z.string().max(50).optional(),
|
||||||
|
bank_clabe: z.string().max(20).optional(),
|
||||||
|
photo_url: z.string().url().max(500).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateEmployeeSchema = createEmployeeSchema.partial().omit({ company_id: true, employee_number: true, hire_date: true });
|
||||||
|
|
||||||
|
const employeeQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
department_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['active', 'inactive', 'on_leave', 'terminated']).optional(),
|
||||||
|
manager_id: z.string().uuid().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Department schemas
|
||||||
|
const createDepartmentSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
code: z.string().max(20).optional(),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
manager_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
color: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDepartmentSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
code: z.string().max(20).optional().nullable(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
manager_id: z.string().uuid().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
color: z.string().max(20).optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const departmentQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Job Position schemas
|
||||||
|
const createJobPositionSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
department_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
requirements: z.string().optional(),
|
||||||
|
responsibilities: z.string().optional(),
|
||||||
|
min_salary: z.number().min(0).optional(),
|
||||||
|
max_salary: z.number().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateJobPositionSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
department_id: z.string().uuid().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
requirements: z.string().optional().nullable(),
|
||||||
|
responsibilities: z.string().optional().nullable(),
|
||||||
|
min_salary: z.number().min(0).optional().nullable(),
|
||||||
|
max_salary: z.number().min(0).optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contract schemas
|
||||||
|
const createContractSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
employee_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
reference: z.string().max(100).optional(),
|
||||||
|
contract_type: z.enum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']),
|
||||||
|
job_position_id: z.string().uuid().optional(),
|
||||||
|
department_id: z.string().uuid().optional(),
|
||||||
|
date_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
trial_date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
wage: z.number().min(0),
|
||||||
|
wage_type: z.string().max(20).optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
hours_per_week: z.number().min(0).max(168).optional(),
|
||||||
|
vacation_days: z.number().int().min(0).optional(),
|
||||||
|
christmas_bonus_days: z.number().int().min(0).optional(),
|
||||||
|
document_url: z.string().url().max(500).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateContractSchema = z.object({
|
||||||
|
reference: z.string().max(100).optional().nullable(),
|
||||||
|
job_position_id: z.string().uuid().optional().nullable(),
|
||||||
|
department_id: z.string().uuid().optional().nullable(),
|
||||||
|
date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
trial_date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
wage: z.number().min(0).optional(),
|
||||||
|
wage_type: z.string().max(20).optional(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
hours_per_week: z.number().min(0).max(168).optional(),
|
||||||
|
vacation_days: z.number().int().min(0).optional(),
|
||||||
|
christmas_bonus_days: z.number().int().min(0).optional(),
|
||||||
|
document_url: z.string().url().max(500).optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contractQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
employee_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'active', 'expired', 'terminated', 'cancelled']).optional(),
|
||||||
|
contract_type: z.enum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']).optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave Type schemas
|
||||||
|
const createLeaveTypeSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
code: z.string().max(20).optional(),
|
||||||
|
leave_type: z.enum(['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other']),
|
||||||
|
requires_approval: z.boolean().optional(),
|
||||||
|
max_days: z.number().int().min(1).optional(),
|
||||||
|
is_paid: z.boolean().optional(),
|
||||||
|
color: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLeaveTypeSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
code: z.string().max(20).optional().nullable(),
|
||||||
|
requires_approval: z.boolean().optional(),
|
||||||
|
max_days: z.number().int().min(1).optional().nullable(),
|
||||||
|
is_paid: z.boolean().optional(),
|
||||||
|
color: z.string().max(20).optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave schemas
|
||||||
|
const createLeaveSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
employee_id: z.string().uuid(),
|
||||||
|
leave_type_id: z.string().uuid(),
|
||||||
|
name: z.string().max(255).optional(),
|
||||||
|
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLeaveSchema = z.object({
|
||||||
|
leave_type_id: z.string().uuid().optional(),
|
||||||
|
name: z.string().max(255).optional().nullable(),
|
||||||
|
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaveQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
employee_id: z.string().uuid().optional(),
|
||||||
|
leave_type_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'submitted', 'approved', 'rejected', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const terminateSchema = z.object({
|
||||||
|
termination_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectSchema = z.object({
|
||||||
|
reason: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
class HrController {
|
||||||
|
// ========== EMPLOYEES ==========
|
||||||
|
|
||||||
|
async getEmployees(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = employeeQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: EmployeeFilters = queryResult.data;
|
||||||
|
const result = await employeesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const employee = await employeesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: employee });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createEmployeeSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de empleado invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateEmployeeDto = parseResult.data;
|
||||||
|
const employee = await employeesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: employee, message: 'Empleado creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateEmployeeSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de empleado invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateEmployeeDto = parseResult.data;
|
||||||
|
const employee = await employeesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: employee, message: 'Empleado actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = terminateSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await employeesService.terminate(req.params.id, parseResult.data.termination_date, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: employee, message: 'Empleado dado de baja exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const employee = await employeesService.reactivate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: employee, message: 'Empleado reactivado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await employeesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Empleado eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubordinates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const subordinates = await employeesService.getSubordinates(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: subordinates });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DEPARTMENTS ==========
|
||||||
|
|
||||||
|
async getDepartments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = departmentQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: DepartmentFilters = queryResult.data;
|
||||||
|
const result = await departmentsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const department = await departmentsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: department });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createDepartmentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de departamento invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateDepartmentDto = parseResult.data;
|
||||||
|
const department = await departmentsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: department, message: 'Departamento creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateDepartmentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de departamento invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateDepartmentDto = parseResult.data;
|
||||||
|
const department = await departmentsService.update(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({ success: true, data: department, message: 'Departamento actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await departmentsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Departamento eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JOB POSITIONS ==========
|
||||||
|
|
||||||
|
async getJobPositions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const positions = await departmentsService.getJobPositions(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: positions });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createJobPositionSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de puesto invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateJobPositionDto = parseResult.data;
|
||||||
|
const position = await departmentsService.createJobPosition(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: position, message: 'Puesto creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateJobPositionSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de puesto invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateJobPositionDto = parseResult.data;
|
||||||
|
const position = await departmentsService.updateJobPosition(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({ success: true, data: position, message: 'Puesto actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await departmentsService.deleteJobPosition(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Puesto eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CONTRACTS ==========
|
||||||
|
|
||||||
|
async getContracts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = contractQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: ContractFilters = queryResult.data;
|
||||||
|
const result = await contractsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contract = await contractsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: contract });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createContractSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de contrato invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateContractDto = parseResult.data;
|
||||||
|
const contract = await contractsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: contract, message: 'Contrato creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateContractSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de contrato invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateContractDto = parseResult.data;
|
||||||
|
const contract = await contractsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: contract, message: 'Contrato actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contract = await contractsService.activate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: contract, message: 'Contrato activado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = terminateSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await contractsService.terminate(req.params.id, parseResult.data.termination_date, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: contract, message: 'Contrato terminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contract = await contractsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: contract, message: 'Contrato cancelado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await contractsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Contrato eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAVE TYPES ==========
|
||||||
|
|
||||||
|
async getLeaveTypes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const leaveTypes = await leavesService.getLeaveTypes(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: leaveTypes });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLeaveTypeSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de tipo de ausencia invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeaveTypeDto = parseResult.data;
|
||||||
|
const leaveType = await leavesService.createLeaveType(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: leaveType, message: 'Tipo de ausencia creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLeaveTypeSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de tipo de ausencia invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeaveTypeDto = parseResult.data;
|
||||||
|
const leaveType = await leavesService.updateLeaveType(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({ success: true, data: leaveType, message: 'Tipo de ausencia actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await leavesService.deleteLeaveType(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Tipo de ausencia eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
|
async getLeaves(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = leaveQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LeaveFilters = queryResult.data;
|
||||||
|
const result = await leavesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const leave = await leavesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: leave });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLeaveSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ausencia invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeaveDto = parseResult.data;
|
||||||
|
const leave = await leavesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: leave, message: 'Solicitud de ausencia creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLeaveSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ausencia invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeaveDto = parseResult.data;
|
||||||
|
const leave = await leavesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud de ausencia actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const leave = await leavesService.submit(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud enviada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const leave = await leavesService.approve(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud aprobada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = rejectSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = await leavesService.reject(req.params.id, parseResult.data.reason, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud rechazada' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const leave = await leavesService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await leavesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Solicitud eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hrController = new HrController();
|
||||||
152
src/modules/hr/hr.routes.ts
Normal file
152
src/modules/hr/hr.routes.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { hrController } from './hr.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== EMPLOYEES ==========
|
||||||
|
|
||||||
|
router.get('/employees', (req, res, next) => hrController.getEmployees(req, res, next));
|
||||||
|
|
||||||
|
router.get('/employees/:id', (req, res, next) => hrController.getEmployee(req, res, next));
|
||||||
|
|
||||||
|
router.get('/employees/:id/subordinates', (req, res, next) => hrController.getSubordinates(req, res, next));
|
||||||
|
|
||||||
|
router.post('/employees', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/employees/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/employees/:id/terminate', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.terminateEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/employees/:id/reactivate', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.reactivateEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/employees/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== DEPARTMENTS ==========
|
||||||
|
|
||||||
|
router.get('/departments', (req, res, next) => hrController.getDepartments(req, res, next));
|
||||||
|
|
||||||
|
router.get('/departments/:id', (req, res, next) => hrController.getDepartment(req, res, next));
|
||||||
|
|
||||||
|
router.post('/departments', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createDepartment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/departments/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateDepartment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/departments/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteDepartment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== JOB POSITIONS ==========
|
||||||
|
|
||||||
|
router.get('/positions', (req, res, next) => hrController.getJobPositions(req, res, next));
|
||||||
|
|
||||||
|
router.post('/positions', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createJobPosition(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/positions/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateJobPosition(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/positions/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteJobPosition(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== CONTRACTS ==========
|
||||||
|
|
||||||
|
router.get('/contracts', (req, res, next) => hrController.getContracts(req, res, next));
|
||||||
|
|
||||||
|
router.get('/contracts/:id', (req, res, next) => hrController.getContract(req, res, next));
|
||||||
|
|
||||||
|
router.post('/contracts', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/contracts/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/contracts/:id/activate', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.activateContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/contracts/:id/terminate', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.terminateContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/contracts/:id/cancel', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.cancelContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/contracts/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LEAVE TYPES ==========
|
||||||
|
|
||||||
|
router.get('/leave-types', (req, res, next) => hrController.getLeaveTypes(req, res, next));
|
||||||
|
|
||||||
|
router.post('/leave-types', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createLeaveType(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/leave-types/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateLeaveType(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/leave-types/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteLeaveType(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
|
router.get('/leaves', (req, res, next) => hrController.getLeaves(req, res, next));
|
||||||
|
|
||||||
|
router.get('/leaves/:id', (req, res, next) => hrController.getLeave(req, res, next));
|
||||||
|
|
||||||
|
router.post('/leaves', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/leaves/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leaves/:id/submit', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.submitLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leaves/:id/approve', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.approveLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leaves/:id/reject', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.rejectLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leaves/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.cancelLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/leaves/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
6
src/modules/hr/index.ts
Normal file
6
src/modules/hr/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from './employees.service.js';
|
||||||
|
export * from './departments.service.js';
|
||||||
|
export * from './contracts.service.js';
|
||||||
|
export * from './leaves.service.js';
|
||||||
|
export * from './hr.controller.js';
|
||||||
|
export { default as hrRoutes } from './hr.routes.js';
|
||||||
517
src/modules/hr/leaves.service.ts
Normal file
517
src/modules/hr/leaves.service.ts
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled';
|
||||||
|
export type LeaveType = 'vacation' | 'sick' | 'personal' | 'maternity' | 'paternity' | 'bereavement' | 'unpaid' | 'other';
|
||||||
|
|
||||||
|
export interface LeaveTypeConfig {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
leave_type: LeaveType;
|
||||||
|
requires_approval: boolean;
|
||||||
|
max_days?: number;
|
||||||
|
is_paid: boolean;
|
||||||
|
color?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Leave {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
employee_id: string;
|
||||||
|
employee_name?: string;
|
||||||
|
employee_number?: string;
|
||||||
|
leave_type_id: string;
|
||||||
|
leave_type_name?: string;
|
||||||
|
name?: string;
|
||||||
|
date_from: Date;
|
||||||
|
date_to: Date;
|
||||||
|
number_of_days: number;
|
||||||
|
status: LeaveStatus;
|
||||||
|
description?: string;
|
||||||
|
approved_by?: string;
|
||||||
|
approved_by_name?: string;
|
||||||
|
approved_at?: Date;
|
||||||
|
rejection_reason?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeaveTypeDto {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
leave_type: LeaveType;
|
||||||
|
requires_approval?: boolean;
|
||||||
|
max_days?: number;
|
||||||
|
is_paid?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeaveTypeDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string | null;
|
||||||
|
requires_approval?: boolean;
|
||||||
|
max_days?: number | null;
|
||||||
|
is_paid?: boolean;
|
||||||
|
color?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeaveDto {
|
||||||
|
company_id: string;
|
||||||
|
employee_id: string;
|
||||||
|
leave_type_id: string;
|
||||||
|
name?: string;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeaveDto {
|
||||||
|
leave_type_id?: string;
|
||||||
|
name?: string | null;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveFilters {
|
||||||
|
company_id?: string;
|
||||||
|
employee_id?: string;
|
||||||
|
leave_type_id?: string;
|
||||||
|
status?: LeaveStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeavesService {
|
||||||
|
// ========== LEAVE TYPES ==========
|
||||||
|
|
||||||
|
async getLeaveTypes(tenantId: string, includeInactive = false): Promise<LeaveTypeConfig[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<LeaveTypeConfig>(
|
||||||
|
`SELECT * FROM hr.leave_types ${whereClause} ORDER BY name`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeaveTypeById(id: string, tenantId: string): Promise<LeaveTypeConfig> {
|
||||||
|
const leaveType = await queryOne<LeaveTypeConfig>(
|
||||||
|
`SELECT * FROM hr.leave_types WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!leaveType) {
|
||||||
|
throw new NotFoundError('Tipo de ausencia no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return leaveType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeaveType(dto: CreateLeaveTypeDto, tenantId: string): Promise<LeaveTypeConfig> {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM hr.leave_types WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un tipo de ausencia con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveType = await queryOne<LeaveTypeConfig>(
|
||||||
|
`INSERT INTO hr.leave_types (tenant_id, name, code, leave_type, requires_approval, max_days, is_paid, color)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.name, dto.code, dto.leave_type,
|
||||||
|
dto.requires_approval ?? true, dto.max_days, dto.is_paid ?? true, dto.color
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return leaveType!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeaveType(id: string, dto: UpdateLeaveTypeDto, tenantId: string): Promise<LeaveTypeConfig> {
|
||||||
|
const existing = await this.getLeaveTypeById(id, tenantId);
|
||||||
|
|
||||||
|
if (dto.name && dto.name !== existing.name) {
|
||||||
|
const nameExists = await queryOne(
|
||||||
|
`SELECT id FROM hr.leave_types WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (nameExists) {
|
||||||
|
throw new ConflictError('Ya existe un tipo de ausencia con ese nombre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = ['name', 'code', 'requires_approval', 'max_days', 'is_paid', 'color', 'active'];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leave_types SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getLeaveTypeById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeaveType(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getLeaveTypeById(id, tenantId);
|
||||||
|
|
||||||
|
const inUse = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.leaves WHERE leave_type_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUse?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un tipo de ausencia que esta en uso');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.leave_types WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
|
async findAll(tenantId: string, filters: LeaveFilters = {}): Promise<{ data: Leave[]; total: number }> {
|
||||||
|
const { company_id, employee_id, leave_type_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND l.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee_id) {
|
||||||
|
whereClause += ` AND l.employee_id = $${paramIndex++}`;
|
||||||
|
params.push(employee_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave_type_id) {
|
||||||
|
whereClause += ` AND l.leave_type_id = $${paramIndex++}`;
|
||||||
|
params.push(leave_type_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND l.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND l.date_from >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND l.date_to <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (l.name ILIKE $${paramIndex} OR e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM hr.leaves l
|
||||||
|
LEFT JOIN hr.employees e ON l.employee_id = e.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Leave>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_name,
|
||||||
|
CONCAT(e.first_name, ' ', e.last_name) as employee_name,
|
||||||
|
e.employee_number,
|
||||||
|
lt.name as leave_type_name,
|
||||||
|
CONCAT(a.first_name, ' ', a.last_name) as approved_by_name
|
||||||
|
FROM hr.leaves l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN hr.employees e ON l.employee_id = e.id
|
||||||
|
LEFT JOIN hr.leave_types lt ON l.leave_type_id = lt.id
|
||||||
|
LEFT JOIN hr.employees a ON l.approved_by = a.user_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.date_from DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Leave> {
|
||||||
|
const leave = await queryOne<Leave>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_name,
|
||||||
|
CONCAT(e.first_name, ' ', e.last_name) as employee_name,
|
||||||
|
e.employee_number,
|
||||||
|
lt.name as leave_type_name,
|
||||||
|
CONCAT(a.first_name, ' ', a.last_name) as approved_by_name
|
||||||
|
FROM hr.leaves l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN hr.employees e ON l.employee_id = e.id
|
||||||
|
LEFT JOIN hr.leave_types lt ON l.leave_type_id = lt.id
|
||||||
|
LEFT JOIN hr.employees a ON l.approved_by = a.user_id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!leave) {
|
||||||
|
throw new NotFoundError('Solicitud de ausencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return leave;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLeaveDto, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
// Calculate number of days
|
||||||
|
const startDate = new Date(dto.date_from);
|
||||||
|
const endDate = new Date(dto.date_to);
|
||||||
|
const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
|
||||||
|
const numberOfDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
|
||||||
|
if (numberOfDays <= 0) {
|
||||||
|
throw new ValidationError('La fecha de fin debe ser igual o posterior a la fecha de inicio');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check leave type max days
|
||||||
|
const leaveType = await this.getLeaveTypeById(dto.leave_type_id, tenantId);
|
||||||
|
if (leaveType.max_days && numberOfDays > leaveType.max_days) {
|
||||||
|
throw new ValidationError(`Este tipo de ausencia tiene un maximo de ${leaveType.max_days} dias`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlapping leaves
|
||||||
|
const overlap = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.leaves
|
||||||
|
WHERE employee_id = $1 AND status IN ('submitted', 'approved')
|
||||||
|
AND ((date_from <= $2 AND date_to >= $2) OR (date_from <= $3 AND date_to >= $3)
|
||||||
|
OR (date_from >= $2 AND date_to <= $3))`,
|
||||||
|
[dto.employee_id, dto.date_from, dto.date_to]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(overlap?.count || '0') > 0) {
|
||||||
|
throw new ValidationError('Ya existe una solicitud de ausencia para estas fechas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = await queryOne<Leave>(
|
||||||
|
`INSERT INTO hr.leaves (
|
||||||
|
tenant_id, company_id, employee_id, leave_type_id, name, date_from, date_to,
|
||||||
|
number_of_days, description, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.employee_id, dto.leave_type_id, dto.name,
|
||||||
|
dto.date_from, dto.date_to, numberOfDays, dto.description, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(leave!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLeaveDto, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar solicitudes en borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.leave_type_id !== undefined) {
|
||||||
|
updateFields.push(`leave_type_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.leave_type_id);
|
||||||
|
}
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate days if dates changed
|
||||||
|
let newDateFrom = existing.date_from;
|
||||||
|
let newDateTo = existing.date_to;
|
||||||
|
|
||||||
|
if (dto.date_from !== undefined) {
|
||||||
|
updateFields.push(`date_from = $${paramIndex++}`);
|
||||||
|
values.push(dto.date_from);
|
||||||
|
newDateFrom = new Date(dto.date_from);
|
||||||
|
}
|
||||||
|
if (dto.date_to !== undefined) {
|
||||||
|
updateFields.push(`date_to = $${paramIndex++}`);
|
||||||
|
values.push(dto.date_to);
|
||||||
|
newDateTo = new Date(dto.date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.date_from !== undefined || dto.date_to !== undefined) {
|
||||||
|
const diffTime = Math.abs(newDateTo.getTime() - newDateFrom.getTime());
|
||||||
|
const numberOfDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
updateFields.push(`number_of_days = $${paramIndex++}`);
|
||||||
|
values.push(numberOfDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(id: string, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden enviar solicitudes en borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET
|
||||||
|
status = 'submitted',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(id: string, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status !== 'submitted') {
|
||||||
|
throw new ValidationError('Solo se pueden aprobar solicitudes enviadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET
|
||||||
|
status = 'approved',
|
||||||
|
approved_by = $1,
|
||||||
|
approved_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update employee status if leave starts today or earlier
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
if (leave.date_from.toISOString().split('T')[0] <= today && leave.date_to.toISOString().split('T')[0] >= today) {
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET status = 'on_leave' WHERE id = $1`,
|
||||||
|
[leave.employee_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(id: string, reason: string, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status !== 'submitted') {
|
||||||
|
throw new ValidationError('Solo se pueden rechazar solicitudes enviadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET
|
||||||
|
status = 'rejected',
|
||||||
|
rejection_reason = $1,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[reason, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La solicitud ya esta cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.status === 'rejected') {
|
||||||
|
throw new ValidationError('No se puede cancelar una solicitud rechazada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar solicitudes en borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.leaves WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const leavesService = new LeavesService();
|
||||||
512
src/modules/inventory/adjustments.service.ts
Normal file
512
src/modules/inventory/adjustments.service.ts
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
export interface AdjustmentLine {
|
||||||
|
id: string;
|
||||||
|
adjustment_id: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_code?: string;
|
||||||
|
location_id: string;
|
||||||
|
location_name?: string;
|
||||||
|
lot_id?: string;
|
||||||
|
lot_name?: string;
|
||||||
|
theoretical_qty: number;
|
||||||
|
counted_qty: number;
|
||||||
|
difference_qty: number;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adjustment {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
location_id: string;
|
||||||
|
location_name?: string;
|
||||||
|
date: Date;
|
||||||
|
status: AdjustmentStatus;
|
||||||
|
notes?: string;
|
||||||
|
lines?: AdjustmentLine[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAdjustmentLineDto {
|
||||||
|
product_id: string;
|
||||||
|
location_id: string;
|
||||||
|
lot_id?: string;
|
||||||
|
counted_qty: number;
|
||||||
|
uom_id: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAdjustmentDto {
|
||||||
|
company_id: string;
|
||||||
|
location_id: string;
|
||||||
|
date?: string;
|
||||||
|
notes?: string;
|
||||||
|
lines: CreateAdjustmentLineDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAdjustmentDto {
|
||||||
|
location_id?: string;
|
||||||
|
date?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAdjustmentLineDto {
|
||||||
|
counted_qty?: number;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdjustmentFilters {
|
||||||
|
company_id?: string;
|
||||||
|
location_id?: string;
|
||||||
|
status?: AdjustmentStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdjustmentsService {
|
||||||
|
async findAll(tenantId: string, filters: AdjustmentFilters = {}): Promise<{ data: Adjustment[]; total: number }> {
|
||||||
|
const { company_id, location_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE a.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND a.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location_id) {
|
||||||
|
whereClause += ` AND a.location_id = $${paramIndex++}`;
|
||||||
|
params.push(location_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND a.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND a.date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND a.date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (a.name ILIKE $${paramIndex} OR a.notes ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.inventory_adjustments a ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Adjustment>(
|
||||||
|
`SELECT a.*,
|
||||||
|
c.name as company_name,
|
||||||
|
l.name as location_name
|
||||||
|
FROM inventory.inventory_adjustments a
|
||||||
|
LEFT JOIN auth.companies c ON a.company_id = c.id
|
||||||
|
LEFT JOIN inventory.locations l ON a.location_id = l.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.date DESC, a.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Adjustment> {
|
||||||
|
const adjustment = await queryOne<Adjustment>(
|
||||||
|
`SELECT a.*,
|
||||||
|
c.name as company_name,
|
||||||
|
l.name as location_name
|
||||||
|
FROM inventory.inventory_adjustments a
|
||||||
|
LEFT JOIN auth.companies c ON a.company_id = c.id
|
||||||
|
LEFT JOIN inventory.locations l ON a.location_id = l.id
|
||||||
|
WHERE a.id = $1 AND a.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!adjustment) {
|
||||||
|
throw new NotFoundError('Ajuste de inventario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<AdjustmentLine>(
|
||||||
|
`SELECT al.*,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
l.name as location_name,
|
||||||
|
lot.name as lot_name,
|
||||||
|
u.name as uom_name
|
||||||
|
FROM inventory.inventory_adjustment_lines al
|
||||||
|
LEFT JOIN inventory.products p ON al.product_id = p.id
|
||||||
|
LEFT JOIN inventory.locations l ON al.location_id = l.id
|
||||||
|
LEFT JOIN inventory.lots lot ON al.lot_id = lot.id
|
||||||
|
LEFT JOIN core.uom u ON al.uom_id = u.id
|
||||||
|
WHERE al.adjustment_id = $1
|
||||||
|
ORDER BY al.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
adjustment.lines = lines;
|
||||||
|
|
||||||
|
return adjustment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateAdjustmentDto, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
if (dto.lines.length === 0) {
|
||||||
|
throw new ValidationError('El ajuste debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate adjustment name
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM inventory.inventory_adjustments WHERE tenant_id = $1 AND name LIKE 'ADJ-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const nextNum = seqResult.rows[0]?.next_num || 1;
|
||||||
|
const adjustmentName = `ADJ-${String(nextNum).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
const adjustmentDate = dto.date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Create adjustment
|
||||||
|
const adjustmentResult = await client.query(
|
||||||
|
`INSERT INTO inventory.inventory_adjustments (
|
||||||
|
tenant_id, company_id, name, location_id, date, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, adjustmentName, dto.location_id, adjustmentDate, dto.notes, userId]
|
||||||
|
);
|
||||||
|
const adjustment = adjustmentResult.rows[0];
|
||||||
|
|
||||||
|
// Create lines with theoretical qty from stock_quants
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
// Get theoretical quantity from stock_quants
|
||||||
|
const stockResult = await client.query(
|
||||||
|
`SELECT COALESCE(SUM(quantity), 0) as qty
|
||||||
|
FROM inventory.stock_quants
|
||||||
|
WHERE product_id = $1 AND location_id = $2
|
||||||
|
AND ($3::uuid IS NULL OR lot_id = $3)`,
|
||||||
|
[line.product_id, line.location_id, line.lot_id || null]
|
||||||
|
);
|
||||||
|
const theoreticalQty = parseFloat(stockResult.rows[0]?.qty || '0');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.inventory_adjustment_lines (
|
||||||
|
adjustment_id, tenant_id, product_id, location_id, lot_id, theoretical_qty,
|
||||||
|
counted_qty
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[
|
||||||
|
adjustment.id, tenantId, line.product_id, line.location_id, line.lot_id,
|
||||||
|
theoreticalQty, line.counted_qty
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(adjustment.id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateAdjustmentDto, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.location_id !== undefined) {
|
||||||
|
updateFields.push(`location_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.location_id);
|
||||||
|
}
|
||||||
|
if (dto.date !== undefined) {
|
||||||
|
updateFields.push(`date = $${paramIndex++}`);
|
||||||
|
values.push(dto.date);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.inventory_adjustments SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(adjustmentId: string, dto: CreateAdjustmentLineDto, tenantId: string): Promise<AdjustmentLine> {
|
||||||
|
const adjustment = await this.findById(adjustmentId, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get theoretical quantity
|
||||||
|
const stockResult = await queryOne<{ qty: string }>(
|
||||||
|
`SELECT COALESCE(SUM(quantity), 0) as qty
|
||||||
|
FROM inventory.stock_quants
|
||||||
|
WHERE product_id = $1 AND location_id = $2
|
||||||
|
AND ($3::uuid IS NULL OR lot_id = $3)`,
|
||||||
|
[dto.product_id, dto.location_id, dto.lot_id || null]
|
||||||
|
);
|
||||||
|
const theoreticalQty = parseFloat(stockResult?.qty || '0');
|
||||||
|
|
||||||
|
const line = await queryOne<AdjustmentLine>(
|
||||||
|
`INSERT INTO inventory.inventory_adjustment_lines (
|
||||||
|
adjustment_id, tenant_id, product_id, location_id, lot_id, theoretical_qty,
|
||||||
|
counted_qty
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
adjustmentId, tenantId, dto.product_id, dto.location_id, dto.lot_id,
|
||||||
|
theoreticalQty, dto.counted_qty
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(adjustmentId: string, lineId: string, dto: UpdateAdjustmentLineDto, tenantId: string): Promise<AdjustmentLine> {
|
||||||
|
const adjustment = await this.findById(adjustmentId, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas en ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = adjustment.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.counted_qty !== undefined) {
|
||||||
|
updateFields.push(`counted_qty = $${paramIndex++}`);
|
||||||
|
values.push(dto.counted_qty);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existingLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(lineId);
|
||||||
|
|
||||||
|
const line = await queryOne<AdjustmentLine>(
|
||||||
|
`UPDATE inventory.inventory_adjustment_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(adjustmentId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const adjustment = await this.findById(adjustmentId, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas en ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = adjustment.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjustment.lines && adjustment.lines.length <= 1) {
|
||||||
|
throw new ValidationError('El ajuste debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.inventory_adjustment_lines WHERE id = $1`, [lineId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
const adjustment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden confirmar ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adjustment.lines || adjustment.lines.length === 0) {
|
||||||
|
throw new ValidationError('El ajuste debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.inventory_adjustments SET
|
||||||
|
status = 'confirmed',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(id: string, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
const adjustment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'confirmed') {
|
||||||
|
throw new ValidationError('Solo se pueden validar ajustes confirmados');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Update status to done
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.inventory_adjustments SET
|
||||||
|
status = 'done',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply stock adjustments
|
||||||
|
for (const line of adjustment.lines!) {
|
||||||
|
const difference = line.counted_qty - line.theoretical_qty;
|
||||||
|
|
||||||
|
if (difference !== 0) {
|
||||||
|
// Check if quant exists
|
||||||
|
const existingQuant = await client.query(
|
||||||
|
`SELECT id, quantity FROM inventory.stock_quants
|
||||||
|
WHERE product_id = $1 AND location_id = $2
|
||||||
|
AND ($3::uuid IS NULL OR lot_id = $3)`,
|
||||||
|
[line.product_id, line.location_id, line.lot_id || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingQuant.rows.length > 0) {
|
||||||
|
// Update existing quant
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_quants SET
|
||||||
|
quantity = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[line.counted_qty, existingQuant.rows[0].id]
|
||||||
|
);
|
||||||
|
} else if (line.counted_qty > 0) {
|
||||||
|
// Create new quant if counted > 0
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_quants (
|
||||||
|
tenant_id, product_id, location_id, lot_id, quantity
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[tenantId, line.product_id, line.location_id, line.lot_id, line.counted_qty]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
const adjustment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status === 'done') {
|
||||||
|
throw new ValidationError('No se puede cancelar un ajuste validado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjustment.status === 'cancelled') {
|
||||||
|
throw new ValidationError('El ajuste ya está cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.inventory_adjustments SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const adjustment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.inventory_adjustments WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adjustmentsService = new AdjustmentsService();
|
||||||
16
src/modules/inventory/index.ts
Normal file
16
src/modules/inventory/index.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export * from './products.service.js';
|
||||||
|
export * from './warehouses.service.js';
|
||||||
|
export {
|
||||||
|
locationsService,
|
||||||
|
Location as InventoryLocation,
|
||||||
|
CreateLocationDto,
|
||||||
|
UpdateLocationDto,
|
||||||
|
LocationFilters
|
||||||
|
} from './locations.service.js';
|
||||||
|
export * from './pickings.service.js';
|
||||||
|
export * from './lots.service.js';
|
||||||
|
export * from './adjustments.service.js';
|
||||||
|
export * from './valuation.service.js';
|
||||||
|
export * from './inventory.controller.js';
|
||||||
|
export * from './valuation.controller.js';
|
||||||
|
export { default as inventoryRoutes } from './inventory.routes.js';
|
||||||
875
src/modules/inventory/inventory.controller.ts
Normal file
875
src/modules/inventory/inventory.controller.ts
Normal file
@ -0,0 +1,875 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { productsService, CreateProductDto, UpdateProductDto, ProductFilters } from './products.service.js';
|
||||||
|
import { warehousesService, CreateWarehouseDto, UpdateWarehouseDto, WarehouseFilters } from './warehouses.service.js';
|
||||||
|
import { locationsService, CreateLocationDto, UpdateLocationDto, LocationFilters } from './locations.service.js';
|
||||||
|
import { pickingsService, CreatePickingDto, PickingFilters } from './pickings.service.js';
|
||||||
|
import { lotsService, CreateLotDto, UpdateLotDto, LotFilters } from './lots.service.js';
|
||||||
|
import { adjustmentsService, CreateAdjustmentDto, UpdateAdjustmentDto, CreateAdjustmentLineDto, UpdateAdjustmentLineDto, AdjustmentFilters } from './adjustments.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Product schemas
|
||||||
|
const createProductSchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
code: z.string().max(100).optional(),
|
||||||
|
barcode: z.string().max(100).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
product_type: z.enum(['storable', 'consumable', 'service']).default('storable'),
|
||||||
|
tracking: z.enum(['none', 'lot', 'serial']).default('none'),
|
||||||
|
category_id: z.string().uuid().optional(),
|
||||||
|
uom_id: z.string().uuid({ message: 'La unidad de medida es requerida' }),
|
||||||
|
purchase_uom_id: z.string().uuid().optional(),
|
||||||
|
cost_price: z.number().min(0).default(0),
|
||||||
|
list_price: z.number().min(0).default(0),
|
||||||
|
valuation_method: z.enum(['standard', 'fifo', 'average']).default('fifo'),
|
||||||
|
weight: z.number().min(0).optional(),
|
||||||
|
volume: z.number().min(0).optional(),
|
||||||
|
can_be_sold: z.boolean().default(true),
|
||||||
|
can_be_purchased: z.boolean().default(true),
|
||||||
|
image_url: z.string().url().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProductSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
barcode: z.string().max(100).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
tracking: z.enum(['none', 'lot', 'serial']).optional(),
|
||||||
|
category_id: z.string().uuid().optional().nullable(),
|
||||||
|
uom_id: z.string().uuid().optional(),
|
||||||
|
purchase_uom_id: z.string().uuid().optional().nullable(),
|
||||||
|
cost_price: z.number().min(0).optional(),
|
||||||
|
list_price: z.number().min(0).optional(),
|
||||||
|
valuation_method: z.enum(['standard', 'fifo', 'average']).optional(),
|
||||||
|
weight: z.number().min(0).optional().nullable(),
|
||||||
|
volume: z.number().min(0).optional().nullable(),
|
||||||
|
can_be_sold: z.boolean().optional(),
|
||||||
|
can_be_purchased: z.boolean().optional(),
|
||||||
|
image_url: z.string().url().max(500).optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const productQuerySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
category_id: z.string().uuid().optional(),
|
||||||
|
product_type: z.enum(['storable', 'consumable', 'service']).optional(),
|
||||||
|
can_be_sold: z.coerce.boolean().optional(),
|
||||||
|
can_be_purchased: z.coerce.boolean().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Warehouse schemas
|
||||||
|
const createWarehouseSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
code: z.string().min(1).max(20),
|
||||||
|
address_id: z.string().uuid().optional(),
|
||||||
|
is_default: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWarehouseSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
address_id: z.string().uuid().optional().nullable(),
|
||||||
|
is_default: z.boolean().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const warehouseQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Location schemas
|
||||||
|
const createLocationSchema = z.object({
|
||||||
|
warehouse_id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
location_type: z.enum(['internal', 'supplier', 'customer', 'inventory', 'production', 'transit']),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
is_scrap_location: z.boolean().default(false),
|
||||||
|
is_return_location: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLocationSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
is_scrap_location: z.boolean().optional(),
|
||||||
|
is_return_location: z.boolean().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationQuerySchema = z.object({
|
||||||
|
warehouse_id: z.string().uuid().optional(),
|
||||||
|
location_type: z.enum(['internal', 'supplier', 'customer', 'inventory', 'production', 'transit']).optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Picking schemas
|
||||||
|
const stockMoveLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
product_uom_id: z.string().uuid({ message: 'La UdM es requerida' }),
|
||||||
|
product_qty: z.number().positive({ message: 'La cantidad debe ser mayor a 0' }),
|
||||||
|
lot_id: z.string().uuid().optional(),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación origen es requerida' }),
|
||||||
|
location_dest_id: z.string().uuid({ message: 'La ubicación destino es requerida' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPickingSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||||
|
picking_type: z.enum(['incoming', 'outgoing', 'internal']),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación origen es requerida' }),
|
||||||
|
location_dest_id: z.string().uuid({ message: 'La ubicación destino es requerida' }),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
scheduled_date: z.string().optional(),
|
||||||
|
origin: z.string().max(255).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
moves: z.array(stockMoveLineSchema).min(1, 'Debe incluir al menos un movimiento'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickingQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
picking_type: z.enum(['incoming', 'outgoing', 'internal']).optional(),
|
||||||
|
status: z.enum(['draft', 'waiting', 'confirmed', 'assigned', 'done', 'cancelled']).optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lot schemas
|
||||||
|
const createLotSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
name: z.string().min(1, 'El nombre del lote es requerido').max(100),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
manufacture_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
expiration_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
removal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
alert_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLotSchema = z.object({
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
manufacture_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
expiration_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
removal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
alert_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lotQuerySchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
expiring_soon: z.coerce.boolean().optional(),
|
||||||
|
expired: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjustment schemas
|
||||||
|
const adjustmentLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación es requerida' }),
|
||||||
|
lot_id: z.string().uuid().optional(),
|
||||||
|
counted_qty: z.number().min(0),
|
||||||
|
uom_id: z.string().uuid({ message: 'La UdM es requerida' }),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAdjustmentSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación es requerida' }),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
lines: z.array(adjustmentLineSchema).min(1, 'Debe incluir al menos una línea'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAdjustmentSchema = z.object({
|
||||||
|
location_id: z.string().uuid().optional(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAdjustmentLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación es requerida' }),
|
||||||
|
lot_id: z.string().uuid().optional(),
|
||||||
|
counted_qty: z.number().min(0),
|
||||||
|
uom_id: z.string().uuid({ message: 'La UdM es requerida' }),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAdjustmentLineSchema = z.object({
|
||||||
|
counted_qty: z.number().min(0).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjustmentQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
location_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'confirmed', 'done', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class InventoryController {
|
||||||
|
// ========== PRODUCTS ==========
|
||||||
|
async getProducts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = productQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: ProductFilters = queryResult.data;
|
||||||
|
const result = await productsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const product = await productsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: product });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createProductSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de producto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateProductDto = parseResult.data;
|
||||||
|
const product = await productsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: product,
|
||||||
|
message: 'Producto creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateProductSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de producto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateProductDto = parseResult.data;
|
||||||
|
const product = await productsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: product,
|
||||||
|
message: 'Producto actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await productsService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Producto eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProductStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stock = await productsService.getStock(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== WAREHOUSES ==========
|
||||||
|
async getWarehouses(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = warehouseQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: WarehouseFilters = queryResult.data;
|
||||||
|
const result = await warehousesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 50)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const warehouse = await warehousesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: warehouse });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createWarehouseSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de almacén inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateWarehouseDto = parseResult.data;
|
||||||
|
const warehouse = await warehousesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: warehouse,
|
||||||
|
message: 'Almacén creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateWarehouseSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de almacén inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateWarehouseDto = parseResult.data;
|
||||||
|
const warehouse = await warehousesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: warehouse,
|
||||||
|
message: 'Almacén actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await warehousesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Almacén eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWarehouseLocations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const locations = await warehousesService.getLocations(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: locations });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWarehouseStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stock = await warehousesService.getStock(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOCATIONS ==========
|
||||||
|
async getLocations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = locationQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LocationFilters = queryResult.data;
|
||||||
|
const result = await locationsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 50)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const location = await locationsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: location });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLocationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ubicación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLocationDto = parseResult.data;
|
||||||
|
const location = await locationsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: location,
|
||||||
|
message: 'Ubicación creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLocationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ubicación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLocationDto = parseResult.data;
|
||||||
|
const location = await locationsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: location,
|
||||||
|
message: 'Ubicación actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocationStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stock = await locationsService.getStock(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PICKINGS ==========
|
||||||
|
async getPickings(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = pickingQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: PickingFilters = queryResult.data;
|
||||||
|
const result = await pickingsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const picking = await pickingsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: picking });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPickingSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de picking inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePickingDto = parseResult.data;
|
||||||
|
const picking = await pickingsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: picking,
|
||||||
|
message: 'Picking creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const picking = await pickingsService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: picking,
|
||||||
|
message: 'Picking confirmado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validatePicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const picking = await pickingsService.validate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: picking,
|
||||||
|
message: 'Picking validado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const picking = await pickingsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: picking,
|
||||||
|
message: 'Picking cancelado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pickingsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Picking eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOTS ==========
|
||||||
|
async getLots(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = lotQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LotFilters = queryResult.data;
|
||||||
|
const result = await lotsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 50)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const lot = await lotsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: lot });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLotSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lote inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLotDto = parseResult.data;
|
||||||
|
const lot = await lotsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: lot,
|
||||||
|
message: 'Lote creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLotSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lote inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLotDto = parseResult.data;
|
||||||
|
const lot = await lotsService.update(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lot,
|
||||||
|
message: 'Lote actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLotMovements(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const movements = await lotsService.getMovements(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: movements });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await lotsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Lote eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ADJUSTMENTS ==========
|
||||||
|
async getAdjustments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = adjustmentQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: AdjustmentFilters = queryResult.data;
|
||||||
|
const result = await adjustmentsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const adjustment = await adjustmentsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: adjustment });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createAdjustmentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ajuste inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateAdjustmentDto = parseResult.data;
|
||||||
|
const adjustment = await adjustmentsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste de inventario creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateAdjustmentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ajuste inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateAdjustmentDto = parseResult.data;
|
||||||
|
const adjustment = await adjustmentsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste de inventario actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createAdjustmentLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateAdjustmentLineDto = parseResult.data;
|
||||||
|
const line = await adjustmentsService.addLine(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea agregada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateAdjustmentLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateAdjustmentLineDto = parseResult.data;
|
||||||
|
const line = await adjustmentsService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await adjustmentsService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const adjustment = await adjustmentsService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste confirmado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const adjustment = await adjustmentsService.validate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste validado exitosamente. Stock actualizado.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const adjustment = await adjustmentsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste cancelado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await adjustmentsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Ajuste eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inventoryController = new InventoryController();
|
||||||
174
src/modules/inventory/inventory.routes.ts
Normal file
174
src/modules/inventory/inventory.routes.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { inventoryController } from './inventory.controller.js';
|
||||||
|
import { valuationController } from './valuation.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== PRODUCTS ==========
|
||||||
|
router.get('/products', (req, res, next) => inventoryController.getProducts(req, res, next));
|
||||||
|
|
||||||
|
router.get('/products/:id', (req, res, next) => inventoryController.getProduct(req, res, next));
|
||||||
|
|
||||||
|
router.get('/products/:id/stock', (req, res, next) => inventoryController.getProductStock(req, res, next));
|
||||||
|
|
||||||
|
router.post('/products', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createProduct(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/products/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateProduct(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/products/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deleteProduct(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== WAREHOUSES ==========
|
||||||
|
router.get('/warehouses', (req, res, next) => inventoryController.getWarehouses(req, res, next));
|
||||||
|
|
||||||
|
router.get('/warehouses/:id', (req, res, next) => inventoryController.getWarehouse(req, res, next));
|
||||||
|
|
||||||
|
router.get('/warehouses/:id/locations', (req, res, next) => inventoryController.getWarehouseLocations(req, res, next));
|
||||||
|
|
||||||
|
router.get('/warehouses/:id/stock', (req, res, next) => inventoryController.getWarehouseStock(req, res, next));
|
||||||
|
|
||||||
|
router.post('/warehouses', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createWarehouse(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/warehouses/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateWarehouse(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/warehouses/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deleteWarehouse(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LOCATIONS ==========
|
||||||
|
router.get('/locations', (req, res, next) => inventoryController.getLocations(req, res, next));
|
||||||
|
|
||||||
|
router.get('/locations/:id', (req, res, next) => inventoryController.getLocation(req, res, next));
|
||||||
|
|
||||||
|
router.get('/locations/:id/stock', (req, res, next) => inventoryController.getLocationStock(req, res, next));
|
||||||
|
|
||||||
|
router.post('/locations', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createLocation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/locations/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateLocation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PICKINGS ==========
|
||||||
|
router.get('/pickings', (req, res, next) => inventoryController.getPickings(req, res, next));
|
||||||
|
|
||||||
|
router.get('/pickings/:id', (req, res, next) => inventoryController.getPicking(req, res, next));
|
||||||
|
|
||||||
|
router.post('/pickings', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createPicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/pickings/:id/confirm', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.confirmPicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/pickings/:id/validate', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.validatePicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/pickings/:id/cancel', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.cancelPicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/pickings/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deletePicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LOTS ==========
|
||||||
|
router.get('/lots', (req, res, next) => inventoryController.getLots(req, res, next));
|
||||||
|
|
||||||
|
router.get('/lots/:id', (req, res, next) => inventoryController.getLot(req, res, next));
|
||||||
|
|
||||||
|
router.get('/lots/:id/movements', (req, res, next) => inventoryController.getLotMovements(req, res, next));
|
||||||
|
|
||||||
|
router.post('/lots', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createLot(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/lots/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateLot(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/lots/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deleteLot(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== ADJUSTMENTS ==========
|
||||||
|
router.get('/adjustments', (req, res, next) => inventoryController.getAdjustments(req, res, next));
|
||||||
|
|
||||||
|
router.get('/adjustments/:id', (req, res, next) => inventoryController.getAdjustment(req, res, next));
|
||||||
|
|
||||||
|
router.post('/adjustments', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/adjustments/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adjustment lines
|
||||||
|
router.post('/adjustments/:id/lines', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.addAdjustmentLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/adjustments/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateAdjustmentLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/adjustments/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.removeAdjustmentLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adjustment workflow
|
||||||
|
router.post('/adjustments/:id/confirm', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.confirmAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/adjustments/:id/validate', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.validateAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/adjustments/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.cancelAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/adjustments/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deleteAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== VALUATION ==========
|
||||||
|
router.get('/valuation/cost', (req, res, next) => valuationController.getProductCost(req, res, next));
|
||||||
|
|
||||||
|
router.get('/valuation/report', (req, res, next) => valuationController.getCompanyReport(req, res, next));
|
||||||
|
|
||||||
|
router.get('/valuation/products/:productId/summary', (req, res, next) =>
|
||||||
|
valuationController.getProductSummary(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/valuation/products/:productId/layers', (req, res, next) =>
|
||||||
|
valuationController.getProductLayers(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/valuation/layers', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
valuationController.createLayer(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/valuation/consume', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
valuationController.consumeFifo(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
212
src/modules/inventory/locations.service.ts
Normal file
212
src/modules/inventory/locations.service.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type LocationType = 'internal' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
warehouse_id?: string;
|
||||||
|
warehouse_name?: string;
|
||||||
|
name: string;
|
||||||
|
complete_name?: string;
|
||||||
|
location_type: LocationType;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
is_scrap_location: boolean;
|
||||||
|
is_return_location: boolean;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLocationDto {
|
||||||
|
warehouse_id?: string;
|
||||||
|
name: string;
|
||||||
|
location_type: LocationType;
|
||||||
|
parent_id?: string;
|
||||||
|
is_scrap_location?: boolean;
|
||||||
|
is_return_location?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLocationDto {
|
||||||
|
name?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
is_scrap_location?: boolean;
|
||||||
|
is_return_location?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationFilters {
|
||||||
|
warehouse_id?: string;
|
||||||
|
location_type?: LocationType;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocationsService {
|
||||||
|
async findAll(tenantId: string, filters: LocationFilters = {}): Promise<{ data: Location[]; total: number }> {
|
||||||
|
const { warehouse_id, location_type, active, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (warehouse_id) {
|
||||||
|
whereClause += ` AND l.warehouse_id = $${paramIndex++}`;
|
||||||
|
params.push(warehouse_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location_type) {
|
||||||
|
whereClause += ` AND l.location_type = $${paramIndex++}`;
|
||||||
|
params.push(location_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND l.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.locations l ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Location>(
|
||||||
|
`SELECT l.*,
|
||||||
|
w.name as warehouse_name,
|
||||||
|
lp.name as parent_name
|
||||||
|
FROM inventory.locations l
|
||||||
|
LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
LEFT JOIN inventory.locations lp ON l.parent_id = lp.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.complete_name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Location> {
|
||||||
|
const location = await queryOne<Location>(
|
||||||
|
`SELECT l.*,
|
||||||
|
w.name as warehouse_name,
|
||||||
|
lp.name as parent_name
|
||||||
|
FROM inventory.locations l
|
||||||
|
LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
LEFT JOIN inventory.locations lp ON l.parent_id = lp.id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
throw new NotFoundError('Ubicación no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLocationDto, tenantId: string, userId: string): Promise<Location> {
|
||||||
|
// Validate parent location if specified
|
||||||
|
if (dto.parent_id) {
|
||||||
|
const parent = await queryOne<Location>(
|
||||||
|
`SELECT id FROM inventory.locations WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[dto.parent_id, tenantId]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Ubicación padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await queryOne<Location>(
|
||||||
|
`INSERT INTO inventory.locations (tenant_id, warehouse_id, name, location_type, parent_id, is_scrap_location, is_return_location, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.warehouse_id,
|
||||||
|
dto.name,
|
||||||
|
dto.location_type,
|
||||||
|
dto.parent_id,
|
||||||
|
dto.is_scrap_location || false,
|
||||||
|
dto.is_return_location || false,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return location!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLocationDto, tenantId: string, userId: string): Promise<Location> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate parent (prevent self-reference)
|
||||||
|
if (dto.parent_id) {
|
||||||
|
if (dto.parent_id === id) {
|
||||||
|
throw new ConflictError('Una ubicación no puede ser su propia ubicación padre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.parent_id !== undefined) {
|
||||||
|
updateFields.push(`parent_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.parent_id);
|
||||||
|
}
|
||||||
|
if (dto.is_scrap_location !== undefined) {
|
||||||
|
updateFields.push(`is_scrap_location = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_scrap_location);
|
||||||
|
}
|
||||||
|
if (dto.is_return_location !== undefined) {
|
||||||
|
updateFields.push(`is_return_location = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_return_location);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const location = await queryOne<Location>(
|
||||||
|
`UPDATE inventory.locations SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return location!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStock(locationId: string, tenantId: string): Promise<any[]> {
|
||||||
|
await this.findById(locationId, tenantId);
|
||||||
|
|
||||||
|
return query(
|
||||||
|
`SELECT sq.*, p.name as product_name, p.code as product_code, u.name as uom_name
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
INNER JOIN inventory.products p ON sq.product_id = p.id
|
||||||
|
LEFT JOIN core.uom u ON p.uom_id = u.id
|
||||||
|
WHERE sq.location_id = $1 AND sq.quantity > 0
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[locationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const locationsService = new LocationsService();
|
||||||
263
src/modules/inventory/lots.service.ts
Normal file
263
src/modules/inventory/lots.service.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Lot {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_code?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
manufacture_date?: Date;
|
||||||
|
expiration_date?: Date;
|
||||||
|
removal_date?: Date;
|
||||||
|
alert_date?: Date;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
quantity_on_hand?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLotDto {
|
||||||
|
product_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
manufacture_date?: string;
|
||||||
|
expiration_date?: string;
|
||||||
|
removal_date?: string;
|
||||||
|
alert_date?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLotDto {
|
||||||
|
ref?: string | null;
|
||||||
|
manufacture_date?: string | null;
|
||||||
|
expiration_date?: string | null;
|
||||||
|
removal_date?: string | null;
|
||||||
|
alert_date?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LotFilters {
|
||||||
|
product_id?: string;
|
||||||
|
expiring_soon?: boolean;
|
||||||
|
expired?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LotMovement {
|
||||||
|
id: string;
|
||||||
|
date: Date;
|
||||||
|
origin: string;
|
||||||
|
location_from: string;
|
||||||
|
location_to: string;
|
||||||
|
quantity: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LotsService {
|
||||||
|
async findAll(tenantId: string, filters: LotFilters = {}): Promise<{ data: Lot[]; total: number }> {
|
||||||
|
const { product_id, expiring_soon, expired, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (product_id) {
|
||||||
|
whereClause += ` AND l.product_id = $${paramIndex++}`;
|
||||||
|
params.push(product_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiring_soon) {
|
||||||
|
whereClause += ` AND l.expiration_date IS NOT NULL AND l.expiration_date <= CURRENT_DATE + INTERVAL '30 days' AND l.expiration_date > CURRENT_DATE`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired) {
|
||||||
|
whereClause += ` AND l.expiration_date IS NOT NULL AND l.expiration_date < CURRENT_DATE`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM inventory.lots l
|
||||||
|
LEFT JOIN inventory.products p ON l.product_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Lot>(
|
||||||
|
`SELECT l.*,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
COALESCE(sq.total_qty, 0) as quantity_on_hand
|
||||||
|
FROM inventory.lots l
|
||||||
|
LEFT JOIN inventory.products p ON l.product_id = p.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT lot_id, SUM(quantity) as total_qty
|
||||||
|
FROM inventory.stock_quants
|
||||||
|
GROUP BY lot_id
|
||||||
|
) sq ON l.id = sq.lot_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.expiration_date ASC NULLS LAST, l.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Lot> {
|
||||||
|
const lot = await queryOne<Lot>(
|
||||||
|
`SELECT l.*,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
COALESCE(sq.total_qty, 0) as quantity_on_hand
|
||||||
|
FROM inventory.lots l
|
||||||
|
LEFT JOIN inventory.products p ON l.product_id = p.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT lot_id, SUM(quantity) as total_qty
|
||||||
|
FROM inventory.stock_quants
|
||||||
|
GROUP BY lot_id
|
||||||
|
) sq ON l.id = sq.lot_id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lot) {
|
||||||
|
throw new NotFoundError('Lote no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLotDto, tenantId: string, userId: string): Promise<Lot> {
|
||||||
|
// Check for unique lot name for product
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM inventory.lots WHERE product_id = $1 AND name = $2`,
|
||||||
|
[dto.product_id, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un lote con ese nombre para este producto');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lot = await queryOne<Lot>(
|
||||||
|
`INSERT INTO inventory.lots (
|
||||||
|
tenant_id, product_id, name, ref, manufacture_date, expiration_date,
|
||||||
|
removal_date, alert_date, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.product_id, dto.name, dto.ref, dto.manufacture_date,
|
||||||
|
dto.expiration_date, dto.removal_date, dto.alert_date, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(lot!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLotDto, tenantId: string): Promise<Lot> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.manufacture_date !== undefined) {
|
||||||
|
updateFields.push(`manufacture_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.manufacture_date);
|
||||||
|
}
|
||||||
|
if (dto.expiration_date !== undefined) {
|
||||||
|
updateFields.push(`expiration_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.expiration_date);
|
||||||
|
}
|
||||||
|
if (dto.removal_date !== undefined) {
|
||||||
|
updateFields.push(`removal_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.removal_date);
|
||||||
|
}
|
||||||
|
if (dto.alert_date !== undefined) {
|
||||||
|
updateFields.push(`alert_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.alert_date);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.lots SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMovements(id: string, tenantId: string): Promise<LotMovement[]> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const movements = await query<LotMovement>(
|
||||||
|
`SELECT sm.id,
|
||||||
|
sm.date,
|
||||||
|
sm.origin,
|
||||||
|
lo.name as location_from,
|
||||||
|
ld.name as location_to,
|
||||||
|
sm.quantity_done as quantity,
|
||||||
|
sm.status
|
||||||
|
FROM inventory.stock_moves sm
|
||||||
|
LEFT JOIN inventory.locations lo ON sm.location_id = lo.id
|
||||||
|
LEFT JOIN inventory.locations ld ON sm.location_dest_id = ld.id
|
||||||
|
WHERE sm.lot_id = $1 AND sm.status = 'done'
|
||||||
|
ORDER BY sm.date DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return movements;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const lot = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if lot has stock
|
||||||
|
if (lot.quantity_on_hand && lot.quantity_on_hand > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un lote con stock');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if lot is used in moves
|
||||||
|
const movesCheck = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.stock_moves WHERE lot_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(movesCheck?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar: el lote tiene movimientos asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.lots WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lotsService = new LotsService();
|
||||||
357
src/modules/inventory/pickings.service.ts
Normal file
357
src/modules/inventory/pickings.service.ts
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type PickingType = 'incoming' | 'outgoing' | 'internal';
|
||||||
|
export type MoveStatus = 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
export interface StockMoveLine {
|
||||||
|
id?: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_code?: string;
|
||||||
|
product_uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
product_qty: number;
|
||||||
|
quantity_done?: number;
|
||||||
|
lot_id?: string;
|
||||||
|
location_id: string;
|
||||||
|
location_name?: string;
|
||||||
|
location_dest_id: string;
|
||||||
|
location_dest_name?: string;
|
||||||
|
status?: MoveStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Picking {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
picking_type: PickingType;
|
||||||
|
location_id: string;
|
||||||
|
location_name?: string;
|
||||||
|
location_dest_id: string;
|
||||||
|
location_dest_name?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
partner_name?: string;
|
||||||
|
scheduled_date?: Date;
|
||||||
|
date_done?: Date;
|
||||||
|
origin?: string;
|
||||||
|
status: MoveStatus;
|
||||||
|
notes?: string;
|
||||||
|
moves?: StockMoveLine[];
|
||||||
|
created_at: Date;
|
||||||
|
validated_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePickingDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
picking_type: PickingType;
|
||||||
|
location_id: string;
|
||||||
|
location_dest_id: string;
|
||||||
|
partner_id?: string;
|
||||||
|
scheduled_date?: string;
|
||||||
|
origin?: string;
|
||||||
|
notes?: string;
|
||||||
|
moves: Omit<StockMoveLine, 'id' | 'product_name' | 'product_code' | 'uom_name' | 'location_name' | 'location_dest_name' | 'quantity_done' | 'status'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePickingDto {
|
||||||
|
partner_id?: string | null;
|
||||||
|
scheduled_date?: string | null;
|
||||||
|
origin?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
moves?: Omit<StockMoveLine, 'id' | 'product_name' | 'product_code' | 'uom_name' | 'location_name' | 'location_dest_name' | 'quantity_done' | 'status'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PickingFilters {
|
||||||
|
company_id?: string;
|
||||||
|
picking_type?: PickingType;
|
||||||
|
status?: MoveStatus;
|
||||||
|
partner_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PickingsService {
|
||||||
|
async findAll(tenantId: string, filters: PickingFilters = {}): Promise<{ data: Picking[]; total: number }> {
|
||||||
|
const { company_id, picking_type, status, partner_id, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND p.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picking_type) {
|
||||||
|
whereClause += ` AND p.picking_type = $${paramIndex++}`;
|
||||||
|
params.push(picking_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND p.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND p.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND p.scheduled_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND p.scheduled_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.origin ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.pickings p ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Picking>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
l.name as location_name,
|
||||||
|
ld.name as location_dest_name,
|
||||||
|
pa.name as partner_name
|
||||||
|
FROM inventory.pickings p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN inventory.locations l ON p.location_id = l.id
|
||||||
|
LEFT JOIN inventory.locations ld ON p.location_dest_id = ld.id
|
||||||
|
LEFT JOIN core.partners pa ON p.partner_id = pa.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.scheduled_date DESC NULLS LAST, p.name DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Picking> {
|
||||||
|
const picking = await queryOne<Picking>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
l.name as location_name,
|
||||||
|
ld.name as location_dest_name,
|
||||||
|
pa.name as partner_name
|
||||||
|
FROM inventory.pickings p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN inventory.locations l ON p.location_id = l.id
|
||||||
|
LEFT JOIN inventory.locations ld ON p.location_dest_id = ld.id
|
||||||
|
LEFT JOIN core.partners pa ON p.partner_id = pa.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!picking) {
|
||||||
|
throw new NotFoundError('Picking no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get moves
|
||||||
|
const moves = await query<StockMoveLine>(
|
||||||
|
`SELECT sm.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
pr.code as product_code,
|
||||||
|
u.name as uom_name,
|
||||||
|
l.name as location_name,
|
||||||
|
ld.name as location_dest_name
|
||||||
|
FROM inventory.stock_moves sm
|
||||||
|
LEFT JOIN inventory.products pr ON sm.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom u ON sm.product_uom_id = u.id
|
||||||
|
LEFT JOIN inventory.locations l ON sm.location_id = l.id
|
||||||
|
LEFT JOIN inventory.locations ld ON sm.location_dest_id = ld.id
|
||||||
|
WHERE sm.picking_id = $1
|
||||||
|
ORDER BY sm.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
picking.moves = moves;
|
||||||
|
|
||||||
|
return picking;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePickingDto, tenantId: string, userId: string): Promise<Picking> {
|
||||||
|
if (dto.moves.length === 0) {
|
||||||
|
throw new ValidationError('El picking debe tener al menos un movimiento');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Create picking
|
||||||
|
const pickingResult = await client.query(
|
||||||
|
`INSERT INTO inventory.pickings (tenant_id, company_id, name, picking_type, location_id, location_dest_id, partner_id, scheduled_date, origin, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.picking_type, dto.location_id, dto.location_dest_id, dto.partner_id, dto.scheduled_date, dto.origin, dto.notes, userId]
|
||||||
|
);
|
||||||
|
const picking = pickingResult.rows[0] as Picking;
|
||||||
|
|
||||||
|
// Create moves
|
||||||
|
for (const move of dto.moves) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_moves (tenant_id, picking_id, product_id, product_uom_id, location_id, location_dest_id, product_qty, lot_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[tenantId, picking.id, move.product_id, move.product_uom_id, move.location_id, move.location_dest_id, move.product_qty, move.lot_id, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(picking.id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<Picking> {
|
||||||
|
const picking = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (picking.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden confirmar pickings en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.pickings SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.stock_moves SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE picking_id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(id: string, tenantId: string, userId: string): Promise<Picking> {
|
||||||
|
const picking = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (picking.status === 'done') {
|
||||||
|
throw new ConflictError('El picking ya está validado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picking.status === 'cancelled') {
|
||||||
|
throw new ConflictError('No se puede validar un picking cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Update stock quants for each move
|
||||||
|
for (const move of picking.moves || []) {
|
||||||
|
const qty = move.product_qty;
|
||||||
|
|
||||||
|
// Decrease from source location
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_quants (product_id, location_id, quantity)
|
||||||
|
VALUES ($1, $2, -$3)
|
||||||
|
ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'))
|
||||||
|
DO UPDATE SET quantity = stock_quants.quantity - $3, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[move.product_id, move.location_id, qty]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Increase in destination location
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_quants (product_id, location_id, quantity)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'))
|
||||||
|
DO UPDATE SET quantity = stock_quants.quantity + $3, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[move.product_id, move.location_dest_id, qty]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update move
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_moves
|
||||||
|
SET quantity_done = $1, status = 'done', date = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP, updated_by = $2
|
||||||
|
WHERE id = $3`,
|
||||||
|
[qty, userId, move.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update picking
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.pickings
|
||||||
|
SET status = 'done', date_done = CURRENT_TIMESTAMP, validated_at = CURRENT_TIMESTAMP, validated_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Picking> {
|
||||||
|
const picking = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (picking.status === 'done') {
|
||||||
|
throw new ConflictError('No se puede cancelar un picking ya validado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picking.status === 'cancelled') {
|
||||||
|
throw new ConflictError('El picking ya está cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.pickings SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.stock_moves SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE picking_id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const picking = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (picking.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden eliminar pickings en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.pickings WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pickingsService = new PickingsService();
|
||||||
374
src/modules/inventory/products.service.ts
Normal file
374
src/modules/inventory/products.service.ts
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type ProductType = 'storable' | 'consumable' | 'service';
|
||||||
|
export type TrackingType = 'none' | 'lot' | 'serial';
|
||||||
|
export type ValuationMethod = 'standard' | 'fifo' | 'average';
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
barcode?: string;
|
||||||
|
description?: string;
|
||||||
|
product_type: ProductType;
|
||||||
|
tracking: TrackingType;
|
||||||
|
category_id?: string;
|
||||||
|
category_name?: string;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
purchase_uom_id?: string;
|
||||||
|
cost_price: number;
|
||||||
|
list_price: number;
|
||||||
|
valuation_method: ValuationMethod;
|
||||||
|
is_storable: boolean;
|
||||||
|
weight?: number;
|
||||||
|
volume?: number;
|
||||||
|
can_be_sold: boolean;
|
||||||
|
can_be_purchased: boolean;
|
||||||
|
image_url?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductDto {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
barcode?: string;
|
||||||
|
description?: string;
|
||||||
|
product_type?: ProductType;
|
||||||
|
tracking?: TrackingType;
|
||||||
|
category_id?: string;
|
||||||
|
uom_id: string;
|
||||||
|
purchase_uom_id?: string;
|
||||||
|
cost_price?: number;
|
||||||
|
list_price?: number;
|
||||||
|
valuation_method?: ValuationMethod;
|
||||||
|
weight?: number;
|
||||||
|
volume?: number;
|
||||||
|
can_be_sold?: boolean;
|
||||||
|
can_be_purchased?: boolean;
|
||||||
|
image_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductDto {
|
||||||
|
name?: string;
|
||||||
|
barcode?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
tracking?: TrackingType;
|
||||||
|
category_id?: string | null;
|
||||||
|
uom_id?: string;
|
||||||
|
purchase_uom_id?: string | null;
|
||||||
|
cost_price?: number;
|
||||||
|
list_price?: number;
|
||||||
|
valuation_method?: ValuationMethod;
|
||||||
|
weight?: number | null;
|
||||||
|
volume?: number | null;
|
||||||
|
can_be_sold?: boolean;
|
||||||
|
can_be_purchased?: boolean;
|
||||||
|
image_url?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductFilters {
|
||||||
|
search?: string;
|
||||||
|
category_id?: string;
|
||||||
|
product_type?: ProductType;
|
||||||
|
can_be_sold?: boolean;
|
||||||
|
can_be_purchased?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductsService {
|
||||||
|
async findAll(tenantId: string, filters: ProductFilters = {}): Promise<{ data: Product[]; total: number }> {
|
||||||
|
const { search, category_id, product_type, can_be_sold, can_be_purchased, active, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1 AND p.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.code ILIKE $${paramIndex} OR p.barcode ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category_id) {
|
||||||
|
whereClause += ` AND p.category_id = $${paramIndex++}`;
|
||||||
|
params.push(category_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product_type) {
|
||||||
|
whereClause += ` AND p.product_type = $${paramIndex++}`;
|
||||||
|
params.push(product_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (can_be_sold !== undefined) {
|
||||||
|
whereClause += ` AND p.can_be_sold = $${paramIndex++}`;
|
||||||
|
params.push(can_be_sold);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (can_be_purchased !== undefined) {
|
||||||
|
whereClause += ` AND p.can_be_purchased = $${paramIndex++}`;
|
||||||
|
params.push(can_be_purchased);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND p.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.products p ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Product>(
|
||||||
|
`SELECT p.*,
|
||||||
|
pc.name as category_name,
|
||||||
|
u.name as uom_name,
|
||||||
|
pu.name as purchase_uom_name
|
||||||
|
FROM inventory.products p
|
||||||
|
LEFT JOIN core.product_categories pc ON p.category_id = pc.id
|
||||||
|
LEFT JOIN core.uom u ON p.uom_id = u.id
|
||||||
|
LEFT JOIN core.uom pu ON p.purchase_uom_id = pu.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.name ASC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Product> {
|
||||||
|
const product = await queryOne<Product>(
|
||||||
|
`SELECT p.*,
|
||||||
|
pc.name as category_name,
|
||||||
|
u.name as uom_name,
|
||||||
|
pu.name as purchase_uom_name
|
||||||
|
FROM inventory.products p
|
||||||
|
LEFT JOIN core.product_categories pc ON p.category_id = pc.id
|
||||||
|
LEFT JOIN core.uom u ON p.uom_id = u.id
|
||||||
|
LEFT JOIN core.uom pu ON p.purchase_uom_id = pu.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2 AND p.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundError('Producto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string, tenantId: string): Promise<Product | null> {
|
||||||
|
return queryOne<Product>(
|
||||||
|
`SELECT * FROM inventory.products WHERE code = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[code, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateProductDto, tenantId: string, userId: string): Promise<Product> {
|
||||||
|
// Check unique code
|
||||||
|
if (dto.code) {
|
||||||
|
const existing = await this.findByCode(dto.code, tenantId);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe un producto con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check unique barcode
|
||||||
|
if (dto.barcode) {
|
||||||
|
const existingBarcode = await queryOne<Product>(
|
||||||
|
`SELECT id FROM inventory.products WHERE barcode = $1 AND deleted_at IS NULL`,
|
||||||
|
[dto.barcode]
|
||||||
|
);
|
||||||
|
if (existingBarcode) {
|
||||||
|
throw new ConflictError(`Ya existe un producto con código de barras ${dto.barcode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await queryOne<Product>(
|
||||||
|
`INSERT INTO inventory.products (
|
||||||
|
tenant_id, name, code, barcode, description, product_type, tracking,
|
||||||
|
category_id, uom_id, purchase_uom_id, cost_price, list_price,
|
||||||
|
valuation_method, weight, volume, can_be_sold, can_be_purchased,
|
||||||
|
image_url, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.name,
|
||||||
|
dto.code,
|
||||||
|
dto.barcode,
|
||||||
|
dto.description,
|
||||||
|
dto.product_type || 'storable',
|
||||||
|
dto.tracking || 'none',
|
||||||
|
dto.category_id,
|
||||||
|
dto.uom_id,
|
||||||
|
dto.purchase_uom_id,
|
||||||
|
dto.cost_price || 0,
|
||||||
|
dto.list_price || 0,
|
||||||
|
dto.valuation_method || 'fifo',
|
||||||
|
dto.weight,
|
||||||
|
dto.volume,
|
||||||
|
dto.can_be_sold !== false,
|
||||||
|
dto.can_be_purchased !== false,
|
||||||
|
dto.image_url,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return product!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateProductDto, tenantId: string, userId: string): Promise<Product> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check unique barcode
|
||||||
|
if (dto.barcode && dto.barcode !== existing.barcode) {
|
||||||
|
const existingBarcode = await queryOne<Product>(
|
||||||
|
`SELECT id FROM inventory.products WHERE barcode = $1 AND id != $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.barcode, id]
|
||||||
|
);
|
||||||
|
if (existingBarcode) {
|
||||||
|
throw new ConflictError(`Ya existe un producto con código de barras ${dto.barcode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.barcode !== undefined) {
|
||||||
|
updateFields.push(`barcode = $${paramIndex++}`);
|
||||||
|
values.push(dto.barcode);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.tracking !== undefined) {
|
||||||
|
updateFields.push(`tracking = $${paramIndex++}`);
|
||||||
|
values.push(dto.tracking);
|
||||||
|
}
|
||||||
|
if (dto.category_id !== undefined) {
|
||||||
|
updateFields.push(`category_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.category_id);
|
||||||
|
}
|
||||||
|
if (dto.uom_id !== undefined) {
|
||||||
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.uom_id);
|
||||||
|
}
|
||||||
|
if (dto.purchase_uom_id !== undefined) {
|
||||||
|
updateFields.push(`purchase_uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.purchase_uom_id);
|
||||||
|
}
|
||||||
|
if (dto.cost_price !== undefined) {
|
||||||
|
updateFields.push(`cost_price = $${paramIndex++}`);
|
||||||
|
values.push(dto.cost_price);
|
||||||
|
}
|
||||||
|
if (dto.list_price !== undefined) {
|
||||||
|
updateFields.push(`list_price = $${paramIndex++}`);
|
||||||
|
values.push(dto.list_price);
|
||||||
|
}
|
||||||
|
if (dto.valuation_method !== undefined) {
|
||||||
|
updateFields.push(`valuation_method = $${paramIndex++}`);
|
||||||
|
values.push(dto.valuation_method);
|
||||||
|
}
|
||||||
|
if (dto.weight !== undefined) {
|
||||||
|
updateFields.push(`weight = $${paramIndex++}`);
|
||||||
|
values.push(dto.weight);
|
||||||
|
}
|
||||||
|
if (dto.volume !== undefined) {
|
||||||
|
updateFields.push(`volume = $${paramIndex++}`);
|
||||||
|
values.push(dto.volume);
|
||||||
|
}
|
||||||
|
if (dto.can_be_sold !== undefined) {
|
||||||
|
updateFields.push(`can_be_sold = $${paramIndex++}`);
|
||||||
|
values.push(dto.can_be_sold);
|
||||||
|
}
|
||||||
|
if (dto.can_be_purchased !== undefined) {
|
||||||
|
updateFields.push(`can_be_purchased = $${paramIndex++}`);
|
||||||
|
values.push(dto.can_be_purchased);
|
||||||
|
}
|
||||||
|
if (dto.image_url !== undefined) {
|
||||||
|
updateFields.push(`image_url = $${paramIndex++}`);
|
||||||
|
values.push(dto.image_url);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const product = await queryOne<Product>(
|
||||||
|
`UPDATE inventory.products
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return product!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if product has stock
|
||||||
|
const stock = await queryOne<{ total: string }>(
|
||||||
|
`SELECT COALESCE(SUM(quantity), 0) as total FROM inventory.stock_quants
|
||||||
|
WHERE product_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseFloat(stock?.total || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un producto que tiene stock');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.products
|
||||||
|
SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1, active = false
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStock(productId: string, tenantId: string): Promise<any[]> {
|
||||||
|
await this.findById(productId, tenantId);
|
||||||
|
|
||||||
|
return query(
|
||||||
|
`SELECT sq.*, l.name as location_name, w.name as warehouse_name
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
INNER JOIN inventory.locations l ON sq.location_id = l.id
|
||||||
|
INNER JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
WHERE sq.product_id = $1
|
||||||
|
ORDER BY w.name, l.name`,
|
||||||
|
[productId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const productsService = new ProductsService();
|
||||||
230
src/modules/inventory/valuation.controller.ts
Normal file
230
src/modules/inventory/valuation.controller.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { valuationService, CreateValuationLayerDto } from './valuation.service.js';
|
||||||
|
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getProductCostSchema = z.object({
|
||||||
|
product_id: z.string().uuid(),
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLayerSchema = z.object({
|
||||||
|
product_id: z.string().uuid(),
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
unit_cost: z.number().nonnegative(),
|
||||||
|
stock_move_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const consumeFifoSchema = z.object({
|
||||||
|
product_id: z.string().uuid(),
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const productLayersSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
include_empty: z.enum(['true', 'false']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTROLLER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ValuationController {
|
||||||
|
/**
|
||||||
|
* Get cost for a product based on its valuation method
|
||||||
|
* GET /api/inventory/valuation/cost
|
||||||
|
*/
|
||||||
|
async getProductCost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = getProductCostSchema.safeParse(req.query);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Parámetros inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product_id, company_id } = validation.data;
|
||||||
|
const result = await valuationService.getProductCost(
|
||||||
|
product_id,
|
||||||
|
company_id,
|
||||||
|
req.user!.tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valuation summary for a product
|
||||||
|
* GET /api/inventory/valuation/products/:productId/summary
|
||||||
|
*/
|
||||||
|
async getProductSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
const { company_id } = req.query;
|
||||||
|
|
||||||
|
if (!company_id || typeof company_id !== 'string') {
|
||||||
|
throw new ValidationError('company_id es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await valuationService.getProductValuationSummary(
|
||||||
|
productId,
|
||||||
|
company_id,
|
||||||
|
req.user!.tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valuation layers for a product
|
||||||
|
* GET /api/inventory/valuation/products/:productId/layers
|
||||||
|
*/
|
||||||
|
async getProductLayers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
const validation = productLayersSchema.safeParse(req.query);
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Parámetros inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { company_id, include_empty } = validation.data;
|
||||||
|
const includeEmpty = include_empty === 'true';
|
||||||
|
|
||||||
|
const result = await valuationService.getProductLayers(
|
||||||
|
productId,
|
||||||
|
company_id,
|
||||||
|
req.user!.tenantId,
|
||||||
|
includeEmpty
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get company-wide valuation report
|
||||||
|
* GET /api/inventory/valuation/report
|
||||||
|
*/
|
||||||
|
async getCompanyReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { company_id } = req.query;
|
||||||
|
|
||||||
|
if (!company_id || typeof company_id !== 'string') {
|
||||||
|
throw new ValidationError('company_id es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await valuationService.getCompanyValuationReport(
|
||||||
|
company_id,
|
||||||
|
req.user!.tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
meta: {
|
||||||
|
total: result.length,
|
||||||
|
totalValue: result.reduce((sum, p) => sum + Number(p.total_value), 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a valuation layer manually (for adjustments)
|
||||||
|
* POST /api/inventory/valuation/layers
|
||||||
|
*/
|
||||||
|
async createLayer(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = createLayerSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateValuationLayerDto = validation.data;
|
||||||
|
|
||||||
|
const result = await valuationService.createLayer(
|
||||||
|
dto,
|
||||||
|
req.user!.tenantId,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Capa de valoración creada',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume stock using FIFO (for testing/manual adjustments)
|
||||||
|
* POST /api/inventory/valuation/consume
|
||||||
|
*/
|
||||||
|
async consumeFifo(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = consumeFifoSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product_id, company_id, quantity } = validation.data;
|
||||||
|
|
||||||
|
const result = await valuationService.consumeFifo(
|
||||||
|
product_id,
|
||||||
|
company_id,
|
||||||
|
quantity,
|
||||||
|
req.user!.tenantId,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: `Consumidas ${result.layers_consumed.length} capas FIFO`,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const valuationController = new ValuationController();
|
||||||
522
src/modules/inventory/valuation.service.ts
Normal file
522
src/modules/inventory/valuation.service.ts
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ValuationMethod = 'standard' | 'fifo' | 'average';
|
||||||
|
|
||||||
|
export interface StockValuationLayer {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
product_id: string;
|
||||||
|
company_id: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_cost: number;
|
||||||
|
value: number;
|
||||||
|
remaining_qty: number;
|
||||||
|
remaining_value: number;
|
||||||
|
stock_move_id?: string;
|
||||||
|
description?: string;
|
||||||
|
account_move_id?: string;
|
||||||
|
journal_entry_id?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateValuationLayerDto {
|
||||||
|
product_id: string;
|
||||||
|
company_id: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_cost: number;
|
||||||
|
stock_move_id?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationSummary {
|
||||||
|
product_id: string;
|
||||||
|
product_name: string;
|
||||||
|
product_code?: string;
|
||||||
|
total_quantity: number;
|
||||||
|
total_value: number;
|
||||||
|
average_cost: number;
|
||||||
|
valuation_method: ValuationMethod;
|
||||||
|
layer_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FifoConsumptionResult {
|
||||||
|
layers_consumed: {
|
||||||
|
layer_id: string;
|
||||||
|
quantity_consumed: number;
|
||||||
|
unit_cost: number;
|
||||||
|
value_consumed: number;
|
||||||
|
}[];
|
||||||
|
total_cost: number;
|
||||||
|
weighted_average_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductCostResult {
|
||||||
|
product_id: string;
|
||||||
|
valuation_method: ValuationMethod;
|
||||||
|
standard_cost: number;
|
||||||
|
fifo_cost?: number;
|
||||||
|
average_cost: number;
|
||||||
|
recommended_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ValuationService {
|
||||||
|
/**
|
||||||
|
* Create a new valuation layer (for incoming stock)
|
||||||
|
* Used when receiving products via purchase orders or inventory adjustments
|
||||||
|
*/
|
||||||
|
async createLayer(
|
||||||
|
dto: CreateValuationLayerDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<StockValuationLayer> {
|
||||||
|
const executeQuery = client
|
||||||
|
? (sql: string, params: any[]) => client.query(sql, params).then(r => r.rows[0])
|
||||||
|
: queryOne;
|
||||||
|
|
||||||
|
const value = dto.quantity * dto.unit_cost;
|
||||||
|
|
||||||
|
const layer = await executeQuery(
|
||||||
|
`INSERT INTO inventory.stock_valuation_layers (
|
||||||
|
tenant_id, product_id, company_id, quantity, unit_cost, value,
|
||||||
|
remaining_qty, remaining_value, stock_move_id, description, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $4, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.product_id,
|
||||||
|
dto.company_id,
|
||||||
|
dto.quantity,
|
||||||
|
dto.unit_cost,
|
||||||
|
value,
|
||||||
|
dto.stock_move_id,
|
||||||
|
dto.description,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Valuation layer created', {
|
||||||
|
layerId: layer?.id,
|
||||||
|
productId: dto.product_id,
|
||||||
|
quantity: dto.quantity,
|
||||||
|
unitCost: dto.unit_cost,
|
||||||
|
});
|
||||||
|
|
||||||
|
return layer as StockValuationLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume stock using FIFO method
|
||||||
|
* Returns the layers consumed and total cost
|
||||||
|
*/
|
||||||
|
async consumeFifo(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
quantity: number,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<FifoConsumptionResult> {
|
||||||
|
const dbClient = client || await getClient();
|
||||||
|
const shouldReleaseClient = !client;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!client) {
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available layers ordered by creation date (FIFO)
|
||||||
|
const layersResult = await dbClient.query(
|
||||||
|
`SELECT * FROM inventory.stock_valuation_layers
|
||||||
|
WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3
|
||||||
|
AND remaining_qty > 0
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
FOR UPDATE`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const layers = layersResult.rows as StockValuationLayer[];
|
||||||
|
let remainingToConsume = quantity;
|
||||||
|
const consumedLayers: FifoConsumptionResult['layers_consumed'] = [];
|
||||||
|
let totalCost = 0;
|
||||||
|
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (remainingToConsume <= 0) break;
|
||||||
|
|
||||||
|
const consumeFromLayer = Math.min(remainingToConsume, Number(layer.remaining_qty));
|
||||||
|
const valueConsumed = consumeFromLayer * Number(layer.unit_cost);
|
||||||
|
|
||||||
|
// Update layer
|
||||||
|
await dbClient.query(
|
||||||
|
`UPDATE inventory.stock_valuation_layers
|
||||||
|
SET remaining_qty = remaining_qty - $1,
|
||||||
|
remaining_value = remaining_value - $2,
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = $3
|
||||||
|
WHERE id = $4`,
|
||||||
|
[consumeFromLayer, valueConsumed, userId, layer.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
consumedLayers.push({
|
||||||
|
layer_id: layer.id,
|
||||||
|
quantity_consumed: consumeFromLayer,
|
||||||
|
unit_cost: Number(layer.unit_cost),
|
||||||
|
value_consumed: valueConsumed,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalCost += valueConsumed;
|
||||||
|
remainingToConsume -= consumeFromLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingToConsume > 0) {
|
||||||
|
// Not enough stock in layers - this is a warning, not an error
|
||||||
|
// The stock might exist without valuation layers (e.g., initial data)
|
||||||
|
logger.warn('Insufficient valuation layers for FIFO consumption', {
|
||||||
|
productId,
|
||||||
|
requestedQty: quantity,
|
||||||
|
availableQty: quantity - remainingToConsume,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightedAvgCost = quantity > 0 ? totalCost / (quantity - remainingToConsume) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
layers_consumed: consumedLayers,
|
||||||
|
total_cost: totalCost,
|
||||||
|
weighted_average_cost: weightedAvgCost,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!client) {
|
||||||
|
await dbClient.query('ROLLBACK');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (shouldReleaseClient) {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the current cost of a product based on its valuation method
|
||||||
|
*/
|
||||||
|
async getProductCost(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<ProductCostResult> {
|
||||||
|
// Get product with its valuation method and standard cost
|
||||||
|
const product = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
valuation_method: ValuationMethod;
|
||||||
|
cost_price: number;
|
||||||
|
}>(
|
||||||
|
`SELECT id, valuation_method, cost_price
|
||||||
|
FROM inventory.products
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[productId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundError('Producto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get FIFO cost (oldest layer's unit cost)
|
||||||
|
const oldestLayer = await queryOne<{ unit_cost: number }>(
|
||||||
|
`SELECT unit_cost FROM inventory.stock_valuation_layers
|
||||||
|
WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3
|
||||||
|
AND remaining_qty > 0
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get average cost from all layers
|
||||||
|
const avgResult = await queryOne<{ avg_cost: number; total_qty: number }>(
|
||||||
|
`SELECT
|
||||||
|
CASE WHEN SUM(remaining_qty) > 0
|
||||||
|
THEN SUM(remaining_value) / SUM(remaining_qty)
|
||||||
|
ELSE 0
|
||||||
|
END as avg_cost,
|
||||||
|
SUM(remaining_qty) as total_qty
|
||||||
|
FROM inventory.stock_valuation_layers
|
||||||
|
WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3
|
||||||
|
AND remaining_qty > 0`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const standardCost = Number(product.cost_price) || 0;
|
||||||
|
const fifoCost = oldestLayer ? Number(oldestLayer.unit_cost) : undefined;
|
||||||
|
const averageCost = Number(avgResult?.avg_cost) || 0;
|
||||||
|
|
||||||
|
// Determine recommended cost based on valuation method
|
||||||
|
let recommendedCost: number;
|
||||||
|
switch (product.valuation_method) {
|
||||||
|
case 'fifo':
|
||||||
|
recommendedCost = fifoCost ?? standardCost;
|
||||||
|
break;
|
||||||
|
case 'average':
|
||||||
|
recommendedCost = averageCost > 0 ? averageCost : standardCost;
|
||||||
|
break;
|
||||||
|
case 'standard':
|
||||||
|
default:
|
||||||
|
recommendedCost = standardCost;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
product_id: productId,
|
||||||
|
valuation_method: product.valuation_method,
|
||||||
|
standard_cost: standardCost,
|
||||||
|
fifo_cost: fifoCost,
|
||||||
|
average_cost: averageCost,
|
||||||
|
recommended_cost: recommendedCost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valuation summary for a product
|
||||||
|
*/
|
||||||
|
async getProductValuationSummary(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<ValuationSummary | null> {
|
||||||
|
const result = await queryOne<ValuationSummary>(
|
||||||
|
`SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
p.valuation_method,
|
||||||
|
COALESCE(SUM(svl.remaining_qty), 0) as total_quantity,
|
||||||
|
COALESCE(SUM(svl.remaining_value), 0) as total_value,
|
||||||
|
CASE WHEN COALESCE(SUM(svl.remaining_qty), 0) > 0
|
||||||
|
THEN COALESCE(SUM(svl.remaining_value), 0) / SUM(svl.remaining_qty)
|
||||||
|
ELSE p.cost_price
|
||||||
|
END as average_cost,
|
||||||
|
COUNT(CASE WHEN svl.remaining_qty > 0 THEN 1 END) as layer_count
|
||||||
|
FROM inventory.products p
|
||||||
|
LEFT JOIN inventory.stock_valuation_layers svl
|
||||||
|
ON p.id = svl.product_id
|
||||||
|
AND svl.company_id = $2
|
||||||
|
AND svl.tenant_id = $3
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $3
|
||||||
|
GROUP BY p.id, p.name, p.code, p.valuation_method, p.cost_price`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all valuation layers for a product
|
||||||
|
*/
|
||||||
|
async getProductLayers(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string,
|
||||||
|
includeEmpty: boolean = false
|
||||||
|
): Promise<StockValuationLayer[]> {
|
||||||
|
const whereClause = includeEmpty
|
||||||
|
? ''
|
||||||
|
: 'AND remaining_qty > 0';
|
||||||
|
|
||||||
|
return query<StockValuationLayer>(
|
||||||
|
`SELECT * FROM inventory.stock_valuation_layers
|
||||||
|
WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inventory valuation report for a company
|
||||||
|
*/
|
||||||
|
async getCompanyValuationReport(
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<ValuationSummary[]> {
|
||||||
|
return query<ValuationSummary>(
|
||||||
|
`SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
p.valuation_method,
|
||||||
|
COALESCE(SUM(svl.remaining_qty), 0) as total_quantity,
|
||||||
|
COALESCE(SUM(svl.remaining_value), 0) as total_value,
|
||||||
|
CASE WHEN COALESCE(SUM(svl.remaining_qty), 0) > 0
|
||||||
|
THEN COALESCE(SUM(svl.remaining_value), 0) / SUM(svl.remaining_qty)
|
||||||
|
ELSE p.cost_price
|
||||||
|
END as average_cost,
|
||||||
|
COUNT(CASE WHEN svl.remaining_qty > 0 THEN 1 END) as layer_count
|
||||||
|
FROM inventory.products p
|
||||||
|
LEFT JOIN inventory.stock_valuation_layers svl
|
||||||
|
ON p.id = svl.product_id
|
||||||
|
AND svl.company_id = $1
|
||||||
|
AND svl.tenant_id = $2
|
||||||
|
WHERE p.tenant_id = $2
|
||||||
|
AND p.product_type = 'storable'
|
||||||
|
AND p.active = true
|
||||||
|
GROUP BY p.id, p.name, p.code, p.valuation_method, p.cost_price
|
||||||
|
HAVING COALESCE(SUM(svl.remaining_qty), 0) > 0
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[companyId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update average cost on product after valuation changes
|
||||||
|
* Call this after creating layers or consuming stock
|
||||||
|
*/
|
||||||
|
async updateProductAverageCost(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<void> {
|
||||||
|
const executeQuery = client
|
||||||
|
? (sql: string, params: any[]) => client.query(sql, params)
|
||||||
|
: query;
|
||||||
|
|
||||||
|
// Only update products using average cost method
|
||||||
|
await executeQuery(
|
||||||
|
`UPDATE inventory.products p
|
||||||
|
SET cost_price = (
|
||||||
|
SELECT CASE WHEN SUM(svl.remaining_qty) > 0
|
||||||
|
THEN SUM(svl.remaining_value) / SUM(svl.remaining_qty)
|
||||||
|
ELSE p.cost_price
|
||||||
|
END
|
||||||
|
FROM inventory.stock_valuation_layers svl
|
||||||
|
WHERE svl.product_id = p.id
|
||||||
|
AND svl.company_id = $2
|
||||||
|
AND svl.tenant_id = $3
|
||||||
|
AND svl.remaining_qty > 0
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE p.id = $1
|
||||||
|
AND p.tenant_id = $3
|
||||||
|
AND p.valuation_method = 'average'`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process stock move for valuation
|
||||||
|
* Creates or consumes valuation layers based on move direction
|
||||||
|
*/
|
||||||
|
async processStockMoveValuation(
|
||||||
|
moveId: string,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const move = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
product_id: string;
|
||||||
|
product_qty: number;
|
||||||
|
location_id: string;
|
||||||
|
location_dest_id: string;
|
||||||
|
company_id: string;
|
||||||
|
}>(
|
||||||
|
`SELECT sm.id, sm.product_id, sm.product_qty,
|
||||||
|
sm.location_id, sm.location_dest_id,
|
||||||
|
p.company_id
|
||||||
|
FROM inventory.stock_moves sm
|
||||||
|
JOIN inventory.pickings p ON sm.picking_id = p.id
|
||||||
|
WHERE sm.id = $1 AND sm.tenant_id = $2`,
|
||||||
|
[moveId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!move) {
|
||||||
|
throw new NotFoundError('Movimiento no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get location types
|
||||||
|
const [srcLoc, destLoc] = await Promise.all([
|
||||||
|
queryOne<{ location_type: string }>(
|
||||||
|
'SELECT location_type FROM inventory.locations WHERE id = $1',
|
||||||
|
[move.location_id]
|
||||||
|
),
|
||||||
|
queryOne<{ location_type: string }>(
|
||||||
|
'SELECT location_type FROM inventory.locations WHERE id = $1',
|
||||||
|
[move.location_dest_id]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const srcIsInternal = srcLoc?.location_type === 'internal';
|
||||||
|
const destIsInternal = destLoc?.location_type === 'internal';
|
||||||
|
|
||||||
|
// Get product cost for new layers
|
||||||
|
const product = await queryOne<{ cost_price: number; valuation_method: string }>(
|
||||||
|
'SELECT cost_price, valuation_method FROM inventory.products WHERE id = $1',
|
||||||
|
[move.product_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!product) return;
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Incoming to internal location (create layer)
|
||||||
|
if (!srcIsInternal && destIsInternal) {
|
||||||
|
await this.createLayer({
|
||||||
|
product_id: move.product_id,
|
||||||
|
company_id: move.company_id,
|
||||||
|
quantity: Number(move.product_qty),
|
||||||
|
unit_cost: Number(product.cost_price),
|
||||||
|
stock_move_id: move.id,
|
||||||
|
description: `Recepción - Move ${move.id}`,
|
||||||
|
}, tenantId, userId, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outgoing from internal location (consume layer with FIFO)
|
||||||
|
if (srcIsInternal && !destIsInternal) {
|
||||||
|
if (product.valuation_method === 'fifo' || product.valuation_method === 'average') {
|
||||||
|
await this.consumeFifo(
|
||||||
|
move.product_id,
|
||||||
|
move.company_id,
|
||||||
|
Number(move.product_qty),
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update average cost if using that method
|
||||||
|
if (product.valuation_method === 'average') {
|
||||||
|
await this.updateProductAverageCost(
|
||||||
|
move.product_id,
|
||||||
|
move.company_id,
|
||||||
|
tenantId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const valuationService = new ValuationService();
|
||||||
234
src/modules/inventory/warehouses.service.ts
Normal file
234
src/modules/inventory/warehouses.service.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Warehouse {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
address_id?: string;
|
||||||
|
is_default: boolean;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
warehouse_id: string;
|
||||||
|
warehouse_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
location_type: 'internal' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
|
||||||
|
parent_id?: string;
|
||||||
|
is_scrap: boolean;
|
||||||
|
is_return: boolean;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWarehouseDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
address_id?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWarehouseDto {
|
||||||
|
name?: string;
|
||||||
|
address_id?: string | null;
|
||||||
|
is_default?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseFilters {
|
||||||
|
company_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WarehousesService {
|
||||||
|
async findAll(tenantId: string, filters: WarehouseFilters = {}): Promise<{ data: Warehouse[]; total: number }> {
|
||||||
|
const { company_id, active, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE w.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND w.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND w.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.warehouses w ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Warehouse>(
|
||||||
|
`SELECT w.*, c.name as company_name
|
||||||
|
FROM inventory.warehouses w
|
||||||
|
LEFT JOIN auth.companies c ON w.company_id = c.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY w.name ASC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Warehouse> {
|
||||||
|
const warehouse = await queryOne<Warehouse>(
|
||||||
|
`SELECT w.*, c.name as company_name
|
||||||
|
FROM inventory.warehouses w
|
||||||
|
LEFT JOIN auth.companies c ON w.company_id = c.id
|
||||||
|
WHERE w.id = $1 AND w.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!warehouse) {
|
||||||
|
throw new NotFoundError('Almacén no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return warehouse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateWarehouseDto, tenantId: string, userId: string): Promise<Warehouse> {
|
||||||
|
// Check unique code within company
|
||||||
|
const existing = await queryOne<Warehouse>(
|
||||||
|
`SELECT id FROM inventory.warehouses WHERE company_id = $1 AND code = $2`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe un almacén con código ${dto.code} en esta empresa`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If is_default, clear other defaults for company
|
||||||
|
if (dto.is_default) {
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.warehouses SET is_default = false WHERE company_id = $1 AND tenant_id = $2`,
|
||||||
|
[dto.company_id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warehouse = await queryOne<Warehouse>(
|
||||||
|
`INSERT INTO inventory.warehouses (tenant_id, company_id, name, code, address_id, is_default, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.code, dto.address_id, dto.is_default || false, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return warehouse!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateWarehouseDto, tenantId: string, userId: string): Promise<Warehouse> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// If setting as default, clear other defaults
|
||||||
|
if (dto.is_default) {
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.warehouses SET is_default = false WHERE company_id = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[existing.company_id, tenantId, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.address_id !== undefined) {
|
||||||
|
updateFields.push(`address_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.address_id);
|
||||||
|
}
|
||||||
|
if (dto.is_default !== undefined) {
|
||||||
|
updateFields.push(`is_default = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_default);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const warehouse = await queryOne<Warehouse>(
|
||||||
|
`UPDATE inventory.warehouses SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return warehouse!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if warehouse has locations with stock
|
||||||
|
const hasStock = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.stock_quants sq
|
||||||
|
INNER JOIN inventory.locations l ON sq.location_id = l.id
|
||||||
|
WHERE l.warehouse_id = $1 AND sq.quantity > 0`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasStock?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un almacén que tiene stock');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.warehouses WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locations
|
||||||
|
async getLocations(warehouseId: string, tenantId: string): Promise<Location[]> {
|
||||||
|
await this.findById(warehouseId, tenantId);
|
||||||
|
|
||||||
|
return query<Location>(
|
||||||
|
`SELECT l.*, w.name as warehouse_name
|
||||||
|
FROM inventory.locations l
|
||||||
|
INNER JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
WHERE l.warehouse_id = $1 AND l.tenant_id = $2
|
||||||
|
ORDER BY l.name`,
|
||||||
|
[warehouseId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStock(warehouseId: string, tenantId: string): Promise<any[]> {
|
||||||
|
await this.findById(warehouseId, tenantId);
|
||||||
|
|
||||||
|
return query(
|
||||||
|
`SELECT sq.*, p.name as product_name, p.code as product_code, l.name as location_name
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
INNER JOIN inventory.products p ON sq.product_id = p.id
|
||||||
|
INNER JOIN inventory.locations l ON sq.location_id = l.id
|
||||||
|
WHERE l.warehouse_id = $1
|
||||||
|
ORDER BY p.name, l.name`,
|
||||||
|
[warehouseId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const warehousesService = new WarehousesService();
|
||||||
5
src/modules/partners/index.ts
Normal file
5
src/modules/partners/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './partners.service.js';
|
||||||
|
export * from './partners.controller.js';
|
||||||
|
export * from './ranking.service.js';
|
||||||
|
export * from './ranking.controller.js';
|
||||||
|
export { default as partnersRoutes } from './partners.routes.js';
|
||||||
201
src/modules/partners/partners.controller.ts
Normal file
201
src/modules/partners/partners.controller.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { partnersService, CreatePartnerDto, UpdatePartnerDto, PartnerFilters } from './partners.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
const createPartnerSchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
legal_name: z.string().max(255).optional(),
|
||||||
|
partner_type: z.enum(['person', 'company']).default('person'),
|
||||||
|
is_customer: z.boolean().default(false),
|
||||||
|
is_supplier: z.boolean().default(false),
|
||||||
|
is_employee: z.boolean().default(false),
|
||||||
|
is_company: z.boolean().default(false),
|
||||||
|
email: z.string().email('Email inválido').max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
mobile: z.string().max(50).optional(),
|
||||||
|
website: z.string().url('URL inválida').max(255).optional(),
|
||||||
|
tax_id: z.string().max(50).optional(),
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePartnerSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
legal_name: z.string().max(255).optional().nullable(),
|
||||||
|
is_customer: z.boolean().optional(),
|
||||||
|
is_supplier: z.boolean().optional(),
|
||||||
|
is_employee: z.boolean().optional(),
|
||||||
|
email: z.string().email('Email inválido').max(255).optional().nullable(),
|
||||||
|
phone: z.string().max(50).optional().nullable(),
|
||||||
|
mobile: z.string().max(50).optional().nullable(),
|
||||||
|
website: z.string().url('URL inválida').max(255).optional().nullable(),
|
||||||
|
tax_id: z.string().max(50).optional().nullable(),
|
||||||
|
company_id: z.string().uuid().optional().nullable(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
is_customer: z.coerce.boolean().optional(),
|
||||||
|
is_supplier: z.coerce.boolean().optional(),
|
||||||
|
is_employee: z.coerce.boolean().optional(),
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class PartnersController {
|
||||||
|
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: PartnerFilters = queryResult.data;
|
||||||
|
const result = await partnersService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCustomers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = queryResult.data;
|
||||||
|
const result = await partnersService.findCustomers(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSuppliers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = queryResult.data;
|
||||||
|
const result = await partnersService.findSuppliers(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const partner = await partnersService.findById(id, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: partner,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPartnerSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de contacto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePartnerDto = parseResult.data;
|
||||||
|
const partner = await partnersService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: partner,
|
||||||
|
message: 'Contacto creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const parseResult = updatePartnerSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de contacto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdatePartnerDto = parseResult.data;
|
||||||
|
const partner = await partnersService.update(id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: partner,
|
||||||
|
message: 'Contacto actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
await partnersService.delete(id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Contacto eliminado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const partnersController = new PartnersController();
|
||||||
90
src/modules/partners/partners.routes.ts
Normal file
90
src/modules/partners/partners.routes.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { partnersController } from './partners.controller.js';
|
||||||
|
import { rankingController } from './ranking.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RANKING ROUTES (must be before /:id routes to avoid conflicts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Calculate rankings (admin, manager)
|
||||||
|
router.post('/rankings/calculate', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.calculateRankings(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all rankings
|
||||||
|
router.get('/rankings', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.findRankings(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top partners
|
||||||
|
router.get('/rankings/top/customers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getTopCustomers(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rankings/top/suppliers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getTopSuppliers(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ABC distribution
|
||||||
|
router.get('/rankings/abc/customers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getCustomerABCDistribution(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rankings/abc/suppliers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getSupplierABCDistribution(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Partners by ABC
|
||||||
|
router.get('/rankings/abc/customers/:abc', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getCustomersByABC(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rankings/abc/suppliers/:abc', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getSuppliersByABC(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Partner-specific ranking
|
||||||
|
router.get('/rankings/partner/:partnerId', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.findPartnerRanking(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rankings/partner/:partnerId/history', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getPartnerHistory(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PARTNER ROUTES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Convenience endpoints for customers and suppliers
|
||||||
|
router.get('/customers', (req, res, next) => partnersController.findCustomers(req, res, next));
|
||||||
|
router.get('/suppliers', (req, res, next) => partnersController.findSuppliers(req, res, next));
|
||||||
|
|
||||||
|
// List all partners (admin, manager, sales, accountant)
|
||||||
|
router.get('/', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.findAll(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get partner by ID
|
||||||
|
router.get('/:id', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.findById(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create partner (admin, manager, sales)
|
||||||
|
router.post('/', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.create(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update partner (admin, manager, sales)
|
||||||
|
router.put('/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.update(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete partner (admin only)
|
||||||
|
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.delete(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
344
src/modules/partners/partners.service.ts
Normal file
344
src/modules/partners/partners.service.ts
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type PartnerType = 'person' | 'company';
|
||||||
|
|
||||||
|
export interface Partner {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
legal_name?: string;
|
||||||
|
partner_type: PartnerType;
|
||||||
|
is_customer: boolean;
|
||||||
|
is_supplier: boolean;
|
||||||
|
is_employee: boolean;
|
||||||
|
is_company: boolean;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
tax_id?: string;
|
||||||
|
company_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePartnerDto {
|
||||||
|
name: string;
|
||||||
|
legal_name?: string;
|
||||||
|
partner_type?: PartnerType;
|
||||||
|
is_customer?: boolean;
|
||||||
|
is_supplier?: boolean;
|
||||||
|
is_employee?: boolean;
|
||||||
|
is_company?: boolean;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
tax_id?: string;
|
||||||
|
company_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePartnerDto {
|
||||||
|
name?: string;
|
||||||
|
legal_name?: string | null;
|
||||||
|
is_customer?: boolean;
|
||||||
|
is_supplier?: boolean;
|
||||||
|
is_employee?: boolean;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
tax_id?: string | null;
|
||||||
|
company_id?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
currency_id?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerFilters {
|
||||||
|
search?: string;
|
||||||
|
is_customer?: boolean;
|
||||||
|
is_supplier?: boolean;
|
||||||
|
is_employee?: boolean;
|
||||||
|
company_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PartnersService {
|
||||||
|
async findAll(tenantId: string, filters: PartnerFilters = {}): Promise<{ data: Partner[]; total: number }> {
|
||||||
|
const { search, is_customer, is_supplier, is_employee, company_id, active, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1 AND p.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.legal_name ILIKE $${paramIndex} OR p.email ILIKE $${paramIndex} OR p.tax_id ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_customer !== undefined) {
|
||||||
|
whereClause += ` AND p.is_customer = $${paramIndex++}`;
|
||||||
|
params.push(is_customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_supplier !== undefined) {
|
||||||
|
whereClause += ` AND p.is_supplier = $${paramIndex++}`;
|
||||||
|
params.push(is_supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_employee !== undefined) {
|
||||||
|
whereClause += ` AND p.is_employee = $${paramIndex++}`;
|
||||||
|
params.push(is_employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND p.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND p.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM core.partners p ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Partner>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
cur.code as currency_code,
|
||||||
|
pp.name as parent_name
|
||||||
|
FROM core.partners p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.currencies cur ON p.currency_id = cur.id
|
||||||
|
LEFT JOIN core.partners pp ON p.parent_id = pp.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.name ASC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Partner> {
|
||||||
|
const partner = await queryOne<Partner>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
cur.code as currency_code,
|
||||||
|
pp.name as parent_name
|
||||||
|
FROM core.partners p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.currencies cur ON p.currency_id = cur.id
|
||||||
|
LEFT JOIN core.partners pp ON p.parent_id = pp.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2 AND p.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partner) {
|
||||||
|
throw new NotFoundError('Contacto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return partner;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePartnerDto, tenantId: string, userId: string): Promise<Partner> {
|
||||||
|
// Validate parent partner exists
|
||||||
|
if (dto.parent_id) {
|
||||||
|
const parent = await queryOne<Partner>(
|
||||||
|
`SELECT id FROM core.partners WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.parent_id, tenantId]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Contacto padre no encontrado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = await queryOne<Partner>(
|
||||||
|
`INSERT INTO core.partners (
|
||||||
|
tenant_id, name, legal_name, partner_type, is_customer, is_supplier,
|
||||||
|
is_employee, is_company, email, phone, mobile, website, tax_id,
|
||||||
|
company_id, parent_id, currency_id, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.name,
|
||||||
|
dto.legal_name,
|
||||||
|
dto.partner_type || 'person',
|
||||||
|
dto.is_customer || false,
|
||||||
|
dto.is_supplier || false,
|
||||||
|
dto.is_employee || false,
|
||||||
|
dto.is_company || false,
|
||||||
|
dto.email?.toLowerCase(),
|
||||||
|
dto.phone,
|
||||||
|
dto.mobile,
|
||||||
|
dto.website,
|
||||||
|
dto.tax_id,
|
||||||
|
dto.company_id,
|
||||||
|
dto.parent_id,
|
||||||
|
dto.currency_id,
|
||||||
|
dto.notes,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return partner!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePartnerDto, tenantId: string, userId: string): Promise<Partner> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate parent (prevent self-reference)
|
||||||
|
if (dto.parent_id) {
|
||||||
|
if (dto.parent_id === id) {
|
||||||
|
throw new ConflictError('Un contacto no puede ser su propio padre');
|
||||||
|
}
|
||||||
|
const parent = await queryOne<Partner>(
|
||||||
|
`SELECT id FROM core.partners WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.parent_id, tenantId]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Contacto padre no encontrado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.legal_name !== undefined) {
|
||||||
|
updateFields.push(`legal_name = $${paramIndex++}`);
|
||||||
|
values.push(dto.legal_name);
|
||||||
|
}
|
||||||
|
if (dto.is_customer !== undefined) {
|
||||||
|
updateFields.push(`is_customer = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_customer);
|
||||||
|
}
|
||||||
|
if (dto.is_supplier !== undefined) {
|
||||||
|
updateFields.push(`is_supplier = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_supplier);
|
||||||
|
}
|
||||||
|
if (dto.is_employee !== undefined) {
|
||||||
|
updateFields.push(`is_employee = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_employee);
|
||||||
|
}
|
||||||
|
if (dto.email !== undefined) {
|
||||||
|
updateFields.push(`email = $${paramIndex++}`);
|
||||||
|
values.push(dto.email?.toLowerCase());
|
||||||
|
}
|
||||||
|
if (dto.phone !== undefined) {
|
||||||
|
updateFields.push(`phone = $${paramIndex++}`);
|
||||||
|
values.push(dto.phone);
|
||||||
|
}
|
||||||
|
if (dto.mobile !== undefined) {
|
||||||
|
updateFields.push(`mobile = $${paramIndex++}`);
|
||||||
|
values.push(dto.mobile);
|
||||||
|
}
|
||||||
|
if (dto.website !== undefined) {
|
||||||
|
updateFields.push(`website = $${paramIndex++}`);
|
||||||
|
values.push(dto.website);
|
||||||
|
}
|
||||||
|
if (dto.tax_id !== undefined) {
|
||||||
|
updateFields.push(`tax_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_id);
|
||||||
|
}
|
||||||
|
if (dto.company_id !== undefined) {
|
||||||
|
updateFields.push(`company_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.company_id);
|
||||||
|
}
|
||||||
|
if (dto.parent_id !== undefined) {
|
||||||
|
updateFields.push(`parent_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.parent_id);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const partner = await queryOne<Partner>(
|
||||||
|
`UPDATE core.partners
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return partner!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if has child contacts
|
||||||
|
const children = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM core.partners
|
||||||
|
WHERE parent_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(children?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un contacto que tiene contactos relacionados');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE core.partners
|
||||||
|
SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1, active = false
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get customers only
|
||||||
|
async findCustomers(tenantId: string, filters: Omit<PartnerFilters, 'is_customer'>): Promise<{ data: Partner[]; total: number }> {
|
||||||
|
return this.findAll(tenantId, { ...filters, is_customer: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get suppliers only
|
||||||
|
async findSuppliers(tenantId: string, filters: Omit<PartnerFilters, 'is_supplier'>): Promise<{ data: Partner[]; total: number }> {
|
||||||
|
return this.findAll(tenantId, { ...filters, is_supplier: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const partnersService = new PartnersService();
|
||||||
368
src/modules/partners/ranking.controller.ts
Normal file
368
src/modules/partners/ranking.controller.ts
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/types/index.js';
|
||||||
|
import { rankingService, ABCClassification } from './ranking.service.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const calculateRankingsSchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
period_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankingFiltersSchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
period_start: z.string().optional(),
|
||||||
|
period_end: z.string().optional(),
|
||||||
|
customer_abc: z.enum(['A', 'B', 'C']).optional(),
|
||||||
|
supplier_abc: z.enum(['A', 'B', 'C']).optional(),
|
||||||
|
min_sales: z.coerce.number().min(0).optional(),
|
||||||
|
min_purchases: z.coerce.number().min(0).optional(),
|
||||||
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTROLLER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class RankingController {
|
||||||
|
/**
|
||||||
|
* POST /rankings/calculate
|
||||||
|
* Calculate partner rankings
|
||||||
|
*/
|
||||||
|
async calculateRankings(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { company_id, period_start, period_end } = calculateRankingsSchema.parse(req.body);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const result = await rankingService.calculateRankings(
|
||||||
|
tenantId,
|
||||||
|
company_id,
|
||||||
|
period_start,
|
||||||
|
period_end
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Rankings calculados exitosamente',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings
|
||||||
|
* List all rankings with filters
|
||||||
|
*/
|
||||||
|
async findRankings(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const filters = rankingFiltersSchema.parse(req.query);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const { data, total } = await rankingService.findRankings(tenantId, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / filters.limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/partner/:partnerId
|
||||||
|
* Get ranking for a specific partner
|
||||||
|
*/
|
||||||
|
async findPartnerRanking(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { partnerId } = req.params;
|
||||||
|
const { period_start, period_end } = req.query as {
|
||||||
|
period_start?: string;
|
||||||
|
period_end?: string;
|
||||||
|
};
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const ranking = await rankingService.findPartnerRanking(
|
||||||
|
partnerId,
|
||||||
|
tenantId,
|
||||||
|
period_start,
|
||||||
|
period_end
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ranking) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No se encontró ranking para este contacto',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: ranking,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/partner/:partnerId/history
|
||||||
|
* Get ranking history for a partner
|
||||||
|
*/
|
||||||
|
async getPartnerHistory(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { partnerId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 12;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const history = await rankingService.getPartnerRankingHistory(
|
||||||
|
partnerId,
|
||||||
|
tenantId,
|
||||||
|
Math.min(limit, 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: history,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/top/customers
|
||||||
|
* Get top customers
|
||||||
|
*/
|
||||||
|
async getTopCustomers(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const data = await rankingService.getTopPartners(
|
||||||
|
tenantId,
|
||||||
|
'customers',
|
||||||
|
Math.min(limit, 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/top/suppliers
|
||||||
|
* Get top suppliers
|
||||||
|
*/
|
||||||
|
async getTopSuppliers(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const data = await rankingService.getTopPartners(
|
||||||
|
tenantId,
|
||||||
|
'suppliers',
|
||||||
|
Math.min(limit, 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/abc/customers
|
||||||
|
* Get ABC distribution for customers
|
||||||
|
*/
|
||||||
|
async getCustomerABCDistribution(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { company_id } = req.query as { company_id?: string };
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const distribution = await rankingService.getABCDistribution(
|
||||||
|
tenantId,
|
||||||
|
'customers',
|
||||||
|
company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: distribution,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/abc/suppliers
|
||||||
|
* Get ABC distribution for suppliers
|
||||||
|
*/
|
||||||
|
async getSupplierABCDistribution(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { company_id } = req.query as { company_id?: string };
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const distribution = await rankingService.getABCDistribution(
|
||||||
|
tenantId,
|
||||||
|
'suppliers',
|
||||||
|
company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: distribution,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/abc/customers/:abc
|
||||||
|
* Get customers by ABC classification
|
||||||
|
*/
|
||||||
|
async getCustomersByABC(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const abc = req.params.abc.toUpperCase() as ABCClassification;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
if (!['A', 'B', 'C'].includes(abc || '')) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Clasificación ABC inválida. Use A, B o C.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, total } = await rankingService.findPartnersByABC(
|
||||||
|
tenantId,
|
||||||
|
abc,
|
||||||
|
'customers',
|
||||||
|
page,
|
||||||
|
Math.min(limit, 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/abc/suppliers/:abc
|
||||||
|
* Get suppliers by ABC classification
|
||||||
|
*/
|
||||||
|
async getSuppliersByABC(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const abc = req.params.abc.toUpperCase() as ABCClassification;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
if (!['A', 'B', 'C'].includes(abc || '')) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Clasificación ABC inválida. Use A, B o C.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, total } = await rankingService.findPartnersByABC(
|
||||||
|
tenantId,
|
||||||
|
abc,
|
||||||
|
'suppliers',
|
||||||
|
page,
|
||||||
|
Math.min(limit, 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rankingController = new RankingController();
|
||||||
373
src/modules/partners/ranking.service.ts
Normal file
373
src/modules/partners/ranking.service.ts
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ABCClassification = 'A' | 'B' | 'C' | null;
|
||||||
|
|
||||||
|
export interface PartnerRanking {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
company_id: string | null;
|
||||||
|
period_start: Date;
|
||||||
|
period_end: Date;
|
||||||
|
total_sales: number;
|
||||||
|
sales_order_count: number;
|
||||||
|
avg_order_value: number;
|
||||||
|
total_purchases: number;
|
||||||
|
purchase_order_count: number;
|
||||||
|
avg_purchase_value: number;
|
||||||
|
avg_payment_days: number | null;
|
||||||
|
on_time_payment_rate: number | null;
|
||||||
|
sales_rank: number | null;
|
||||||
|
purchase_rank: number | null;
|
||||||
|
customer_abc: ABCClassification;
|
||||||
|
supplier_abc: ABCClassification;
|
||||||
|
customer_score: number | null;
|
||||||
|
supplier_score: number | null;
|
||||||
|
overall_score: number | null;
|
||||||
|
sales_trend: number | null;
|
||||||
|
purchase_trend: number | null;
|
||||||
|
calculated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankingCalculationResult {
|
||||||
|
partners_processed: number;
|
||||||
|
customers_ranked: number;
|
||||||
|
suppliers_ranked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankingFilters {
|
||||||
|
company_id?: string;
|
||||||
|
period_start?: string;
|
||||||
|
period_end?: string;
|
||||||
|
customer_abc?: ABCClassification;
|
||||||
|
supplier_abc?: ABCClassification;
|
||||||
|
min_sales?: number;
|
||||||
|
min_purchases?: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopPartner {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
is_customer: boolean;
|
||||||
|
is_supplier: boolean;
|
||||||
|
customer_rank: number | null;
|
||||||
|
supplier_rank: number | null;
|
||||||
|
customer_abc: ABCClassification;
|
||||||
|
supplier_abc: ABCClassification;
|
||||||
|
total_sales_ytd: number;
|
||||||
|
total_purchases_ytd: number;
|
||||||
|
last_ranking_date: Date | null;
|
||||||
|
customer_category: string | null;
|
||||||
|
supplier_category: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class RankingService {
|
||||||
|
/**
|
||||||
|
* Calculate rankings for all partners in a tenant
|
||||||
|
* Uses the database function for atomic calculation
|
||||||
|
*/
|
||||||
|
async calculateRankings(
|
||||||
|
tenantId: string,
|
||||||
|
companyId?: string,
|
||||||
|
periodStart?: string,
|
||||||
|
periodEnd?: string
|
||||||
|
): Promise<RankingCalculationResult> {
|
||||||
|
const result = await queryOne<{
|
||||||
|
partners_processed: string;
|
||||||
|
customers_ranked: string;
|
||||||
|
suppliers_ranked: string;
|
||||||
|
}>(
|
||||||
|
`SELECT * FROM core.calculate_partner_rankings($1, $2, $3, $4)`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
companyId || null,
|
||||||
|
periodStart || null,
|
||||||
|
periodEnd || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Error calculando rankings');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Partner rankings calculated', {
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
partners_processed: parseInt(result.partners_processed, 10),
|
||||||
|
customers_ranked: parseInt(result.customers_ranked, 10),
|
||||||
|
suppliers_ranked: parseInt(result.suppliers_ranked, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rankings for a specific period
|
||||||
|
*/
|
||||||
|
async findRankings(
|
||||||
|
tenantId: string,
|
||||||
|
filters: RankingFilters = {}
|
||||||
|
): Promise<{ data: PartnerRanking[]; total: number }> {
|
||||||
|
const {
|
||||||
|
company_id,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
customer_abc,
|
||||||
|
supplier_abc,
|
||||||
|
min_sales,
|
||||||
|
min_purchases,
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
} = filters;
|
||||||
|
|
||||||
|
const conditions: string[] = ['pr.tenant_id = $1'];
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
conditions.push(`pr.company_id = $${idx++}`);
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period_start) {
|
||||||
|
conditions.push(`pr.period_start >= $${idx++}`);
|
||||||
|
params.push(period_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period_end) {
|
||||||
|
conditions.push(`pr.period_end <= $${idx++}`);
|
||||||
|
params.push(period_end);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer_abc) {
|
||||||
|
conditions.push(`pr.customer_abc = $${idx++}`);
|
||||||
|
params.push(customer_abc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supplier_abc) {
|
||||||
|
conditions.push(`pr.supplier_abc = $${idx++}`);
|
||||||
|
params.push(supplier_abc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min_sales !== undefined) {
|
||||||
|
conditions.push(`pr.total_sales >= $${idx++}`);
|
||||||
|
params.push(min_sales);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min_purchases !== undefined) {
|
||||||
|
conditions.push(`pr.total_purchases >= $${idx++}`);
|
||||||
|
params.push(min_purchases);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ');
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM core.partner_rankings pr WHERE ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get data with pagination
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const data = await query<PartnerRanking>(
|
||||||
|
`SELECT pr.*,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM core.partner_rankings pr
|
||||||
|
JOIN core.partners p ON pr.partner_id = p.id
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY pr.overall_score DESC NULLS LAST, pr.total_sales DESC
|
||||||
|
LIMIT $${idx} OFFSET $${idx + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ranking for a specific partner
|
||||||
|
*/
|
||||||
|
async findPartnerRanking(
|
||||||
|
partnerId: string,
|
||||||
|
tenantId: string,
|
||||||
|
periodStart?: string,
|
||||||
|
periodEnd?: string
|
||||||
|
): Promise<PartnerRanking | null> {
|
||||||
|
let sql = `
|
||||||
|
SELECT pr.*, p.name as partner_name
|
||||||
|
FROM core.partner_rankings pr
|
||||||
|
JOIN core.partners p ON pr.partner_id = p.id
|
||||||
|
WHERE pr.partner_id = $1 AND pr.tenant_id = $2
|
||||||
|
`;
|
||||||
|
const params: any[] = [partnerId, tenantId];
|
||||||
|
|
||||||
|
if (periodStart && periodEnd) {
|
||||||
|
sql += ` AND pr.period_start = $3 AND pr.period_end = $4`;
|
||||||
|
params.push(periodStart, periodEnd);
|
||||||
|
} else {
|
||||||
|
// Get most recent ranking
|
||||||
|
sql += ` ORDER BY pr.calculated_at DESC LIMIT 1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryOne<PartnerRanking>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top partners (customers or suppliers)
|
||||||
|
*/
|
||||||
|
async getTopPartners(
|
||||||
|
tenantId: string,
|
||||||
|
type: 'customers' | 'suppliers',
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<TopPartner[]> {
|
||||||
|
const orderColumn = type === 'customers' ? 'customer_rank' : 'supplier_rank';
|
||||||
|
const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc';
|
||||||
|
|
||||||
|
return query<TopPartner>(
|
||||||
|
`SELECT * FROM core.top_partners_view
|
||||||
|
WHERE tenant_id = $1 AND ${orderColumn} IS NOT NULL
|
||||||
|
ORDER BY ${orderColumn} ASC
|
||||||
|
LIMIT $2`,
|
||||||
|
[tenantId, limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ABC distribution summary
|
||||||
|
*/
|
||||||
|
async getABCDistribution(
|
||||||
|
tenantId: string,
|
||||||
|
type: 'customers' | 'suppliers',
|
||||||
|
companyId?: string
|
||||||
|
): Promise<{
|
||||||
|
A: { count: number; total_value: number; percentage: number };
|
||||||
|
B: { count: number; total_value: number; percentage: number };
|
||||||
|
C: { count: number; total_value: number; percentage: number };
|
||||||
|
}> {
|
||||||
|
const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc';
|
||||||
|
const valueColumn = type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd';
|
||||||
|
|
||||||
|
let whereClause = `tenant_id = $1 AND ${abcColumn} IS NOT NULL`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
// Note: company_id filter would need to be added if partners have company_id
|
||||||
|
// For now, we use the denormalized data on partners table
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<{
|
||||||
|
abc: string;
|
||||||
|
count: string;
|
||||||
|
total_value: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
${abcColumn} as abc,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(${valueColumn}), 0) as total_value
|
||||||
|
FROM core.partners
|
||||||
|
WHERE ${whereClause} AND deleted_at IS NULL
|
||||||
|
GROUP BY ${abcColumn}
|
||||||
|
ORDER BY ${abcColumn}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const grandTotal = result.reduce((sum, r) => sum + parseFloat(r.total_value), 0);
|
||||||
|
|
||||||
|
const distribution = {
|
||||||
|
A: { count: 0, total_value: 0, percentage: 0 },
|
||||||
|
B: { count: 0, total_value: 0, percentage: 0 },
|
||||||
|
C: { count: 0, total_value: 0, percentage: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const abc = row.abc as 'A' | 'B' | 'C';
|
||||||
|
if (abc in distribution) {
|
||||||
|
distribution[abc] = {
|
||||||
|
count: parseInt(row.count, 10),
|
||||||
|
total_value: parseFloat(row.total_value),
|
||||||
|
percentage: grandTotal > 0 ? (parseFloat(row.total_value) / grandTotal) * 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return distribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ranking history for a partner
|
||||||
|
*/
|
||||||
|
async getPartnerRankingHistory(
|
||||||
|
partnerId: string,
|
||||||
|
tenantId: string,
|
||||||
|
limit: number = 12
|
||||||
|
): Promise<PartnerRanking[]> {
|
||||||
|
return query<PartnerRanking>(
|
||||||
|
`SELECT pr.*, p.name as partner_name
|
||||||
|
FROM core.partner_rankings pr
|
||||||
|
JOIN core.partners p ON pr.partner_id = p.id
|
||||||
|
WHERE pr.partner_id = $1 AND pr.tenant_id = $2
|
||||||
|
ORDER BY pr.period_end DESC
|
||||||
|
LIMIT $3`,
|
||||||
|
[partnerId, tenantId, limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get partners by ABC classification
|
||||||
|
*/
|
||||||
|
async findPartnersByABC(
|
||||||
|
tenantId: string,
|
||||||
|
abc: ABCClassification,
|
||||||
|
type: 'customers' | 'suppliers',
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<{ data: TopPartner[]; total: number }> {
|
||||||
|
const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc';
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM core.partners
|
||||||
|
WHERE tenant_id = $1 AND ${abcColumn} = $2 AND deleted_at IS NULL`,
|
||||||
|
[tenantId, abc]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await query<TopPartner>(
|
||||||
|
`SELECT * FROM core.top_partners_view
|
||||||
|
WHERE tenant_id = $1 AND ${abcColumn} = $2
|
||||||
|
ORDER BY ${type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd'} DESC
|
||||||
|
LIMIT $3 OFFSET $4`,
|
||||||
|
[tenantId, abc, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rankingService = new RankingService();
|
||||||
5
src/modules/projects/index.ts
Normal file
5
src/modules/projects/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './projects.service.js';
|
||||||
|
export * from './tasks.service.js';
|
||||||
|
export * from './timesheets.service.js';
|
||||||
|
export * from './projects.controller.js';
|
||||||
|
export { default as projectsRoutes } from './projects.routes.js';
|
||||||
569
src/modules/projects/projects.controller.ts
Normal file
569
src/modules/projects/projects.controller.ts
Normal file
@ -0,0 +1,569 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { projectsService, CreateProjectDto, UpdateProjectDto, ProjectFilters } from './projects.service.js';
|
||||||
|
import { tasksService, CreateTaskDto, UpdateTaskDto, TaskFilters } from './tasks.service.js';
|
||||||
|
import { timesheetsService, CreateTimesheetDto, UpdateTimesheetDto, TimesheetFilters } from './timesheets.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Project schemas
|
||||||
|
const createProjectSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
code: z.string().max(50).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
manager_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
date_start: z.string().optional(),
|
||||||
|
date_end: z.string().optional(),
|
||||||
|
privacy: z.enum(['public', 'private', 'followers']).default('public'),
|
||||||
|
allow_timesheets: z.boolean().default(true),
|
||||||
|
color: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProjectSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
code: z.string().max(50).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
manager_id: z.string().uuid().optional().nullable(),
|
||||||
|
partner_id: z.string().uuid().optional().nullable(),
|
||||||
|
date_start: z.string().optional().nullable(),
|
||||||
|
date_end: z.string().optional().nullable(),
|
||||||
|
status: z.enum(['draft', 'active', 'completed', 'cancelled', 'on_hold']).optional(),
|
||||||
|
privacy: z.enum(['public', 'private', 'followers']).optional(),
|
||||||
|
allow_timesheets: z.boolean().optional(),
|
||||||
|
color: z.string().max(20).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
manager_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'active', 'completed', 'cancelled', 'on_hold']).optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task schemas
|
||||||
|
const createTaskSchema = z.object({
|
||||||
|
project_id: z.string().uuid({ message: 'El proyecto es requerido' }),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
assigned_to: z.string().uuid().optional(),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
date_deadline: z.string().optional(),
|
||||||
|
estimated_hours: z.number().positive().optional(),
|
||||||
|
priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal'),
|
||||||
|
color: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskSchema = z.object({
|
||||||
|
stage_id: z.string().uuid().optional().nullable(),
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
assigned_to: z.string().uuid().optional().nullable(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
date_deadline: z.string().optional().nullable(),
|
||||||
|
estimated_hours: z.number().positive().optional().nullable(),
|
||||||
|
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
|
||||||
|
status: z.enum(['todo', 'in_progress', 'review', 'done', 'cancelled']).optional(),
|
||||||
|
sequence: z.number().int().positive().optional(),
|
||||||
|
color: z.string().max(20).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskQuerySchema = z.object({
|
||||||
|
project_id: z.string().uuid().optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
assigned_to: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['todo', 'in_progress', 'review', 'done', 'cancelled']).optional(),
|
||||||
|
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveTaskSchema = z.object({
|
||||||
|
stage_id: z.string().uuid().nullable(),
|
||||||
|
sequence: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignTaskSchema = z.object({
|
||||||
|
user_id: z.string().uuid({ message: 'El usuario es requerido' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timesheet schemas
|
||||||
|
const createTimesheetSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
project_id: z.string().uuid({ message: 'El proyecto es requerido' }),
|
||||||
|
task_id: z.string().uuid().optional(),
|
||||||
|
date: z.string({ message: 'La fecha es requerida' }),
|
||||||
|
hours: z.number().positive('Las horas deben ser positivas').max(24),
|
||||||
|
description: z.string().optional(),
|
||||||
|
billable: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTimesheetSchema = z.object({
|
||||||
|
task_id: z.string().uuid().optional().nullable(),
|
||||||
|
date: z.string().optional(),
|
||||||
|
hours: z.number().positive().max(24).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
billable: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const timesheetQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
project_id: z.string().uuid().optional(),
|
||||||
|
task_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class ProjectsController {
|
||||||
|
// ========== PROJECTS ==========
|
||||||
|
async getProjects(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = projectQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: ProjectFilters = queryResult.data;
|
||||||
|
const result = await projectsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProject(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const project = await projectsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: project });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createProjectSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de proyecto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateProjectDto = parseResult.data;
|
||||||
|
const project = await projectsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: project,
|
||||||
|
message: 'Proyecto creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProject(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateProjectSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de proyecto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateProjectDto = parseResult.data;
|
||||||
|
const project = await projectsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: project,
|
||||||
|
message: 'Proyecto actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProject(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await projectsService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Proyecto eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stats = await projectsService.getStats(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectTasks(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = taskQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: TaskFilters = { ...queryResult.data, project_id: req.params.id };
|
||||||
|
const result = await tasksService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectTimesheets(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = timesheetQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: TimesheetFilters = { ...queryResult.data, project_id: req.params.id };
|
||||||
|
const result = await timesheetsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== TASKS ==========
|
||||||
|
async getTasks(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = taskQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: TaskFilters = queryResult.data;
|
||||||
|
const result = await tasksService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const task = await tasksService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: task });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createTaskSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de tarea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateTaskDto = parseResult.data;
|
||||||
|
const task = await tasksService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: task,
|
||||||
|
message: 'Tarea creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateTaskSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de tarea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateTaskDto = parseResult.data;
|
||||||
|
const task = await tasksService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: task,
|
||||||
|
message: 'Tarea actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await tasksService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Tarea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = moveTaskSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de movimiento inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stage_id, sequence } = parseResult.data;
|
||||||
|
const task = await tasksService.move(req.params.id, stage_id, sequence, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: task,
|
||||||
|
message: 'Tarea movida exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = assignTaskSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de asignación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user_id } = parseResult.data;
|
||||||
|
const task = await tasksService.assign(req.params.id, user_id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: task,
|
||||||
|
message: 'Tarea asignada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== TIMESHEETS ==========
|
||||||
|
async getTimesheets(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = timesheetQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: TimesheetFilters = queryResult.data;
|
||||||
|
const result = await timesheetsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const timesheet = await timesheetsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: timesheet });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createTimesheetSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de timesheet inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateTimesheetDto = parseResult.data;
|
||||||
|
const timesheet = await timesheetsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: timesheet,
|
||||||
|
message: 'Tiempo registrado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateTimesheetSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de timesheet inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateTimesheetDto = parseResult.data;
|
||||||
|
const timesheet = await timesheetsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: timesheet,
|
||||||
|
message: 'Timesheet actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await timesheetsService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Timesheet eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const timesheet = await timesheetsService.submit(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: timesheet,
|
||||||
|
message: 'Timesheet enviado para aprobación',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const timesheet = await timesheetsService.approve(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: timesheet,
|
||||||
|
message: 'Timesheet aprobado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const timesheet = await timesheetsService.reject(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: timesheet,
|
||||||
|
message: 'Timesheet rechazado',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyTimesheets(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = timesheetQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: TimesheetFilters = queryResult.data;
|
||||||
|
const result = await timesheetsService.getMyTimesheets(req.tenantId!, req.user!.userId, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingApprovals(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = timesheetQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: TimesheetFilters = queryResult.data;
|
||||||
|
const result = await timesheetsService.getPendingApprovals(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectsController = new ProjectsController();
|
||||||
75
src/modules/projects/projects.routes.ts
Normal file
75
src/modules/projects/projects.routes.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { projectsController } from './projects.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== PROJECTS ==========
|
||||||
|
router.get('/', (req, res, next) => projectsController.getProjects(req, res, next));
|
||||||
|
|
||||||
|
router.get('/:id', (req, res, next) => projectsController.getProject(req, res, next));
|
||||||
|
|
||||||
|
router.post('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
projectsController.createProject(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
projectsController.updateProject(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
projectsController.deleteProject(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/:id/stats', (req, res, next) => projectsController.getProjectStats(req, res, next));
|
||||||
|
|
||||||
|
router.get('/:id/tasks', (req, res, next) => projectsController.getProjectTasks(req, res, next));
|
||||||
|
|
||||||
|
router.get('/:id/timesheets', (req, res, next) => projectsController.getProjectTimesheets(req, res, next));
|
||||||
|
|
||||||
|
// ========== TASKS ==========
|
||||||
|
router.get('/tasks/all', (req, res, next) => projectsController.getTasks(req, res, next));
|
||||||
|
|
||||||
|
router.get('/tasks/:id', (req, res, next) => projectsController.getTask(req, res, next));
|
||||||
|
|
||||||
|
router.post('/tasks', (req, res, next) => projectsController.createTask(req, res, next));
|
||||||
|
|
||||||
|
router.put('/tasks/:id', (req, res, next) => projectsController.updateTask(req, res, next));
|
||||||
|
|
||||||
|
router.delete('/tasks/:id', (req, res, next) => projectsController.deleteTask(req, res, next));
|
||||||
|
|
||||||
|
router.post('/tasks/:id/move', (req, res, next) => projectsController.moveTask(req, res, next));
|
||||||
|
|
||||||
|
router.post('/tasks/:id/assign', (req, res, next) => projectsController.assignTask(req, res, next));
|
||||||
|
|
||||||
|
// ========== TIMESHEETS ==========
|
||||||
|
router.get('/timesheets/all', (req, res, next) => projectsController.getTimesheets(req, res, next));
|
||||||
|
|
||||||
|
router.get('/timesheets/me', (req, res, next) => projectsController.getMyTimesheets(req, res, next));
|
||||||
|
|
||||||
|
router.get('/timesheets/pending', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
projectsController.getPendingApprovals(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/timesheets/:id', (req, res, next) => projectsController.getTimesheet(req, res, next));
|
||||||
|
|
||||||
|
router.post('/timesheets', (req, res, next) => projectsController.createTimesheet(req, res, next));
|
||||||
|
|
||||||
|
router.put('/timesheets/:id', (req, res, next) => projectsController.updateTimesheet(req, res, next));
|
||||||
|
|
||||||
|
router.delete('/timesheets/:id', (req, res, next) => projectsController.deleteTimesheet(req, res, next));
|
||||||
|
|
||||||
|
router.post('/timesheets/:id/submit', (req, res, next) => projectsController.submitTimesheet(req, res, next));
|
||||||
|
|
||||||
|
router.post('/timesheets/:id/approve', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
projectsController.approveTimesheet(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/timesheets/:id/reject', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
projectsController.rejectTimesheet(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
309
src/modules/projects/projects.service.ts
Normal file
309
src/modules/projects/projects.service.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
manager_name?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
partner_name?: string;
|
||||||
|
analytic_account_id?: string;
|
||||||
|
date_start?: Date;
|
||||||
|
date_end?: Date;
|
||||||
|
status: 'draft' | 'active' | 'completed' | 'cancelled' | 'on_hold';
|
||||||
|
privacy: 'public' | 'private' | 'followers';
|
||||||
|
allow_timesheets: boolean;
|
||||||
|
color?: string;
|
||||||
|
task_count?: number;
|
||||||
|
completed_task_count?: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
date_start?: string;
|
||||||
|
date_end?: string;
|
||||||
|
privacy?: 'public' | 'private' | 'followers';
|
||||||
|
allow_timesheets?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
manager_id?: string | null;
|
||||||
|
partner_id?: string | null;
|
||||||
|
date_start?: string | null;
|
||||||
|
date_end?: string | null;
|
||||||
|
status?: 'draft' | 'active' | 'completed' | 'cancelled' | 'on_hold';
|
||||||
|
privacy?: 'public' | 'private' | 'followers';
|
||||||
|
allow_timesheets?: boolean;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectFilters {
|
||||||
|
company_id?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
status?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectsService {
|
||||||
|
async findAll(tenantId: string, filters: ProjectFilters = {}): Promise<{ data: Project[]; total: number }> {
|
||||||
|
const { company_id, manager_id, partner_id, status, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1 AND p.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND p.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manager_id) {
|
||||||
|
whereClause += ` AND p.manager_id = $${paramIndex++}`;
|
||||||
|
params.push(manager_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND p.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND p.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.code ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM projects.projects p ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Project>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
u.name as manager_name,
|
||||||
|
pr.name as partner_name,
|
||||||
|
(SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL) as task_count,
|
||||||
|
(SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.status = 'done' AND t.deleted_at IS NULL) as completed_task_count
|
||||||
|
FROM projects.projects p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN auth.users u ON p.manager_id = u.id
|
||||||
|
LEFT JOIN core.partners pr ON p.partner_id = pr.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Project> {
|
||||||
|
const project = await queryOne<Project>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
u.name as manager_name,
|
||||||
|
pr.name as partner_name,
|
||||||
|
(SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL) as task_count,
|
||||||
|
(SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.status = 'done' AND t.deleted_at IS NULL) as completed_task_count
|
||||||
|
FROM projects.projects p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN auth.users u ON p.manager_id = u.id
|
||||||
|
LEFT JOIN core.partners pr ON p.partner_id = pr.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2 AND p.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundError('Proyecto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateProjectDto, tenantId: string, userId: string): Promise<Project> {
|
||||||
|
// Check unique code if provided
|
||||||
|
if (dto.code) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM projects.projects WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un proyecto con ese código');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await queryOne<Project>(
|
||||||
|
`INSERT INTO projects.projects (
|
||||||
|
tenant_id, company_id, name, code, description, manager_id, partner_id,
|
||||||
|
date_start, date_end, privacy, allow_timesheets, color, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.code, dto.description,
|
||||||
|
dto.manager_id, dto.partner_id, dto.date_start, dto.date_end,
|
||||||
|
dto.privacy || 'public', dto.allow_timesheets ?? true, dto.color, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return project!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateProjectDto, tenantId: string, userId: string): Promise<Project> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.code !== undefined) {
|
||||||
|
if (dto.code) {
|
||||||
|
const existingCode = await queryOne(
|
||||||
|
`SELECT id FROM projects.projects WHERE company_id = $1 AND code = $2 AND id != $3 AND deleted_at IS NULL`,
|
||||||
|
[existing.company_id, dto.code, id]
|
||||||
|
);
|
||||||
|
if (existingCode) {
|
||||||
|
throw new ConflictError('Ya existe un proyecto con ese código');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateFields.push(`code = $${paramIndex++}`);
|
||||||
|
values.push(dto.code);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.manager_id !== undefined) {
|
||||||
|
updateFields.push(`manager_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.manager_id);
|
||||||
|
}
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.date_start !== undefined) {
|
||||||
|
updateFields.push(`date_start = $${paramIndex++}`);
|
||||||
|
values.push(dto.date_start);
|
||||||
|
}
|
||||||
|
if (dto.date_end !== undefined) {
|
||||||
|
updateFields.push(`date_end = $${paramIndex++}`);
|
||||||
|
values.push(dto.date_end);
|
||||||
|
}
|
||||||
|
if (dto.status !== undefined) {
|
||||||
|
updateFields.push(`status = $${paramIndex++}`);
|
||||||
|
values.push(dto.status);
|
||||||
|
}
|
||||||
|
if (dto.privacy !== undefined) {
|
||||||
|
updateFields.push(`privacy = $${paramIndex++}`);
|
||||||
|
values.push(dto.privacy);
|
||||||
|
}
|
||||||
|
if (dto.allow_timesheets !== undefined) {
|
||||||
|
updateFields.push(`allow_timesheets = $${paramIndex++}`);
|
||||||
|
values.push(dto.allow_timesheets);
|
||||||
|
}
|
||||||
|
if (dto.color !== undefined) {
|
||||||
|
updateFields.push(`color = $${paramIndex++}`);
|
||||||
|
values.push(dto.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.projects SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.projects SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(id: string, tenantId: string): Promise<object> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const stats = await queryOne<{
|
||||||
|
total_tasks: number;
|
||||||
|
completed_tasks: number;
|
||||||
|
in_progress_tasks: number;
|
||||||
|
total_hours: number;
|
||||||
|
total_milestones: number;
|
||||||
|
completed_milestones: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND deleted_at IS NULL) as total_tasks,
|
||||||
|
(SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND status = 'done' AND deleted_at IS NULL) as completed_tasks,
|
||||||
|
(SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND status = 'in_progress' AND deleted_at IS NULL) as in_progress_tasks,
|
||||||
|
(SELECT COALESCE(SUM(hours), 0) FROM projects.timesheets WHERE project_id = $1) as total_hours,
|
||||||
|
(SELECT COUNT(*) FROM projects.milestones WHERE project_id = $1) as total_milestones,
|
||||||
|
(SELECT COUNT(*) FROM projects.milestones WHERE project_id = $1 AND status = 'completed') as completed_milestones`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_tasks: parseInt(String(stats?.total_tasks || 0)),
|
||||||
|
completed_tasks: parseInt(String(stats?.completed_tasks || 0)),
|
||||||
|
in_progress_tasks: parseInt(String(stats?.in_progress_tasks || 0)),
|
||||||
|
completion_percentage: stats?.total_tasks
|
||||||
|
? Math.round((parseInt(String(stats.completed_tasks)) / parseInt(String(stats.total_tasks))) * 100)
|
||||||
|
: 0,
|
||||||
|
total_hours: parseFloat(String(stats?.total_hours || 0)),
|
||||||
|
total_milestones: parseInt(String(stats?.total_milestones || 0)),
|
||||||
|
completed_milestones: parseInt(String(stats?.completed_milestones || 0)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectsService = new ProjectsService();
|
||||||
293
src/modules/projects/tasks.service.ts
Normal file
293
src/modules/projects/tasks.service.ts
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
stage_name?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
assigned_to?: string;
|
||||||
|
assigned_name?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
date_deadline?: Date;
|
||||||
|
estimated_hours?: number;
|
||||||
|
spent_hours?: number;
|
||||||
|
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
status: 'todo' | 'in_progress' | 'review' | 'done' | 'cancelled';
|
||||||
|
sequence: number;
|
||||||
|
color?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskDto {
|
||||||
|
project_id: string;
|
||||||
|
stage_id?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
assigned_to?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
date_deadline?: string;
|
||||||
|
estimated_hours?: number;
|
||||||
|
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskDto {
|
||||||
|
stage_id?: string | null;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
assigned_to?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
date_deadline?: string | null;
|
||||||
|
estimated_hours?: number | null;
|
||||||
|
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
status?: 'todo' | 'in_progress' | 'review' | 'done' | 'cancelled';
|
||||||
|
sequence?: number;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskFilters {
|
||||||
|
project_id?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
assigned_to?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TasksService {
|
||||||
|
async findAll(tenantId: string, filters: TaskFilters = {}): Promise<{ data: Task[]; total: number }> {
|
||||||
|
const { project_id, stage_id, assigned_to, status, priority, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE t.tenant_id = $1 AND t.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (project_id) {
|
||||||
|
whereClause += ` AND t.project_id = $${paramIndex++}`;
|
||||||
|
params.push(project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage_id) {
|
||||||
|
whereClause += ` AND t.stage_id = $${paramIndex++}`;
|
||||||
|
params.push(stage_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigned_to) {
|
||||||
|
whereClause += ` AND t.assigned_to = $${paramIndex++}`;
|
||||||
|
params.push(assigned_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND t.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority) {
|
||||||
|
whereClause += ` AND t.priority = $${paramIndex++}`;
|
||||||
|
params.push(priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND t.name ILIKE $${paramIndex}`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM projects.tasks t ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Task>(
|
||||||
|
`SELECT t.*,
|
||||||
|
p.name as project_name,
|
||||||
|
ps.name as stage_name,
|
||||||
|
u.name as assigned_name,
|
||||||
|
pt.name as parent_name,
|
||||||
|
COALESCE((SELECT SUM(hours) FROM projects.timesheets WHERE task_id = t.id), 0) as spent_hours
|
||||||
|
FROM projects.tasks t
|
||||||
|
LEFT JOIN projects.projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN projects.project_stages ps ON t.stage_id = ps.id
|
||||||
|
LEFT JOIN auth.users u ON t.assigned_to = u.id
|
||||||
|
LEFT JOIN projects.tasks pt ON t.parent_id = pt.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.sequence, t.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Task> {
|
||||||
|
const task = await queryOne<Task>(
|
||||||
|
`SELECT t.*,
|
||||||
|
p.name as project_name,
|
||||||
|
ps.name as stage_name,
|
||||||
|
u.name as assigned_name,
|
||||||
|
pt.name as parent_name,
|
||||||
|
COALESCE((SELECT SUM(hours) FROM projects.timesheets WHERE task_id = t.id), 0) as spent_hours
|
||||||
|
FROM projects.tasks t
|
||||||
|
LEFT JOIN projects.projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN projects.project_stages ps ON t.stage_id = ps.id
|
||||||
|
LEFT JOIN auth.users u ON t.assigned_to = u.id
|
||||||
|
LEFT JOIN projects.tasks pt ON t.parent_id = pt.id
|
||||||
|
WHERE t.id = $1 AND t.tenant_id = $2 AND t.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundError('Tarea no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateTaskDto, tenantId: string, userId: string): Promise<Task> {
|
||||||
|
// Get next sequence for project
|
||||||
|
const seqResult = await queryOne<{ max_seq: number }>(
|
||||||
|
`SELECT COALESCE(MAX(sequence), 0) + 1 as max_seq FROM projects.tasks WHERE project_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[dto.project_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const task = await queryOne<Task>(
|
||||||
|
`INSERT INTO projects.tasks (
|
||||||
|
tenant_id, project_id, stage_id, name, description, assigned_to, parent_id,
|
||||||
|
date_deadline, estimated_hours, priority, sequence, color, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.project_id, dto.stage_id, dto.name, dto.description,
|
||||||
|
dto.assigned_to, dto.parent_id, dto.date_deadline, dto.estimated_hours,
|
||||||
|
dto.priority || 'normal', seqResult?.max_seq || 1, dto.color, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return task!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTaskDto, tenantId: string, userId: string): Promise<Task> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.stage_id !== undefined) {
|
||||||
|
updateFields.push(`stage_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.stage_id);
|
||||||
|
}
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.assigned_to !== undefined) {
|
||||||
|
updateFields.push(`assigned_to = $${paramIndex++}`);
|
||||||
|
values.push(dto.assigned_to);
|
||||||
|
}
|
||||||
|
if (dto.parent_id !== undefined) {
|
||||||
|
if (dto.parent_id === id) {
|
||||||
|
throw new ValidationError('Una tarea no puede ser su propio padre');
|
||||||
|
}
|
||||||
|
updateFields.push(`parent_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.parent_id);
|
||||||
|
}
|
||||||
|
if (dto.date_deadline !== undefined) {
|
||||||
|
updateFields.push(`date_deadline = $${paramIndex++}`);
|
||||||
|
values.push(dto.date_deadline);
|
||||||
|
}
|
||||||
|
if (dto.estimated_hours !== undefined) {
|
||||||
|
updateFields.push(`estimated_hours = $${paramIndex++}`);
|
||||||
|
values.push(dto.estimated_hours);
|
||||||
|
}
|
||||||
|
if (dto.priority !== undefined) {
|
||||||
|
updateFields.push(`priority = $${paramIndex++}`);
|
||||||
|
values.push(dto.priority);
|
||||||
|
}
|
||||||
|
if (dto.status !== undefined) {
|
||||||
|
updateFields.push(`status = $${paramIndex++}`);
|
||||||
|
values.push(dto.status);
|
||||||
|
}
|
||||||
|
if (dto.sequence !== undefined) {
|
||||||
|
updateFields.push(`sequence = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence);
|
||||||
|
}
|
||||||
|
if (dto.color !== undefined) {
|
||||||
|
updateFields.push(`color = $${paramIndex++}`);
|
||||||
|
values.push(dto.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.tasks SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.tasks SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(id: string, stageId: string | null, sequence: number, tenantId: string, userId: string): Promise<Task> {
|
||||||
|
const task = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.tasks SET stage_id = $1, sequence = $2, updated_by = $3, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[stageId, sequence, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assign(id: string, userId: string, tenantId: string, currentUserId: string): Promise<Task> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.tasks SET assigned_to = $1, updated_by = $2, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[userId, currentUserId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tasksService = new TasksService();
|
||||||
302
src/modules/projects/timesheets.service.ts
Normal file
302
src/modules/projects/timesheets.service.ts
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Timesheet {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name?: string;
|
||||||
|
task_id?: string;
|
||||||
|
task_name?: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name?: string;
|
||||||
|
date: Date;
|
||||||
|
hours: number;
|
||||||
|
description?: string;
|
||||||
|
billable: boolean;
|
||||||
|
status: 'draft' | 'submitted' | 'approved' | 'rejected';
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTimesheetDto {
|
||||||
|
company_id: string;
|
||||||
|
project_id: string;
|
||||||
|
task_id?: string;
|
||||||
|
date: string;
|
||||||
|
hours: number;
|
||||||
|
description?: string;
|
||||||
|
billable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTimesheetDto {
|
||||||
|
task_id?: string | null;
|
||||||
|
date?: string;
|
||||||
|
hours?: number;
|
||||||
|
description?: string | null;
|
||||||
|
billable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimesheetFilters {
|
||||||
|
company_id?: string;
|
||||||
|
project_id?: string;
|
||||||
|
task_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimesheetsService {
|
||||||
|
async findAll(tenantId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> {
|
||||||
|
const { company_id, project_id, task_id, user_id, status, date_from, date_to, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE ts.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND ts.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project_id) {
|
||||||
|
whereClause += ` AND ts.project_id = $${paramIndex++}`;
|
||||||
|
params.push(project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task_id) {
|
||||||
|
whereClause += ` AND ts.task_id = $${paramIndex++}`;
|
||||||
|
params.push(task_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND ts.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND ts.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND ts.date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND ts.date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM projects.timesheets ts ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Timesheet>(
|
||||||
|
`SELECT ts.*,
|
||||||
|
p.name as project_name,
|
||||||
|
t.name as task_name,
|
||||||
|
u.name as user_name
|
||||||
|
FROM projects.timesheets ts
|
||||||
|
LEFT JOIN projects.projects p ON ts.project_id = p.id
|
||||||
|
LEFT JOIN projects.tasks t ON ts.task_id = t.id
|
||||||
|
LEFT JOIN auth.users u ON ts.user_id = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ts.date DESC, ts.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Timesheet> {
|
||||||
|
const timesheet = await queryOne<Timesheet>(
|
||||||
|
`SELECT ts.*,
|
||||||
|
p.name as project_name,
|
||||||
|
t.name as task_name,
|
||||||
|
u.name as user_name
|
||||||
|
FROM projects.timesheets ts
|
||||||
|
LEFT JOIN projects.projects p ON ts.project_id = p.id
|
||||||
|
LEFT JOIN projects.tasks t ON ts.task_id = t.id
|
||||||
|
LEFT JOIN auth.users u ON ts.user_id = u.id
|
||||||
|
WHERE ts.id = $1 AND ts.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
throw new NotFoundError('Timesheet no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return timesheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateTimesheetDto, tenantId: string, userId: string): Promise<Timesheet> {
|
||||||
|
if (dto.hours <= 0 || dto.hours > 24) {
|
||||||
|
throw new ValidationError('Las horas deben estar entre 0 y 24');
|
||||||
|
}
|
||||||
|
|
||||||
|
const timesheet = await queryOne<Timesheet>(
|
||||||
|
`INSERT INTO projects.timesheets (
|
||||||
|
tenant_id, company_id, project_id, task_id, user_id, date,
|
||||||
|
hours, description, billable, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.project_id, dto.task_id, userId,
|
||||||
|
dto.date, dto.hours, dto.description, dto.billable ?? true, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return timesheet!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTimesheetDto, tenantId: string, userId: string): Promise<Timesheet> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar timesheets en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== userId) {
|
||||||
|
throw new ValidationError('Solo puedes editar tus propios timesheets');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.task_id !== undefined) {
|
||||||
|
updateFields.push(`task_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.task_id);
|
||||||
|
}
|
||||||
|
if (dto.date !== undefined) {
|
||||||
|
updateFields.push(`date = $${paramIndex++}`);
|
||||||
|
values.push(dto.date);
|
||||||
|
}
|
||||||
|
if (dto.hours !== undefined) {
|
||||||
|
if (dto.hours <= 0 || dto.hours > 24) {
|
||||||
|
throw new ValidationError('Las horas deben estar entre 0 y 24');
|
||||||
|
}
|
||||||
|
updateFields.push(`hours = $${paramIndex++}`);
|
||||||
|
values.push(dto.hours);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.billable !== undefined) {
|
||||||
|
updateFields.push(`billable = $${paramIndex++}`);
|
||||||
|
values.push(dto.billable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.timesheets SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar timesheets en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== userId) {
|
||||||
|
throw new ValidationError('Solo puedes eliminar tus propios timesheets');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM projects.timesheets WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(id: string, tenantId: string, userId: string): Promise<Timesheet> {
|
||||||
|
const timesheet = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (timesheet.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden enviar timesheets en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timesheet.user_id !== userId) {
|
||||||
|
throw new ValidationError('Solo puedes enviar tus propios timesheets');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.timesheets SET status = 'submitted', updated_by = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(id: string, tenantId: string, userId: string): Promise<Timesheet> {
|
||||||
|
const timesheet = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (timesheet.status !== 'submitted') {
|
||||||
|
throw new ValidationError('Solo se pueden aprobar timesheets enviados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.timesheets SET status = 'approved', approved_by = $1, approved_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(id: string, tenantId: string, userId: string): Promise<Timesheet> {
|
||||||
|
const timesheet = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (timesheet.status !== 'submitted') {
|
||||||
|
throw new ValidationError('Solo se pueden rechazar timesheets enviados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE projects.timesheets SET status = 'rejected', updated_by = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyTimesheets(tenantId: string, userId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> {
|
||||||
|
return this.findAll(tenantId, { ...filters, user_id: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingApprovals(tenantId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> {
|
||||||
|
return this.findAll(tenantId, { ...filters, status: 'submitted' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const timesheetsService = new TimesheetsService();
|
||||||
4
src/modules/purchases/index.ts
Normal file
4
src/modules/purchases/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './purchases.service.js';
|
||||||
|
export * from './rfqs.service.js';
|
||||||
|
export * from './purchases.controller.js';
|
||||||
|
export { default as purchasesRoutes } from './purchases.routes.js';
|
||||||
352
src/modules/purchases/purchases.controller.ts
Normal file
352
src/modules/purchases/purchases.controller.ts
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { purchasesService, CreatePurchaseOrderDto, UpdatePurchaseOrderDto, PurchaseOrderFilters } from './purchases.service.js';
|
||||||
|
import { rfqsService, CreateRfqDto, UpdateRfqDto, CreateRfqLineDto, UpdateRfqLineDto, RfqFilters } from './rfqs.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
const orderLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid(),
|
||||||
|
description: z.string().min(1),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
uom_id: z.string().uuid(),
|
||||||
|
price_unit: z.number().min(0),
|
||||||
|
discount: z.number().min(0).max(100).default(0),
|
||||||
|
amount_untaxed: z.number().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createOrderSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
partner_id: z.string().uuid(),
|
||||||
|
order_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
expected_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
currency_id: z.string().uuid(),
|
||||||
|
payment_term_id: z.string().uuid().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
lines: z.array(orderLineSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOrderSchema = z.object({
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
order_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
expected_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
payment_term_id: z.string().uuid().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
lines: z.array(orderLineSchema).min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'sent', 'confirmed', 'done', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== RFQ SCHEMAS ==========
|
||||||
|
const rfqLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().min(1),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
uom_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRfqSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
partner_ids: z.array(z.string().uuid()).min(1),
|
||||||
|
request_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
deadline_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
lines: z.array(rfqLineSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRfqSchema = z.object({
|
||||||
|
partner_ids: z.array(z.string().uuid()).min(1).optional(),
|
||||||
|
deadline_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRfqLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().min(1),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
uom_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRfqLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional().nullable(),
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
uom_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rfqQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'sent', 'responded', 'accepted', 'rejected', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class PurchasesController {
|
||||||
|
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: PurchaseOrderFilters = queryResult.data;
|
||||||
|
const result = await purchasesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await purchasesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: order });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createOrderSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de orden inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePurchaseOrderDto = parseResult.data;
|
||||||
|
const order = await purchasesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de compra creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateOrderSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de orden inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdatePurchaseOrderDto = parseResult.data;
|
||||||
|
const order = await purchasesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de compra actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await purchasesService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: order, message: 'Orden de compra confirmada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await purchasesService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: order, message: 'Orden de compra cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await purchasesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Orden de compra eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RFQs ==========
|
||||||
|
async getRfqs(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = rfqQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: RfqFilters = queryResult.data;
|
||||||
|
const result = await rfqsService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rfq = await rfqsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: rfq });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createRfqSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de RFQ inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateRfqDto = parseResult.data;
|
||||||
|
const rfq = await rfqsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: rfq, message: 'Solicitud de cotización creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateRfqSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de RFQ inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateRfqDto = parseResult.data;
|
||||||
|
const rfq = await rfqsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: rfq, message: 'Solicitud de cotización actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRfqLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createRfqLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateRfqLineDto = parseResult.data;
|
||||||
|
const line = await rfqsService.addLine(req.params.id, dto, req.tenantId!);
|
||||||
|
res.status(201).json({ success: true, data: line, message: 'Línea agregada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRfqLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateRfqLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateRfqLineDto = parseResult.data;
|
||||||
|
const line = await rfqsService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
res.json({ success: true, data: line, message: 'Línea actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRfqLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rfqsService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rfq = await rfqsService.send(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: rfq, message: 'Solicitud enviada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRfqResponded(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rfq = await rfqsService.markResponded(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: rfq, message: 'Solicitud marcada como respondida' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rfq = await rfqsService.accept(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: rfq, message: 'Solicitud aceptada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rfq = await rfqsService.reject(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: rfq, message: 'Solicitud rechazada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rfq = await rfqsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: rfq, message: 'Solicitud cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rfqsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Solicitud eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const purchasesController = new PurchasesController();
|
||||||
90
src/modules/purchases/purchases.routes.ts
Normal file
90
src/modules/purchases/purchases.routes.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { purchasesController } from './purchases.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// List purchase orders
|
||||||
|
router.get('/', requireRoles('admin', 'manager', 'warehouse', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.findAll(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get purchase order by ID
|
||||||
|
router.get('/:id', requireRoles('admin', 'manager', 'warehouse', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.findById(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create purchase order
|
||||||
|
router.post('/', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.create(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update purchase order
|
||||||
|
router.put('/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.update(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Confirm purchase order
|
||||||
|
router.post('/:id/confirm', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.confirm(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel purchase order
|
||||||
|
router.post('/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.cancel(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete purchase order
|
||||||
|
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.delete(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== RFQs (Request for Quotation) ==========
|
||||||
|
router.get('/rfqs', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.getRfqs(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rfqs/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.getRfq(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/rfqs', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.createRfq(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/rfqs/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.updateRfq(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/rfqs/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.deleteRfq(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// RFQ Lines
|
||||||
|
router.post('/rfqs/:id/lines', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.addRfqLine(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/rfqs/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.updateRfqLine(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/rfqs/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.removeRfqLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// RFQ Workflow
|
||||||
|
router.post('/rfqs/:id/send', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.sendRfq(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/rfqs/:id/responded', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.markRfqResponded(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/rfqs/:id/accept', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.acceptRfq(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/rfqs/:id/reject', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.rejectRfq(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/rfqs/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
purchasesController.cancelRfq(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
386
src/modules/purchases/purchases.service.ts
Normal file
386
src/modules/purchases/purchases.service.ts
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type OrderStatus = 'draft' | 'sent' | 'confirmed' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
export interface PurchaseOrderLine {
|
||||||
|
id?: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_code?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
qty_received?: number;
|
||||||
|
qty_invoiced?: number;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount?: number;
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax?: number;
|
||||||
|
amount_total: number;
|
||||||
|
expected_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOrder {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
order_date: Date;
|
||||||
|
expected_date?: Date;
|
||||||
|
effective_date?: Date;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
payment_term_id?: string;
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
status: OrderStatus;
|
||||||
|
receipt_status?: string;
|
||||||
|
invoice_status?: string;
|
||||||
|
notes?: string;
|
||||||
|
lines?: PurchaseOrderLine[];
|
||||||
|
created_at: Date;
|
||||||
|
confirmed_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePurchaseOrderDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
order_date: string;
|
||||||
|
expected_date?: string;
|
||||||
|
currency_id: string;
|
||||||
|
payment_term_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
lines: Omit<PurchaseOrderLine, 'id' | 'product_name' | 'product_code' | 'uom_name' | 'qty_received' | 'qty_invoiced' | 'amount_tax' | 'amount_total'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePurchaseOrderDto {
|
||||||
|
ref?: string | null;
|
||||||
|
partner_id?: string;
|
||||||
|
order_date?: string;
|
||||||
|
expected_date?: string | null;
|
||||||
|
currency_id?: string;
|
||||||
|
payment_term_id?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
lines?: Omit<PurchaseOrderLine, 'id' | 'product_name' | 'product_code' | 'uom_name' | 'qty_received' | 'qty_invoiced' | 'amount_tax' | 'amount_total'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseOrderFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
status?: OrderStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PurchasesService {
|
||||||
|
async findAll(tenantId: string, filters: PurchaseOrderFilters = {}): Promise<{ data: PurchaseOrder[]; total: number }> {
|
||||||
|
const { company_id, partner_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE po.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND po.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND po.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND po.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND po.order_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND po.order_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (po.name ILIKE $${paramIndex} OR po.ref ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM purchase.purchase_orders po ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<PurchaseOrder>(
|
||||||
|
`SELECT po.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM purchase.purchase_orders po
|
||||||
|
LEFT JOIN auth.companies c ON po.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON po.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cur ON po.currency_id = cur.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY po.order_date DESC, po.name DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<PurchaseOrder> {
|
||||||
|
const order = await queryOne<PurchaseOrder>(
|
||||||
|
`SELECT po.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM purchase.purchase_orders po
|
||||||
|
LEFT JOIN auth.companies c ON po.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON po.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cur ON po.currency_id = cur.id
|
||||||
|
WHERE po.id = $1 AND po.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundError('Orden de compra no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<PurchaseOrderLine>(
|
||||||
|
`SELECT pol.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
pr.code as product_code,
|
||||||
|
u.name as uom_name
|
||||||
|
FROM purchase.purchase_order_lines pol
|
||||||
|
LEFT JOIN inventory.products pr ON pol.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom u ON pol.uom_id = u.id
|
||||||
|
WHERE pol.order_id = $1
|
||||||
|
ORDER BY pol.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
order.lines = lines;
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePurchaseOrderDto, tenantId: string, userId: string): Promise<PurchaseOrder> {
|
||||||
|
if (dto.lines.length === 0) {
|
||||||
|
throw new ValidationError('La orden de compra debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
let amountUntaxed = 0;
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
const lineTotal = line.quantity * line.price_unit * (1 - (line.discount || 0) / 100);
|
||||||
|
amountUntaxed += lineTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
const orderResult = await client.query(
|
||||||
|
`INSERT INTO purchase.purchase_orders (tenant_id, company_id, name, ref, partner_id, order_date, expected_date, currency_id, payment_term_id, amount_untaxed, amount_total, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.ref, dto.partner_id, dto.order_date, dto.expected_date, dto.currency_id, dto.payment_term_id, amountUntaxed, amountUntaxed, dto.notes, userId]
|
||||||
|
);
|
||||||
|
const order = orderResult.rows[0] as PurchaseOrder;
|
||||||
|
|
||||||
|
// Create lines (include tenant_id for multi-tenant security)
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
const lineUntaxed = line.quantity * line.price_unit * (1 - (line.discount || 0) / 100);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO purchase.purchase_order_lines (order_id, tenant_id, product_id, description, quantity, uom_id, price_unit, discount, amount_untaxed, amount_tax, amount_total, expected_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, $11)`,
|
||||||
|
[order.id, tenantId, line.product_id, line.description, line.quantity, line.uom_id, line.price_unit, line.discount || 0, lineUntaxed, lineUntaxed, dto.expected_date]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(order.id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePurchaseOrderDto, tenantId: string, userId: string): Promise<PurchaseOrder> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden modificar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Update order header
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.order_date !== undefined) {
|
||||||
|
updateFields.push(`order_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.order_date);
|
||||||
|
}
|
||||||
|
if (dto.expected_date !== undefined) {
|
||||||
|
updateFields.push(`expected_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.expected_date);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_term_id !== undefined) {
|
||||||
|
updateFields.push(`payment_term_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_term_id);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
if (updateFields.length > 2) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE purchase.purchase_orders SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lines if provided
|
||||||
|
if (dto.lines) {
|
||||||
|
// Delete existing lines
|
||||||
|
await client.query(`DELETE FROM purchase.purchase_order_lines WHERE order_id = $1`, [id]);
|
||||||
|
|
||||||
|
// Calculate totals and insert new lines
|
||||||
|
let amountUntaxed = 0;
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
const lineUntaxed = line.quantity * line.price_unit * (1 - (line.discount || 0) / 100);
|
||||||
|
amountUntaxed += lineUntaxed;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO purchase.purchase_order_lines (order_id, tenant_id, product_id, description, quantity, uom_id, price_unit, discount, amount_untaxed, amount_tax, amount_total)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10)`,
|
||||||
|
[id, tenantId, line.product_id, line.description, line.quantity, line.uom_id, line.price_unit, line.discount || 0, lineUntaxed, lineUntaxed]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order totals
|
||||||
|
await client.query(
|
||||||
|
`UPDATE purchase.purchase_orders SET amount_untaxed = $1, amount_total = $2 WHERE id = $3`,
|
||||||
|
[amountUntaxed, amountUntaxed, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<PurchaseOrder> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden confirmar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.lines || order.lines.length === 0) {
|
||||||
|
throw new ValidationError('La orden debe tener al menos una línea para confirmar');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE purchase.purchase_orders
|
||||||
|
SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP, confirmed_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<PurchaseOrder> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status === 'cancelled') {
|
||||||
|
throw new ConflictError('La orden ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'done') {
|
||||||
|
throw new ConflictError('No se puede cancelar una orden completada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE purchase.purchase_orders
|
||||||
|
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden eliminar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM purchase.purchase_orders WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const purchasesService = new PurchasesService();
|
||||||
485
src/modules/purchases/rfqs.service.ts
Normal file
485
src/modules/purchases/rfqs.service.ts
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type RfqStatus = 'draft' | 'sent' | 'responded' | 'accepted' | 'rejected' | 'cancelled';
|
||||||
|
|
||||||
|
export interface RfqLine {
|
||||||
|
id: string;
|
||||||
|
rfq_id: string;
|
||||||
|
product_id?: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_code?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rfq {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
partner_ids: string[];
|
||||||
|
partner_names?: string[];
|
||||||
|
request_date: Date;
|
||||||
|
deadline_date?: Date;
|
||||||
|
response_date?: Date;
|
||||||
|
status: RfqStatus;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
lines?: RfqLine[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRfqLineDto {
|
||||||
|
product_id?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRfqDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_ids: string[];
|
||||||
|
request_date?: string;
|
||||||
|
deadline_date?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
lines: CreateRfqLineDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRfqDto {
|
||||||
|
partner_ids?: string[];
|
||||||
|
deadline_date?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRfqLineDto {
|
||||||
|
product_id?: string | null;
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
uom_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RfqFilters {
|
||||||
|
company_id?: string;
|
||||||
|
status?: RfqStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RfqsService {
|
||||||
|
async findAll(tenantId: string, filters: RfqFilters = {}): Promise<{ data: Rfq[]; total: number }> {
|
||||||
|
const { company_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE r.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND r.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND r.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND r.request_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND r.request_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (r.name ILIKE $${paramIndex} OR r.description ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM purchase.rfqs r ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Rfq>(
|
||||||
|
`SELECT r.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM purchase.rfqs r
|
||||||
|
LEFT JOIN auth.companies c ON r.company_id = c.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY r.request_date DESC, r.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Rfq> {
|
||||||
|
const rfq = await queryOne<Rfq>(
|
||||||
|
`SELECT r.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM purchase.rfqs r
|
||||||
|
LEFT JOIN auth.companies c ON r.company_id = c.id
|
||||||
|
WHERE r.id = $1 AND r.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rfq) {
|
||||||
|
throw new NotFoundError('Solicitud de cotización no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get partner names
|
||||||
|
if (rfq.partner_ids && rfq.partner_ids.length > 0) {
|
||||||
|
const partners = await query<{ id: string; name: string }>(
|
||||||
|
`SELECT id, name FROM core.partners WHERE id = ANY($1)`,
|
||||||
|
[rfq.partner_ids]
|
||||||
|
);
|
||||||
|
rfq.partner_names = partners.map(p => p.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<RfqLine>(
|
||||||
|
`SELECT rl.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
pr.code as product_code,
|
||||||
|
u.name as uom_name
|
||||||
|
FROM purchase.rfq_lines rl
|
||||||
|
LEFT JOIN inventory.products pr ON rl.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom u ON rl.uom_id = u.id
|
||||||
|
WHERE rl.rfq_id = $1
|
||||||
|
ORDER BY rl.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
rfq.lines = lines;
|
||||||
|
|
||||||
|
return rfq;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateRfqDto, tenantId: string, userId: string): Promise<Rfq> {
|
||||||
|
if (dto.lines.length === 0) {
|
||||||
|
throw new ValidationError('La solicitud debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.partner_ids.length === 0) {
|
||||||
|
throw new ValidationError('Debe especificar al menos un proveedor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate RFQ name
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM purchase.rfqs WHERE tenant_id = $1 AND name LIKE 'RFQ-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const nextNum = seqResult.rows[0]?.next_num || 1;
|
||||||
|
const rfqName = `RFQ-${String(nextNum).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
const requestDate = dto.request_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Create RFQ
|
||||||
|
const rfqResult = await client.query(
|
||||||
|
`INSERT INTO purchase.rfqs (
|
||||||
|
tenant_id, company_id, name, partner_ids, request_date, deadline_date,
|
||||||
|
description, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, rfqName, dto.partner_ids, requestDate,
|
||||||
|
dto.deadline_date, dto.description, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const rfq = rfqResult.rows[0];
|
||||||
|
|
||||||
|
// Create lines
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO purchase.rfq_lines (rfq_id, tenant_id, product_id, description, quantity, uom_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[rfq.id, tenantId, line.product_id, line.description, line.quantity, line.uom_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(rfq.id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateRfqDto, tenantId: string, userId: string): Promise<Rfq> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar solicitudes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_ids !== undefined) {
|
||||||
|
updateFields.push(`partner_ids = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_ids);
|
||||||
|
}
|
||||||
|
if (dto.deadline_date !== undefined) {
|
||||||
|
updateFields.push(`deadline_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.deadline_date);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE purchase.rfqs SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(rfqId: string, dto: CreateRfqLineDto, tenantId: string): Promise<RfqLine> {
|
||||||
|
const rfq = await this.findById(rfqId, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a solicitudes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = await queryOne<RfqLine>(
|
||||||
|
`INSERT INTO purchase.rfq_lines (rfq_id, tenant_id, product_id, description, quantity, uom_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[rfqId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(rfqId: string, lineId: string, dto: UpdateRfqLineDto, tenantId: string): Promise<RfqLine> {
|
||||||
|
const rfq = await this.findById(rfqId, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas en solicitudes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = rfq.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.product_id !== undefined) {
|
||||||
|
updateFields.push(`product_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.product_id);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.quantity !== undefined) {
|
||||||
|
updateFields.push(`quantity = $${paramIndex++}`);
|
||||||
|
values.push(dto.quantity);
|
||||||
|
}
|
||||||
|
if (dto.uom_id !== undefined) {
|
||||||
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.uom_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existingLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(lineId);
|
||||||
|
|
||||||
|
const line = await queryOne<RfqLine>(
|
||||||
|
`UPDATE purchase.rfq_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(rfqId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const rfq = await this.findById(rfqId, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas en solicitudes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = rfq.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rfq.lines && rfq.lines.length <= 1) {
|
||||||
|
throw new ValidationError('La solicitud debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM purchase.rfq_lines WHERE id = $1`, [lineId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(id: string, tenantId: string, userId: string): Promise<Rfq> {
|
||||||
|
const rfq = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden enviar solicitudes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rfq.lines || rfq.lines.length === 0) {
|
||||||
|
throw new ValidationError('La solicitud debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE purchase.rfqs SET
|
||||||
|
status = 'sent',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markResponded(id: string, tenantId: string, userId: string): Promise<Rfq> {
|
||||||
|
const rfq = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status !== 'sent') {
|
||||||
|
throw new ValidationError('Solo se pueden marcar como respondidas solicitudes enviadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE purchase.rfqs SET
|
||||||
|
status = 'responded',
|
||||||
|
response_date = CURRENT_DATE,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async accept(id: string, tenantId: string, userId: string): Promise<Rfq> {
|
||||||
|
const rfq = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status !== 'responded' && rfq.status !== 'sent') {
|
||||||
|
throw new ValidationError('Solo se pueden aceptar solicitudes enviadas o respondidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE purchase.rfqs SET
|
||||||
|
status = 'accepted',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(id: string, tenantId: string, userId: string): Promise<Rfq> {
|
||||||
|
const rfq = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status !== 'responded' && rfq.status !== 'sent') {
|
||||||
|
throw new ValidationError('Solo se pueden rechazar solicitudes enviadas o respondidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE purchase.rfqs SET
|
||||||
|
status = 'rejected',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Rfq> {
|
||||||
|
const rfq = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La solicitud ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rfq.status === 'accepted') {
|
||||||
|
throw new ValidationError('No se puede cancelar una solicitud aceptada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE purchase.rfqs SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const rfq = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (rfq.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar solicitudes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM purchase.rfqs WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rfqsService = new RfqsService();
|
||||||
3
src/modules/reports/index.ts
Normal file
3
src/modules/reports/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './reports.service.js';
|
||||||
|
export * from './reports.controller.js';
|
||||||
|
export { default as reportsRoutes } from './reports.routes.js';
|
||||||
434
src/modules/reports/reports.controller.ts
Normal file
434
src/modules/reports/reports.controller.ts
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/types/index.js';
|
||||||
|
import { reportsService } from './reports.service.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const reportFiltersSchema = z.object({
|
||||||
|
report_type: z.enum(['financial', 'accounting', 'tax', 'management', 'custom']).optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
is_system: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefinitionSchema = z.object({
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
report_type: z.enum(['financial', 'accounting', 'tax', 'management', 'custom']).optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
base_query: z.string().optional(),
|
||||||
|
query_function: z.string().optional(),
|
||||||
|
parameters_schema: z.record(z.any()).optional(),
|
||||||
|
columns_config: z.array(z.any()).optional(),
|
||||||
|
export_formats: z.array(z.string()).optional(),
|
||||||
|
required_permissions: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeReportSchema = z.object({
|
||||||
|
definition_id: z.string().uuid(),
|
||||||
|
parameters: z.record(z.any()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createScheduleSchema = z.object({
|
||||||
|
definition_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
cron_expression: z.string().min(1),
|
||||||
|
default_parameters: z.record(z.any()).optional(),
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
timezone: z.string().optional(),
|
||||||
|
delivery_method: z.enum(['none', 'email', 'storage', 'webhook']).optional(),
|
||||||
|
delivery_config: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trialBalanceSchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
include_zero: z.coerce.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generalLedgerSchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
account_id: z.string().uuid(),
|
||||||
|
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTROLLER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ReportsController {
|
||||||
|
// ==================== DEFINITIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /reports/definitions
|
||||||
|
* List all report definitions
|
||||||
|
*/
|
||||||
|
async findAllDefinitions(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const filters = reportFiltersSchema.parse(req.query);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const { data, total } = await reportsService.findAllDefinitions(tenantId, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / filters.limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /reports/definitions/:id
|
||||||
|
* Get a specific report definition
|
||||||
|
*/
|
||||||
|
async findDefinitionById(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const definition = await reportsService.findDefinitionById(id, tenantId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: definition,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /reports/definitions
|
||||||
|
* Create a custom report definition
|
||||||
|
*/
|
||||||
|
async createDefinition(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto = createDefinitionSchema.parse(req.body);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const definition = await reportsService.createDefinition(dto, tenantId, userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Definición de reporte creada exitosamente',
|
||||||
|
data: definition,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXECUTIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /reports/execute
|
||||||
|
* Execute a report
|
||||||
|
*/
|
||||||
|
async executeReport(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto = executeReportSchema.parse(req.body);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const execution = await reportsService.executeReport(dto, tenantId, userId);
|
||||||
|
|
||||||
|
res.status(202).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Reporte en ejecución',
|
||||||
|
data: execution,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /reports/executions/:id
|
||||||
|
* Get execution details and results
|
||||||
|
*/
|
||||||
|
async findExecutionById(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const execution = await reportsService.findExecutionById(id, tenantId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: execution,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /reports/executions
|
||||||
|
* Get recent executions
|
||||||
|
*/
|
||||||
|
async findRecentExecutions(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { definition_id, limit } = req.query;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const executions = await reportsService.findRecentExecutions(
|
||||||
|
tenantId,
|
||||||
|
definition_id as string,
|
||||||
|
parseInt(limit as string) || 20
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: executions,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SCHEDULES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /reports/schedules
|
||||||
|
* List all schedules
|
||||||
|
*/
|
||||||
|
async findAllSchedules(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const schedules = await reportsService.findAllSchedules(tenantId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: schedules,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /reports/schedules
|
||||||
|
* Create a schedule
|
||||||
|
*/
|
||||||
|
async createSchedule(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto = createScheduleSchema.parse(req.body);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const schedule = await reportsService.createSchedule(dto, tenantId, userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Programación creada exitosamente',
|
||||||
|
data: schedule,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /reports/schedules/:id/toggle
|
||||||
|
* Enable/disable a schedule
|
||||||
|
*/
|
||||||
|
async toggleSchedule(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { is_active } = req.body;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const schedule = await reportsService.toggleSchedule(id, tenantId, is_active);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: is_active ? 'Programación activada' : 'Programación desactivada',
|
||||||
|
data: schedule,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /reports/schedules/:id
|
||||||
|
* Delete a schedule
|
||||||
|
*/
|
||||||
|
async deleteSchedule(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
await reportsService.deleteSchedule(id, tenantId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Programación eliminada',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUICK REPORTS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /reports/quick/trial-balance
|
||||||
|
* Generate trial balance directly
|
||||||
|
*/
|
||||||
|
async getTrialBalance(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const params = trialBalanceSchema.parse(req.query);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const data = await reportsService.generateTrialBalance(
|
||||||
|
tenantId,
|
||||||
|
params.company_id || null,
|
||||||
|
params.date_from,
|
||||||
|
params.date_to,
|
||||||
|
params.include_zero || false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totals = {
|
||||||
|
initial_debit: 0,
|
||||||
|
initial_credit: 0,
|
||||||
|
period_debit: 0,
|
||||||
|
period_credit: 0,
|
||||||
|
final_debit: 0,
|
||||||
|
final_credit: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
totals.initial_debit += parseFloat(row.initial_debit) || 0;
|
||||||
|
totals.initial_credit += parseFloat(row.initial_credit) || 0;
|
||||||
|
totals.period_debit += parseFloat(row.period_debit) || 0;
|
||||||
|
totals.period_credit += parseFloat(row.period_credit) || 0;
|
||||||
|
totals.final_debit += parseFloat(row.final_debit) || 0;
|
||||||
|
totals.final_credit += parseFloat(row.final_credit) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
summary: {
|
||||||
|
row_count: data.length,
|
||||||
|
totals,
|
||||||
|
},
|
||||||
|
parameters: params,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /reports/quick/general-ledger
|
||||||
|
* Generate general ledger directly
|
||||||
|
*/
|
||||||
|
async getGeneralLedger(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const params = generalLedgerSchema.parse(req.query);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const data = await reportsService.generateGeneralLedger(
|
||||||
|
tenantId,
|
||||||
|
params.company_id || null,
|
||||||
|
params.account_id,
|
||||||
|
params.date_from,
|
||||||
|
params.date_to
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totals = {
|
||||||
|
debit: 0,
|
||||||
|
credit: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
totals.debit += parseFloat(row.debit) || 0;
|
||||||
|
totals.credit += parseFloat(row.credit) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
summary: {
|
||||||
|
row_count: data.length,
|
||||||
|
totals,
|
||||||
|
final_balance: data.length > 0 ? data[data.length - 1].running_balance : 0,
|
||||||
|
},
|
||||||
|
parameters: params,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportsController = new ReportsController();
|
||||||
96
src/modules/reports/reports.routes.ts
Normal file
96
src/modules/reports/reports.routes.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { reportsController } from './reports.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QUICK REPORTS (direct access without execution record)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
router.get('/quick/trial-balance',
|
||||||
|
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.getTrialBalance(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/quick/general-ledger',
|
||||||
|
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.getGeneralLedger(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DEFINITIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// List all report definitions
|
||||||
|
router.get('/definitions',
|
||||||
|
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.findAllDefinitions(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get specific definition
|
||||||
|
router.get('/definitions/:id',
|
||||||
|
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.findDefinitionById(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create custom definition (admin only)
|
||||||
|
router.post('/definitions',
|
||||||
|
requireRoles('admin', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.createDefinition(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXECUTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Execute a report
|
||||||
|
router.post('/execute',
|
||||||
|
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.executeReport(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get recent executions
|
||||||
|
router.get('/executions',
|
||||||
|
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.findRecentExecutions(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get specific execution
|
||||||
|
router.get('/executions/:id',
|
||||||
|
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.findExecutionById(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SCHEDULES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// List schedules
|
||||||
|
router.get('/schedules',
|
||||||
|
requireRoles('admin', 'manager', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.findAllSchedules(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create schedule
|
||||||
|
router.post('/schedules',
|
||||||
|
requireRoles('admin', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.createSchedule(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle schedule
|
||||||
|
router.patch('/schedules/:id/toggle',
|
||||||
|
requireRoles('admin', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.toggleSchedule(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete schedule
|
||||||
|
router.delete('/schedules/:id',
|
||||||
|
requireRoles('admin', 'super_admin'),
|
||||||
|
(req, res, next) => reportsController.deleteSchedule(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
580
src/modules/reports/reports.service.ts
Normal file
580
src/modules/reports/reports.service.ts
Normal file
@ -0,0 +1,580 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ReportType = 'financial' | 'accounting' | 'tax' | 'management' | 'custom';
|
||||||
|
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
export type DeliveryMethod = 'none' | 'email' | 'storage' | 'webhook';
|
||||||
|
|
||||||
|
export interface ReportDefinition {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
report_type: ReportType;
|
||||||
|
category: string | null;
|
||||||
|
base_query: string | null;
|
||||||
|
query_function: string | null;
|
||||||
|
parameters_schema: Record<string, any>;
|
||||||
|
columns_config: any[];
|
||||||
|
grouping_options: string[];
|
||||||
|
totals_config: Record<string, any>;
|
||||||
|
export_formats: string[];
|
||||||
|
pdf_template: string | null;
|
||||||
|
xlsx_template: string | null;
|
||||||
|
is_system: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
required_permissions: string[];
|
||||||
|
version: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportExecution {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
definition_id: string;
|
||||||
|
definition_name?: string;
|
||||||
|
definition_code?: string;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
status: ExecutionStatus;
|
||||||
|
started_at: Date | null;
|
||||||
|
completed_at: Date | null;
|
||||||
|
execution_time_ms: number | null;
|
||||||
|
row_count: number | null;
|
||||||
|
result_data: any;
|
||||||
|
result_summary: Record<string, any> | null;
|
||||||
|
output_files: any[];
|
||||||
|
error_message: string | null;
|
||||||
|
error_details: Record<string, any> | null;
|
||||||
|
requested_by: string;
|
||||||
|
requested_by_name?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSchedule {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
definition_id: string;
|
||||||
|
definition_name?: string;
|
||||||
|
company_id: string | null;
|
||||||
|
name: string;
|
||||||
|
default_parameters: Record<string, any>;
|
||||||
|
cron_expression: string;
|
||||||
|
timezone: string;
|
||||||
|
is_active: boolean;
|
||||||
|
last_execution_id: string | null;
|
||||||
|
last_run_at: Date | null;
|
||||||
|
next_run_at: Date | null;
|
||||||
|
delivery_method: DeliveryMethod;
|
||||||
|
delivery_config: Record<string, any>;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReportDefinitionDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
report_type?: ReportType;
|
||||||
|
category?: string;
|
||||||
|
base_query?: string;
|
||||||
|
query_function?: string;
|
||||||
|
parameters_schema?: Record<string, any>;
|
||||||
|
columns_config?: any[];
|
||||||
|
export_formats?: string[];
|
||||||
|
required_permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteReportDto {
|
||||||
|
definition_id: string;
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportFilters {
|
||||||
|
report_type?: ReportType;
|
||||||
|
category?: string;
|
||||||
|
is_system?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ReportsService {
|
||||||
|
// ==================== DEFINITIONS ====================
|
||||||
|
|
||||||
|
async findAllDefinitions(
|
||||||
|
tenantId: string,
|
||||||
|
filters: ReportFilters = {}
|
||||||
|
): Promise<{ data: ReportDefinition[]; total: number }> {
|
||||||
|
const { report_type, category, is_system, search, page = 1, limit = 20 } = filters;
|
||||||
|
const conditions: string[] = ['tenant_id = $1', 'is_active = true'];
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (report_type) {
|
||||||
|
conditions.push(`report_type = $${idx++}`);
|
||||||
|
params.push(report_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
conditions.push(`category = $${idx++}`);
|
||||||
|
params.push(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_system !== undefined) {
|
||||||
|
conditions.push(`is_system = $${idx++}`);
|
||||||
|
params.push(is_system);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
conditions.push(`(name ILIKE $${idx} OR code ILIKE $${idx} OR description ILIKE $${idx})`);
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ');
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM reports.report_definitions WHERE ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const data = await query<ReportDefinition>(
|
||||||
|
`SELECT * FROM reports.report_definitions
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY is_system DESC, name ASC
|
||||||
|
LIMIT $${idx} OFFSET $${idx + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDefinitionById(id: string, tenantId: string): Promise<ReportDefinition> {
|
||||||
|
const definition = await queryOne<ReportDefinition>(
|
||||||
|
`SELECT * FROM reports.report_definitions WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
throw new NotFoundError('Definición de reporte no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDefinitionByCode(code: string, tenantId: string): Promise<ReportDefinition | null> {
|
||||||
|
return queryOne<ReportDefinition>(
|
||||||
|
`SELECT * FROM reports.report_definitions WHERE code = $1 AND tenant_id = $2`,
|
||||||
|
[code, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDefinition(
|
||||||
|
dto: CreateReportDefinitionDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<ReportDefinition> {
|
||||||
|
const definition = await queryOne<ReportDefinition>(
|
||||||
|
`INSERT INTO reports.report_definitions (
|
||||||
|
tenant_id, code, name, description, report_type, category,
|
||||||
|
base_query, query_function, parameters_schema, columns_config,
|
||||||
|
export_formats, required_permissions, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.code,
|
||||||
|
dto.name,
|
||||||
|
dto.description || null,
|
||||||
|
dto.report_type || 'custom',
|
||||||
|
dto.category || null,
|
||||||
|
dto.base_query || null,
|
||||||
|
dto.query_function || null,
|
||||||
|
JSON.stringify(dto.parameters_schema || {}),
|
||||||
|
JSON.stringify(dto.columns_config || []),
|
||||||
|
JSON.stringify(dto.export_formats || ['pdf', 'xlsx', 'csv']),
|
||||||
|
JSON.stringify(dto.required_permissions || []),
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Report definition created', { definitionId: definition?.id, code: dto.code });
|
||||||
|
|
||||||
|
return definition!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXECUTIONS ====================
|
||||||
|
|
||||||
|
async executeReport(
|
||||||
|
dto: ExecuteReportDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<ReportExecution> {
|
||||||
|
const definition = await this.findDefinitionById(dto.definition_id, tenantId);
|
||||||
|
|
||||||
|
// Validar parámetros contra el schema
|
||||||
|
this.validateParameters(dto.parameters, definition.parameters_schema);
|
||||||
|
|
||||||
|
// Crear registro de ejecución
|
||||||
|
const execution = await queryOne<ReportExecution>(
|
||||||
|
`INSERT INTO reports.report_executions (
|
||||||
|
tenant_id, definition_id, parameters, status, requested_by
|
||||||
|
) VALUES ($1, $2, $3, 'pending', $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.definition_id, JSON.stringify(dto.parameters), userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ejecutar el reporte de forma asíncrona
|
||||||
|
this.runReportExecution(execution!.id, definition, dto.parameters, tenantId)
|
||||||
|
.catch(err => logger.error('Report execution failed', { executionId: execution!.id, error: err }));
|
||||||
|
|
||||||
|
return execution!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runReportExecution(
|
||||||
|
executionId: string,
|
||||||
|
definition: ReportDefinition,
|
||||||
|
parameters: Record<string, any>,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Marcar como ejecutando
|
||||||
|
await query(
|
||||||
|
`UPDATE reports.report_executions SET status = 'running', started_at = NOW() WHERE id = $1`,
|
||||||
|
[executionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let resultData: any;
|
||||||
|
let rowCount = 0;
|
||||||
|
|
||||||
|
if (definition.query_function) {
|
||||||
|
// Ejecutar función PostgreSQL
|
||||||
|
const funcParams = this.buildFunctionParams(definition.query_function, parameters, tenantId);
|
||||||
|
resultData = await query(
|
||||||
|
`SELECT * FROM ${definition.query_function}(${funcParams.placeholders})`,
|
||||||
|
funcParams.values
|
||||||
|
);
|
||||||
|
rowCount = resultData.length;
|
||||||
|
} else if (definition.base_query) {
|
||||||
|
// Ejecutar query base con parámetros sustituidos
|
||||||
|
// IMPORTANTE: Sanitizar los parámetros para evitar SQL injection
|
||||||
|
const sanitizedQuery = this.buildSafeQuery(definition.base_query, parameters, tenantId);
|
||||||
|
resultData = await query(sanitizedQuery.sql, sanitizedQuery.values);
|
||||||
|
rowCount = resultData.length;
|
||||||
|
} else {
|
||||||
|
throw new Error('La definición del reporte no tiene query ni función definida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Calcular resumen si hay config de totales
|
||||||
|
const resultSummary = this.calculateSummary(resultData, definition.totals_config);
|
||||||
|
|
||||||
|
// Actualizar con resultados
|
||||||
|
await query(
|
||||||
|
`UPDATE reports.report_executions
|
||||||
|
SET status = 'completed',
|
||||||
|
completed_at = NOW(),
|
||||||
|
execution_time_ms = $2,
|
||||||
|
row_count = $3,
|
||||||
|
result_data = $4,
|
||||||
|
result_summary = $5
|
||||||
|
WHERE id = $1`,
|
||||||
|
[executionId, executionTime, rowCount, JSON.stringify(resultData), JSON.stringify(resultSummary)]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Report execution completed', { executionId, rowCount, executionTime });
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE reports.report_executions
|
||||||
|
SET status = 'failed',
|
||||||
|
completed_at = NOW(),
|
||||||
|
execution_time_ms = $2,
|
||||||
|
error_message = $3,
|
||||||
|
error_details = $4
|
||||||
|
WHERE id = $1`,
|
||||||
|
[
|
||||||
|
executionId,
|
||||||
|
executionTime,
|
||||||
|
error.message,
|
||||||
|
JSON.stringify({ stack: error.stack }),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.error('Report execution failed', { executionId, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFunctionParams(
|
||||||
|
functionName: string,
|
||||||
|
parameters: Record<string, any>,
|
||||||
|
tenantId: string
|
||||||
|
): { placeholders: string; values: any[] } {
|
||||||
|
// Construir parámetros para funciones conocidas
|
||||||
|
const values: any[] = [tenantId];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (functionName.includes('trial_balance')) {
|
||||||
|
values.push(
|
||||||
|
parameters.company_id || null,
|
||||||
|
parameters.date_from,
|
||||||
|
parameters.date_to,
|
||||||
|
parameters.include_zero || false
|
||||||
|
);
|
||||||
|
return { placeholders: '$1, $2, $3, $4, $5', values };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (functionName.includes('general_ledger')) {
|
||||||
|
values.push(
|
||||||
|
parameters.company_id || null,
|
||||||
|
parameters.account_id,
|
||||||
|
parameters.date_from,
|
||||||
|
parameters.date_to
|
||||||
|
);
|
||||||
|
return { placeholders: '$1, $2, $3, $4, $5', values };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: solo tenant_id
|
||||||
|
return { placeholders: '$1', values };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSafeQuery(
|
||||||
|
baseQuery: string,
|
||||||
|
parameters: Record<string, any>,
|
||||||
|
tenantId: string
|
||||||
|
): { sql: string; values: any[] } {
|
||||||
|
// Reemplazar placeholders de forma segura
|
||||||
|
let sql = baseQuery;
|
||||||
|
const values: any[] = [tenantId];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
// Reemplazar {{tenant_id}} con $1
|
||||||
|
sql = sql.replace(/\{\{tenant_id\}\}/g, '$1');
|
||||||
|
|
||||||
|
// Reemplazar otros parámetros
|
||||||
|
for (const [key, value] of Object.entries(parameters)) {
|
||||||
|
const placeholder = `{{${key}}}`;
|
||||||
|
if (sql.includes(placeholder)) {
|
||||||
|
sql = sql.replace(new RegExp(placeholder, 'g'), `$${idx}`);
|
||||||
|
values.push(value);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sql, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateSummary(data: any[], totalsConfig: Record<string, any>): Record<string, any> {
|
||||||
|
if (!totalsConfig.show_totals || !totalsConfig.total_columns) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const column of totalsConfig.total_columns) {
|
||||||
|
summary[column] = data.reduce((sum, row) => sum + (parseFloat(row[column]) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateParameters(params: Record<string, any>, schema: Record<string, any>): void {
|
||||||
|
for (const [key, config] of Object.entries(schema)) {
|
||||||
|
const paramConfig = config as { required?: boolean; type?: string };
|
||||||
|
|
||||||
|
if (paramConfig.required && (params[key] === undefined || params[key] === null)) {
|
||||||
|
throw new ValidationError(`Parámetro requerido: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findExecutionById(id: string, tenantId: string): Promise<ReportExecution> {
|
||||||
|
const execution = await queryOne<ReportExecution>(
|
||||||
|
`SELECT re.*,
|
||||||
|
rd.name as definition_name,
|
||||||
|
rd.code as definition_code,
|
||||||
|
u.full_name as requested_by_name
|
||||||
|
FROM reports.report_executions re
|
||||||
|
JOIN reports.report_definitions rd ON re.definition_id = rd.id
|
||||||
|
JOIN auth.users u ON re.requested_by = u.id
|
||||||
|
WHERE re.id = $1 AND re.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!execution) {
|
||||||
|
throw new NotFoundError('Ejecución de reporte no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return execution;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRecentExecutions(
|
||||||
|
tenantId: string,
|
||||||
|
definitionId?: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<ReportExecution[]> {
|
||||||
|
let sql = `
|
||||||
|
SELECT re.*,
|
||||||
|
rd.name as definition_name,
|
||||||
|
rd.code as definition_code,
|
||||||
|
u.full_name as requested_by_name
|
||||||
|
FROM reports.report_executions re
|
||||||
|
JOIN reports.report_definitions rd ON re.definition_id = rd.id
|
||||||
|
JOIN auth.users u ON re.requested_by = u.id
|
||||||
|
WHERE re.tenant_id = $1
|
||||||
|
`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
|
||||||
|
if (definitionId) {
|
||||||
|
sql += ` AND re.definition_id = $2`;
|
||||||
|
params.push(definitionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY re.created_at DESC LIMIT $${params.length + 1}`;
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
return query<ReportExecution>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SCHEDULES ====================
|
||||||
|
|
||||||
|
async findAllSchedules(tenantId: string): Promise<ReportSchedule[]> {
|
||||||
|
return query<ReportSchedule>(
|
||||||
|
`SELECT rs.*,
|
||||||
|
rd.name as definition_name
|
||||||
|
FROM reports.report_schedules rs
|
||||||
|
JOIN reports.report_definitions rd ON rs.definition_id = rd.id
|
||||||
|
WHERE rs.tenant_id = $1
|
||||||
|
ORDER BY rs.name`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSchedule(
|
||||||
|
data: {
|
||||||
|
definition_id: string;
|
||||||
|
name: string;
|
||||||
|
cron_expression: string;
|
||||||
|
default_parameters?: Record<string, any>;
|
||||||
|
company_id?: string;
|
||||||
|
timezone?: string;
|
||||||
|
delivery_method?: DeliveryMethod;
|
||||||
|
delivery_config?: Record<string, any>;
|
||||||
|
},
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<ReportSchedule> {
|
||||||
|
// Verificar que la definición existe
|
||||||
|
await this.findDefinitionById(data.definition_id, tenantId);
|
||||||
|
|
||||||
|
const schedule = await queryOne<ReportSchedule>(
|
||||||
|
`INSERT INTO reports.report_schedules (
|
||||||
|
tenant_id, definition_id, name, cron_expression,
|
||||||
|
default_parameters, company_id, timezone,
|
||||||
|
delivery_method, delivery_config, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
data.definition_id,
|
||||||
|
data.name,
|
||||||
|
data.cron_expression,
|
||||||
|
JSON.stringify(data.default_parameters || {}),
|
||||||
|
data.company_id || null,
|
||||||
|
data.timezone || 'America/Mexico_City',
|
||||||
|
data.delivery_method || 'none',
|
||||||
|
JSON.stringify(data.delivery_config || {}),
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Report schedule created', { scheduleId: schedule?.id, name: data.name });
|
||||||
|
|
||||||
|
return schedule!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleSchedule(id: string, tenantId: string, isActive: boolean): Promise<ReportSchedule> {
|
||||||
|
const schedule = await queryOne<ReportSchedule>(
|
||||||
|
`UPDATE reports.report_schedules
|
||||||
|
SET is_active = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND tenant_id = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[id, tenantId, isActive]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError('Programación no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSchedule(id: string, tenantId: string): Promise<void> {
|
||||||
|
const result = await query(
|
||||||
|
`DELETE FROM reports.report_schedules WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if any row was deleted
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
// Try to verify it existed
|
||||||
|
const exists = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM reports.report_schedules WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
throw new NotFoundError('Programación no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUICK REPORTS ====================
|
||||||
|
|
||||||
|
async generateTrialBalance(
|
||||||
|
tenantId: string,
|
||||||
|
companyId: string | null,
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string,
|
||||||
|
includeZero: boolean = false
|
||||||
|
): Promise<any[]> {
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM reports.generate_trial_balance($1, $2, $3, $4, $5)`,
|
||||||
|
[tenantId, companyId, dateFrom, dateTo, includeZero]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateGeneralLedger(
|
||||||
|
tenantId: string,
|
||||||
|
companyId: string | null,
|
||||||
|
accountId: string,
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string
|
||||||
|
): Promise<any[]> {
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM reports.generate_general_ledger($1, $2, $3, $4, $5)`,
|
||||||
|
[tenantId, companyId, accountId, dateFrom, dateTo]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportsService = new ReportsService();
|
||||||
209
src/modules/sales/customer-groups.service.ts
Normal file
209
src/modules/sales/customer-groups.service.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface CustomerGroupMember {
|
||||||
|
id: string;
|
||||||
|
customer_group_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
joined_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerGroup {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
discount_percentage: number;
|
||||||
|
members?: CustomerGroupMember[];
|
||||||
|
member_count?: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomerGroupDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
discount_percentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCustomerGroupDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
discount_percentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerGroupFilters {
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomerGroupsService {
|
||||||
|
async findAll(tenantId: string, filters: CustomerGroupFilters = {}): Promise<{ data: CustomerGroup[]; total: number }> {
|
||||||
|
const { search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE cg.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (cg.name ILIKE $${paramIndex} OR cg.description ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM sales.customer_groups cg ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<CustomerGroup>(
|
||||||
|
`SELECT cg.*,
|
||||||
|
(SELECT COUNT(*) FROM sales.customer_group_members cgm WHERE cgm.customer_group_id = cg.id) as member_count
|
||||||
|
FROM sales.customer_groups cg
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY cg.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<CustomerGroup> {
|
||||||
|
const group = await queryOne<CustomerGroup>(
|
||||||
|
`SELECT cg.*,
|
||||||
|
(SELECT COUNT(*) FROM sales.customer_group_members cgm WHERE cgm.customer_group_id = cg.id) as member_count
|
||||||
|
FROM sales.customer_groups cg
|
||||||
|
WHERE cg.id = $1 AND cg.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundError('Grupo de clientes no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get members
|
||||||
|
const members = await query<CustomerGroupMember>(
|
||||||
|
`SELECT cgm.*,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM sales.customer_group_members cgm
|
||||||
|
LEFT JOIN core.partners p ON cgm.partner_id = p.id
|
||||||
|
WHERE cgm.customer_group_id = $1
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
group.members = members;
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCustomerGroupDto, tenantId: string, userId: string): Promise<CustomerGroup> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.customer_groups WHERE tenant_id = $1 AND name = $2`,
|
||||||
|
[tenantId, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un grupo de clientes con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne<CustomerGroup>(
|
||||||
|
`INSERT INTO sales.customer_groups (tenant_id, name, description, discount_percentage, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.description, dto.discount_percentage || 0, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return group!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCustomerGroupDto, tenantId: string): Promise<CustomerGroup> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.customer_groups WHERE tenant_id = $1 AND name = $2 AND id != $3`,
|
||||||
|
[tenantId, dto.name, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un grupo de clientes con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.discount_percentage !== undefined) {
|
||||||
|
updateFields.push(`discount_percentage = $${paramIndex++}`);
|
||||||
|
values.push(dto.discount_percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.customer_groups SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const group = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (group.member_count && group.member_count > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un grupo con miembros');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM sales.customer_groups WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(groupId: string, partnerId: string, tenantId: string): Promise<CustomerGroupMember> {
|
||||||
|
await this.findById(groupId, tenantId);
|
||||||
|
|
||||||
|
// Check if already member
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.customer_group_members WHERE customer_group_id = $1 AND partner_id = $2`,
|
||||||
|
[groupId, partnerId]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('El cliente ya es miembro de este grupo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await queryOne<CustomerGroupMember>(
|
||||||
|
`INSERT INTO sales.customer_group_members (customer_group_id, partner_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING *`,
|
||||||
|
[groupId, partnerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return member!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(groupId: string, memberId: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(groupId, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.customer_group_members WHERE id = $1 AND customer_group_id = $2`,
|
||||||
|
[memberId, groupId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customerGroupsService = new CustomerGroupsService();
|
||||||
7
src/modules/sales/index.ts
Normal file
7
src/modules/sales/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export * from './pricelists.service.js';
|
||||||
|
export * from './sales-teams.service.js';
|
||||||
|
export * from './customer-groups.service.js';
|
||||||
|
export * from './quotations.service.js';
|
||||||
|
export * from './orders.service.js';
|
||||||
|
export * from './sales.controller.js';
|
||||||
|
export { default as salesRoutes } from './sales.routes.js';
|
||||||
707
src/modules/sales/orders.service.ts
Normal file
707
src/modules/sales/orders.service.ts
Normal file
@ -0,0 +1,707 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { taxesService } from '../financial/taxes.service.js';
|
||||||
|
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
|
||||||
|
|
||||||
|
export interface SalesOrderLine {
|
||||||
|
id: string;
|
||||||
|
order_id: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
qty_delivered: number;
|
||||||
|
qty_invoiced: number;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount: number;
|
||||||
|
tax_ids: string[];
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
analytic_account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesOrder {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
client_order_ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
order_date: Date;
|
||||||
|
validity_date?: Date;
|
||||||
|
commitment_date?: Date;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
pricelist_id?: string;
|
||||||
|
pricelist_name?: string;
|
||||||
|
payment_term_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
sales_team_name?: string;
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
status: 'draft' | 'sent' | 'sale' | 'done' | 'cancelled';
|
||||||
|
invoice_status: 'pending' | 'partial' | 'invoiced';
|
||||||
|
delivery_status: 'pending' | 'partial' | 'delivered';
|
||||||
|
invoice_policy: 'order' | 'delivery';
|
||||||
|
picking_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
terms_conditions?: string;
|
||||||
|
lines?: SalesOrderLine[];
|
||||||
|
created_at: Date;
|
||||||
|
confirmed_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesOrderDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
client_order_ref?: string;
|
||||||
|
order_date?: string;
|
||||||
|
validity_date?: string;
|
||||||
|
commitment_date?: string;
|
||||||
|
currency_id: string;
|
||||||
|
pricelist_id?: string;
|
||||||
|
payment_term_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
invoice_policy?: 'order' | 'delivery';
|
||||||
|
notes?: string;
|
||||||
|
terms_conditions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSalesOrderDto {
|
||||||
|
partner_id?: string;
|
||||||
|
client_order_ref?: string | null;
|
||||||
|
order_date?: string;
|
||||||
|
validity_date?: string | null;
|
||||||
|
commitment_date?: string | null;
|
||||||
|
currency_id?: string;
|
||||||
|
pricelist_id?: string | null;
|
||||||
|
payment_term_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
invoice_policy?: 'order' | 'delivery';
|
||||||
|
notes?: string | null;
|
||||||
|
terms_conditions?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesOrderLineDto {
|
||||||
|
product_id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
analytic_account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSalesOrderLineDto {
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
uom_id?: string;
|
||||||
|
price_unit?: number;
|
||||||
|
discount?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
analytic_account_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesOrderFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
status?: string;
|
||||||
|
invoice_status?: string;
|
||||||
|
delivery_status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrdersService {
|
||||||
|
async findAll(tenantId: string, filters: SalesOrderFilters = {}): Promise<{ data: SalesOrder[]; total: number }> {
|
||||||
|
const { company_id, partner_id, status, invoice_status, delivery_status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE so.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND so.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND so.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND so.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice_status) {
|
||||||
|
whereClause += ` AND so.invoice_status = $${paramIndex++}`;
|
||||||
|
params.push(invoice_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delivery_status) {
|
||||||
|
whereClause += ` AND so.delivery_status = $${paramIndex++}`;
|
||||||
|
params.push(delivery_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND so.order_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND so.order_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (so.name ILIKE $${paramIndex} OR so.client_order_ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM sales.sales_orders so
|
||||||
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<SalesOrder>(
|
||||||
|
`SELECT so.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
pl.name as pricelist_name,
|
||||||
|
u.name as user_name,
|
||||||
|
st.name as sales_team_name
|
||||||
|
FROM sales.sales_orders so
|
||||||
|
LEFT JOIN auth.companies c ON so.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON so.currency_id = cu.id
|
||||||
|
LEFT JOIN sales.pricelists pl ON so.pricelist_id = pl.id
|
||||||
|
LEFT JOIN auth.users u ON so.user_id = u.id
|
||||||
|
LEFT JOIN sales.sales_teams st ON so.sales_team_id = st.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY so.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<SalesOrder> {
|
||||||
|
const order = await queryOne<SalesOrder>(
|
||||||
|
`SELECT so.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
pl.name as pricelist_name,
|
||||||
|
u.name as user_name,
|
||||||
|
st.name as sales_team_name
|
||||||
|
FROM sales.sales_orders so
|
||||||
|
LEFT JOIN auth.companies c ON so.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON so.currency_id = cu.id
|
||||||
|
LEFT JOIN sales.pricelists pl ON so.pricelist_id = pl.id
|
||||||
|
LEFT JOIN auth.users u ON so.user_id = u.id
|
||||||
|
LEFT JOIN sales.sales_teams st ON so.sales_team_id = st.id
|
||||||
|
WHERE so.id = $1 AND so.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundError('Orden de venta no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<SalesOrderLine>(
|
||||||
|
`SELECT sol.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
um.name as uom_name
|
||||||
|
FROM sales.sales_order_lines sol
|
||||||
|
LEFT JOIN inventory.products pr ON sol.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom um ON sol.uom_id = um.id
|
||||||
|
WHERE sol.order_id = $1
|
||||||
|
ORDER BY sol.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
order.lines = lines;
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateSalesOrderDto, tenantId: string, userId: string): Promise<SalesOrder> {
|
||||||
|
// Generate sequence number using atomic database function
|
||||||
|
const orderNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.SALES_ORDER, tenantId);
|
||||||
|
|
||||||
|
const orderDate = dto.order_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const order = await queryOne<SalesOrder>(
|
||||||
|
`INSERT INTO sales.sales_orders (
|
||||||
|
tenant_id, company_id, name, client_order_ref, partner_id, order_date,
|
||||||
|
validity_date, commitment_date, currency_id, pricelist_id, payment_term_id,
|
||||||
|
user_id, sales_team_id, invoice_policy, notes, terms_conditions, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, orderNumber, dto.client_order_ref, dto.partner_id,
|
||||||
|
orderDate, dto.validity_date, dto.commitment_date, dto.currency_id,
|
||||||
|
dto.pricelist_id, dto.payment_term_id, userId, dto.sales_team_id,
|
||||||
|
dto.invoice_policy || 'order', dto.notes, dto.terms_conditions, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return order!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateSalesOrderDto, tenantId: string, userId: string): Promise<SalesOrder> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.client_order_ref !== undefined) {
|
||||||
|
updateFields.push(`client_order_ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.client_order_ref);
|
||||||
|
}
|
||||||
|
if (dto.order_date !== undefined) {
|
||||||
|
updateFields.push(`order_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.order_date);
|
||||||
|
}
|
||||||
|
if (dto.validity_date !== undefined) {
|
||||||
|
updateFields.push(`validity_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.validity_date);
|
||||||
|
}
|
||||||
|
if (dto.commitment_date !== undefined) {
|
||||||
|
updateFields.push(`commitment_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.commitment_date);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.pricelist_id !== undefined) {
|
||||||
|
updateFields.push(`pricelist_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.pricelist_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_term_id !== undefined) {
|
||||||
|
updateFields.push(`payment_term_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_term_id);
|
||||||
|
}
|
||||||
|
if (dto.sales_team_id !== undefined) {
|
||||||
|
updateFields.push(`sales_team_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.sales_team_id);
|
||||||
|
}
|
||||||
|
if (dto.invoice_policy !== undefined) {
|
||||||
|
updateFields.push(`invoice_policy = $${paramIndex++}`);
|
||||||
|
values.push(dto.invoice_policy);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
if (dto.terms_conditions !== undefined) {
|
||||||
|
updateFields.push(`terms_conditions = $${paramIndex++}`);
|
||||||
|
values.push(dto.terms_conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_orders SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.sales_orders WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(orderId: string, dto: CreateSalesOrderLineDto, tenantId: string, userId: string): Promise<SalesOrderLine> {
|
||||||
|
const order = await this.findById(orderId, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate amounts with taxes using taxesService
|
||||||
|
const taxResult = await taxesService.calculateTaxes(
|
||||||
|
{
|
||||||
|
quantity: dto.quantity,
|
||||||
|
priceUnit: dto.price_unit,
|
||||||
|
discount: dto.discount || 0,
|
||||||
|
taxIds: dto.tax_ids || [],
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
'sales'
|
||||||
|
);
|
||||||
|
const amountUntaxed = taxResult.amountUntaxed;
|
||||||
|
const amountTax = taxResult.amountTax;
|
||||||
|
const amountTotal = taxResult.amountTotal;
|
||||||
|
|
||||||
|
const line = await queryOne<SalesOrderLine>(
|
||||||
|
`INSERT INTO sales.sales_order_lines (
|
||||||
|
order_id, tenant_id, product_id, description, quantity, uom_id,
|
||||||
|
price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total, analytic_account_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
orderId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id,
|
||||||
|
dto.price_unit, dto.discount || 0, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.analytic_account_id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order totals
|
||||||
|
await this.updateTotals(orderId);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(orderId: string, lineId: string, dto: UpdateSalesOrderLineDto, tenantId: string): Promise<SalesOrderLine> {
|
||||||
|
const order = await this.findById(orderId, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas de órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = order.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea de orden no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const quantity = dto.quantity ?? existingLine.quantity;
|
||||||
|
const priceUnit = dto.price_unit ?? existingLine.price_unit;
|
||||||
|
const discount = dto.discount ?? existingLine.discount;
|
||||||
|
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.quantity !== undefined) {
|
||||||
|
updateFields.push(`quantity = $${paramIndex++}`);
|
||||||
|
values.push(dto.quantity);
|
||||||
|
}
|
||||||
|
if (dto.uom_id !== undefined) {
|
||||||
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.uom_id);
|
||||||
|
}
|
||||||
|
if (dto.price_unit !== undefined) {
|
||||||
|
updateFields.push(`price_unit = $${paramIndex++}`);
|
||||||
|
values.push(dto.price_unit);
|
||||||
|
}
|
||||||
|
if (dto.discount !== undefined) {
|
||||||
|
updateFields.push(`discount = $${paramIndex++}`);
|
||||||
|
values.push(dto.discount);
|
||||||
|
}
|
||||||
|
if (dto.tax_ids !== undefined) {
|
||||||
|
updateFields.push(`tax_ids = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_ids);
|
||||||
|
}
|
||||||
|
if (dto.analytic_account_id !== undefined) {
|
||||||
|
updateFields.push(`analytic_account_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.analytic_account_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate amounts
|
||||||
|
const subtotal = quantity * priceUnit;
|
||||||
|
const discountAmount = subtotal * discount / 100;
|
||||||
|
const amountUntaxed = subtotal - discountAmount;
|
||||||
|
const amountTax = 0; // TODO: Calculate taxes
|
||||||
|
const amountTotal = amountUntaxed + amountTax;
|
||||||
|
|
||||||
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
||||||
|
values.push(amountUntaxed);
|
||||||
|
updateFields.push(`amount_tax = $${paramIndex++}`);
|
||||||
|
values.push(amountTax);
|
||||||
|
updateFields.push(`amount_total = $${paramIndex++}`);
|
||||||
|
values.push(amountTotal);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(lineId, orderId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_order_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND order_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order totals
|
||||||
|
await this.updateTotals(orderId);
|
||||||
|
|
||||||
|
const updated = await queryOne<SalesOrderLine>(
|
||||||
|
`SELECT * FROM sales.sales_order_lines WHERE id = $1`,
|
||||||
|
[lineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(orderId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const order = await this.findById(orderId, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas de órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.sales_order_lines WHERE id = $1 AND order_id = $2`,
|
||||||
|
[lineId, orderId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order totals
|
||||||
|
await this.updateTotals(orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<SalesOrder> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden confirmar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.lines || order.lines.length === 0) {
|
||||||
|
throw new ValidationError('La orden debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Update order status to 'sent' (Odoo-compatible: quotation sent to customer)
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
status = 'sent',
|
||||||
|
confirmed_at = CURRENT_TIMESTAMP,
|
||||||
|
confirmed_by = $1,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create delivery picking (optional - depends on business logic)
|
||||||
|
// This would create an inventory.pickings record for delivery
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<SalesOrder> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status === 'done') {
|
||||||
|
throw new ValidationError('No se pueden cancelar órdenes completadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La orden ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any deliveries or invoices
|
||||||
|
if (order.delivery_status !== 'pending') {
|
||||||
|
throw new ValidationError('No se puede cancelar: ya hay entregas asociadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.invoice_status !== 'pending') {
|
||||||
|
throw new ValidationError('No se puede cancelar: ya hay facturas asociadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
status = 'cancelled',
|
||||||
|
cancelled_at = CURRENT_TIMESTAMP,
|
||||||
|
cancelled_by = $1,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice(id: string, tenantId: string, userId: string): Promise<{ orderId: string; invoiceId: string }> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'sent' && order.status !== 'sale' && order.status !== 'done') {
|
||||||
|
throw new ValidationError('Solo se pueden facturar órdenes confirmadas (sent/sale)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.invoice_status === 'invoiced') {
|
||||||
|
throw new ValidationError('La orden ya está completamente facturada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are quantities to invoice
|
||||||
|
const linesToInvoice = order.lines?.filter(l => {
|
||||||
|
if (order.invoice_policy === 'order') {
|
||||||
|
return l.quantity > l.qty_invoiced;
|
||||||
|
} else {
|
||||||
|
return l.qty_delivered > l.qty_invoiced;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!linesToInvoice || linesToInvoice.length === 0) {
|
||||||
|
throw new ValidationError('No hay líneas para facturar');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate invoice number
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM financial.invoices WHERE tenant_id = $1 AND name LIKE 'INV-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const invoiceNumber = `INV-${String(seqResult.rows[0]?.next_num || 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// Create invoice
|
||||||
|
const invoiceResult = await client.query(
|
||||||
|
`INSERT INTO financial.invoices (
|
||||||
|
tenant_id, company_id, name, partner_id, invoice_date, due_date,
|
||||||
|
currency_id, invoice_type, amount_untaxed, amount_tax, amount_total,
|
||||||
|
source_document, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, CURRENT_DATE, CURRENT_DATE + INTERVAL '30 days',
|
||||||
|
$5, 'customer', 0, 0, 0, $6, $7)
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId, order.company_id, invoiceNumber, order.partner_id, order.currency_id, order.name, userId]
|
||||||
|
);
|
||||||
|
const invoiceId = invoiceResult.rows[0].id;
|
||||||
|
|
||||||
|
// Create invoice lines and update qty_invoiced
|
||||||
|
for (const line of linesToInvoice) {
|
||||||
|
const qtyToInvoice = order.invoice_policy === 'order'
|
||||||
|
? line.quantity - line.qty_invoiced
|
||||||
|
: line.qty_delivered - line.qty_invoiced;
|
||||||
|
|
||||||
|
const lineAmount = qtyToInvoice * line.price_unit * (1 - line.discount / 100);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO financial.invoice_lines (
|
||||||
|
invoice_id, tenant_id, product_id, description, quantity, uom_id,
|
||||||
|
price_unit, discount, amount_untaxed, amount_tax, amount_total
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $9)`,
|
||||||
|
[invoiceId, tenantId, line.product_id, line.description, qtyToInvoice, line.uom_id, line.price_unit, line.discount, lineAmount]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_order_lines SET qty_invoiced = qty_invoiced + $1 WHERE id = $2`,
|
||||||
|
[qtyToInvoice, line.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
amount_untaxed = (SELECT COALESCE(SUM(amount_untaxed), 0) FROM financial.invoice_lines WHERE invoice_id = $1),
|
||||||
|
amount_total = (SELECT COALESCE(SUM(amount_total), 0) FROM financial.invoice_lines WHERE invoice_id = $1)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order invoice_status
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
invoice_status = CASE
|
||||||
|
WHEN (SELECT SUM(qty_invoiced) FROM sales.sales_order_lines WHERE order_id = $1) >=
|
||||||
|
(SELECT SUM(quantity) FROM sales.sales_order_lines WHERE order_id = $1)
|
||||||
|
THEN 'invoiced'::sales.invoice_status
|
||||||
|
ELSE 'partial'::sales.invoice_status
|
||||||
|
END,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return { orderId: id, invoiceId };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTotals(orderId: string): Promise<void> {
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
amount_untaxed = COALESCE((SELECT SUM(amount_untaxed) FROM sales.sales_order_lines WHERE order_id = $1), 0),
|
||||||
|
amount_tax = COALESCE((SELECT SUM(amount_tax) FROM sales.sales_order_lines WHERE order_id = $1), 0),
|
||||||
|
amount_total = COALESCE((SELECT SUM(amount_total) FROM sales.sales_order_lines WHERE order_id = $1), 0)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[orderId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ordersService = new OrdersService();
|
||||||
249
src/modules/sales/pricelists.service.ts
Normal file
249
src/modules/sales/pricelists.service.ts
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface PricelistItem {
|
||||||
|
id: string;
|
||||||
|
pricelist_id: string;
|
||||||
|
product_id?: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_category_id?: string;
|
||||||
|
category_name?: string;
|
||||||
|
price: number;
|
||||||
|
min_quantity: number;
|
||||||
|
valid_from?: Date;
|
||||||
|
valid_to?: Date;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pricelist {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id?: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
active: boolean;
|
||||||
|
items?: PricelistItem[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePricelistDto {
|
||||||
|
company_id?: string;
|
||||||
|
name: string;
|
||||||
|
currency_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePricelistDto {
|
||||||
|
name?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePricelistItemDto {
|
||||||
|
product_id?: string;
|
||||||
|
product_category_id?: string;
|
||||||
|
price: number;
|
||||||
|
min_quantity?: number;
|
||||||
|
valid_from?: string;
|
||||||
|
valid_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricelistFilters {
|
||||||
|
company_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PricelistsService {
|
||||||
|
async findAll(tenantId: string, filters: PricelistFilters = {}): Promise<{ data: Pricelist[]; total: number }> {
|
||||||
|
const { company_id, active, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND p.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND p.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM sales.pricelists p ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Pricelist>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM sales.pricelists p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.currencies cu ON p.currency_id = cu.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Pricelist> {
|
||||||
|
const pricelist = await queryOne<Pricelist>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM sales.pricelists p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.currencies cu ON p.currency_id = cu.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pricelist) {
|
||||||
|
throw new NotFoundError('Lista de precios no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get items
|
||||||
|
const items = await query<PricelistItem>(
|
||||||
|
`SELECT pi.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
pc.name as category_name
|
||||||
|
FROM sales.pricelist_items pi
|
||||||
|
LEFT JOIN inventory.products pr ON pi.product_id = pr.id
|
||||||
|
LEFT JOIN core.product_categories pc ON pi.product_category_id = pc.id
|
||||||
|
WHERE pi.pricelist_id = $1
|
||||||
|
ORDER BY pi.min_quantity, pr.name`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
pricelist.items = items;
|
||||||
|
|
||||||
|
return pricelist;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePricelistDto, tenantId: string, userId: string): Promise<Pricelist> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.pricelists WHERE tenant_id = $1 AND name = $2`,
|
||||||
|
[tenantId, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una lista de precios con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricelist = await queryOne<Pricelist>(
|
||||||
|
`INSERT INTO sales.pricelists (tenant_id, company_id, name, currency_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.currency_id, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return pricelist!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePricelistDto, tenantId: string, userId: string): Promise<Pricelist> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.pricelists WHERE tenant_id = $1 AND name = $2 AND id != $3`,
|
||||||
|
[tenantId, dto.name, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una lista de precios con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.pricelists SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addItem(pricelistId: string, dto: CreatePricelistItemDto, tenantId: string, userId: string): Promise<PricelistItem> {
|
||||||
|
await this.findById(pricelistId, tenantId);
|
||||||
|
|
||||||
|
if (!dto.product_id && !dto.product_category_id) {
|
||||||
|
throw new ValidationError('Debe especificar un producto o una categoría');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.product_id && dto.product_category_id) {
|
||||||
|
throw new ValidationError('Debe especificar solo un producto o solo una categoría, no ambos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await queryOne<PricelistItem>(
|
||||||
|
`INSERT INTO sales.pricelist_items (pricelist_id, product_id, product_category_id, price, min_quantity, valid_from, valid_to, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[pricelistId, dto.product_id, dto.product_category_id, dto.price, dto.min_quantity || 1, dto.valid_from, dto.valid_to, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return item!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(pricelistId: string, itemId: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(pricelistId, tenantId);
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`DELETE FROM sales.pricelist_items WHERE id = $1 AND pricelist_id = $2`,
|
||||||
|
[itemId, pricelistId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProductPrice(productId: string, pricelistId: string, quantity: number = 1): Promise<number | null> {
|
||||||
|
const item = await queryOne<{ price: number }>(
|
||||||
|
`SELECT price FROM sales.pricelist_items
|
||||||
|
WHERE pricelist_id = $1
|
||||||
|
AND (product_id = $2 OR product_category_id = (SELECT category_id FROM inventory.products WHERE id = $2))
|
||||||
|
AND active = true
|
||||||
|
AND min_quantity <= $3
|
||||||
|
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||||
|
AND (valid_to IS NULL OR valid_to >= CURRENT_DATE)
|
||||||
|
ORDER BY product_id NULLS LAST, min_quantity DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[pricelistId, productId, quantity]
|
||||||
|
);
|
||||||
|
|
||||||
|
return item?.price || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pricelistsService = new PricelistsService();
|
||||||
588
src/modules/sales/quotations.service.ts
Normal file
588
src/modules/sales/quotations.service.ts
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { taxesService } from '../financial/taxes.service.js';
|
||||||
|
|
||||||
|
export interface QuotationLine {
|
||||||
|
id: string;
|
||||||
|
quotation_id: string;
|
||||||
|
product_id?: string;
|
||||||
|
product_name?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount: number;
|
||||||
|
tax_ids: string[];
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quotation {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
quotation_date: Date;
|
||||||
|
validity_date: Date;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
pricelist_id?: string;
|
||||||
|
pricelist_name?: string;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
sales_team_name?: string;
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
status: 'draft' | 'sent' | 'confirmed' | 'cancelled' | 'expired';
|
||||||
|
sale_order_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
terms_conditions?: string;
|
||||||
|
lines?: QuotationLine[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuotationDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
quotation_date?: string;
|
||||||
|
validity_date: string;
|
||||||
|
currency_id: string;
|
||||||
|
pricelist_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
terms_conditions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateQuotationDto {
|
||||||
|
partner_id?: string;
|
||||||
|
quotation_date?: string;
|
||||||
|
validity_date?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
pricelist_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
terms_conditions?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuotationLineDto {
|
||||||
|
product_id?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateQuotationLineDto {
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
uom_id?: string;
|
||||||
|
price_unit?: number;
|
||||||
|
discount?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotationFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuotationsService {
|
||||||
|
async findAll(tenantId: string, filters: QuotationFilters = {}): Promise<{ data: Quotation[]; total: number }> {
|
||||||
|
const { company_id, partner_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE q.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND q.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND q.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND q.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND q.quotation_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND q.quotation_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (q.name ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM sales.quotations q
|
||||||
|
LEFT JOIN core.partners p ON q.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Quotation>(
|
||||||
|
`SELECT q.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
pl.name as pricelist_name,
|
||||||
|
u.name as user_name,
|
||||||
|
st.name as sales_team_name
|
||||||
|
FROM sales.quotations q
|
||||||
|
LEFT JOIN auth.companies c ON q.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON q.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON q.currency_id = cu.id
|
||||||
|
LEFT JOIN sales.pricelists pl ON q.pricelist_id = pl.id
|
||||||
|
LEFT JOIN auth.users u ON q.user_id = u.id
|
||||||
|
LEFT JOIN sales.sales_teams st ON q.sales_team_id = st.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Quotation> {
|
||||||
|
const quotation = await queryOne<Quotation>(
|
||||||
|
`SELECT q.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
pl.name as pricelist_name,
|
||||||
|
u.name as user_name,
|
||||||
|
st.name as sales_team_name
|
||||||
|
FROM sales.quotations q
|
||||||
|
LEFT JOIN auth.companies c ON q.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON q.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON q.currency_id = cu.id
|
||||||
|
LEFT JOIN sales.pricelists pl ON q.pricelist_id = pl.id
|
||||||
|
LEFT JOIN auth.users u ON q.user_id = u.id
|
||||||
|
LEFT JOIN sales.sales_teams st ON q.sales_team_id = st.id
|
||||||
|
WHERE q.id = $1 AND q.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!quotation) {
|
||||||
|
throw new NotFoundError('Cotización no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<QuotationLine>(
|
||||||
|
`SELECT ql.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
um.name as uom_name
|
||||||
|
FROM sales.quotation_lines ql
|
||||||
|
LEFT JOIN inventory.products pr ON ql.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom um ON ql.uom_id = um.id
|
||||||
|
WHERE ql.quotation_id = $1
|
||||||
|
ORDER BY ql.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
quotation.lines = lines;
|
||||||
|
|
||||||
|
return quotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateQuotationDto, tenantId: string, userId: string): Promise<Quotation> {
|
||||||
|
// Generate sequence number
|
||||||
|
const seqResult = await queryOne<{ next_num: number }>(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM sales.quotations WHERE tenant_id = $1 AND name LIKE 'QUO-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const quotationNumber = `QUO-${String(seqResult?.next_num || 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
const quotationDate = dto.quotation_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const quotation = await queryOne<Quotation>(
|
||||||
|
`INSERT INTO sales.quotations (
|
||||||
|
tenant_id, company_id, name, partner_id, quotation_date, validity_date,
|
||||||
|
currency_id, pricelist_id, user_id, sales_team_id, notes, terms_conditions, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, quotationNumber, dto.partner_id,
|
||||||
|
quotationDate, dto.validity_date, dto.currency_id, dto.pricelist_id,
|
||||||
|
userId, dto.sales_team_id, dto.notes, dto.terms_conditions, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return quotation!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateQuotationDto, tenantId: string, userId: string): Promise<Quotation> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.quotation_date !== undefined) {
|
||||||
|
updateFields.push(`quotation_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.quotation_date);
|
||||||
|
}
|
||||||
|
if (dto.validity_date !== undefined) {
|
||||||
|
updateFields.push(`validity_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.validity_date);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.pricelist_id !== undefined) {
|
||||||
|
updateFields.push(`pricelist_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.pricelist_id);
|
||||||
|
}
|
||||||
|
if (dto.sales_team_id !== undefined) {
|
||||||
|
updateFields.push(`sales_team_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.sales_team_id);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
if (dto.terms_conditions !== undefined) {
|
||||||
|
updateFields.push(`terms_conditions = $${paramIndex++}`);
|
||||||
|
values.push(dto.terms_conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotations SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.quotations WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(quotationId: string, dto: CreateQuotationLineDto, tenantId: string, userId: string): Promise<QuotationLine> {
|
||||||
|
const quotation = await this.findById(quotationId, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate amounts with taxes using taxesService
|
||||||
|
const taxResult = await taxesService.calculateTaxes(
|
||||||
|
{
|
||||||
|
quantity: dto.quantity,
|
||||||
|
priceUnit: dto.price_unit,
|
||||||
|
discount: dto.discount || 0,
|
||||||
|
taxIds: dto.tax_ids || [],
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
'sales'
|
||||||
|
);
|
||||||
|
const amountUntaxed = taxResult.amountUntaxed;
|
||||||
|
const amountTax = taxResult.amountTax;
|
||||||
|
const amountTotal = taxResult.amountTotal;
|
||||||
|
|
||||||
|
const line = await queryOne<QuotationLine>(
|
||||||
|
`INSERT INTO sales.quotation_lines (
|
||||||
|
quotation_id, tenant_id, product_id, description, quantity, uom_id,
|
||||||
|
price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
quotationId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id,
|
||||||
|
dto.price_unit, dto.discount || 0, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update quotation totals
|
||||||
|
await this.updateTotals(quotationId);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(quotationId: string, lineId: string, dto: UpdateQuotationLineDto, tenantId: string): Promise<QuotationLine> {
|
||||||
|
const quotation = await this.findById(quotationId, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas de cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = quotation.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea de cotización no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const quantity = dto.quantity ?? existingLine.quantity;
|
||||||
|
const priceUnit = dto.price_unit ?? existingLine.price_unit;
|
||||||
|
const discount = dto.discount ?? existingLine.discount;
|
||||||
|
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.quantity !== undefined) {
|
||||||
|
updateFields.push(`quantity = $${paramIndex++}`);
|
||||||
|
values.push(dto.quantity);
|
||||||
|
}
|
||||||
|
if (dto.uom_id !== undefined) {
|
||||||
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.uom_id);
|
||||||
|
}
|
||||||
|
if (dto.price_unit !== undefined) {
|
||||||
|
updateFields.push(`price_unit = $${paramIndex++}`);
|
||||||
|
values.push(dto.price_unit);
|
||||||
|
}
|
||||||
|
if (dto.discount !== undefined) {
|
||||||
|
updateFields.push(`discount = $${paramIndex++}`);
|
||||||
|
values.push(dto.discount);
|
||||||
|
}
|
||||||
|
if (dto.tax_ids !== undefined) {
|
||||||
|
updateFields.push(`tax_ids = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate amounts
|
||||||
|
const subtotal = quantity * priceUnit;
|
||||||
|
const discountAmount = subtotal * discount / 100;
|
||||||
|
const amountUntaxed = subtotal - discountAmount;
|
||||||
|
const amountTax = 0; // TODO: Calculate taxes
|
||||||
|
const amountTotal = amountUntaxed + amountTax;
|
||||||
|
|
||||||
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
||||||
|
values.push(amountUntaxed);
|
||||||
|
updateFields.push(`amount_tax = $${paramIndex++}`);
|
||||||
|
values.push(amountTax);
|
||||||
|
updateFields.push(`amount_total = $${paramIndex++}`);
|
||||||
|
values.push(amountTotal);
|
||||||
|
|
||||||
|
values.push(lineId, quotationId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotation_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND quotation_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update quotation totals
|
||||||
|
await this.updateTotals(quotationId);
|
||||||
|
|
||||||
|
const updated = await queryOne<QuotationLine>(
|
||||||
|
`SELECT * FROM sales.quotation_lines WHERE id = $1`,
|
||||||
|
[lineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(quotationId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const quotation = await this.findById(quotationId, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas de cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.quotation_lines WHERE id = $1 AND quotation_id = $2`,
|
||||||
|
[lineId, quotationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update quotation totals
|
||||||
|
await this.updateTotals(quotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(id: string, tenantId: string, userId: string): Promise<Quotation> {
|
||||||
|
const quotation = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden enviar cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quotation.lines || quotation.lines.length === 0) {
|
||||||
|
throw new ValidationError('La cotización debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotations SET status = 'sent', updated_by = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Send email notification
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<{ quotation: Quotation; orderId: string }> {
|
||||||
|
const quotation = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (!['draft', 'sent'].includes(quotation.status)) {
|
||||||
|
throw new ValidationError('Solo se pueden confirmar cotizaciones en estado borrador o enviado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quotation.lines || quotation.lines.length === 0) {
|
||||||
|
throw new ValidationError('La cotización debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate order sequence number
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 4) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM sales.sales_orders WHERE tenant_id = $1 AND name LIKE 'SO-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const orderNumber = `SO-${String(seqResult.rows[0]?.next_num || 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// Create sales order
|
||||||
|
const orderResult = await client.query(
|
||||||
|
`INSERT INTO sales.sales_orders (
|
||||||
|
tenant_id, company_id, name, partner_id, order_date, currency_id,
|
||||||
|
pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax,
|
||||||
|
amount_total, notes, terms_conditions, created_by
|
||||||
|
)
|
||||||
|
SELECT tenant_id, company_id, $1, partner_id, CURRENT_DATE, currency_id,
|
||||||
|
pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax,
|
||||||
|
amount_total, notes, terms_conditions, $2
|
||||||
|
FROM sales.quotations WHERE id = $3
|
||||||
|
RETURNING id`,
|
||||||
|
[orderNumber, userId, id]
|
||||||
|
);
|
||||||
|
const orderId = orderResult.rows[0].id;
|
||||||
|
|
||||||
|
// Copy lines to order (include tenant_id for multi-tenant security)
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO sales.sales_order_lines (
|
||||||
|
order_id, tenant_id, product_id, description, quantity, uom_id, price_unit,
|
||||||
|
discount, tax_ids, amount_untaxed, amount_tax, amount_total
|
||||||
|
)
|
||||||
|
SELECT $1, $3, product_id, description, quantity, uom_id, price_unit,
|
||||||
|
discount, tax_ids, amount_untaxed, amount_tax, amount_total
|
||||||
|
FROM sales.quotation_lines WHERE quotation_id = $2 AND tenant_id = $3`,
|
||||||
|
[orderId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update quotation status
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.quotations SET status = 'confirmed', sale_order_id = $1,
|
||||||
|
updated_by = $2, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3`,
|
||||||
|
[orderId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return {
|
||||||
|
quotation: await this.findById(id, tenantId),
|
||||||
|
orderId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Quotation> {
|
||||||
|
const quotation = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status === 'confirmed') {
|
||||||
|
throw new ValidationError('No se pueden cancelar cotizaciones confirmadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotation.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La cotización ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotations SET status = 'cancelled', updated_by = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTotals(quotationId: string): Promise<void> {
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotations SET
|
||||||
|
amount_untaxed = COALESCE((SELECT SUM(amount_untaxed) FROM sales.quotation_lines WHERE quotation_id = $1), 0),
|
||||||
|
amount_tax = COALESCE((SELECT SUM(amount_tax) FROM sales.quotation_lines WHERE quotation_id = $1), 0),
|
||||||
|
amount_total = COALESCE((SELECT SUM(amount_total) FROM sales.quotation_lines WHERE quotation_id = $1), 0)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[quotationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const quotationsService = new QuotationsService();
|
||||||
241
src/modules/sales/sales-teams.service.ts
Normal file
241
src/modules/sales/sales-teams.service.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface SalesTeamMember {
|
||||||
|
id: string;
|
||||||
|
sales_team_id: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name?: string;
|
||||||
|
user_email?: string;
|
||||||
|
role?: string;
|
||||||
|
joined_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesTeam {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
team_leader_id?: string;
|
||||||
|
team_leader_name?: string;
|
||||||
|
target_monthly?: number;
|
||||||
|
target_annual?: number;
|
||||||
|
active: boolean;
|
||||||
|
members?: SalesTeamMember[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesTeamDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
team_leader_id?: string;
|
||||||
|
target_monthly?: number;
|
||||||
|
target_annual?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSalesTeamDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
team_leader_id?: string | null;
|
||||||
|
target_monthly?: number | null;
|
||||||
|
target_annual?: number | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesTeamFilters {
|
||||||
|
company_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SalesTeamsService {
|
||||||
|
async findAll(tenantId: string, filters: SalesTeamFilters = {}): Promise<{ data: SalesTeam[]; total: number }> {
|
||||||
|
const { company_id, active, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE st.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND st.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND st.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM sales.sales_teams st ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<SalesTeam>(
|
||||||
|
`SELECT st.*,
|
||||||
|
c.name as company_name,
|
||||||
|
u.full_name as team_leader_name
|
||||||
|
FROM sales.sales_teams st
|
||||||
|
LEFT JOIN auth.companies c ON st.company_id = c.id
|
||||||
|
LEFT JOIN auth.users u ON st.team_leader_id = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY st.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<SalesTeam> {
|
||||||
|
const team = await queryOne<SalesTeam>(
|
||||||
|
`SELECT st.*,
|
||||||
|
c.name as company_name,
|
||||||
|
u.full_name as team_leader_name
|
||||||
|
FROM sales.sales_teams st
|
||||||
|
LEFT JOIN auth.companies c ON st.company_id = c.id
|
||||||
|
LEFT JOIN auth.users u ON st.team_leader_id = u.id
|
||||||
|
WHERE st.id = $1 AND st.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new NotFoundError('Equipo de ventas no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get members
|
||||||
|
const members = await query<SalesTeamMember>(
|
||||||
|
`SELECT stm.*,
|
||||||
|
u.full_name as user_name,
|
||||||
|
u.email as user_email
|
||||||
|
FROM sales.sales_team_members stm
|
||||||
|
LEFT JOIN auth.users u ON stm.user_id = u.id
|
||||||
|
WHERE stm.sales_team_id = $1
|
||||||
|
ORDER BY stm.joined_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
team.members = members;
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateSalesTeamDto, tenantId: string, userId: string): Promise<SalesTeam> {
|
||||||
|
// Check unique code in company
|
||||||
|
if (dto.code) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.sales_teams WHERE company_id = $1 AND code = $2`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un equipo con ese código en esta empresa');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await queryOne<SalesTeam>(
|
||||||
|
`INSERT INTO sales.sales_teams (tenant_id, company_id, name, code, team_leader_id, target_monthly, target_annual, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.code, dto.team_leader_id, dto.target_monthly, dto.target_annual, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return team!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateSalesTeamDto, tenantId: string, userId: string): Promise<SalesTeam> {
|
||||||
|
const team = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.code !== undefined) {
|
||||||
|
// Check unique code
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.sales_teams WHERE company_id = $1 AND code = $2 AND id != $3`,
|
||||||
|
[team.company_id, dto.code, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un equipo con ese código en esta empresa');
|
||||||
|
}
|
||||||
|
updateFields.push(`code = $${paramIndex++}`);
|
||||||
|
values.push(dto.code);
|
||||||
|
}
|
||||||
|
if (dto.team_leader_id !== undefined) {
|
||||||
|
updateFields.push(`team_leader_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.team_leader_id);
|
||||||
|
}
|
||||||
|
if (dto.target_monthly !== undefined) {
|
||||||
|
updateFields.push(`target_monthly = $${paramIndex++}`);
|
||||||
|
values.push(dto.target_monthly);
|
||||||
|
}
|
||||||
|
if (dto.target_annual !== undefined) {
|
||||||
|
updateFields.push(`target_annual = $${paramIndex++}`);
|
||||||
|
values.push(dto.target_annual);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_teams SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(teamId: string, userId: string, role: string, tenantId: string): Promise<SalesTeamMember> {
|
||||||
|
await this.findById(teamId, tenantId);
|
||||||
|
|
||||||
|
// Check if already member
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.sales_team_members WHERE sales_team_id = $1 AND user_id = $2`,
|
||||||
|
[teamId, userId]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('El usuario ya es miembro de este equipo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await queryOne<SalesTeamMember>(
|
||||||
|
`INSERT INTO sales.sales_team_members (sales_team_id, user_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[teamId, userId, role]
|
||||||
|
);
|
||||||
|
|
||||||
|
return member!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(teamId: string, memberId: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(teamId, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.sales_team_members WHERE id = $1 AND sales_team_id = $2`,
|
||||||
|
[memberId, teamId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesTeamsService = new SalesTeamsService();
|
||||||
889
src/modules/sales/sales.controller.ts
Normal file
889
src/modules/sales/sales.controller.ts
Normal file
@ -0,0 +1,889 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { pricelistsService, CreatePricelistDto, UpdatePricelistDto, CreatePricelistItemDto, PricelistFilters } from './pricelists.service.js';
|
||||||
|
import { salesTeamsService, CreateSalesTeamDto, UpdateSalesTeamDto, SalesTeamFilters } from './sales-teams.service.js';
|
||||||
|
import { customerGroupsService, CreateCustomerGroupDto, UpdateCustomerGroupDto, CustomerGroupFilters } from './customer-groups.service.js';
|
||||||
|
import { quotationsService, CreateQuotationDto, UpdateQuotationDto, CreateQuotationLineDto, UpdateQuotationLineDto, QuotationFilters } from './quotations.service.js';
|
||||||
|
import { ordersService, CreateSalesOrderDto, UpdateSalesOrderDto, CreateSalesOrderLineDto, UpdateSalesOrderLineDto, SalesOrderFilters } from './orders.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Pricelist schemas
|
||||||
|
const createPricelistSchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
currency_id: z.string().uuid({ message: 'La moneda es requerida' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePricelistSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPricelistItemSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
product_category_id: z.string().uuid().optional(),
|
||||||
|
price: z.number().min(0, 'El precio debe ser positivo'),
|
||||||
|
min_quantity: z.number().positive().default(1),
|
||||||
|
valid_from: z.string().optional(),
|
||||||
|
valid_to: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pricelistQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sales Team schemas
|
||||||
|
const createSalesTeamSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
code: z.string().max(50).optional(),
|
||||||
|
team_leader_id: z.string().uuid().optional(),
|
||||||
|
target_monthly: z.number().positive().optional(),
|
||||||
|
target_annual: z.number().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSalesTeamSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
code: z.string().max(50).optional(),
|
||||||
|
team_leader_id: z.string().uuid().optional().nullable(),
|
||||||
|
target_monthly: z.number().positive().optional().nullable(),
|
||||||
|
target_annual: z.number().positive().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addTeamMemberSchema = z.object({
|
||||||
|
user_id: z.string().uuid({ message: 'El usuario es requerido' }),
|
||||||
|
role: z.string().max(100).default('member'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const salesTeamQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Customer Group schemas
|
||||||
|
const createCustomerGroupSchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
discount_percentage: z.number().min(0).max(100).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCustomerGroupSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
discount_percentage: z.number().min(0).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addGroupMemberSchema = z.object({
|
||||||
|
partner_id: z.string().uuid({ message: 'El cliente es requerido' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerGroupQuerySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quotation schemas
|
||||||
|
const createQuotationSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
partner_id: z.string().uuid({ message: 'El cliente es requerido' }),
|
||||||
|
quotation_date: z.string().optional(),
|
||||||
|
validity_date: z.string({ message: 'La fecha de validez es requerida' }),
|
||||||
|
currency_id: z.string().uuid({ message: 'La moneda es requerida' }),
|
||||||
|
pricelist_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms_conditions: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateQuotationSchema = z.object({
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
quotation_date: z.string().optional(),
|
||||||
|
validity_date: z.string().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
pricelist_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
terms_conditions: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createQuotationLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().min(1, 'La descripción es requerida'),
|
||||||
|
quantity: z.number().positive('La cantidad debe ser positiva'),
|
||||||
|
uom_id: z.string().uuid({ message: 'La unidad de medida es requerida' }),
|
||||||
|
price_unit: z.number().min(0, 'El precio debe ser positivo'),
|
||||||
|
discount: z.number().min(0).max(100).default(0),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateQuotationLineSchema = z.object({
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
uom_id: z.string().uuid().optional(),
|
||||||
|
price_unit: z.number().min(0).optional(),
|
||||||
|
discount: z.number().min(0).max(100).optional(),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const quotationQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'sent', 'confirmed', 'cancelled', 'expired']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sales Order schemas
|
||||||
|
const createSalesOrderSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
partner_id: z.string().uuid({ message: 'El cliente es requerido' }),
|
||||||
|
client_order_ref: z.string().max(100).optional(),
|
||||||
|
order_date: z.string().optional(),
|
||||||
|
validity_date: z.string().optional(),
|
||||||
|
commitment_date: z.string().optional(),
|
||||||
|
currency_id: z.string().uuid({ message: 'La moneda es requerida' }),
|
||||||
|
pricelist_id: z.string().uuid().optional(),
|
||||||
|
payment_term_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
invoice_policy: z.enum(['order', 'delivery']).default('order'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms_conditions: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSalesOrderSchema = z.object({
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
client_order_ref: z.string().max(100).optional().nullable(),
|
||||||
|
order_date: z.string().optional(),
|
||||||
|
validity_date: z.string().optional().nullable(),
|
||||||
|
commitment_date: z.string().optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
pricelist_id: z.string().uuid().optional().nullable(),
|
||||||
|
payment_term_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
invoice_policy: z.enum(['order', 'delivery']).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
terms_conditions: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSalesOrderLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
description: z.string().min(1, 'La descripción es requerida'),
|
||||||
|
quantity: z.number().positive('La cantidad debe ser positiva'),
|
||||||
|
uom_id: z.string().uuid({ message: 'La unidad de medida es requerida' }),
|
||||||
|
price_unit: z.number().min(0, 'El precio debe ser positivo'),
|
||||||
|
discount: z.number().min(0).max(100).default(0),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
analytic_account_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSalesOrderLineSchema = z.object({
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
uom_id: z.string().uuid().optional(),
|
||||||
|
price_unit: z.number().min(0).optional(),
|
||||||
|
discount: z.number().min(0).max(100).optional(),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
analytic_account_id: z.string().uuid().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const salesOrderQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'sent', 'sale', 'done', 'cancelled']).optional(),
|
||||||
|
invoice_status: z.enum(['pending', 'partial', 'invoiced']).optional(),
|
||||||
|
delivery_status: z.enum(['pending', 'partial', 'delivered']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class SalesController {
|
||||||
|
// ========== PRICELISTS ==========
|
||||||
|
async getPricelists(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = pricelistQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: PricelistFilters = queryResult.data;
|
||||||
|
const result = await pricelistsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const pricelist = await pricelistsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: pricelist });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPricelistSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lista de precios inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePricelistDto = parseResult.data;
|
||||||
|
const pricelist = await pricelistsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: pricelist,
|
||||||
|
message: 'Lista de precios creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updatePricelistSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lista de precios inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdatePricelistDto = parseResult.data;
|
||||||
|
const pricelist = await pricelistsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: pricelist,
|
||||||
|
message: 'Lista de precios actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPricelistItem(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPricelistItemSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de item inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePricelistItemDto = parseResult.data;
|
||||||
|
const item = await pricelistsService.addItem(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: item,
|
||||||
|
message: 'Item agregado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePricelistItem(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pricelistsService.removeItem(req.params.id, req.params.itemId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Item eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== SALES TEAMS ==========
|
||||||
|
async getSalesTeams(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = salesTeamQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: SalesTeamFilters = queryResult.data;
|
||||||
|
const result = await salesTeamsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const team = await salesTeamsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: team });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createSalesTeamSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de equipo de ventas inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateSalesTeamDto = parseResult.data;
|
||||||
|
const team = await salesTeamsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: team,
|
||||||
|
message: 'Equipo de ventas creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateSalesTeamSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de equipo de ventas inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateSalesTeamDto = parseResult.data;
|
||||||
|
const team = await salesTeamsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: team,
|
||||||
|
message: 'Equipo de ventas actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSalesTeamMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = addTeamMemberSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await salesTeamsService.addMember(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.user_id,
|
||||||
|
parseResult.data.role,
|
||||||
|
req.tenantId!
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: member,
|
||||||
|
message: 'Miembro agregado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSalesTeamMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await salesTeamsService.removeMember(req.params.id, req.params.memberId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Miembro eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CUSTOMER GROUPS ==========
|
||||||
|
async getCustomerGroups(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = customerGroupQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: CustomerGroupFilters = queryResult.data;
|
||||||
|
const result = await customerGroupsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const group = await customerGroupsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: group });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createCustomerGroupSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de grupo de clientes inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateCustomerGroupDto = parseResult.data;
|
||||||
|
const group = await customerGroupsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: group,
|
||||||
|
message: 'Grupo de clientes creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateCustomerGroupSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de grupo de clientes inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateCustomerGroupDto = parseResult.data;
|
||||||
|
const group = await customerGroupsService.update(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: group,
|
||||||
|
message: 'Grupo de clientes actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await customerGroupsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Grupo de clientes eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCustomerGroupMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = addGroupMemberSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await customerGroupsService.addMember(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.partner_id,
|
||||||
|
req.tenantId!
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: member,
|
||||||
|
message: 'Cliente agregado al grupo exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCustomerGroupMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await customerGroupsService.removeMember(req.params.id, req.params.memberId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Cliente eliminado del grupo exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== QUOTATIONS ==========
|
||||||
|
async getQuotations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = quotationQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: QuotationFilters = queryResult.data;
|
||||||
|
const result = await quotationsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const quotation = await quotationsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: quotation });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createQuotationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de cotización inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateQuotationDto = parseResult.data;
|
||||||
|
const quotation = await quotationsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: quotation,
|
||||||
|
message: 'Cotización creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateQuotationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de cotización inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateQuotationDto = parseResult.data;
|
||||||
|
const quotation = await quotationsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: quotation,
|
||||||
|
message: 'Cotización actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await quotationsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Cotización eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createQuotationLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateQuotationLineDto = parseResult.data;
|
||||||
|
const line = await quotationsService.addLine(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea agregada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateQuotationLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateQuotationLineDto = parseResult.data;
|
||||||
|
const line = await quotationsService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await quotationsService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const quotation = await quotationsService.send(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: quotation,
|
||||||
|
message: 'Cotización enviada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await quotationsService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.quotation,
|
||||||
|
orderId: result.orderId,
|
||||||
|
message: 'Cotización confirmada y orden de venta creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const quotation = await quotationsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: quotation,
|
||||||
|
message: 'Cotización cancelada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== SALES ORDERS ==========
|
||||||
|
async getOrders(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = salesOrderQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: SalesOrderFilters = queryResult.data;
|
||||||
|
const result = await ordersService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await ordersService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: order });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createSalesOrderSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de orden inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateSalesOrderDto = parseResult.data;
|
||||||
|
const order = await ordersService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de venta creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateSalesOrderSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de orden inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateSalesOrderDto = parseResult.data;
|
||||||
|
const order = await ordersService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de venta actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ordersService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Orden de venta eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createSalesOrderLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateSalesOrderLineDto = parseResult.data;
|
||||||
|
const line = await ordersService.addLine(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea agregada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateSalesOrderLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateSalesOrderLineDto = parseResult.data;
|
||||||
|
const line = await ordersService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ordersService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await ordersService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de venta confirmada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await ordersService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de venta cancelada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrderInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await ordersService.createInvoice(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Factura creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesController = new SalesController();
|
||||||
159
src/modules/sales/sales.routes.ts
Normal file
159
src/modules/sales/sales.routes.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { salesController } from './sales.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== PRICELISTS ==========
|
||||||
|
router.get('/pricelists', (req, res, next) => salesController.getPricelists(req, res, next));
|
||||||
|
|
||||||
|
router.get('/pricelists/:id', (req, res, next) => salesController.getPricelist(req, res, next));
|
||||||
|
|
||||||
|
router.post('/pricelists', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.createPricelist(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/pricelists/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.updatePricelist(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/pricelists/:id/items', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.addPricelistItem(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/pricelists/:id/items/:itemId', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.removePricelistItem(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== SALES TEAMS ==========
|
||||||
|
router.get('/teams', (req, res, next) => salesController.getSalesTeams(req, res, next));
|
||||||
|
|
||||||
|
router.get('/teams/:id', (req, res, next) => salesController.getSalesTeam(req, res, next));
|
||||||
|
|
||||||
|
router.post('/teams', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.createSalesTeam(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/teams/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.updateSalesTeam(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/teams/:id/members', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.addSalesTeamMember(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/teams/:id/members/:memberId', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.removeSalesTeamMember(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== CUSTOMER GROUPS ==========
|
||||||
|
router.get('/customer-groups', (req, res, next) => salesController.getCustomerGroups(req, res, next));
|
||||||
|
|
||||||
|
router.get('/customer-groups/:id', (req, res, next) => salesController.getCustomerGroup(req, res, next));
|
||||||
|
|
||||||
|
router.post('/customer-groups', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.createCustomerGroup(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/customer-groups/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.updateCustomerGroup(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/customer-groups/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.deleteCustomerGroup(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/customer-groups/:id/members', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.addCustomerGroupMember(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/customer-groups/:id/members/:memberId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.removeCustomerGroupMember(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== QUOTATIONS ==========
|
||||||
|
router.get('/quotations', (req, res, next) => salesController.getQuotations(req, res, next));
|
||||||
|
|
||||||
|
router.get('/quotations/:id', (req, res, next) => salesController.getQuotation(req, res, next));
|
||||||
|
|
||||||
|
router.post('/quotations', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.createQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/quotations/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.updateQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/quotations/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.deleteQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/quotations/:id/lines', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.addQuotationLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/quotations/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.updateQuotationLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/quotations/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.removeQuotationLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/quotations/:id/send', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.sendQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/quotations/:id/confirm', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.confirmQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/quotations/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.cancelQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== SALES ORDERS ==========
|
||||||
|
router.get('/orders', (req, res, next) => salesController.getOrders(req, res, next));
|
||||||
|
|
||||||
|
router.get('/orders/:id', (req, res, next) => salesController.getOrder(req, res, next));
|
||||||
|
|
||||||
|
router.post('/orders', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.createOrder(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/orders/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.updateOrder(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/orders/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.deleteOrder(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/orders/:id/lines', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.addOrderLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/orders/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.updateOrderLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/orders/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.removeOrderLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/orders/:id/confirm', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.confirmOrder(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/orders/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.cancelOrder(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/orders/:id/invoice', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
salesController.createOrderInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
350
src/modules/system/activities.service.ts
Normal file
350
src/modules/system/activities.service.ts
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
model: string;
|
||||||
|
record_id: string;
|
||||||
|
activity_type: 'call' | 'meeting' | 'email' | 'todo' | 'follow_up' | 'custom';
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
assigned_to?: string;
|
||||||
|
assigned_to_name?: string;
|
||||||
|
assigned_by?: string;
|
||||||
|
assigned_by_name?: string;
|
||||||
|
due_date: Date;
|
||||||
|
due_time?: string;
|
||||||
|
status: 'planned' | 'done' | 'cancelled' | 'overdue';
|
||||||
|
created_at: Date;
|
||||||
|
created_by?: string;
|
||||||
|
completed_at?: Date;
|
||||||
|
completed_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateActivityDto {
|
||||||
|
model: string;
|
||||||
|
record_id: string;
|
||||||
|
activity_type: 'call' | 'meeting' | 'email' | 'todo' | 'follow_up' | 'custom';
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
assigned_to?: string;
|
||||||
|
due_date: string;
|
||||||
|
due_time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateActivityDto {
|
||||||
|
activity_type?: 'call' | 'meeting' | 'email' | 'todo' | 'follow_up' | 'custom';
|
||||||
|
summary?: string;
|
||||||
|
description?: string | null;
|
||||||
|
assigned_to?: string | null;
|
||||||
|
due_date?: string;
|
||||||
|
due_time?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityFilters {
|
||||||
|
model?: string;
|
||||||
|
record_id?: string;
|
||||||
|
activity_type?: string;
|
||||||
|
assigned_to?: string;
|
||||||
|
status?: string;
|
||||||
|
due_from?: string;
|
||||||
|
due_to?: string;
|
||||||
|
overdue_only?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivitiesService {
|
||||||
|
async findAll(tenantId: string, filters: ActivityFilters = {}): Promise<{ data: Activity[]; total: number }> {
|
||||||
|
const { model, record_id, activity_type, assigned_to, status, due_from, due_to, overdue_only, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE a.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
whereClause += ` AND a.model = $${paramIndex++}`;
|
||||||
|
params.push(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record_id) {
|
||||||
|
whereClause += ` AND a.record_id = $${paramIndex++}`;
|
||||||
|
params.push(record_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity_type) {
|
||||||
|
whereClause += ` AND a.activity_type = $${paramIndex++}`;
|
||||||
|
params.push(activity_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigned_to) {
|
||||||
|
whereClause += ` AND a.assigned_to = $${paramIndex++}`;
|
||||||
|
params.push(assigned_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND a.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (due_from) {
|
||||||
|
whereClause += ` AND a.due_date >= $${paramIndex++}`;
|
||||||
|
params.push(due_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (due_to) {
|
||||||
|
whereClause += ` AND a.due_date <= $${paramIndex++}`;
|
||||||
|
params.push(due_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overdue_only) {
|
||||||
|
whereClause += ` AND a.status = 'planned' AND a.due_date < CURRENT_DATE`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (a.summary ILIKE $${paramIndex} OR a.description ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM system.activities a ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Activity>(
|
||||||
|
`SELECT a.*,
|
||||||
|
uto.first_name || ' ' || uto.last_name as assigned_to_name,
|
||||||
|
uby.first_name || ' ' || uby.last_name as assigned_by_name
|
||||||
|
FROM system.activities a
|
||||||
|
LEFT JOIN auth.users uto ON a.assigned_to = uto.id
|
||||||
|
LEFT JOIN auth.users uby ON a.assigned_by = uby.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.due_date ASC, a.due_time ASC NULLS LAST, a.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByRecord(model: string, recordId: string, tenantId: string): Promise<Activity[]> {
|
||||||
|
const activities = await query<Activity>(
|
||||||
|
`SELECT a.*,
|
||||||
|
uto.first_name || ' ' || uto.last_name as assigned_to_name,
|
||||||
|
uby.first_name || ' ' || uby.last_name as assigned_by_name
|
||||||
|
FROM system.activities a
|
||||||
|
LEFT JOIN auth.users uto ON a.assigned_to = uto.id
|
||||||
|
LEFT JOIN auth.users uby ON a.assigned_by = uby.id
|
||||||
|
WHERE a.model = $1 AND a.record_id = $2 AND a.tenant_id = $3
|
||||||
|
ORDER BY a.due_date ASC, a.due_time ASC NULLS LAST`,
|
||||||
|
[model, recordId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUser(userId: string, tenantId: string, status?: string): Promise<Activity[]> {
|
||||||
|
let whereClause = 'WHERE a.assigned_to = $1 AND a.tenant_id = $2';
|
||||||
|
const params: any[] = [userId, tenantId];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ' AND a.status = $3';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities = await query<Activity>(
|
||||||
|
`SELECT a.*,
|
||||||
|
uto.first_name || ' ' || uto.last_name as assigned_to_name,
|
||||||
|
uby.first_name || ' ' || uby.last_name as assigned_by_name
|
||||||
|
FROM system.activities a
|
||||||
|
LEFT JOIN auth.users uto ON a.assigned_to = uto.id
|
||||||
|
LEFT JOIN auth.users uby ON a.assigned_by = uby.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.due_date ASC, a.due_time ASC NULLS LAST`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Activity> {
|
||||||
|
const activity = await queryOne<Activity>(
|
||||||
|
`SELECT a.*,
|
||||||
|
uto.first_name || ' ' || uto.last_name as assigned_to_name,
|
||||||
|
uby.first_name || ' ' || uby.last_name as assigned_by_name
|
||||||
|
FROM system.activities a
|
||||||
|
LEFT JOIN auth.users uto ON a.assigned_to = uto.id
|
||||||
|
LEFT JOIN auth.users uby ON a.assigned_by = uby.id
|
||||||
|
WHERE a.id = $1 AND a.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activity) {
|
||||||
|
throw new NotFoundError('Actividad no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateActivityDto, tenantId: string, userId: string): Promise<Activity> {
|
||||||
|
const activity = await queryOne<Activity>(
|
||||||
|
`INSERT INTO system.activities (
|
||||||
|
tenant_id, model, record_id, activity_type, summary, description,
|
||||||
|
assigned_to, assigned_by, due_date, due_time, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.model, dto.record_id, dto.activity_type,
|
||||||
|
dto.summary, dto.description, dto.assigned_to || userId,
|
||||||
|
userId, dto.due_date, dto.due_time
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return activity!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateActivityDto, tenantId: string): Promise<Activity> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status === 'done' || existing.status === 'cancelled') {
|
||||||
|
throw new ValidationError('No se puede editar una actividad completada o cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.activity_type !== undefined) {
|
||||||
|
updateFields.push(`activity_type = $${paramIndex++}`);
|
||||||
|
values.push(dto.activity_type);
|
||||||
|
}
|
||||||
|
if (dto.summary !== undefined) {
|
||||||
|
updateFields.push(`summary = $${paramIndex++}`);
|
||||||
|
values.push(dto.summary);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.assigned_to !== undefined) {
|
||||||
|
updateFields.push(`assigned_to = $${paramIndex++}`);
|
||||||
|
values.push(dto.assigned_to);
|
||||||
|
}
|
||||||
|
if (dto.due_date !== undefined) {
|
||||||
|
updateFields.push(`due_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.due_date);
|
||||||
|
}
|
||||||
|
if (dto.due_time !== undefined) {
|
||||||
|
updateFields.push(`due_time = $${paramIndex++}`);
|
||||||
|
values.push(dto.due_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE system.activities SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markDone(id: string, tenantId: string, userId: string): Promise<Activity> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'planned' && existing.status !== 'overdue') {
|
||||||
|
throw new ValidationError('Solo se pueden completar actividades planificadas o vencidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = await queryOne<Activity>(
|
||||||
|
`UPDATE system.activities SET
|
||||||
|
status = 'done',
|
||||||
|
completed_at = CURRENT_TIMESTAMP,
|
||||||
|
completed_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3
|
||||||
|
RETURNING *`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return activity!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string): Promise<Activity> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status === 'done') {
|
||||||
|
throw new ValidationError('No se puede cancelar una actividad completada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = await queryOne<Activity>(
|
||||||
|
`UPDATE system.activities SET status = 'cancelled'
|
||||||
|
WHERE id = $1 AND tenant_id = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return activity!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reschedule(id: string, dueDate: string, dueTime: string | null, tenantId: string): Promise<Activity> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status === 'done' || existing.status === 'cancelled') {
|
||||||
|
throw new ValidationError('No se puede reprogramar una actividad completada o cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = await queryOne<Activity>(
|
||||||
|
`UPDATE system.activities SET
|
||||||
|
due_date = $1,
|
||||||
|
due_time = $2,
|
||||||
|
status = 'planned'
|
||||||
|
WHERE id = $3 AND tenant_id = $4
|
||||||
|
RETURNING *`,
|
||||||
|
[dueDate, dueTime, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return activity!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM system.activities WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markOverdueActivities(tenantId?: string): Promise<number> {
|
||||||
|
let whereClause = `WHERE status = 'planned' AND due_date < CURRENT_DATE`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
whereClause += ' AND tenant_id = $1';
|
||||||
|
params.push(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE system.activities SET status = 'overdue' ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activitiesService = new ActivitiesService();
|
||||||
5
src/modules/system/index.ts
Normal file
5
src/modules/system/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './messages.service.js';
|
||||||
|
export * from './notifications.service.js';
|
||||||
|
export * from './activities.service.js';
|
||||||
|
export * from './system.controller.js';
|
||||||
|
export { default as systemRoutes } from './system.routes.js';
|
||||||
234
src/modules/system/messages.service.ts
Normal file
234
src/modules/system/messages.service.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
model: string;
|
||||||
|
record_id: string;
|
||||||
|
message_type: 'comment' | 'note' | 'email' | 'notification' | 'system';
|
||||||
|
subject?: string;
|
||||||
|
body: string;
|
||||||
|
author_id?: string;
|
||||||
|
author_name?: string;
|
||||||
|
author_email?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
attachment_ids: string[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMessageDto {
|
||||||
|
model: string;
|
||||||
|
record_id: string;
|
||||||
|
message_type?: 'comment' | 'note' | 'email' | 'notification' | 'system';
|
||||||
|
subject?: string;
|
||||||
|
body: string;
|
||||||
|
parent_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageFilters {
|
||||||
|
model?: string;
|
||||||
|
record_id?: string;
|
||||||
|
message_type?: string;
|
||||||
|
author_id?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Follower {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
record_id: string;
|
||||||
|
partner_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
partner_name?: string;
|
||||||
|
email_notifications: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddFollowerDto {
|
||||||
|
model: string;
|
||||||
|
record_id: string;
|
||||||
|
user_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
email_notifications?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessagesService {
|
||||||
|
async findAll(tenantId: string, filters: MessageFilters = {}): Promise<{ data: Message[]; total: number }> {
|
||||||
|
const { model, record_id, message_type, author_id, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE m.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
whereClause += ` AND m.model = $${paramIndex++}`;
|
||||||
|
params.push(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record_id) {
|
||||||
|
whereClause += ` AND m.record_id = $${paramIndex++}`;
|
||||||
|
params.push(record_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message_type) {
|
||||||
|
whereClause += ` AND m.message_type = $${paramIndex++}`;
|
||||||
|
params.push(message_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (author_id) {
|
||||||
|
whereClause += ` AND m.author_id = $${paramIndex++}`;
|
||||||
|
params.push(author_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (m.subject ILIKE $${paramIndex} OR m.body ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM system.messages m ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Message>(
|
||||||
|
`SELECT m.*,
|
||||||
|
u.first_name || ' ' || u.last_name as author_name,
|
||||||
|
u.email as author_email
|
||||||
|
FROM system.messages m
|
||||||
|
LEFT JOIN auth.users u ON m.author_id = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByRecord(model: string, recordId: string, tenantId: string): Promise<Message[]> {
|
||||||
|
const messages = await query<Message>(
|
||||||
|
`SELECT m.*,
|
||||||
|
u.first_name || ' ' || u.last_name as author_name,
|
||||||
|
u.email as author_email
|
||||||
|
FROM system.messages m
|
||||||
|
LEFT JOIN auth.users u ON m.author_id = u.id
|
||||||
|
WHERE m.model = $1 AND m.record_id = $2 AND m.tenant_id = $3
|
||||||
|
ORDER BY m.created_at DESC`,
|
||||||
|
[model, recordId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Message> {
|
||||||
|
const message = await queryOne<Message>(
|
||||||
|
`SELECT m.*,
|
||||||
|
u.first_name || ' ' || u.last_name as author_name,
|
||||||
|
u.email as author_email
|
||||||
|
FROM system.messages m
|
||||||
|
LEFT JOIN auth.users u ON m.author_id = u.id
|
||||||
|
WHERE m.id = $1 AND m.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
throw new NotFoundError('Mensaje no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateMessageDto, tenantId: string, userId: string): Promise<Message> {
|
||||||
|
// Get user info for author fields
|
||||||
|
const user = await queryOne<{ first_name: string; last_name: string; email: string }>(
|
||||||
|
`SELECT first_name, last_name, email FROM auth.users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const message = await queryOne<Message>(
|
||||||
|
`INSERT INTO system.messages (
|
||||||
|
tenant_id, model, record_id, message_type, subject, body,
|
||||||
|
author_id, author_name, author_email, parent_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.model, dto.record_id,
|
||||||
|
dto.message_type || 'comment', dto.subject, dto.body,
|
||||||
|
userId, user ? `${user.first_name} ${user.last_name}` : null,
|
||||||
|
user?.email, dto.parent_id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return message!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM system.messages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== FOLLOWERS ==========
|
||||||
|
async getFollowers(model: string, recordId: string): Promise<Follower[]> {
|
||||||
|
const followers = await query<Follower>(
|
||||||
|
`SELECT mf.*,
|
||||||
|
u.first_name || ' ' || u.last_name as user_name,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM system.message_followers mf
|
||||||
|
LEFT JOIN auth.users u ON mf.user_id = u.id
|
||||||
|
LEFT JOIN core.partners p ON mf.partner_id = p.id
|
||||||
|
WHERE mf.model = $1 AND mf.record_id = $2
|
||||||
|
ORDER BY mf.created_at DESC`,
|
||||||
|
[model, recordId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return followers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFollower(dto: AddFollowerDto): Promise<Follower> {
|
||||||
|
const follower = await queryOne<Follower>(
|
||||||
|
`INSERT INTO system.message_followers (
|
||||||
|
model, record_id, user_id, partner_id, email_notifications
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (model, record_id, COALESCE(user_id, partner_id)) DO UPDATE
|
||||||
|
SET email_notifications = EXCLUDED.email_notifications
|
||||||
|
RETURNING *`,
|
||||||
|
[dto.model, dto.record_id, dto.user_id, dto.partner_id, dto.email_notifications ?? true]
|
||||||
|
);
|
||||||
|
|
||||||
|
return follower!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFollower(model: string, recordId: string, userId?: string, partnerId?: string): Promise<void> {
|
||||||
|
if (userId) {
|
||||||
|
await query(
|
||||||
|
`DELETE FROM system.message_followers
|
||||||
|
WHERE model = $1 AND record_id = $2 AND user_id = $3`,
|
||||||
|
[model, recordId, userId]
|
||||||
|
);
|
||||||
|
} else if (partnerId) {
|
||||||
|
await query(
|
||||||
|
`DELETE FROM system.message_followers
|
||||||
|
WHERE model = $1 AND record_id = $2 AND partner_id = $3`,
|
||||||
|
[model, recordId, partnerId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messagesService = new MessagesService();
|
||||||
227
src/modules/system/notifications.service.ts
Normal file
227
src/modules/system/notifications.service.ts
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
user_id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
url?: string;
|
||||||
|
model?: string;
|
||||||
|
record_id?: string;
|
||||||
|
status: 'pending' | 'sent' | 'read' | 'failed';
|
||||||
|
read_at?: Date;
|
||||||
|
created_at: Date;
|
||||||
|
sent_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateNotificationDto {
|
||||||
|
user_id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
url?: string;
|
||||||
|
model?: string;
|
||||||
|
record_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationFilters {
|
||||||
|
user_id?: string;
|
||||||
|
status?: string;
|
||||||
|
unread_only?: boolean;
|
||||||
|
model?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationsService {
|
||||||
|
async findAll(tenantId: string, filters: NotificationFilters = {}): Promise<{ data: Notification[]; total: number }> {
|
||||||
|
const { user_id, status, unread_only, model, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE n.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND n.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND n.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unread_only) {
|
||||||
|
whereClause += ` AND n.read_at IS NULL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
whereClause += ` AND n.model = $${paramIndex++}`;
|
||||||
|
params.push(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (n.title ILIKE $${paramIndex} OR n.message ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM system.notifications n ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Notification>(
|
||||||
|
`SELECT n.*
|
||||||
|
FROM system.notifications n
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUser(userId: string, tenantId: string, unreadOnly: boolean = false): Promise<Notification[]> {
|
||||||
|
let whereClause = 'WHERE n.user_id = $1 AND n.tenant_id = $2';
|
||||||
|
if (unreadOnly) {
|
||||||
|
whereClause += ' AND n.read_at IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = await query<Notification>(
|
||||||
|
`SELECT n.*
|
||||||
|
FROM system.notifications n
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[userId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(userId: string, tenantId: string): Promise<number> {
|
||||||
|
const result = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM system.notifications
|
||||||
|
WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`,
|
||||||
|
[userId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseInt(result?.count || '0', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Notification> {
|
||||||
|
const notification = await queryOne<Notification>(
|
||||||
|
`SELECT n.*
|
||||||
|
FROM system.notifications n
|
||||||
|
WHERE n.id = $1 AND n.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!notification) {
|
||||||
|
throw new NotFoundError('Notificación no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateNotificationDto, tenantId: string): Promise<Notification> {
|
||||||
|
const notification = await queryOne<Notification>(
|
||||||
|
`INSERT INTO system.notifications (
|
||||||
|
tenant_id, user_id, title, message, url, model, record_id, status, sent_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'sent', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.user_id, dto.title, dto.message, dto.url, dto.model, dto.record_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return notification!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBulk(notifications: CreateNotificationDto[], tenantId: string): Promise<number> {
|
||||||
|
if (notifications.length === 0) return 0;
|
||||||
|
|
||||||
|
const values = notifications.map((n, i) => {
|
||||||
|
const base = i * 7;
|
||||||
|
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, 'sent', CURRENT_TIMESTAMP)`;
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
const params = notifications.flatMap(n => [
|
||||||
|
tenantId, n.user_id, n.title, n.message, n.url, n.model, n.record_id
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO system.notifications (
|
||||||
|
tenant_id, user_id, title, message, url, model, record_id, status, sent_at
|
||||||
|
)
|
||||||
|
VALUES ${values}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return notifications.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(id: string, tenantId: string): Promise<Notification> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const notification = await queryOne<Notification>(
|
||||||
|
`UPDATE system.notifications SET
|
||||||
|
status = 'read',
|
||||||
|
read_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1 AND tenant_id = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return notification!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead(userId: string, tenantId: string): Promise<number> {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE system.notifications SET
|
||||||
|
status = 'read',
|
||||||
|
read_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`,
|
||||||
|
[userId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM system.notifications WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOld(daysToKeep: number = 30, tenantId?: string): Promise<number> {
|
||||||
|
let whereClause = `WHERE read_at IS NOT NULL AND created_at < CURRENT_TIMESTAMP - INTERVAL '${daysToKeep} days'`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
whereClause += ' AND tenant_id = $1';
|
||||||
|
params.push(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`DELETE FROM system.notifications ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsService = new NotificationsService();
|
||||||
404
src/modules/system/system.controller.ts
Normal file
404
src/modules/system/system.controller.ts
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { messagesService, CreateMessageDto, MessageFilters, AddFollowerDto } from './messages.service.js';
|
||||||
|
import { notificationsService, CreateNotificationDto, NotificationFilters } from './notifications.service.js';
|
||||||
|
import { activitiesService, CreateActivityDto, UpdateActivityDto, ActivityFilters } from './activities.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// ========== MESSAGE SCHEMAS ==========
|
||||||
|
const createMessageSchema = z.object({
|
||||||
|
model: z.string().min(1).max(100),
|
||||||
|
record_id: z.string().uuid(),
|
||||||
|
message_type: z.enum(['comment', 'note', 'email', 'notification', 'system']).default('comment'),
|
||||||
|
subject: z.string().max(255).optional(),
|
||||||
|
body: z.string().min(1),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageQuerySchema = z.object({
|
||||||
|
model: z.string().optional(),
|
||||||
|
record_id: z.string().uuid().optional(),
|
||||||
|
message_type: z.enum(['comment', 'note', 'email', 'notification', 'system']).optional(),
|
||||||
|
author_id: z.string().uuid().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addFollowerSchema = z.object({
|
||||||
|
model: z.string().min(1).max(100),
|
||||||
|
record_id: z.string().uuid(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
email_notifications: z.boolean().default(true),
|
||||||
|
}).refine(data => data.user_id || data.partner_id, {
|
||||||
|
message: 'Debe especificar user_id o partner_id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== NOTIFICATION SCHEMAS ==========
|
||||||
|
const createNotificationSchema = z.object({
|
||||||
|
user_id: z.string().uuid(),
|
||||||
|
title: z.string().min(1).max(255),
|
||||||
|
message: z.string().min(1),
|
||||||
|
url: z.string().max(500).optional(),
|
||||||
|
model: z.string().max(100).optional(),
|
||||||
|
record_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationQuerySchema = z.object({
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['pending', 'sent', 'read', 'failed']).optional(),
|
||||||
|
unread_only: z.coerce.boolean().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== ACTIVITY SCHEMAS ==========
|
||||||
|
const createActivitySchema = z.object({
|
||||||
|
model: z.string().min(1).max(100),
|
||||||
|
record_id: z.string().uuid(),
|
||||||
|
activity_type: z.enum(['call', 'meeting', 'email', 'todo', 'follow_up', 'custom']),
|
||||||
|
summary: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
assigned_to: z.string().uuid().optional(),
|
||||||
|
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
due_time: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateActivitySchema = z.object({
|
||||||
|
activity_type: z.enum(['call', 'meeting', 'email', 'todo', 'follow_up', 'custom']).optional(),
|
||||||
|
summary: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
assigned_to: z.string().uuid().optional().nullable(),
|
||||||
|
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
due_time: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rescheduleActivitySchema = z.object({
|
||||||
|
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
due_time: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activityQuerySchema = z.object({
|
||||||
|
model: z.string().optional(),
|
||||||
|
record_id: z.string().uuid().optional(),
|
||||||
|
activity_type: z.enum(['call', 'meeting', 'email', 'todo', 'follow_up', 'custom']).optional(),
|
||||||
|
assigned_to: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['planned', 'done', 'cancelled', 'overdue']).optional(),
|
||||||
|
due_from: z.string().optional(),
|
||||||
|
due_to: z.string().optional(),
|
||||||
|
overdue_only: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
class SystemController {
|
||||||
|
// ========== MESSAGES ==========
|
||||||
|
async getMessages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = messageQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: MessageFilters = queryResult.data;
|
||||||
|
const result = await messagesService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessagesByRecord(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { model, recordId } = req.params;
|
||||||
|
const messages = await messagesService.findByRecord(model, recordId, req.tenantId!);
|
||||||
|
res.json({ success: true, data: messages });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const message = await messagesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: message });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMessage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createMessageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de mensaje inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateMessageDto = parseResult.data;
|
||||||
|
const message = await messagesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: message, message: 'Mensaje creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMessage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await messagesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Mensaje eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== FOLLOWERS ==========
|
||||||
|
async getFollowers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { model, recordId } = req.params;
|
||||||
|
const followers = await messagesService.getFollowers(model, recordId);
|
||||||
|
res.json({ success: true, data: followers });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFollower(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = addFollowerSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de seguidor inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: AddFollowerDto = parseResult.data;
|
||||||
|
const follower = await messagesService.addFollower(dto);
|
||||||
|
res.status(201).json({ success: true, data: follower, message: 'Seguidor agregado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFollower(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { model, recordId } = req.params;
|
||||||
|
const { user_id, partner_id } = req.query;
|
||||||
|
await messagesService.removeFollower(model, recordId, user_id as string, partner_id as string);
|
||||||
|
res.json({ success: true, message: 'Seguidor eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== NOTIFICATIONS ==========
|
||||||
|
async getNotifications(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = notificationQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: NotificationFilters = queryResult.data;
|
||||||
|
const result = await notificationsService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyNotifications(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const unreadOnly = req.query.unread_only === 'true';
|
||||||
|
const notifications = await notificationsService.findByUser(req.user!.userId, req.tenantId!, unreadOnly);
|
||||||
|
const unreadCount = await notificationsService.getUnreadCount(req.user!.userId, req.tenantId!);
|
||||||
|
res.json({ success: true, data: notifications, meta: { unread_count: unreadCount } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const count = await notificationsService.getUnreadCount(req.user!.userId, req.tenantId!);
|
||||||
|
res.json({ success: true, data: { count } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNotification(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const notification = await notificationsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: notification });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNotification(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createNotificationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de notificación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateNotificationDto = parseResult.data;
|
||||||
|
const notification = await notificationsService.create(dto, req.tenantId!);
|
||||||
|
res.status(201).json({ success: true, data: notification, message: 'Notificación creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markNotificationAsRead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const notification = await notificationsService.markAsRead(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: notification, message: 'Notificación marcada como leída' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllNotificationsAsRead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const count = await notificationsService.markAllAsRead(req.user!.userId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: `${count} notificaciones marcadas como leídas` });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNotification(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await notificationsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Notificación eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ACTIVITIES ==========
|
||||||
|
async getActivities(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = activityQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: ActivityFilters = queryResult.data;
|
||||||
|
const result = await activitiesService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActivitiesByRecord(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { model, recordId } = req.params;
|
||||||
|
const activities = await activitiesService.findByRecord(model, recordId, req.tenantId!);
|
||||||
|
res.json({ success: true, data: activities });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyActivities(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const activities = await activitiesService.findByUser(req.user!.userId, req.tenantId!, status);
|
||||||
|
res.json({ success: true, data: activities });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activity = await activitiesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: activity });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createActivitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de actividad inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateActivityDto = parseResult.data;
|
||||||
|
const activity = await activitiesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: activity, message: 'Actividad creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateActivitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de actividad inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateActivityDto = parseResult.data;
|
||||||
|
const activity = await activitiesService.update(req.params.id, dto, req.tenantId!);
|
||||||
|
res.json({ success: true, data: activity, message: 'Actividad actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markActivityDone(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activity = await activitiesService.markDone(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: activity, message: 'Actividad completada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activity = await activitiesService.cancel(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: activity, message: 'Actividad cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescheduleActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = rescheduleActivitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de reprogramación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const { due_date, due_time } = parseResult.data;
|
||||||
|
const activity = await activitiesService.reschedule(req.params.id, due_date, due_time ?? null, req.tenantId!);
|
||||||
|
res.json({ success: true, data: activity, message: 'Actividad reprogramada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await activitiesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Actividad eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const systemController = new SystemController();
|
||||||
48
src/modules/system/system.routes.ts
Normal file
48
src/modules/system/system.routes.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { systemController } from './system.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== MESSAGES (Chatter) ==========
|
||||||
|
router.get('/messages', (req, res, next) => systemController.getMessages(req, res, next));
|
||||||
|
router.get('/messages/record/:model/:recordId', (req, res, next) => systemController.getMessagesByRecord(req, res, next));
|
||||||
|
router.get('/messages/:id', (req, res, next) => systemController.getMessage(req, res, next));
|
||||||
|
router.post('/messages', (req, res, next) => systemController.createMessage(req, res, next));
|
||||||
|
router.delete('/messages/:id', (req, res, next) => systemController.deleteMessage(req, res, next));
|
||||||
|
|
||||||
|
// ========== FOLLOWERS ==========
|
||||||
|
router.get('/followers/:model/:recordId', (req, res, next) => systemController.getFollowers(req, res, next));
|
||||||
|
router.post('/followers', (req, res, next) => systemController.addFollower(req, res, next));
|
||||||
|
router.delete('/followers/:model/:recordId', (req, res, next) => systemController.removeFollower(req, res, next));
|
||||||
|
|
||||||
|
// ========== NOTIFICATIONS ==========
|
||||||
|
router.get('/notifications', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
systemController.getNotifications(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/notifications/me', (req, res, next) => systemController.getMyNotifications(req, res, next));
|
||||||
|
router.get('/notifications/me/count', (req, res, next) => systemController.getUnreadCount(req, res, next));
|
||||||
|
router.get('/notifications/:id', (req, res, next) => systemController.getNotification(req, res, next));
|
||||||
|
router.post('/notifications', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
systemController.createNotification(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/notifications/:id/read', (req, res, next) => systemController.markNotificationAsRead(req, res, next));
|
||||||
|
router.post('/notifications/read-all', (req, res, next) => systemController.markAllNotificationsAsRead(req, res, next));
|
||||||
|
router.delete('/notifications/:id', (req, res, next) => systemController.deleteNotification(req, res, next));
|
||||||
|
|
||||||
|
// ========== ACTIVITIES ==========
|
||||||
|
router.get('/activities', (req, res, next) => systemController.getActivities(req, res, next));
|
||||||
|
router.get('/activities/record/:model/:recordId', (req, res, next) => systemController.getActivitiesByRecord(req, res, next));
|
||||||
|
router.get('/activities/me', (req, res, next) => systemController.getMyActivities(req, res, next));
|
||||||
|
router.get('/activities/:id', (req, res, next) => systemController.getActivity(req, res, next));
|
||||||
|
router.post('/activities', (req, res, next) => systemController.createActivity(req, res, next));
|
||||||
|
router.put('/activities/:id', (req, res, next) => systemController.updateActivity(req, res, next));
|
||||||
|
router.post('/activities/:id/done', (req, res, next) => systemController.markActivityDone(req, res, next));
|
||||||
|
router.post('/activities/:id/cancel', (req, res, next) => systemController.cancelActivity(req, res, next));
|
||||||
|
router.post('/activities/:id/reschedule', (req, res, next) => systemController.rescheduleActivity(req, res, next));
|
||||||
|
router.delete('/activities/:id', (req, res, next) => systemController.deleteActivity(req, res, next));
|
||||||
|
|
||||||
|
export default router;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user