Skip to content

Implementación Técnica: Normalización de Identificadores de Tipo de Comprobante

Documento de referencia: docs/features/compra/tipo-comprobante-normalizacion-process.mdFecha: 2026-01-07 Versión: 1.0


Índice

  1. Arquitectura y Decisiones Técnicas
  2. Estado Actual del Sistema
  3. Migraciones de Base de Datos
  4. Actualización de Backend
  5. Actualización de Frontend
  6. Testing
  7. Deployment
  8. Rollback

Arquitectura y Decisiones Técnicas

Decisión: Relaciones Lógicas vs Foreign Keys Físicas

⚠️ CRÍTICO: Debido a la arquitectura multi-tenant con schemas separados, NO se utilizan Foreign Keys físicas.

Razón técnica:

  • Cada sucursal/caja tiene su propio schema PostgreSQL independiente (suc0001, suc0002, suc0001caja001, etc.)
  • Cada schema tiene su propia tabla comprob con IDs propios
  • PostgreSQL no soporta FK entre schemas diferentes
  • La integridad referencial se garantiza mediante validaciones en el backend

Implementación:

Schema suc0001:
  - comprob (id=5, codigo=1, descri="Factura A")
  - subdicom.tipcom = 5  ← Referencia lógica, NO FK física

Schema suc0002:
  - comprob (id=8, codigo=1, descri="Factura A")
  - subdicom.tipcom = 8  ← Referencia lógica, NO FK física

Ventajas:

  • Independencia total entre tenants
  • Cada tenant puede personalizar su catálogo de tipos
  • No hay acoplamiento entre schemas
  • Mejor performance (no validación FK cross-schema)

Desventajas:

  • Requiere validación explícita en backend
  • No hay protección automática de BD contra datos huérfanos

Estado Actual del Sistema

Análisis del Código Actual

Tabla comprob (Tipos de Comprobante)

Archivo: migrations/tenancy/20240823200780_new_table_comprob.php

php
// Líneas 32-41
$table = $this->table('comprob', ['id' => false, 'primary_key' => 'id']);

$table
    ->addColumn('id', Literal::from('serial'), ['null' => false])        // PK auto-incremental
    ->addColumn('descri', 'string', ['limit' => 55, 'null' => false])   // Descripción
    ->addColumn('tipo', 'string', ['limit' => 1, 'null' => false])      // D = Débito, C = Crédito
    ->addColumn('codigo', 'decimal', ['precision' => 2, 'null' => false])  // Código ARCA
    ->addColumn('cuenta', 'decimal', ['precision' => 10, 'null' => true])
    ->addColumn('fopera', 'string', ['limit' => 1, 'null' => true])
    ->create();

Observaciones:

  • id: PRIMARY KEY, serial (auto-incremental) → ❌ NO usado actualmente en relaciones
  • codigo: decimal(2), código ARCA → ✅ Usado incorrectamente como FK
  • Sin constraint UNIQUE en codigo
  • Sin FK constraints hacia/desde otras tablas

Tabla subdicom (Documentos de Compra)

Archivo: migrations/tenancy/20240823200782_new_table_subdicom.php

php
// Línea 34
->addColumn('tipcom', 'decimal', ['precision' => 2, 'null' => true])

Problema actual:

  • tipcom almacena código ARCA directamente (1, 6, 11, etc.)
  • NO es FK a comprob.id
  • NO es FK a comprob.codigo
  • Sin validación de que el código existe

Uso en el Código

TipoComprobante.php (líneas 28-43):

php
public function getByCodigo(int $codigo)
{
    $sql = "SELECT id, codigo::int, descri as nombre, tipo FROM {$this->table}
            WHERE codigo = :codigo";

    $stmt = $this->conn->prepare($sql);
    $stmt->bindValue(':codigo', $codigo);
    $stmt->execute();

    return $stmt->rowCount() > 0 ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
}

❌ Retorna datos pero no se usa id como referencia.

ComprobanteController.php (línea 145):

php
$tipoComprobante = $modelTipoComprobante->getByCodigo($data['tipo_comprobante']);

❌ Busca por código, inserta código en subdicom.tipcom.

LibroDigital.php (líneas 33-45, 244):

php
$sql = "SELECT s.tipcom AS TIPO_COMPROBANTE, ... FROM subdicom s ...";
// tipcom se usa directamente como código ARCA
$tipoComprobante = str_pad($r['tipo_comprobante'], 3, '0', STR_PAD_LEFT);

✅ Uso correcto de código ARCA en libro IVA.


Migraciones de Base de Datos

Migration 1: Crear Tabla de Mapeo Temporal

