Skip to content

Conceptos de Notas - Documentación Técnica Backend

Módulo: ventas Feature: Conceptos para notas de crédito y débito Tipo: Resource (CRUD) Fecha: 2026-02-11


Referencia de Negocio

Conceptos de Notas - Requisitos de Negocio


Descripción General

Sistema de gestión de conceptos (motivos) para notas de crédito y notas de débito en ventas. Implementa una arquitectura polimórfica donde un único controlador maneja ambos tipos de conceptos mediante discriminación por código de tipo de comprobante.

Los conceptos se utilizan para clasificar los motivos de emisión de notas de ajuste (ej: "Anulación", "Descuento", "Bonificación", "Interés"). Cada concepto puede asociarse opcionalmente con una cuenta contable para integración con el módulo de Contabilidad.

Características distintivas:

  • Polimorfismo por código: Mismo controller/modelo base, discriminación dinámica
  • Protección de integridad: No permite eliminar el último concepto
  • Integración Contabilidad: Enriquecimiento automático con datos de cuentas
  • Selección automática: Método especial para obtener concepto de anulación

Arquitectura Implementada

1. Capa API

Archivo Legacy: backend/mod-ventas/concepto-comprobante.php

Endpoint Base: /backend/mod-ventas/concepto-comprobante.php

Métodos HTTP:

  • GET - Listar conceptos por tipo de comprobante

    • Query Params:
      • codigo (int, required): Código ARCA del tipo de comprobante
        • Notas de Crédito: 3 (A), 8 (B), 13 (C)
        • Notas de Débito: 2 (A), 7 (B), 12 (C)
    • Response: 200 OK - Array de conceptos con cuenta contable enriquecida
  • POST - Crear nuevo concepto

    • Body:
      • codigo (int, required): Código ARCA
      • nombre (string, required, max 50): Descripción del concepto
      • tipo (string, required, length 1): Tipo de concepto
      • cuenta_contable (int, optional): ID de cuenta contable
    • Response: 200 OK - ID del concepto creado
  • PUT - Actualizar concepto

    • Body:
      • codigo (int, required)
      • id (int, required)
      • nombre (string, optional)
      • tipo (string, optional)
      • cuenta_contable (int, optional)
    • Response: 204 No Content
  • DELETE - Eliminar concepto (soft delete)

    • Body:
      • codigo (int, required)
      • id (int, required)
    • Response: 204 No Content
    • Error: 400 Bad Request si es el último concepto

Middleware Stack:

  1. JWT Authentication (extrae payload)
  2. Schema setting (establece search_path)

⚠️ NOTA: Este endpoint utiliza arquitectura legacy (pre-Slim Framework). Pendiente migración a Slim con middleware de validación.


2. Capa Controller

Archivo: controller/modulo-venta/ConceptoComprobanteController.php

Responsabilidades:

  • Validar presencia de parámetros requeridos
  • Discriminar dinámicamente entre ConceptoNotaCredito y ConceptoNotaDebito según código
  • Enriquecer respuestas con datos de cuenta contable (si módulo Contabilidad activo)
  • Delegar operaciones CRUD al modelo correspondiente

Métodos:

  • getAll(array $options): ?array

    • Valida codigo requerido
    • Selecciona modelo por código
    • Ejecuta getAll() en modelo
    • Si módulo Contabilidad activo, enriquece con datos de CuentaModel
    • Retorna array de ConceptoComprobanteDTO
  • insert(array $options): string

    • Valida codigo, nombre, tipo requeridos
    • Selecciona modelo
    • Ejecuta insert() en modelo
    • Retorna ID del concepto creado
  • update(array $options): bool

    • Valida codigo, id requeridos
    • Selecciona modelo
    • Ejecuta update() en modelo
    • Retorna booleano de éxito
  • delete(array $data): bool

    • Valida codigo, id requeridos
    • Selecciona modelo
    • Ejecuta delete() en modelo
    • Retorna booleano de éxito

