erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-GASTOS-EMPLEADOS.md

59 KiB

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)

-- 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)

-- 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)

-- 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)

-- 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)

-- 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

// 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<Expense>,
    @InjectRepository(ExpenseSheet)
    private readonly sheetRepo: Repository<ExpenseSheet>,
    @InjectRepository(ExpenseCategory)
    private readonly categoryRepo: Repository<ExpenseCategory>,
    @InjectRepository(ExpensePolicy)
    private readonly policyRepo: Repository<ExpensePolicy>,
    private readonly dataSource: DataSource,
  ) {}

  /**
   * Crear nuevo gasto
   */
  async createExpense(dto: CreateExpenseDto, userId: string): Promise<Expense> {
    // 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<void> {
    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<ExpenseSheet> {
    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<ExpenseSheet> {
    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<ExpenseSheet> {
    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<boolean> {
    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<void> {
    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

// 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<ExpenseSheet>,
    @InjectRepository(Expense)
    private readonly expenseRepo: Repository<Expense>,
    @InjectRepository(ExpensePolicy)
    private readonly policyRepo: Repository<ExpensePolicy>,
    private readonly dataSource: DataSource,
    private readonly accountingService: AccountingService,
  ) {}

  /**
   * Aprobar reporte de gastos (Manager)
   */
  async approveSheet(
    sheetId: string,
    userId: string,
    comment?: string
  ): Promise<ExpenseSheet> {
    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<ExpenseSheet> {
    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<void> {
    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<ExpenseSheet> {
    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<JournalEntry> {
    const lines: JournalEntryLine[] = [];

    // Agrupar gastos por cuenta
    const expensesByAccount = new Map<string, number>();

    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

// 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<ExpenseSheet>,
    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<ExpenseSheet> {
    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<ExpenseSheet> {
    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<void> {
    await this.sheetRepo.update(
      { id: sheetId },
      {
        state: ExpenseSheetState.DONE,
        paymentState: 'paid',
        updatedBy: userId,
      }
    );
  }
}

Parte 4: API REST

4.1 Endpoints de Gastos

// 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<ExpenseResponseDto> {
    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<PaginatedResponse<ExpenseResponseDto>> {
    return this.expenseService.findByEmployee(user.employeeId, query);
  }

  @Get(':id')
  @ApiOperation({ summary: 'Obtener gasto' })
  async getExpense(
    @Param('id', ParseUUIDPipe) id: string,
    @CurrentUser() user: User,
  ): Promise<ExpenseResponseDto> {
    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<ExpenseResponseDto> {
    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<void> {
    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<AttachmentResponseDto> {
    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<ExpenseSheetResponseDto> {
    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<PaginatedResponse<ExpenseSheetResponseDto>> {
    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<PaginatedResponse<ExpenseSheetResponseDto>> {
    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<ExpenseSheetResponseDto> {
    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<ExpenseSheetResponseDto> {
    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<ExpenseSheetResponseDto> {
    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<ExpenseSheetResponseDto> {
    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<ExpenseSheetResponseDto> {
    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

// 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

// 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

-- 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

// 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<ExpenseSummaryDto[]> {
    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<MonthlyTrendDto[]> {
    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<BudgetComparisonDto> {
    // 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)

// Cuando un gasto es refacturable a cliente
async handleReinvoiceableExpense(expense: Expense): Promise<void> {
  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

// En PayrollService - obtener reembolsos pendientes
async getExpenseReimbursementsForPayslip(
  employeeId: string,
  periodEnd: Date
): Promise<PayslipInput[]> {
  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

// 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

// Detectar patrones sospechosos
async detectSuspiciousPatterns(expense: Expense): Promise<Warning[]> {
  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