Archivo: migrations/tenancy/20260107000001_create_comprob_codigo_id_mapping.php

Propósito: Mantener trazabilidad de la conversión código → id para rollback y auditoría.

php
<?php

use Phinx\Migration\AbstractMigration;

class CreateComprobCodigoIdMapping extends AbstractMigration
{
    public function change()
    {
        $table = $this->table('comprob_codigo_id_mapping', ['id' => false]);

        $table
            ->addColumn('codigo', 'decimal', ['precision' => 2, 'null' => false])
            ->addColumn('comprob_id', 'integer', ['null' => false])
            ->addColumn('table_name', 'string', ['limit' => 50, 'null' => false])
            ->addColumn('record_id', 'integer', ['null' => false])
            ->addColumn('old_value', 'decimal', ['precision' => 2, 'null' => false])
            ->addColumn('new_value', 'integer', ['null' => false])
            ->addColumn('migrated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP'])
            ->addIndex(['table_name', 'record_id'])
            ->addIndex(['codigo'])
            ->create();
    }
}

Estructura:

  • codigo: Código ARCA original
  • comprob_id: ID interno de comprob correspondiente
  • table_name: Tabla afectada (subdicom, ordcte, etc.)
  • record_id: ID del registro modificado
  • old_value: Valor anterior (código ARCA)
  • new_value: Valor nuevo (ID interno)

Migration 2: Migrar subdicom.tipcom de Código a ID

Archivo: migrations/tenancy/20260107000002_migrate_subdicom_tipcom_to_id.php

Propósito: Convertir los códigos ARCA en subdicom.tipcom a IDs internos de comprob.

php
<?php

use Phinx\Migration\AbstractMigration;