Método Privado:

  • getModelByCodigo(int $codigo): ConceptoComprobante
    • Utiliza match expression para discriminación polimórfica
    • Si código en TipoComprobante::getCodes(NOTA_DE_CREDITO)ConceptoNotaCredito
    • Si código en TipoComprobante::getCodes(NOTA_DE_DEBITO)ConceptoNotaDebito
    • Caso contrario → null

Dependencias:

  • ConceptoNotaCredito (model)
  • ConceptoNotaDebito (model)
  • CuentaModel (Contabilidad, condicional)
  • Config (para verificar módulos activos)
  • TipoComprobante (Constants)
  • NombreTipoComprobante (Enum)

3. Capa Model

3.1 ConceptoComprobante (Base)

Archivo: models/modulo-venta/ConceptoComprobante.php

Herencia: extends Model

Constructor:

  • Recibe PDO $conn y string $table
  • Pasa tabla dinámica al constructor padre
  • Define reglas de validación

Validaciones (Model Layer):

php
'nombre' => 'required|max:50',
'cuenta_contable' => 'integer',
'tipo' => 'required|max:1'

Métodos CRUD:

  • getAll(): array

    • Query: SELECT id, descri as nombre, cuenta as cuenta_contable, tipcon as tipo FROM {table} WHERE deleted_at IS NULL
    • Mapea resultados a ConceptoComprobanteDTO
    • Retorna array vacío si no hay registros
  • insert(array $data): string

    • Valida datos con self::validate()
    • Query: INSERT INTO {table} (descri, tipcon, cuenta) VALUES (:nombre, :tipo, :cuenta_contable)
    • cuenta_contable opcional (null si no provisto)
    • Retorna lastInsertId()
  • update(array $data): bool

    • Valida datos con self::validate()
    • Query: UPDATE {table} SET descri = :nombre, tipcon = :tipo, cuenta = :cuenta_contable WHERE id = :id
    • Retorna true si rowCount() > 0
  • delete(int $id): bool

    • Soft Delete con protección
    • Query condicional: Solo permite borrado si hay más de 1 registro activo
    • Query: UPDATE {table} SET deleted_at = CURRENT_TIMESTAMP WHERE id = :id AND (SELECT COUNT(*) FROM {table} WHERE deleted_at IS NULL) > 1
    • Lanza BadRequest si rowCount() === 0 (último registro o no encontrado)
    • Retorna true si éxito

Características de implementación:

  • Tabla dinámica: El nombre de tabla se inyecta en constructor (polimorfismo)
  • Soft deletes: Usa deleted_at IS NULL en todas las queries de lectura
  • Protección de integridad: No permite eliminar el último concepto de cada tipo
  • Mapeo de columnas: descrinombre, tipcontipo, cuentacuenta_contable

3.2 ConceptoNotaCredito

Archivo: models/modulo-venta/ConceptoNotaCredito.php

Herencia: extends ConceptoComprobante

Constructor:

php
public function __construct(PDO $conn) {
    parent::__construct($conn, 'concecre');
}

Método Adicional:

  • getConceptoAnulacion(): array
    • Propósito: Selección automática de concepto para procesos de anulación
    • Lógica:
      1. Prioridad 1: Buscar concepto con "anulacion" en descripción (case-insensitive)
        • Query: SELECT id, descri as descripcion FROM concecre WHERE LOWER(descri) LIKE '%anulacion%' AND deleted_at IS NULL LIMIT 1
      2. Fallback: Primer registro activo ordenado por ID
        • Query: SELECT id, descri as descripcion FROM concecre WHERE deleted_at IS NULL ORDER BY id LIMIT 1
      3. Error: Lanza ServerException si no hay conceptos configurados
    • Retorna: ['id' => int, 'descripcion' => string]

Uso típico: Este método es utilizado por servicios de anulación automática de facturas/recibos para obtener el concepto por defecto sin intervención del usuario.


