# SPEC-GASTOS-EMPLEADOS ## Metadatos | Campo | Valor | |-------|-------| | **Código** | SPEC-TRANS-014 | | **Versión** | 1.0.0 | | **Fecha** | 2025-01-15 | | **Autor** | Requirements-Analyst Agent | | **Estado** | DRAFT | | **Prioridad** | P0 | | **Módulos Afectados** | MGN-010 (RRHH Básico) | | **Gaps Cubiertos** | GAP-MGN-010-002 | ## Resumen Ejecutivo Esta especificación define el sistema de gestión de gastos de empleados para ERP Core: 1. **Gastos Individuales**: Registro de gastos con categorías, montos y comprobantes 2. **Reportes de Gastos**: Agrupación de gastos para aprobación 3. **Flujo de Aprobación**: Workflow multinivel (manager → contabilidad) 4. **Reembolso**: Integración con pagos y nómina 5. **Políticas**: Límites y validaciones por categoría 6. **Refacturación**: Gastos refacturables a clientes/proyectos ### Referencia Odoo 18 Basado en análisis del módulo `hr_expense` de Odoo 18: - **hr.expense**: Línea de gasto individual - **hr.expense.sheet**: Reporte/hoja de gastos - **product.template** (con `hr_expense_ok`): Categorías de gasto - Integración con contabilidad vía `account.move` --- ## Parte 1: Arquitectura del Sistema ### 1.1 Visión General ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ SISTEMA DE GASTOS DE EMPLEADOS │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Empleado │───▶│ Gasto │◀───│ Categoría │ │ │ │ │ │ (expense) │ │ (product) │ │ │ └──────────────┘ └──────┬───────┘ └──────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────┐ │ │ │ Expense Sheet │ │ │ │ (Reporte de Gastos) │ │ │ └──────────────┬───────────┘ │ │ │ │ │ ┌───────────────────┼───────────────────┐ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Manager │ │ Contabilidad│ │ Tesorería │ │ │ │ Approval │───▶│ Validation │───▶│ Payment │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────┐ │ │ │ Journal Entry │ │ │ │ + Reembolso │ │ │ └──────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 1.2 Flujo de Gastos ``` 1. REGISTRAR GASTO ├─ Empleado crea gasto (categoría, monto, fecha) ├─ Adjunta comprobante (recibo/factura) └─ Indica si pagó empleado o empresa │ ▼ 2. CREAR REPORTE ├─ Agrupar gastos en expense_sheet ├─ Agregar descripción/justificación └─ Enviar a aprobación │ ▼ 3. APROBACIÓN MANAGER ├─ Manager revisa gastos ├─ Aprueba o rechaza └─ Si rechaza → vuelve a draft │ ▼ 4. VALIDACIÓN CONTABLE ├─ Contabilidad verifica documentación ├─ Genera asiento contable (en borrador) └─ Publica asiento │ ▼ 5. REEMBOLSO ├─ Si pagó empleado → crear pago ├─ Opciones: transferencia, efectivo, nómina └─ Marcar como pagado ``` --- ## Parte 2: Modelo de Datos ### 2.1 Diagrama Entidad-Relación ``` ┌─────────────────────┐ ┌─────────────────────┐ │ expense_sheets │ │ expense_categories│ │─────────────────────│ │─────────────────────│ │ id (PK) │ │ id (PK) │ │ employee_id (FK) │ │ code │ │ name │ │ name │ │ state │ │ expense_account_id │ │ submission_date │ │ cost_type │ │ approval_date │◀──┐ │ default_unit_price │ │ accounting_date │ │ │ uom_id │ │ total_amount │ │ │ reinvoiceable │ │ journal_entry_id │ │ └─────────────────────┘ └─────────┬───────────┘ │ │ │ │ │ │ 1:N │ │ ▼ │ ▼ ┌─────────────────────┐ │ ┌─────────────────────┐ │ expenses │ │ │ expense_policies │ │─────────────────────│ │ │─────────────────────│ │ id (PK) │ │ │ id (PK) │ │ sheet_id (FK) │───┘ │ category_id (FK) │ │ employee_id (FK) │ │ company_id (FK) │ │ category_id (FK) │───────│ max_amount │ │ name │ │ requires_receipt │ │ expense_date │ │ approval_limit │ │ unit_amount │ │ auto_approve_below │ │ quantity │ └─────────────────────┘ │ total_amount │ │ currency_id (FK) │ │ paid_by │ │ analytic_account_id │ │ state │ └─────────────────────┘ ``` ### 2.2 Definición de Tablas #### expense_categories (Categorías de Gasto) ```sql -- Categorías de gastos (basado en product con hr_expense_ok) CREATE TABLE expense_categories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación code VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description TEXT, -- Configuración contable expense_account_id UUID NOT NULL REFERENCES accounts(id), -- Tipo de costo cost_type expense_cost_type NOT NULL DEFAULT 'actual', -- actual: empleado ingresa monto real -- fixed: monto fijo configurado -- mileage: cálculo por distancia default_unit_price DECIMAL(15,4) DEFAULT 0, uom_id UUID REFERENCES units_of_measure(id), -- km, unidad, día -- Refacturación reinvoiceable BOOLEAN NOT NULL DEFAULT false, sale_account_id UUID REFERENCES accounts(id), -- Estado active BOOLEAN NOT NULL DEFAULT true, company_id UUID NOT NULL REFERENCES companies(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL REFERENCES users(id), updated_by UUID NOT NULL REFERENCES users(id) ); CREATE TYPE expense_cost_type AS ENUM ('actual', 'fixed', 'mileage'); -- Categorías predeterminadas COMMENT ON TABLE expense_categories IS 'Categorías de gastos: viajes, alimentación, hospedaje, etc.'; ``` #### expenses (Gastos Individuales) ```sql -- Líneas de gasto individual CREATE TABLE expenses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones sheet_id UUID REFERENCES expense_sheets(id) ON DELETE SET NULL, employee_id UUID NOT NULL REFERENCES employees(id), category_id UUID NOT NULL REFERENCES expense_categories(id), -- Descripción name VARCHAR(255) NOT NULL, description TEXT, reference VARCHAR(100), -- Número de factura/recibo -- Fecha y montos expense_date DATE NOT NULL, unit_amount DECIMAL(15,2) NOT NULL, quantity DECIMAL(10,4) NOT NULL DEFAULT 1, total_amount DECIMAL(15,2) NOT NULL GENERATED ALWAYS AS (unit_amount * quantity) STORED, -- Moneda currency_id UUID NOT NULL REFERENCES currencies(id), exchange_rate DECIMAL(12,6) DEFAULT 1, amount_company_currency DECIMAL(15,2), -- Quién pagó paid_by expense_paid_by NOT NULL DEFAULT 'employee', -- Analítica (centro de costo / proyecto) analytic_account_id UUID REFERENCES analytic_accounts(id), project_id UUID REFERENCES projects(id), -- Refacturación a cliente sale_order_id UUID REFERENCES sale_orders(id), reinvoice_status reinvoice_status DEFAULT 'no', -- Estado del gasto individual state expense_state NOT NULL DEFAULT 'draft', -- Auditoría company_id UUID NOT NULL REFERENCES companies(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL REFERENCES users(id), updated_by UUID NOT NULL REFERENCES users(id), -- Constraints CONSTRAINT positive_amount CHECK (unit_amount >= 0), CONSTRAINT positive_quantity CHECK (quantity > 0) ); CREATE TYPE expense_paid_by AS ENUM ('employee', 'company'); CREATE TYPE expense_state AS ENUM ('draft', 'reported', 'submitted', 'approved', 'posted', 'done', 'refused'); CREATE TYPE reinvoice_status AS ENUM ('no', 'pending', 'invoiced'); CREATE INDEX idx_expenses_employee ON expenses(employee_id); CREATE INDEX idx_expenses_sheet ON expenses(sheet_id); CREATE INDEX idx_expenses_date ON expenses(expense_date); CREATE INDEX idx_expenses_state ON expenses(state); ``` #### expense_sheets (Reportes de Gastos) ```sql -- Hojas/reportes de gastos para aprobación CREATE TABLE expense_sheets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(255) NOT NULL, reference VARCHAR(50) UNIQUE, -- EXP-2025-0001 -- Empleado employee_id UUID NOT NULL REFERENCES employees(id), department_id UUID REFERENCES departments(id), manager_id UUID REFERENCES employees(id), -- Fechas submission_date DATE, approval_date DATE, accounting_date DATE, -- Totales calculados total_amount DECIMAL(15,2) NOT NULL DEFAULT 0, currency_id UUID NOT NULL REFERENCES currencies(id), -- Estado del workflow state expense_sheet_state NOT NULL DEFAULT 'draft', -- Aprobaciones approved_by UUID REFERENCES users(id), validated_by UUID REFERENCES users(id), refused_by UUID REFERENCES users(id), refuse_reason TEXT, -- Contabilidad journal_id UUID REFERENCES journals(id), journal_entry_id UUID REFERENCES journal_entries(id), -- Pago/Reembolso payment_state payment_state DEFAULT 'not_paid', payment_id UUID REFERENCES payments(id), payment_mode expense_payment_mode, -- Auditoría company_id UUID NOT NULL REFERENCES companies(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL REFERENCES users(id), updated_by UUID NOT NULL REFERENCES users(id) ); CREATE TYPE expense_sheet_state AS ENUM ( 'draft', -- Borrador 'submitted', -- Enviado a aprobación 'approved', -- Aprobado por manager 'posted', -- Contabilizado 'done', -- Pagado/Completado 'refused' -- Rechazado ); CREATE TYPE payment_state AS ENUM ('not_paid', 'partial', 'paid'); CREATE TYPE expense_payment_mode AS ENUM ('bank_transfer', 'cash', 'payslip'); CREATE INDEX idx_expense_sheets_employee ON expense_sheets(employee_id); CREATE INDEX idx_expense_sheets_state ON expense_sheets(state); CREATE INDEX idx_expense_sheets_date ON expense_sheets(submission_date); -- Secuencia para referencia CREATE SEQUENCE expense_sheet_seq; ``` #### expense_attachments (Comprobantes) ```sql -- Archivos adjuntos (recibos, facturas) CREATE TABLE expense_attachments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), expense_id UUID NOT NULL REFERENCES expenses(id) ON DELETE CASCADE, -- Archivo filename VARCHAR(255) NOT NULL, file_path VARCHAR(500) NOT NULL, file_size INTEGER, mime_type VARCHAR(100), -- OCR (si aplica) ocr_processed BOOLEAN DEFAULT false, ocr_data JSONB, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL REFERENCES users(id) ); CREATE INDEX idx_expense_attachments_expense ON expense_attachments(expense_id); ``` #### expense_policies (Políticas de Gasto) ```sql -- Políticas y límites por categoría CREATE TABLE expense_policies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Alcance company_id UUID NOT NULL REFERENCES companies(id), category_id UUID REFERENCES expense_categories(id), -- NULL = todas department_id UUID REFERENCES departments(id), -- NULL = todos -- Límites max_amount_per_expense DECIMAL(15,2), max_amount_per_day DECIMAL(15,2), max_amount_per_month DECIMAL(15,2), -- Reglas requires_receipt BOOLEAN NOT NULL DEFAULT true, receipt_required_above DECIMAL(15,2), -- Requerido solo arriba de X requires_description BOOLEAN NOT NULL DEFAULT false, -- Auto-aprobación auto_approve_below DECIMAL(15,2), -- Auto-aprobar montos menores -- Aprobación multinivel approval_limit_manager DECIMAL(15,2), -- Hasta X, solo manager approval_limit_director DECIMAL(15,2), -- Hasta X, director -- Arriba de director → CFO/Finance -- Vigencia valid_from DATE NOT NULL DEFAULT CURRENT_DATE, valid_to DATE, active BOOLEAN NOT NULL DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_expense_policies_company ON expense_policies(company_id); CREATE INDEX idx_expense_policies_category ON expense_policies(category_id); ``` --- ## Parte 3: Servicios de Aplicación ### 3.1 ExpenseService ```typescript // src/modules/hr-expense/services/expense.service.ts import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Expense, ExpenseState, PaidBy } from '../entities/expense.entity'; import { ExpenseSheet, ExpenseSheetState } from '../entities/expense-sheet.entity'; import { ExpenseCategory } from '../entities/expense-category.entity'; import { ExpensePolicy } from '../entities/expense-policy.entity'; @Injectable() export class ExpenseService { constructor( @InjectRepository(Expense) private readonly expenseRepo: Repository, @InjectRepository(ExpenseSheet) private readonly sheetRepo: Repository, @InjectRepository(ExpenseCategory) private readonly categoryRepo: Repository, @InjectRepository(ExpensePolicy) private readonly policyRepo: Repository, private readonly dataSource: DataSource, ) {} /** * Crear nuevo gasto */ async createExpense(dto: CreateExpenseDto, userId: string): Promise { // Obtener empleado del usuario const employee = await this.getEmployeeByUser(userId); // Obtener categoría y validar const category = await this.categoryRepo.findOneOrFail({ where: { id: dto.categoryId, active: true } }); // Calcular monto según tipo de categoría let unitAmount = dto.unitAmount; if (category.costType === 'fixed') { unitAmount = category.defaultUnitPrice; } else if (category.costType === 'mileage') { unitAmount = category.defaultUnitPrice; // precio por km } // Validar políticas await this.validateExpensePolicy({ categoryId: category.id, amount: unitAmount * dto.quantity, employeeId: employee.id, companyId: employee.companyId, }); // Crear gasto const expense = this.expenseRepo.create({ employeeId: employee.id, categoryId: category.id, name: dto.name || category.name, description: dto.description, reference: dto.reference, expenseDate: dto.expenseDate, unitAmount, quantity: dto.quantity || 1, currencyId: dto.currencyId || employee.companyCurrencyId, paidBy: dto.paidBy || PaidBy.EMPLOYEE, analyticAccountId: dto.analyticAccountId, projectId: dto.projectId, companyId: employee.companyId, state: ExpenseState.DRAFT, createdBy: userId, updatedBy: userId, }); return this.expenseRepo.save(expense); } /** * Validar gasto contra políticas */ private async validateExpensePolicy(params: { categoryId: string; amount: number; employeeId: string; companyId: string; }): Promise { const policies = await this.policyRepo.find({ where: [ { companyId: params.companyId, categoryId: params.categoryId, active: true }, { companyId: params.companyId, categoryId: null, active: true }, // Global ], order: { categoryId: 'DESC' } // Específica primero }); if (policies.length === 0) return; // Sin políticas = permitido const policy = policies[0]; // Validar monto máximo por gasto if (policy.maxAmountPerExpense && params.amount > policy.maxAmountPerExpense) { throw new BadRequestException( `El monto ${params.amount} excede el máximo permitido de ${policy.maxAmountPerExpense}` ); } // Validar monto diario if (policy.maxAmountPerDay) { const todayTotal = await this.getTodayTotal(params.employeeId, params.categoryId); if (todayTotal + params.amount > policy.maxAmountPerDay) { throw new BadRequestException( `El gasto excedería el límite diario de ${policy.maxAmountPerDay}` ); } } // Validar monto mensual if (policy.maxAmountPerMonth) { const monthTotal = await this.getMonthTotal(params.employeeId, params.categoryId); if (monthTotal + params.amount > policy.maxAmountPerMonth) { throw new BadRequestException( `El gasto excedería el límite mensual de ${policy.maxAmountPerMonth}` ); } } } /** * Crear reporte de gastos (expense sheet) */ async createExpenseSheet( dto: CreateExpenseSheetDto, userId: string ): Promise { const employee = await this.getEmployeeByUser(userId); // Generar referencia const reference = await this.generateSheetReference(employee.companyId); // Crear hoja const sheet = this.sheetRepo.create({ name: dto.name || `Gastos ${new Date().toLocaleDateString()}`, reference, employeeId: employee.id, departmentId: employee.departmentId, managerId: employee.managerId, currencyId: employee.companyCurrencyId, state: ExpenseSheetState.DRAFT, companyId: employee.companyId, createdBy: userId, updatedBy: userId, }); const savedSheet = await this.sheetRepo.save(sheet); // Agregar gastos si se proporcionaron if (dto.expenseIds?.length > 0) { await this.addExpensesToSheet(savedSheet.id, dto.expenseIds, userId); } return this.getSheetWithTotals(savedSheet.id); } /** * Agregar gastos a un reporte */ async addExpensesToSheet( sheetId: string, expenseIds: string[], userId: string ): Promise { const sheet = await this.sheetRepo.findOneOrFail({ where: { id: sheetId } }); if (sheet.state !== ExpenseSheetState.DRAFT) { throw new BadRequestException('Solo se pueden agregar gastos a reportes en borrador'); } // Actualizar gastos await this.expenseRepo.update( { id: In(expenseIds), state: ExpenseState.DRAFT }, { sheetId, state: ExpenseState.REPORTED, updatedBy: userId } ); // Recalcular total return this.recalculateSheetTotal(sheetId); } /** * Enviar reporte a aprobación */ async submitSheet(sheetId: string, userId: string): Promise { const sheet = await this.sheetRepo.findOneOrFail({ where: { id: sheetId }, relations: ['expenses', 'expenses.attachments'] }); // Validaciones if (sheet.state !== ExpenseSheetState.DRAFT) { throw new BadRequestException('El reporte ya fue enviado'); } if (!sheet.expenses || sheet.expenses.length === 0) { throw new BadRequestException('El reporte debe tener al menos un gasto'); } // Validar comprobantes requeridos await this.validateReceiptsRequired(sheet); // Verificar auto-aprobación const canAutoApprove = await this.checkAutoApproval(sheet); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Actualizar estado del reporte sheet.state = canAutoApprove ? ExpenseSheetState.APPROVED : ExpenseSheetState.SUBMITTED; sheet.submissionDate = new Date(); sheet.updatedBy = userId; if (canAutoApprove) { sheet.approvalDate = new Date(); sheet.approvedBy = userId; // Sistema } await queryRunner.manager.save(sheet); // Actualizar estado de gastos await queryRunner.manager.update(Expense, { sheetId: sheet.id }, { state: ExpenseState.SUBMITTED, updatedBy: userId } ); await queryRunner.commitTransaction(); // Notificar al manager si no fue auto-aprobado if (!canAutoApprove && sheet.managerId) { await this.notifyManager(sheet); } return sheet; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } /** * Verificar si aplica auto-aprobación */ private async checkAutoApproval(sheet: ExpenseSheet): Promise { const policy = await this.policyRepo.findOne({ where: { companyId: sheet.companyId, active: true }, order: { categoryId: 'DESC' } }); if (!policy?.autoApproveBelow) return false; return sheet.totalAmount <= policy.autoApproveBelow; } /** * Validar comprobantes requeridos */ private async validateReceiptsRequired(sheet: ExpenseSheet): Promise { for (const expense of sheet.expenses) { const policy = await this.policyRepo.findOne({ where: [ { companyId: sheet.companyId, categoryId: expense.categoryId, active: true }, { companyId: sheet.companyId, categoryId: null, active: true } ], order: { categoryId: 'DESC' } }); if (!policy) continue; const needsReceipt = policy.requiresReceipt || (policy.receiptRequiredAbove && expense.totalAmount > policy.receiptRequiredAbove); if (needsReceipt && (!expense.attachments || expense.attachments.length === 0)) { throw new BadRequestException( `El gasto "${expense.name}" requiere comprobante adjunto` ); } } } } ``` ### 3.2 ExpenseApprovalService ```typescript // src/modules/hr-expense/services/expense-approval.service.ts import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { ExpenseSheet, ExpenseSheetState } from '../entities/expense-sheet.entity'; import { Expense, ExpenseState } from '../entities/expense.entity'; import { ExpensePolicy } from '../entities/expense-policy.entity'; @Injectable() export class ExpenseApprovalService { constructor( @InjectRepository(ExpenseSheet) private readonly sheetRepo: Repository, @InjectRepository(Expense) private readonly expenseRepo: Repository, @InjectRepository(ExpensePolicy) private readonly policyRepo: Repository, private readonly dataSource: DataSource, private readonly accountingService: AccountingService, ) {} /** * Aprobar reporte de gastos (Manager) */ async approveSheet( sheetId: string, userId: string, comment?: string ): Promise { const sheet = await this.sheetRepo.findOneOrFail({ where: { id: sheetId }, relations: ['employee', 'expenses'] }); // Validar estado if (sheet.state !== ExpenseSheetState.SUBMITTED) { throw new BadRequestException('El reporte no está pendiente de aprobación'); } // Validar que el usuario puede aprobar await this.validateApprovalAuthority(sheet, userId); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Actualizar reporte sheet.state = ExpenseSheetState.APPROVED; sheet.approvalDate = new Date(); sheet.approvedBy = userId; sheet.updatedBy = userId; await queryRunner.manager.save(sheet); // Actualizar gastos await queryRunner.manager.update(Expense, { sheetId: sheet.id }, { state: ExpenseState.APPROVED, updatedBy: userId } ); // Registrar en historial await this.logApprovalAction(queryRunner, sheet.id, 'approved', userId, comment); await queryRunner.commitTransaction(); // Notificar al empleado await this.notifyEmployee(sheet, 'approved'); return sheet; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } /** * Rechazar reporte de gastos */ async refuseSheet( sheetId: string, userId: string, reason: string ): Promise { if (!reason?.trim()) { throw new BadRequestException('Debe proporcionar un motivo de rechazo'); } const sheet = await this.sheetRepo.findOneOrFail({ where: { id: sheetId }, relations: ['employee', 'expenses'] }); // Validar estado (puede rechazar desde submitted, approved o posted) if (![ExpenseSheetState.SUBMITTED, ExpenseSheetState.APPROVED, ExpenseSheetState.POSTED] .includes(sheet.state)) { throw new BadRequestException('El reporte no puede ser rechazado en este estado'); } // Si está posted, reversar asiento contable if (sheet.state === ExpenseSheetState.POSTED && sheet.journalEntryId) { await this.accountingService.reverseJournalEntry(sheet.journalEntryId, userId); } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Actualizar reporte sheet.state = ExpenseSheetState.REFUSED; sheet.refusedBy = userId; sheet.refuseReason = reason; sheet.updatedBy = userId; await queryRunner.manager.save(sheet); // Actualizar gastos await queryRunner.manager.update(Expense, { sheetId: sheet.id }, { state: ExpenseState.REFUSED, updatedBy: userId } ); await this.logApprovalAction(queryRunner, sheet.id, 'refused', userId, reason); await queryRunner.commitTransaction(); // Notificar al empleado await this.notifyEmployee(sheet, 'refused', reason); return sheet; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } /** * Validar autoridad de aprobación */ private async validateApprovalAuthority( sheet: ExpenseSheet, userId: string ): Promise { const user = await this.getUserWithRoles(userId); // Verificar si es manager del empleado const isManager = sheet.managerId === user.employeeId; // Verificar si tiene rol de aprobador de gastos const isExpenseApprover = user.roles.some(r => r.permissions.includes('expense.approve') ); if (!isManager && !isExpenseApprover) { throw new ForbiddenException('No tiene autoridad para aprobar este reporte'); } // Verificar límites de aprobación const policy = await this.policyRepo.findOne({ where: { companyId: sheet.companyId, active: true } }); if (policy) { // Manager tiene límite if (isManager && !isExpenseApprover) { if (policy.approvalLimitManager && sheet.totalAmount > policy.approvalLimitManager) { throw new ForbiddenException( `El monto ${sheet.totalAmount} excede su límite de aprobación (${policy.approvalLimitManager}). ` + 'Requiere aprobación de nivel superior.' ); } } } } /** * Contabilizar reporte aprobado */ async postSheet(sheetId: string, userId: string): Promise { const sheet = await this.sheetRepo.findOneOrFail({ where: { id: sheetId }, relations: ['expenses', 'expenses.category', 'employee'] }); if (sheet.state !== ExpenseSheetState.APPROVED) { throw new BadRequestException('El reporte debe estar aprobado para contabilizar'); } if (!sheet.journalId) { throw new BadRequestException('Debe especificar un diario contable'); } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Crear asiento contable const journalEntry = await this.createJournalEntry(sheet, userId); // Actualizar reporte sheet.state = ExpenseSheetState.POSTED; sheet.accountingDate = new Date(); sheet.validatedBy = userId; sheet.journalEntryId = journalEntry.id; sheet.updatedBy = userId; await queryRunner.manager.save(sheet); // Actualizar gastos await queryRunner.manager.update(Expense, { sheetId: sheet.id }, { state: ExpenseState.POSTED, updatedBy: userId } ); await queryRunner.commitTransaction(); return sheet; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } /** * Crear asiento contable para gastos */ private async createJournalEntry( sheet: ExpenseSheet, userId: string ): Promise { const lines: JournalEntryLine[] = []; // Agrupar gastos por cuenta const expensesByAccount = new Map(); for (const expense of sheet.expenses) { const accountId = expense.category.expenseAccountId; const current = expensesByAccount.get(accountId) || 0; expensesByAccount.set(accountId, current + expense.totalAmount); } // Líneas de débito (gastos) for (const [accountId, amount] of expensesByAccount) { lines.push({ accountId, debit: amount, credit: 0, name: `Gastos ${sheet.reference}`, analyticAccountId: sheet.expenses[0]?.analyticAccountId, }); } // Línea de crédito // Si pagó empleado → cuenta por pagar al empleado // Si pagó empresa → cuenta de banco/tarjeta corporativa const employeePaidAmount = sheet.expenses .filter(e => e.paidBy === PaidBy.EMPLOYEE) .reduce((sum, e) => sum + e.totalAmount, 0); const companyPaidAmount = sheet.expenses .filter(e => e.paidBy === PaidBy.COMPANY) .reduce((sum, e) => sum + e.totalAmount, 0); if (employeePaidAmount > 0) { // Cuenta por pagar al empleado (como proveedor) const employeePayableAccount = await this.getEmployeePayableAccount(sheet.employee); lines.push({ accountId: employeePayableAccount.id, partnerId: sheet.employee.partnerId, debit: 0, credit: employeePaidAmount, name: `Reembolso a ${sheet.employee.name}`, }); } if (companyPaidAmount > 0) { // Cuenta de tarjeta corporativa o banco const companyExpenseAccount = await this.getCompanyExpenseAccount(sheet.companyId); lines.push({ accountId: companyExpenseAccount.id, debit: 0, credit: companyPaidAmount, name: `Gastos empresa ${sheet.reference}`, }); } return this.accountingService.createJournalEntry({ journalId: sheet.journalId, date: sheet.accountingDate || new Date(), reference: sheet.reference, lines, state: 'draft', // En Odoo 18, se crea en borrador createdBy: userId, }); } } ``` ### 3.3 ExpenseReimbursementService ```typescript // src/modules/hr-expense/services/expense-reimbursement.service.ts import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { ExpenseSheet, ExpenseSheetState, PaymentMode } from '../entities/expense-sheet.entity'; @Injectable() export class ExpenseReimbursementService { constructor( @InjectRepository(ExpenseSheet) private readonly sheetRepo: Repository, private readonly dataSource: DataSource, private readonly paymentService: PaymentService, private readonly payrollService: PayrollService, ) {} /** * Reembolsar gastos vía pago directo */ async reimburseViaPayment( sheetId: string, dto: ReimbursePaymentDto, userId: string ): Promise { const sheet = await this.sheetRepo.findOneOrFail({ where: { id: sheetId }, relations: ['employee', 'expenses'] }); if (sheet.state !== ExpenseSheetState.POSTED) { throw new BadRequestException('El reporte debe estar contabilizado para reembolsar'); } // Calcular monto a reembolsar (solo lo pagado por empleado) const reimbursementAmount = sheet.expenses .filter(e => e.paidBy === 'employee') .reduce((sum, e) => sum + e.totalAmount, 0); if (reimbursementAmount <= 0) { throw new BadRequestException('No hay montos a reembolsar al empleado'); } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Crear pago const payment = await this.paymentService.createPayment({ partnerId: sheet.employee.partnerId, amount: reimbursementAmount, currencyId: sheet.currencyId, journalId: dto.journalId, paymentMethodId: dto.paymentMethodId, reference: `Reembolso ${sheet.reference}`, date: new Date(), }, queryRunner); // Conciliar con asiento de gastos await this.paymentService.reconcileWithEntry( payment.id, sheet.journalEntryId, queryRunner ); // Actualizar reporte sheet.paymentState = 'paid'; sheet.paymentId = payment.id; sheet.paymentMode = dto.journalType === 'bank' ? PaymentMode.BANK_TRANSFER : PaymentMode.CASH; sheet.state = ExpenseSheetState.DONE; sheet.updatedBy = userId; await queryRunner.manager.save(sheet); await queryRunner.commitTransaction(); return sheet; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } /** * Reembolsar gastos vía nómina */ async reimburseViaPayslip( sheetId: string, userId: string ): Promise { const sheet = await this.sheetRepo.findOneOrFail({ where: { id: sheetId }, relations: ['employee', 'expenses'] }); // Para nómina, puede estar en approved (no necesita estar posted) if (![ExpenseSheetState.APPROVED, ExpenseSheetState.POSTED].includes(sheet.state)) { throw new BadRequestException( 'El reporte debe estar aprobado o contabilizado para incluir en nómina' ); } // Calcular monto const reimbursementAmount = sheet.expenses .filter(e => e.paidBy === 'employee') .reduce((sum, e) => sum + e.totalAmount, 0); if (reimbursementAmount <= 0) { throw new BadRequestException('No hay montos a reembolsar'); } // Registrar input en nómina await this.payrollService.addPayslipInput({ employeeId: sheet.employeeId, inputTypeCode: 'EXPENSE_REIMB', amount: reimbursementAmount, description: `Reembolso gastos ${sheet.reference}`, sourceType: 'expense_sheet', sourceId: sheet.id, }); // Actualizar estado sheet.paymentMode = PaymentMode.PAYSLIP; sheet.paymentState = 'pending'; // Se pagará con la nómina sheet.updatedBy = userId; await this.sheetRepo.save(sheet); return sheet; } /** * Marcar como pagado (cuando se procesa la nómina) */ async markAsPaidViaPayslip( sheetId: string, payslipId: string, userId: string ): Promise { await this.sheetRepo.update( { id: sheetId }, { state: ExpenseSheetState.DONE, paymentState: 'paid', updatedBy: userId, } ); } } ``` --- ## Parte 4: API REST ### 4.1 Endpoints de Gastos ```typescript // src/modules/hr-expense/controllers/expense.controller.ts @Controller('api/v1/expenses') @UseGuards(JwtAuthGuard) @ApiTags('Expenses') export class ExpenseController { constructor( private readonly expenseService: ExpenseService, private readonly approvalService: ExpenseApprovalService, private readonly reimbursementService: ExpenseReimbursementService, ) {} // ============ GASTOS INDIVIDUALES ============ @Post() @ApiOperation({ summary: 'Crear gasto' }) async createExpense( @Body() dto: CreateExpenseDto, @CurrentUser() user: User, ): Promise { const expense = await this.expenseService.createExpense(dto, user.id); return this.mapToResponse(expense); } @Get() @ApiOperation({ summary: 'Listar mis gastos' }) async listMyExpenses( @Query() query: ExpenseQueryDto, @CurrentUser() user: User, ): Promise> { return this.expenseService.findByEmployee(user.employeeId, query); } @Get(':id') @ApiOperation({ summary: 'Obtener gasto' }) async getExpense( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.expenseService.findOne(id, user); } @Patch(':id') @ApiOperation({ summary: 'Actualizar gasto' }) async updateExpense( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateExpenseDto, @CurrentUser() user: User, ): Promise { return this.expenseService.update(id, dto, user.id); } @Delete(':id') @ApiOperation({ summary: 'Eliminar gasto' }) async deleteExpense( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { return this.expenseService.delete(id, user.id); } @Post(':id/attachments') @UseInterceptors(FileInterceptor('file')) @ApiOperation({ summary: 'Adjuntar comprobante' }) async uploadAttachment( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: User, ): Promise { return this.expenseService.addAttachment(id, file, user.id); } // ============ REPORTES DE GASTOS ============ @Post('sheets') @ApiOperation({ summary: 'Crear reporte de gastos' }) async createSheet( @Body() dto: CreateExpenseSheetDto, @CurrentUser() user: User, ): Promise { const sheet = await this.expenseService.createExpenseSheet(dto, user.id); return this.mapSheetToResponse(sheet); } @Get('sheets') @ApiOperation({ summary: 'Listar mis reportes' }) async listMySheets( @Query() query: SheetQueryDto, @CurrentUser() user: User, ): Promise> { return this.expenseService.findSheetsByEmployee(user.employeeId, query); } @Get('sheets/to-approve') @ApiOperation({ summary: 'Reportes pendientes de aprobación' }) @Roles('expense.approve') async listToApprove( @Query() query: SheetQueryDto, @CurrentUser() user: User, ): Promise> { return this.expenseService.findSheetsToApprove(user, query); } @Post('sheets/:id/submit') @ApiOperation({ summary: 'Enviar reporte a aprobación' }) async submitSheet( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { const sheet = await this.expenseService.submitSheet(id, user.id); return this.mapSheetToResponse(sheet); } @Post('sheets/:id/approve') @ApiOperation({ summary: 'Aprobar reporte' }) @Roles('expense.approve') async approveSheet( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveSheetDto, @CurrentUser() user: User, ): Promise { const sheet = await this.approvalService.approveSheet(id, user.id, dto.comment); return this.mapSheetToResponse(sheet); } @Post('sheets/:id/refuse') @ApiOperation({ summary: 'Rechazar reporte' }) @Roles('expense.approve') async refuseSheet( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RefuseSheetDto, @CurrentUser() user: User, ): Promise { const sheet = await this.approvalService.refuseSheet(id, user.id, dto.reason); return this.mapSheetToResponse(sheet); } @Post('sheets/:id/post') @ApiOperation({ summary: 'Contabilizar reporte' }) @Roles('accounting.expense') async postSheet( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, ): Promise { const sheet = await this.approvalService.postSheet(id, user.id); return this.mapSheetToResponse(sheet); } @Post('sheets/:id/reimburse') @ApiOperation({ summary: 'Reembolsar gastos' }) @Roles('accounting.payment') async reimburseSheet( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ReimburseDto, @CurrentUser() user: User, ): Promise { let sheet: ExpenseSheet; if (dto.method === 'payslip') { sheet = await this.reimbursementService.reimburseViaPayslip(id, user.id); } else { sheet = await this.reimbursementService.reimburseViaPayment(id, dto, user.id); } return this.mapSheetToResponse(sheet); } } ``` ### 4.2 DTOs ```typescript // src/modules/hr-expense/dto/expense.dto.ts export class CreateExpenseDto { @IsUUID() categoryId: string; @IsString() @IsOptional() name?: string; @IsString() @IsOptional() description?: string; @IsString() @IsOptional() reference?: string; @IsDateString() expenseDate: string; @IsNumber() @Min(0) unitAmount: number; @IsNumber() @Min(0.0001) @IsOptional() quantity?: number = 1; @IsUUID() @IsOptional() currencyId?: string; @IsEnum(PaidBy) @IsOptional() paidBy?: PaidBy = PaidBy.EMPLOYEE; @IsUUID() @IsOptional() analyticAccountId?: string; @IsUUID() @IsOptional() projectId?: string; } export class CreateExpenseSheetDto { @IsString() @IsOptional() name?: string; @IsUUID({ each: true }) @IsOptional() expenseIds?: string[]; } export class RefuseSheetDto { @IsString() @MinLength(10) reason: string; } export class ReimburseDto { @IsEnum(['payment', 'payslip']) method: 'payment' | 'payslip'; @IsUUID() @ValidateIf(o => o.method === 'payment') journalId?: string; @IsUUID() @ValidateIf(o => o.method === 'payment') paymentMethodId?: string; } ``` --- ## Parte 5: Categorías Predeterminadas ### 5.1 Seed de Categorías ```typescript // src/modules/hr-expense/seeds/expense-categories.seed.ts export const DEFAULT_EXPENSE_CATEGORIES = [ { code: 'MIL', name: 'Kilometraje', description: 'Reembolso por uso de vehículo personal', costType: 'mileage', defaultUnitPrice: 0.30, // MXN/km o configurable uomCode: 'km', expenseAccountCode: '601.01', // Gastos de viaje reinvoiceable: true, }, { code: 'FOOD', name: 'Alimentación', description: 'Comidas y alimentos durante trabajo', costType: 'actual', defaultUnitPrice: 0, uomCode: 'unit', expenseAccountCode: '601.02', // Gastos de representación reinvoiceable: true, }, { code: 'HOTEL', name: 'Hospedaje', description: 'Hoteles y alojamiento', costType: 'actual', defaultUnitPrice: 0, uomCode: 'night', expenseAccountCode: '601.01', reinvoiceable: true, }, { code: 'TRANS', name: 'Transporte', description: 'Taxis, Uber, transporte público', costType: 'actual', defaultUnitPrice: 0, uomCode: 'unit', expenseAccountCode: '601.01', reinvoiceable: true, }, { code: 'FLIGHT', name: 'Vuelos', description: 'Boletos de avión', costType: 'actual', defaultUnitPrice: 0, uomCode: 'unit', expenseAccountCode: '601.01', reinvoiceable: true, }, { code: 'COMM', name: 'Comunicaciones', description: 'Teléfono, internet, mensajería', costType: 'actual', defaultUnitPrice: 0, uomCode: 'unit', expenseAccountCode: '601.03', reinvoiceable: false, }, { code: 'OFFICE', name: 'Suministros de Oficina', description: 'Papelería y materiales de oficina', costType: 'actual', defaultUnitPrice: 0, uomCode: 'unit', expenseAccountCode: '601.04', reinvoiceable: false, }, { code: 'GIFT', name: 'Regalos Corporativos', description: 'Obsequios a clientes', costType: 'actual', defaultUnitPrice: 0, uomCode: 'unit', expenseAccountCode: '601.02', reinvoiceable: false, }, { code: 'PARK', name: 'Estacionamiento', description: 'Gastos de estacionamiento', costType: 'actual', defaultUnitPrice: 0, uomCode: 'unit', expenseAccountCode: '601.01', reinvoiceable: true, }, { code: 'OTHER', name: 'Otros Gastos', description: 'Gastos varios no categorizados', costType: 'actual', defaultUnitPrice: 0, uomCode: 'unit', expenseAccountCode: '601.99', reinvoiceable: false, }, ]; ``` --- ## Parte 6: Reportes y Analytics ### 6.1 Queries de Reportes ```sql -- Vista de gastos por empleado/período CREATE VIEW v_expense_summary_employee AS SELECT e.employee_id, emp.name as employee_name, emp.department_id, d.name as department_name, DATE_TRUNC('month', e.expense_date) as month, ec.code as category_code, ec.name as category_name, COUNT(*) as expense_count, SUM(e.total_amount) as total_amount, e.currency_id FROM expenses e JOIN employees emp ON e.employee_id = emp.id LEFT JOIN departments d ON emp.department_id = d.id JOIN expense_categories ec ON e.category_id = ec.id WHERE e.state NOT IN ('draft', 'refused') GROUP BY e.employee_id, emp.name, emp.department_id, d.name, DATE_TRUNC('month', e.expense_date), ec.code, ec.name, e.currency_id; -- Vista de gastos por departamento CREATE VIEW v_expense_summary_department AS SELECT d.id as department_id, d.name as department_name, DATE_TRUNC('month', e.expense_date) as month, ec.code as category_code, COUNT(DISTINCT e.employee_id) as employee_count, COUNT(*) as expense_count, SUM(e.total_amount) as total_amount, AVG(e.total_amount) as avg_expense, e.currency_id FROM expenses e JOIN employees emp ON e.employee_id = emp.id JOIN departments d ON emp.department_id = d.id JOIN expense_categories ec ON e.category_id = ec.id WHERE e.state NOT IN ('draft', 'refused') GROUP BY d.id, d.name, DATE_TRUNC('month', e.expense_date), ec.code, e.currency_id; -- Gastos pendientes de reembolso CREATE VIEW v_pending_reimbursements AS SELECT es.id as sheet_id, es.reference, es.employee_id, emp.name as employee_name, es.total_amount, es.currency_id, es.state, es.submission_date, es.approval_date, CURRENT_DATE - es.approval_date::date as days_pending FROM expense_sheets es JOIN employees emp ON es.employee_id = emp.id WHERE es.state = 'posted' AND es.payment_state != 'paid' ORDER BY es.approval_date; ``` ### 6.2 Dashboard Service ```typescript // src/modules/hr-expense/services/expense-analytics.service.ts @Injectable() export class ExpenseAnalyticsService { /** * Obtener resumen de gastos por período */ async getExpenseSummary( companyId: string, dateFrom: Date, dateTo: Date, groupBy: 'employee' | 'department' | 'category' ): Promise { const qb = this.expenseRepo .createQueryBuilder('e') .select([ groupBy === 'employee' ? 'e.employee_id' : null, groupBy === 'department' ? 'emp.department_id' : null, groupBy === 'category' ? 'e.category_id' : null, 'COUNT(*) as count', 'SUM(e.total_amount) as total', 'AVG(e.total_amount) as average', ].filter(Boolean)) .leftJoin('e.employee', 'emp') .where('e.company_id = :companyId', { companyId }) .andWhere('e.expense_date BETWEEN :dateFrom AND :dateTo', { dateFrom, dateTo }) .andWhere('e.state NOT IN (:...excluded)', { excluded: ['draft', 'refused'] }) .groupBy(groupBy === 'employee' ? 'e.employee_id' : groupBy === 'department' ? 'emp.department_id' : 'e.category_id'); return qb.getRawMany(); } /** * Obtener tendencia mensual */ async getMonthlyTrend( companyId: string, months: number = 12 ): Promise { const dateFrom = new Date(); dateFrom.setMonth(dateFrom.getMonth() - months); return this.dataSource.query(` SELECT DATE_TRUNC('month', expense_date) as month, COUNT(*) as count, SUM(total_amount) as total FROM expenses WHERE company_id = $1 AND expense_date >= $2 AND state NOT IN ('draft', 'refused') GROUP BY DATE_TRUNC('month', expense_date) ORDER BY month `, [companyId, dateFrom]); } /** * Comparar contra presupuesto */ async getBudgetComparison( companyId: string, analyticAccountId: string, year: number ): Promise { // Obtener presupuesto const budget = await this.budgetRepo.findOne({ where: { companyId, analyticAccountId, year } }); // Obtener gastos reales const actual = await this.dataSource.query(` SELECT SUM(total_amount) as total FROM expenses WHERE company_id = $1 AND analytic_account_id = $2 AND EXTRACT(YEAR FROM expense_date) = $3 AND state NOT IN ('draft', 'refused') `, [companyId, analyticAccountId, year]); return { budgeted: budget?.amount || 0, actual: actual[0]?.total || 0, variance: (budget?.amount || 0) - (actual[0]?.total || 0), percentUsed: budget?.amount ? ((actual[0]?.total || 0) / budget.amount) * 100 : 0, }; } } ``` --- ## Parte 7: Integración con Otros Módulos ### 7.1 Integración con Proyectos (Refacturación) ```typescript // Cuando un gasto es refacturable a cliente async handleReinvoiceableExpense(expense: Expense): Promise { if (!expense.category.reinvoiceable || !expense.saleOrderId) { return; } // Crear línea en orden de venta await this.saleOrderService.addExpenseLine({ orderId: expense.saleOrderId, productId: expense.category.saleProductId, name: `${expense.category.name}: ${expense.name}`, quantity: expense.quantity, priceUnit: expense.unitAmount, expenseId: expense.id, }); // Actualizar estado de refacturación await this.expenseRepo.update(expense.id, { reinvoiceStatus: 'pending' }); } ``` ### 7.2 Integración con Nómina ```typescript // En PayrollService - obtener reembolsos pendientes async getExpenseReimbursementsForPayslip( employeeId: string, periodEnd: Date ): Promise { const sheets = await this.expenseSheetRepo.find({ where: { employeeId, paymentMode: PaymentMode.PAYSLIP, paymentState: 'pending', approvalDate: LessThanOrEqual(periodEnd), }, relations: ['expenses'] }); return sheets.map(sheet => ({ inputTypeCode: 'EXPENSE_REIMB', amount: sheet.expenses .filter(e => e.paidBy === 'employee') .reduce((sum, e) => sum + e.totalAmount, 0), description: `Reembolso ${sheet.reference}`, sourceType: 'expense_sheet', sourceId: sheet.id, })); } ``` --- ## Parte 8: Consideraciones de Seguridad ### 8.1 Permisos ```typescript // Permisos requeridos export const EXPENSE_PERMISSIONS = { // Empleado 'expense.own.create': 'Crear gastos propios', 'expense.own.read': 'Ver gastos propios', 'expense.own.update': 'Editar gastos propios (en draft)', 'expense.own.delete': 'Eliminar gastos propios (en draft)', // Manager 'expense.team.read': 'Ver gastos del equipo', 'expense.approve': 'Aprobar/rechazar reportes', // Contabilidad 'expense.all.read': 'Ver todos los gastos', 'expense.post': 'Contabilizar reportes', 'expense.reimburse': 'Procesar reembolsos', // Administrador 'expense.categories.manage': 'Gestionar categorías', 'expense.policies.manage': 'Gestionar políticas', }; ``` ### 8.2 Validaciones de Fraude ```typescript // Detectar patrones sospechosos async detectSuspiciousPatterns(expense: Expense): Promise { const warnings: Warning[] = []; // Gasto duplicado (mismo monto, fecha, categoría) const duplicates = await this.expenseRepo.count({ where: { employeeId: expense.employeeId, categoryId: expense.categoryId, expenseDate: expense.expenseDate, totalAmount: expense.totalAmount, id: Not(expense.id), } }); if (duplicates > 0) { warnings.push({ type: 'duplicate', message: 'Posible gasto duplicado detectado', }); } // Gasto en fin de semana const dayOfWeek = new Date(expense.expenseDate).getDay(); if (dayOfWeek === 0 || dayOfWeek === 6) { warnings.push({ type: 'weekend', message: 'Gasto registrado en fin de semana', }); } // Monto redondo sospechoso (posible estimación) if (expense.totalAmount % 100 === 0 && expense.totalAmount >= 500) { warnings.push({ type: 'round_amount', message: 'Monto redondo - verificar comprobante', }); } return warnings; } ``` --- ## Apéndice A: Diagrama de Estados ``` ┌─────────────────────────────────────────┐ │ FLUJO DE ESTADOS │ └─────────────────────────────────────────┘ GASTO INDIVIDUAL REPORTE DE GASTOS ================ ================= ┌─────────┐ ┌─────────┐ │ DRAFT │ │ DRAFT │ └────┬────┘ └────┬────┘ │ agregar a reporte │ submit() ▼ ▼ ┌──────────┐ ┌───────────┐ │ REPORTED │ │ SUBMITTED │◀──────┐ └────┬─────┘ └─────┬─────┘ │ │ submit reporte │ │ ▼ ┌─────┴─────┐ │ ┌───────────┐ │ │ │ │ SUBMITTED │ approve() refuse() │ └─────┬─────┘ │ │ │ │ ▼ ▼ │ │ ┌─────────┐ ┌─────────┐ │ │ approve │APPROVED │ │ REFUSED │ │ ▼ └────┬────┘ └─────────┘ │ ┌──────────┐ │ │ │ APPROVED │ │ post() │ └────┬─────┘ ▼ │ │ ┌─────────┐ │ │ post │ POSTED │──────────────┘ ▼ └────┬────┘ refuse() ┌─────────┐ │ │ POSTED │ │ reimburse() └────┬────┘ ▼ │ ┌─────────┐ │ paid │ DONE │ ▼ └─────────┘ ┌─────────┐ │ DONE │ └─────────┘ ``` --- ## Apéndice B: Checklist de Implementación - [ ] Modelo de datos (migraciones) - [ ] Entidades TypeORM - [ ] ExpenseService (CRUD gastos) - [ ] ExpenseSheetService (reportes) - [ ] ExpenseApprovalService (workflow) - [ ] ExpenseReimbursementService (pagos) - [ ] ExpenseAnalyticsService (reportes) - [ ] Controladores REST - [ ] DTOs y validaciones - [ ] Seed de categorías - [ ] Permisos y roles - [ ] Tests unitarios - [ ] Tests de integración - [ ] Documentación API (Swagger) --- *Documento generado como parte del análisis de gaps ERP Core vs Odoo 18* *Referencia: GAP-MGN-010-002 - Sistema de Gastos de Empleados*