class MigrateSubdicomTipcomToId extends AbstractMigration
{
    public function up()
    {
        // PASO 1: Backup de valores actuales a tabla de mapeo
        $this->execute("
            INSERT INTO comprob_codigo_id_mapping (codigo, comprob_id, table_name, record_id, old_value, new_value)
            SELECT
                s.tipcom AS codigo,
                c.id AS comprob_id,
                'subdicom' AS table_name,
                s.id AS record_id,
                s.tipcom AS old_value,
                c.id AS new_value
            FROM subdicom s
            INNER JOIN comprob c ON c.codigo = s.tipcom
            WHERE s.tipcom IS NOT NULL
        ");

        // PASO 2: Agregar columna temporal tipcom_new (integer)
        $this->table('subdicom')
            ->addColumn('tipcom_new', 'integer', ['null' => true, 'after' => 'tipcom'])
            ->update();

        // PASO 3: Convertir códigos a IDs mediante JOIN
        $this->execute("
            UPDATE subdicom s
            SET tipcom_new = c.id
            FROM comprob c
            WHERE c.codigo = s.tipcom
              AND s.tipcom IS NOT NULL
        ");

        // PASO 4: Validar que todos los registros se mapearon
        $unmapped = $this->fetchRow("
            SELECT COUNT(*) as count
            FROM subdicom
            WHERE tipcom IS NOT NULL
              AND tipcom_new IS NULL
        ");

        if ($unmapped['count'] > 0) {
            throw new \Exception(
                "ERROR: {$unmapped['count']} registros de subdicom no pudieron mapearse. " .
                "Existen códigos ARCA que no están en la tabla comprob."
            );
        }

        // PASO 5: Intercambiar columnas
        $this->execute("ALTER TABLE subdicom DROP COLUMN tipcom");
        $this->execute("ALTER TABLE subdicom RENAME COLUMN tipcom_new TO tipcom");

        // PASO 6: Cambiar tipo de columna de decimal a integer
        $this->table('subdicom')
            ->changeColumn('tipcom', 'integer', ['null' => true])
            ->update();
    }

    public function down()
    {
        // Rollback: Restaurar desde tabla de mapeo
        $this->execute("ALTER TABLE subdicom ADD COLUMN tipcom_old DECIMAL(2)");

        $this->execute("
            UPDATE subdicom s
            SET tipcom_old = m.old_value
            FROM comprob_codigo_id_mapping m
            WHERE m.table_name = 'subdicom'
              AND m.record_id = s.id
        ");

        $this->execute("ALTER TABLE subdicom DROP COLUMN tipcom");
        $this->execute("ALTER TABLE subdicom RENAME COLUMN tipcom_old TO tipcom");
    }
}

Validaciones críticas:

  1. Todos los códigos en subdicom.tipcom deben existir en comprob.codigo
  2. Si hay códigos huérfanos, la migración falla con error claro
  3. El rollback restaura el estado original desde la tabla de mapeo

Migration 3: Migrar ordcte.id_tipo de Código a ID

Archivo: migrations/tenancy/20260107000003_migrate_ordcte_id_tipo_to_id.php

Propósito: Convertir ordcte.id_tipo (actualmente código ARCA) a ID interno.

php
<?php

use Phinx\Migration\AbstractMigration;

class MigrateOrdcteIdTipoToId extends AbstractMigration
{
    public function up()
    {
        // PASO 1: Backup
        $this->execute("
            INSERT INTO comprob_codigo_id_mapping (codigo, comprob_id, table_name, record_id, old_value, new_value)
            SELECT
                o.id_tipo AS codigo,
                c.id AS comprob_id,
                'ordcte' AS table_name,
                o.id AS record_id,
                o.id_tipo AS old_value,
                c.id AS new_value
            FROM ordcte o
            INNER JOIN comprob c ON c.codigo = o.id_tipo
            WHERE o.id_tipo IS NOT NULL
        ");

        // PASO 2: Columna temporal
        $this->table('ordcte')
            ->addColumn('id_tipo_new', 'integer', ['null' => true, 'after' => 'id_tipo'])
            ->update();

        // PASO 3: Conversión
        $this->execute("
            UPDATE ordcte o
            SET id_tipo_new = c.id
            FROM comprob c
            WHERE c.codigo = o.id_tipo
              AND o.id_tipo IS NOT NULL
        ");

        // PASO 4: Validación
        $unmapped = $this->fetchRow("
            SELECT COUNT(*) as count
            FROM ordcte
            WHERE id_tipo IS NOT NULL
              AND id_tipo_new IS NULL
        ");

        if ($unmapped['count'] > 0) {
            throw new \Exception(
                "ERROR: {$unmapped['count']} registros de ordcte no pudieron mapearse."
            );
        }

        // PASO 5: Intercambio
        $this->execute("ALTER TABLE ordcte DROP COLUMN id_tipo");
        $this->execute("ALTER TABLE ordcte RENAME COLUMN id_tipo_new TO id_tipo");
    }

    public function down()
    {
        // Rollback similar a subdicom
        $this->execute("ALTER TABLE ordcte ADD COLUMN id_tipo_old INTEGER");

        $this->execute("
            UPDATE ordcte o
            SET id_tipo_old = m.old_value::integer
            FROM comprob_codigo_id_mapping m
            WHERE m.table_name = 'ordcte'
              AND m.record_id = o.id
        ");

        $this->execute("ALTER TABLE ordcte DROP COLUMN id_tipo");
        $this->execute("ALTER TABLE ordcte RENAME COLUMN id_tipo_old TO id_tipo");
    }
}

Script de Validación Post-Migración

Archivo: migrations/scripts/validate-tipcom-migration.php

php
<?php

/**
 * Script de validación post-migración
 * Verifica la integridad de datos después de convertir códigos ARCA a IDs
 */

require_once __DIR__ . '/../vendor/autoload.php';

use App\connection\Database;

$db = new Database();
$conn = $db->getConnection();

echo "=== VALIDACIÓN DE MIGRACIÓN DE TIPO COMPROBANTE ===\n\n";

// TEST 1: Verificar que no hay valores NULL en subdicom.tipcom
echo "TEST 1: Verificando valores NULL en subdicom.tipcom...\n";
$stmt = $conn->query("SELECT COUNT(*) as count FROM subdicom WHERE tipcom IS NULL AND deleted_at IS NULL");
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result['count'] > 0) {
    echo "❌ FALLO: {$result['count']} registros con tipcom NULL\n";
    exit(1);
}
echo "✓ PASS: No hay valores NULL\n\n";

// TEST 2: Verificar que todos los tipcom existen en comprob.id
echo "TEST 2: Verificando integridad referencial de subdicom.tipcom...\n";
$stmt = $conn->query("
    SELECT COUNT(*) as count
    FROM subdicom s
    LEFT JOIN comprob c ON c.id = s.tipcom
    WHERE s.deleted_at IS NULL
      AND c.id IS NULL
");
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result['count'] > 0) {
    echo "❌ FALLO: {$result['count']} registros con tipcom inválido\n";

    // Mostrar registros problemáticos
    $stmt = $conn->query("
        SELECT s.id, s.tipcom
        FROM subdicom s
        LEFT JOIN comprob c ON c.id = s.tipcom
        WHERE s.deleted_at IS NULL
          AND c.id IS NULL
        LIMIT 10
    ");
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        echo "  - subdicom.id={$row['id']}, tipcom={$row['tipcom']}\n";
    }
    exit(1);
}
echo "✓ PASS: Todos los tipcom son válidos\n\n";

// TEST 3: Verificar integridad de ordcte.id_tipo
echo "TEST 3: Verificando integridad referencial de ordcte.id_tipo...\n";
$stmt = $conn->query("
    SELECT COUNT(*) as count
    FROM ordcte o
    LEFT JOIN comprob c ON c.id = o.id_tipo
    WHERE o.deleted_at IS NULL
      AND o.id_tipo IS NOT NULL
      AND c.id IS NULL
");
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result['count'] > 0) {
    echo "❌ FALLO: {$result['count']} registros con id_tipo inválido\n";
    exit(1);
}
echo "✓ PASS: Todos los id_tipo son válidos\n\n";

// TEST 4: Verificar que la cantidad de registros coincide
echo "TEST 4: Verificando cantidad de registros migrados...\n";
$stmt = $conn->query("SELECT COUNT(*) as count FROM comprob_codigo_id_mapping WHERE table_name = 'subdicom'");
$mapped = $stmt->fetch(PDO::FETCH_ASSOC)['count'];

$stmt = $conn->query("SELECT COUNT(*) as count FROM subdicom WHERE deleted_at IS NULL");
$total = $stmt->fetch(PDO::FETCH_ASSOC)['count'];

echo "  - Registros en subdicom: {$total}\n";
echo "  - Registros mapeados: {$mapped}\n";

if ($mapped != $total) {
    echo "⚠ ADVERTENCIA: La cantidad no coincide exactamente (puede ser normal si hay registros con tipcom NULL)\n";
} else {
    echo "✓ PASS: Cantidades coinciden\n";
}
echo "\n";

// TEST 5: Verificar que los códigos ARCA siguen existiendo en comprob
echo "TEST 5: Verificando que códigos ARCA están preservados en comprob...\n";
$stmt = $conn->query("SELECT COUNT(DISTINCT codigo) as count FROM comprob");
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo "  - Códigos ARCA únicos en comprob: {$result['count']}\n";

if ($result['count'] < 3) {
    echo "❌ FALLO: Deberían existir al menos los códigos básicos (Factura, NC, ND)\n";
    exit(1);
}
echo "✓ PASS: Códigos ARCA preservados\n\n";

echo "=== TODAS LAS VALIDACIONES PASARON ✓ ===\n";
exit(0);

Ejecución:

bash
php migrations/scripts/validate-tipcom-migration.php

Actualización de Backend

0. Modificar Validador de Tipos de Comprobante (ABM existente)

⚠️ IMPORTANTE: Ya existe un ABM (CRUD) de tipos de comprobante, pero actualmente valida que el código ARCA sea único. Esta validación debe REMOVERSE para permitir tipos personalizados con códigos duplicados.

Archivo: Validators/Compras/TipoComprobanteValidator.php (o similar)

Cambio crítico:

php
// ANTES (validación actual - REMOVER):
protected function getRules(): array
{
    return [
        'descri' => 'required|string|max:55',
        'tipo' => 'required|string|in:D,C',
        'codigo' => 'required|integer|unique:comprob,codigo',  // ← REMOVER unique
        // ...
    ];
}

// DESPUÉS (permitir códigos ARCA duplicados):
protected function getRules(): array
{
    return [
        'descri' => 'required|string|max:55|unique:comprob,descri',  // ← Nombre único
        'tipo' => 'required|string|in:D,C',
        'codigo' => 'required|integer|min:1|max:999',  // ← Sin unique, solo validar rango
        // ...
    ];
}

Cambios en validaciones:

  1. REMOVER: unique:comprob,codigo (permitir códigos duplicados)
  2. AGREGAR: unique:comprob,descri (nombres únicos por sucursal)
  3. MANTENER: Validación de rango de código ARCA (1-999)

Justificación:

  • El código ARCA ahora es solo referencia fiscal, no identificador único
  • El identificador único es el id (PK auto-incremental)
  • Múltiples tipos pueden referenciar al mismo código ARCA
  • El nombre sí debe ser único para evitar confusión

Mensajes de error actualizados:

php
protected function getMessages(): array
{
    return [
        'descri.required' => 'El nombre del tipo es requerido',
        'descri.unique' => 'Ya existe un tipo de comprobante con ese nombre',  // ← NUEVO
        'codigo.required' => 'El código ARCA es requerido',
        'codigo.integer' => 'El código debe ser un número entero',
        'codigo.min' => 'El código ARCA debe ser mayor a 0',
        'codigo.max' => 'El código ARCA no es válido',
        // ... REMOVER mensaje de 'codigo.unique'
    ];
}

Validación adicional en Service (opcional):

php
// TipoComprobanteService.php
public function insert(TipoComprobanteRequest $data): TipoComprobanteResponse
{
    $this->connections->beginTransaction('oficial');
    try {
        // Validar que el nombre no esté duplicado (validador ya lo hace)
        if ($this->model->existsByNombre($data->descri)) {
            throw new ServerException("Ya existe un tipo con el nombre '{$data->descri}'");
        }

        // NO validar que el código sea único (permitir duplicados)
        // Comentario para desarrolladores futuros:
        // El código ARCA puede repetirse, es intencional para tipos personalizados

        $result = $this->model->insert($data);

        $this->registrarAuditoria("INSERT", "TIPO_COMPROBANTE", $this->model->getTable(), $result->id);

        $this->connections->commit('oficial');
        return $result;
    } catch (Exception $e) {
        $this->connections->rollback('oficial');
        throw $e;
    }
}

1. Actualizar TipoComprobante.php

Archivo: models/modulo-compra/TipoComprobante.php

⚠️ Nota: El CRUD ya existe, solo se agregan métodos auxiliares para la normalización.

Cambios:

php
<?php

namespace App\models\Compra;

use PDO;

class TipoComprobante
{
    private PDO $conn;
    private string $table = 'comprob';

    public function __construct(PDO $conn)
    {
        $this->conn = $conn;
    }

    /**
     * Obtener todos los tipos de comprobante
     * @return array
     */
    public function getAll(): array
    {
        $sql = "SELECT id, codigo::int, descri as nombre, tipo
                FROM {$this->table}
                ORDER BY codigo";

        $stmt = $this->conn->query($sql);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Obtener tipo de comprobante por ID (método principal)
     * @param int $id
     * @return array|null
     */
    public function getById(int $id): ?array
    {
        $sql = "SELECT id, codigo::int, descri as nombre, tipo
                FROM {$this->table}
                WHERE id = :id";

        $stmt = $this->conn->prepare($sql);
        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->rowCount() > 0 ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
    }

    /**
     * Obtener código ARCA desde ID (para libro IVA)
     * @param int $id
     * @return int|null
     */
    public function getCodigoById(int $id): ?int
    {
        $sql = "SELECT codigo::int FROM {$this->table} WHERE id = :id";

        $stmt = $this->conn->prepare($sql);
        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
        $stmt->execute();

        $result = $stmt->fetch(PDO::FETCH_ASSOC);
        return $result ? (int)$result['codigo'] : null;
    }

    /**
     * Verificar si existe un tipo por nombre (para validar duplicados)
     * @param string $nombre
     * @param int|null $excludeId Excluir este ID (para updates)
     * @return bool
     */
    public function existsByNombre(string $nombre, ?int $excludeId = null): bool
    {
        $sql = "SELECT 1 FROM {$this->table} WHERE descri = :nombre";

        if ($excludeId !== null) {
            $sql .= " AND id != :excludeId";
        }

        $stmt = $this->conn->prepare($sql);
        $stmt->bindValue(':nombre', $nombre);

        if ($excludeId !== null) {
            $stmt->bindValue(':excludeId', $excludeId, PDO::PARAM_INT);
        }

        $stmt->execute();
        return $stmt->rowCount() > 0;
    }

    /**
     * @deprecated Use getById() para referencias internas
     * Mantener por compatibilidad temporal, será removido en v4.0.0
     */
    public function getByCodigo(int $codigo): ?array
    {
        $sql = "SELECT id, codigo::int, descri as nombre, tipo
                FROM {$this->table}
                WHERE codigo = :codigo
                LIMIT 1";  // ← Ahora puede haber múltiples con mismo código

        $stmt = $this->conn->prepare($sql);
        $stmt->bindValue(':codigo', $codigo);
        $stmt->execute();

        return $stmt->rowCount() > 0 ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
    }

    /**
     * Obtener todos los tipos con un código ARCA específico
     * @param int $codigo
     * @return array
     */
    public function getAllByCodigo(int $codigo): array
    {
        $sql = "SELECT id, codigo::int, descri as nombre, tipo
                FROM {$this->table}
                WHERE codigo = :codigo
                ORDER BY descri";

        $stmt = $this->conn->prepare($sql);
        $stmt->bindValue(':codigo', $codigo, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Verificar si un ID existe (para validaciones)
     * @param int $id
     * @return bool
     */
    public function exists(int $id): bool
    {
        $sql = "SELECT 1 FROM {$this->table} WHERE id = :id";

        $stmt = $this->conn->prepare($sql);
        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->rowCount() > 0;
    }

    /**
     * Verificar si un tipo está en uso (para validación de eliminación)
     * @param int $id
     * @return array ['in_use' => bool, 'count' => int, 'tables' => array]
     */
    public function checkUsage(int $id): array
    {
        $usage = [
            'in_use' => false,
            'count' => 0,
            'tables' => []
        ];

        // Verificar en subdicom
        $stmt = $this->conn->prepare("SELECT COUNT(*) as count FROM subdicom WHERE tipcom = :id AND deleted_at IS NULL");
        $stmt->execute(['id' => $id]);
        $count = $stmt->fetch(PDO::FETCH_ASSOC)['count'];
        if ($count > 0) {
            $usage['in_use'] = true;
            $usage['count'] += $count;
            $usage['tables'][] = ['table' => 'subdicom', 'count' => $count];
        }

        // Verificar en ordcte
        $stmt = $this->conn->prepare("SELECT COUNT(*) as count FROM ordcte WHERE id_tipo = :id AND deleted_at IS NULL");
        $stmt->execute(['id' => $id]);
        $count = $stmt->fetch(PDO::FETCH_ASSOC)['count'];
        if ($count > 0) {
            $usage['in_use'] = true;
            $usage['count'] += $count;
            $usage['tables'][] = ['table' => 'ordcte', 'count' => $count];
        }

        return $usage;
    }
}

2. Actualizar Validador

Archivo: Validators/Compras/ComprobanteValidator.php

php
<?php

namespace App\Validators\Compras;

use App\Validators\Validator;

class ComprobanteValidator extends Validator
{
    protected function getRules(): array
    {
        return [
            'tipo_comprobante' => 'required|integer|exists:comprob,id',  // ← Cambio: exists en campo 'id'
            'proveedor' => 'required|integer',
            'fecha' => 'required|date',
            'importe' => 'required|numeric|min:0',
            // ... otros campos
        ];
    }

    protected function getMessages(): array
    {
        return [
            'tipo_comprobante.required' => 'El tipo de comprobante es requerido',
            'tipo_comprobante.integer' => 'El tipo de comprobante debe ser un número entero',
            'tipo_comprobante.exists' => 'El tipo de comprobante seleccionado no es válido',  // ← Validación de existencia
            // ... otros mensajes
        ];
    }
}

Nota: El validador exists:comprob,id ejecuta la query en el schema actual del tenant automáticamente.


3. Actualizar LibroDigital.php

Archivo: models/modulo-compra/LibroDigital.php

Cambio en consulta SQL:

php
// ANTES (líneas 33-45):
$sql = "SELECT
            s.id AS ID,
            s.tipcom AS TIPO_COMPROBANTE,  -- ← tipcom era código ARCA directo
            s.fecha AS FECHA,
            ...
        FROM subdicom s
        LEFT JOIN aliva a ON s.por1 = a.porcentaje
        WHERE s.fecha BETWEEN :desde AND :hasta
        ORDER BY s.fecha";

// DESPUÉS:
$sql = "SELECT
            s.id AS ID,
            c.codigo AS TIPO_COMPROBANTE,  -- ← JOIN para obtener código ARCA desde ID
            s.fecha AS FECHA,
            ...
        FROM subdicom s
        LEFT JOIN aliva a ON s.por1 = a.porcentaje
        INNER JOIN comprob c ON c.id = s.tipcom  -- ← NUEVO JOIN
        WHERE s.fecha BETWEEN :desde AND :hasta
        ORDER BY s.fecha";

Formato de código ARCA (línea 244):

php
// Mantener formato de 3 dígitos
$tipoComprobante = str_pad($r['tipo_comprobante'], 3, '0', STR_PAD_LEFT);
// Output: 001, 006, 011, etc.

4. Actualizar ComprobanteController.php

Archivo: controller/modulo-compra/ComprobanteController.php

php
// ANTES (línea 145):
$tipoComprobante = $modelTipoComprobante->getByCodigo($data['tipo_comprobante']);

// DESPUÉS:
$tipoComprobante = $modelTipoComprobante->getById($data['tipo_comprobante']);
// $data['tipo_comprobante'] ahora es el ID, no el código

Inserción en subdicom:

php
// El modelo ya no necesita cambios, simplemente recibe el ID
$result = $this->model->insert([
    'tipo_comprobante' => $data['tipo_comprobante'],  // ID interno
    'proveedor' => $data['proveedor'],
    // ...
]);

// En Comprobante.php (línea 139), se guarda directamente:
$stmt->bindValue(':tipo_comprobante', $data['tipo_comprobante'], PDO::PARAM_INT);

Actualización de Frontend

1. Actualizar Forms de Compras

Archivo: bautista-app/view/mod-compras/subdiario-compras.php

Dropdown de Tipo de Comprobante:

php
<!-- ANTES -->
<select name="tipo_comprobante" id="tipo_comprobante">
    <?php foreach ($tiposComprobante as $tipo): ?>
        <option value="<?= $tipo['codigo'] ?>">  <!-- código ARCA -->
            <?= $tipo['nombre'] ?>
        </option>
    <?php endforeach; ?>
</select>

<!-- DESPUÉS -->
<select name="tipo_comprobante" id="tipo_comprobante">
    <?php foreach ($tiposComprobante as $tipo): ?>
        <option value="<?= $tipo['id'] ?>">  <!-- ID interno -->
            <?= $tipo['nombre'] ?> (Código ARCA: <?= $tipo['codigo'] ?>)
        </option>
    <?php endforeach; ?>
</select>

Mostrar código ARCA como información adicional mejora la experiencia del usuario que conoce los códigos fiscales.


2. Actualizar JavaScript

Archivo: bautista-app/js/components/forms/ctacte/form-comprobante.js

javascript
// ANTES
const formData = {
    tipo_comprobante: 1,  // código ARCA
    proveedor: 123,
    // ...
};

// DESPUÉS
const formData = {
    tipo_comprobante: tipoSelect.value,  // ID interno (5, 7, 8, etc.)
    proveedor: 123,
    // ...
};

Sin cambios adicionales necesarios si se usa value del <option> correctamente.


Testing

1. Unit Tests

Archivo: Tests/Unit/Compras/TipoComprobanteTest.php

php
<?php

namespace App\Tests\Unit\Compras;

use PHPUnit\Framework\TestCase;
use App\models\Compra\TipoComprobante;
use PDO;

class TipoComprobanteTest extends TestCase
{
    private PDO $mockConn;
    private PDOStatement $mockStmt;

    protected function setUp(): void
    {
        $this->mockConn = $this->createMock(PDO::class);
        $this->mockStmt = $this->createMock(PDOStatement::class);
    }

    public function testGetByIdReturnsData(): void
    {
        // Mock setup
        $this->mockStmt->method('rowCount')->willReturn(1);
        $this->mockStmt->method('fetch')->willReturn([
            'id' => 5,
            'codigo' => 1,
            'nombre' => 'Factura A',
            'tipo' => 'D'
        ]);

        $this->mockConn->method('prepare')->willReturn($this->mockStmt);

        // Execute
        $model = new TipoComprobante($this->mockConn);
        $result = $model->getById(5);

        // Assert
        $this->assertIsArray($result);
        $this->assertEquals(5, $result['id']);
        $this->assertEquals(1, $result['codigo']);
    }

    public function testGetCodigoByIdReturnsArcaCode(): void
    {
        $this->mockStmt->method('fetch')->willReturn(['codigo' => 1]);
        $this->mockConn->method('prepare')->willReturn($this->mockStmt);

        $model = new TipoComprobante($this->mockConn);
        $codigo = $model->getCodigoById(5);

        $this->assertEquals(1, $codigo);
    }

    public function testExistsReturnsTrueWhenIdExists(): void
    {
        $this->mockStmt->method('rowCount')->willReturn(1);
        $this->mockConn->method('prepare')->willReturn($this->mockStmt);

        $model = new TipoComprobante($this->mockConn);
        $exists = $model->exists(5);

        $this->assertTrue($exists);
    }

    public function testCheckUsageReturnsCorrectData(): void
    {
        // Mock para subdicom
        $this->mockStmt->method('fetch')
            ->willReturnOnConsecutiveCalls(
                ['count' => 10],  // subdicom
                ['count' => 5]    // ordcte
            );
        $this->mockConn->method('prepare')->willReturn($this->mockStmt);

        $model = new TipoComprobante($this->mockConn);
        $usage = $model->checkUsage(5);

        $this->assertTrue($usage['in_use']);
        $this->assertEquals(15, $usage['count']);
        $this->assertCount(2, $usage['tables']);
    }
}

2. Integration Tests

Archivo: Tests/Integration/Compras/TipoComprobanteIntegrationTest.php

php
<?php

namespace App\Tests\Integration\Compras;

use App\Tests\Integration\BaseIntegrationTestCase;
use App\models\Compra\TipoComprobante;
use App\models\Compra\Comprobante;

class TipoComprobanteIntegrationTest extends BaseIntegrationTestCase
{
    public function testCreateComprobanteWithValidTipoComprobante(): void
    {
        // Crear tipo de comprobante
        $this->conn->exec("
            INSERT INTO comprob (id, codigo, descri, tipo)
            VALUES (100, 1, 'Factura A Test', 'D')
        ");

        // Crear comprobante usando ID
        $this->conn->exec("
            INSERT INTO subdicom (tipcom, nrocom, fecha, importe)
            VALUES (100, '00001-00000001', '2026-01-07', 1000.00)
        ");

        // Validar
        $stmt = $this->conn->query("SELECT tipcom FROM subdicom WHERE nrocom = '00001-00000001'");
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);

        $this->assertEquals(100, $result['tipcom']);
    }

    public function testLibroIVATranslatesIdToArcaCode(): void
    {
        // Crear tipo y comprobante
        $this->conn->exec("INSERT INTO comprob (id, codigo, descri, tipo) VALUES (100, 6, 'Factura B', 'D')");
        $this->conn->exec("INSERT INTO subdicom (tipcom, nrocom, fecha, importe) VALUES (100, '00001-00000001', '2026-01-07', 1000)");

        // Consulta como en LibroDigital.php
        $stmt = $this->conn->query("
            SELECT c.codigo AS tipo_comprobante
            FROM subdicom s
            INNER JOIN comprob c ON c.id = s.tipcom
            WHERE s.nrocom = '00001-00000001'
        ");
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);

        // Validar que se obtiene el código ARCA
        $this->assertEquals(6, $result['tipo_comprobante']);

        // Validar formato de 3 dígitos
        $formatted = str_pad($result['tipo_comprobante'], 3, '0', STR_PAD_LEFT);
        $this->assertEquals('006', $formatted);
    }

    public function testCannotInsertWithInvalidTipoComprobante(): void
    {
        $this->expectException(\PDOException::class);

        // Intentar insertar con tipcom que no existe
        // Nota: Sin FK física, esto NO fallará en BD
        // La validación debe hacerse en el backend
        $this->conn->exec("
            INSERT INTO subdicom (tipcom, nrocom, fecha, importe)
            VALUES (999, '00001-00000001', '2026-01-07', 1000)
        ");

        // Esta línea no debería alcanzarse si hay validación
    }
}

Checklist de Implementación Completo

Backend

  • [ ] CRÍTICO: Validador TipoComprobanteValidator - unique:comprob,codigo REMOVIDO
  • [ ] CRÍTICO: Validador TipoComprobanteValidator - unique:comprob,descri AGREGADO
  • [ ] Migración 1: Tabla de mapeo creada
  • [ ] Migración 2: subdicom.tipcom convertido a ID
  • [ ] Migración 3: ordcte.id_tipo convertido a ID
  • [ ] Script de validación ejecutado y pasado
  • [ ] TipoComprobante.php actualizado con métodos nuevos (existsByNombre, getAllByCodigo)
  • [ ] ComprobanteController.php usa getById()
  • [ ] LibroDigital.php hace JOIN para obtener código ARCA
  • [ ] Validador ComprobanteValidator actualizado con exists:comprob,id
  • [ ] Tests unitarios ejecutados y pasados
  • [ ] Tests de integración ejecutados y pasados
  • [ ] NUEVO: Test de creación de tipos con código ARCA duplicado

Frontend

  • [ ] Dropdowns usan value="" en lugar de código
  • [ ] Se muestra código ARCA como información adicional
  • [ ] JavaScript envía IDs en requests
  • [ ] Formularios validados en staging

Resumen de Cambios Técnicos

ComponenteCambioImpacto
TipoComprobanteValidator.phpREMOVER unique:comprob,codigoCRÍTICO - Permite tipos personalizados
TipoComprobanteValidator.phpAGREGAR unique:comprob,descriALTO - Nombres únicos
subdicom.tipcomdecimal(2) → integer (ID)CRÍTICO - Requiere migración de datos
ordcte.id_tipointeger (código) → integer (ID)CRÍTICO - Requiere migración de datos
TipoComprobante.phpNuevos métodos: existsByNombre(), getAllByCodigo(), getById(), getCodigoById(), exists(), checkUsage()ALTO - API pública del modelo
LibroDigital.phpJOIN con comprob para obtener códigoMEDIO - Cambio en query SQL
ComprobanteValidator.phpValidación: exists:comprob,idALTO - Validación obligatoria
Frontend formsvalue="" en dropdownsBAJO - Cambio visual mínimo
Multi-tenancySin FK físicas, validación en backendCRÍTICO - Decisión arquitectural

Objetivo principal habilitado: Usuarios pueden crear tipos personalizados (ej: "Detalle Bancario") que referencien a códigos ARCA estándar (ej: código 2 = ND).


Última actualización: 2026-01-07 Autor: Equipo de Desarrollo Revisión requerida: Arquitecto de Software, DBA, QA Lead