3.3 ConceptoNotaDebito

Archivo: models/modulo-venta/ConceptoNotaDebito.php

Herencia: extends ConceptoComprobante

Constructor:

php
public function __construct(PDO $conn) {
    parent::__construct($conn, 'concedeb');
}

Sin métodos adicionales: Utiliza todos los métodos heredados de ConceptoComprobante.


4. Capa Resources (DTOs)

Archivo: Resources/Venta/ConceptoComprobante.php

Herencia: extends DTO

Propiedades:

php
public ?int $id;
public ?string $tipo;
public ?string $nombre;
public int|array|null $cuenta_contable;  // Union type para soportar enriquecimiento
public ?string $fecha_eliminacion;

Características:

  • cuenta_contable puede ser int (ID) o array (objeto cuenta enriquecido)
  • Soporta fromArray() y toArray() (heredados de DTO)

5. Constants y Enums

5.1 TipoComprobante (Constants)

Archivo: Resources/Venta/Constants/TipoComprobante.php

Mapeo ARCA:

php
const CODES = [
    'NOTA_DE_CREDITO' => [
        'A' => 3,
        'B' => 8,
        'C' => 13
    ],
    'NOTA_DE_DEBITO' => [
        'A' => 2,
        'B' => 7,
        'C' => 12
    ]
];

Métodos relevantes:

  • getCodes(Comprobante $comprobante): ?array - Retorna array de códigos por tipo
    • Ejemplo: getCodes(NOTA_DE_CREDITO)[3, 8, 13]

Uso en discriminación:

php
in_array($codigo, TipoComprobante::getCodes(NombreTipoComprobante::NOTA_DE_CREDITO))

5.2 NombreTipoComprobante (Enum)

Archivo: Resources/Venta/Enums/NombreTipoComprobante.php

php
enum NombreTipoComprobante: string {
    case NOTA_DE_CREDITO = 'NOTA_DE_CREDITO';
    case NOTA_DE_DEBITO = 'NOTA_DE_DEBITO';
}

Esquema de Base de Datos

Tabla: concecre (Conceptos Nota de Crédito)

Nivel de Schema: EMPRESA y SUCURSAL (multi-nivel)

CampoTipoConstraintsDescripción
idSERIALPRIMARY KEY, NOT NULLIdentificador único
descriVARCHAR(50)NOT NULLDescripción del concepto
tipconVARCHAR(1)NOT NULLTipo de concepto (código interno)
cuentaDECIMAL(10,0)NULLID de cuenta contable (FK virtual)
deleted_atTIMESTAMPNULLFecha de eliminación (soft delete)

Índices:

  • PRIMARY KEY en id (automático por SERIAL)

Constraints:

  • descri NOT NULL
  • tipcon NOT NULL

Migración: 20240823200726_new_table_concecre.php

Condición de creación:

  • Módulo Ventas habilitado
  • Tabla credito (notas de crédito) existe

Tabla: concedeb (Conceptos Nota de Débito)

Nivel de Schema: EMPRESA y SUCURSAL (multi-nivel)

CampoTipoConstraintsDescripción
idSERIALPRIMARY KEY, NOT NULLIdentificador único
descriVARCHAR(50)NOT NULLDescripción del concepto
tipconVARCHAR(1)NOT NULLTipo de concepto (código interno)
cuentaBIGINTNULLID de cuenta contable (FK virtual)
deleted_atTIMESTAMPNULLFecha de eliminación (soft delete)

Índices:

  • PRIMARY KEY en id (automático por SERIAL)

Constraints:

  • descri NOT NULL
  • tipcon NOT NULL

⚠️ NOTA: cuenta es BIGINT en concedeb vs DECIMAL(10,0) en concecre - inconsistencia de tipo.

Migración: 20240902144030_new_table_concedeb.php

Condición de creación:

  • Módulo Ventas habilitado
  • Tabla debito (notas de débito) existe

Relaciones

Relación virtual con Contabilidad:

  • concecre.cuentacuenta_contable.id (NO FK, relación lógica)
  • concedeb.cuentacuenta_contable.id (NO FK, relación lógica)

Enriquecimiento condicional: Si módulo Contabilidad está activo, el controller consulta CuentaModel y enriquece el campo cuenta_contable del DTO con objeto completo de cuenta.


Validaciones Implementadas

Nivel 1: Validación Estructural (Controller)

Ubicación: ConceptoComprobanteController.php

OperaciónCampos RequeridosError
GETcodigo400 Bad Request
POSTcodigo, nombre, tipo400 Bad Request
PUTcodigo, id400 Bad Request
DELETEcodigo, id400 Bad Request

Implementación: Validación manual con if (!$options['field'])


Nivel 2: Validación de Modelo (Model)

Ubicación: ConceptoComprobante.php - trait Validatable

Reglas:

php
'nombre' => 'required|max:50',
'cuenta_contable' => 'integer',
'tipo' => 'required|max:1'

Ejecución: Llamado en insert() y update() vía self::validate($data)


Nivel 3: Validación de Negocio (Model)

Regla de Integridad: No permitir eliminar el último concepto

Implementación: Query con subquery condicional

sql
UPDATE {table}
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = :id
  AND (SELECT COUNT(*) FROM {table} WHERE deleted_at IS NULL) > 1

Error: Lanza BadRequest con mensaje "No se puede eliminar el último concepto de comprobante."


Puntos de Integración

1. Integración con Contabilidad

Componente: controller/ConceptoComprobanteController::getAll()

Flujo de enriquecimiento:

  1. Controller consulta Config::getOneByKey('PermisosEmpresa')
  2. Si Modulo::CONTABILIDAD está habilitado (=== 1)
  3. Para cada concepto con cuenta_contable definida:
    • Consulta CuentaModel::getById($cuenta_contable, 'min')
    • Reemplaza ID numérico con objeto cuenta completo

DTO transformado:

php
// Antes del enriquecimiento
['cuenta_contable' => 1234]

// Después del enriquecimiento
['cuenta_contable' => ['id' => 1234, 'nombre' => 'Cuenta X', ...]]

2. Integración con Procesos de Anulación

Método: ConceptoNotaCredito::getConceptoAnulacion()

Uso: Servicios de anulación automática (ej: anular factura, anular recibo) obtienen el concepto por defecto sin intervención del usuario.

Flujo:

  1. Servicio llama getConceptoAnulacion()
  2. Método busca concepto con "anulacion" en descripción
  3. Si no encuentra, retorna primer concepto activo
  4. Si no hay conceptos, lanza excepción

Casos de uso típicos:

  • Anulación de factura electrónica (requiere nota de crédito automática)
  • Proceso masivo de anulación de comprobantes

Testing

Tests Unitarios

Archivo: Tests/Unit/Venta/ConceptoNotaCreditoTest.php

Cobertura:

  • testGetConceptoAnulacionReturnsAnulacionConceptIfFound()

    • Verifica que retorna concepto con "anulacion" si existe
  • testGetConceptoAnulacionReturnsFirstConceptAsFallbackIfAnulacionNotFound()

    • Verifica fallback a primer registro si no hay "anulacion"
  • testGetConceptoAnulacionThrowsExceptionIfNoConceptsAreConfigured()

    • Verifica excepción ServerException si no hay conceptos

Estrategia: Mocking de PDO y PDOStatement para tests unitarios puros

Comando de ejecución:

bash
vendor/bin/phpunit Tests/Unit/Venta/ConceptoNotaCreditoTest.php

Tests de Integración

Estado: No implementados

Tests recomendados:

  • CRUD completo con base de datos real
  • Verificación de soft deletes
  • Protección contra eliminación del último concepto
  • Enriquecimiento con cuenta contable
  • Discriminación polimórfica por código

Rendimiento

Consultas Optimizadas

Queries con filtrado activo:

sql
WHERE deleted_at IS NULL

Siempre presente en getAll() para excluir registros eliminados.

Índices sugeridos (no implementados):

sql
-- Mejorar performance de soft deletes
CREATE INDEX idx_concecre_deleted_at ON concecre(deleted_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_concedeb_deleted_at ON concedeb(deleted_at) WHERE deleted_at IS NULL;

-- Optimizar búsqueda de anulación
CREATE INDEX idx_concecre_descri_gin ON concecre USING gin(to_tsvector('spanish', descri));

Caching

Estado: No implementado

Oportunidad de mejora:

  • Cachear lista de conceptos por tipo (bajo cambio de frecuencia)
  • Cache key: conceptos_notas_{tipo}_{schema}
  • Invalidación: On insert/update/delete

Seguridad

Sanitización de Entrada

Método: Prepared statements en todos los queries

Ejemplo:

php
$stmt = $this->conn->prepare("SELECT ... WHERE id = :id");
$stmt->execute(['id' => $id]);

No hay concatenación directa de variables en SQL


Control de Acceso

Autenticación: JWT verificado en middleware

Autorización: Pendiente de implementación

⚠️ Mejora sugerida: Agregar verificación de permisos específicos por operación:

  • conceptos_notas.listar
  • conceptos_notas.crear
  • conceptos_notas.editar
  • conceptos_notas.eliminar

Auditoría

Estado: No implementado

⚠️ Mejora crítica: Implementar AuditableInterface en un Service layer

Operaciones a auditar:

  • INSERT - Creación de concepto
  • UPDATE - Modificación de concepto
  • DELETE - Eliminación (soft) de concepto

Ejemplo de implementación sugerida:

php
$this->registrarAuditoria(
    "INSERT",
    "VENTAS",
    $this->model->getTable(),
    $conceptoId
);

Deuda Técnica

1. Arquitectura Legacy

Problema: Endpoint usa arquitectura pre-Slim (direct PHP file)

Impacto:

  • No hay validación centralizada (Validator middleware)
  • Manejo de errores inconsistente
  • Difícil testing de endpoints

Solución sugerida: Migrar a Slim Framework con:

php
// Routes/Ventas/ConceptoComprobanteRoutes.php
$group->get('', [ConceptoComprobanteController::class, 'getAll'])
    ->add(new ConceptoComprobanteValidator('get'));

2. Inconsistencia de Tipos en Schema

Problema:

  • concecre.cuenta → DECIMAL(10,0)
  • concedeb.cuenta → BIGINT

Impacto: Posible inconsistencia en queries cross-table

Solución: Normalizar ambos a BIGINT mediante migración


3. Falta de Foreign Keys

Problema: cuenta no tiene FK a cuenta_contable

Impacto:

  • Posible referencia a cuentas inexistentes
  • Sin eliminación en cascada

Solución: Agregar FK con ON DELETE SET NULL

sql
ALTER TABLE concecre ADD CONSTRAINT fk_concecre_cuenta
    FOREIGN KEY (cuenta) REFERENCES cuenta_contable(id)
    ON DELETE SET NULL;

4. Falta de Service Layer

Problema: Lógica de negocio en Controller y Model

Impacto:

  • No hay transacciones explícitas
  • No hay auditoría
  • Difícil reutilización de lógica

Solución: Crear ConceptoComprobanteService con:

  • Manejo de transacciones
  • Auditoría implementada
  • Lógica de validación compleja

5. Sin Validador Dedicado

Problema: Validación manual en controller

Impacto: Código repetitivo, mensajes de error inconsistentes

Solución: Crear ConceptoComprobanteValidator middleware


Referencias


Última actualización: 2026-02-11 Versión del sistema: 3.13.1 Estado: Documentación retrospectiva completada

⚠️ NOTA IMPORTANTE: Esta documentación fue generada automáticamente analizando el código implementado. Validar cambios futuros contra este baseline.