Appearance
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
- Arquitectura y Decisiones Técnicas
- Estado Actual del Sistema
- Migraciones de Base de Datos
- Actualización de Backend
- Actualización de Frontend
- Testing
- Deployment
- 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
comprobcon 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ísicaVentajas:
- 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 relacionescodigo: 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:
tipcomalmacena 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 originalcomprob_id: ID interno de comprob correspondientetable_name: Tabla afectada (subdicom, ordcte, etc.)record_id: ID del registro modificadoold_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:
- Todos los códigos en
subdicom.tipcomdeben existir encomprob.codigo - Si hay códigos huérfanos, la migración falla con error claro
- 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.phpActualizació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:
- REMOVER:
unique:comprob,codigo(permitir códigos duplicados) - AGREGAR:
unique:comprob,descri(nombres únicos por sucursal) - 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ódigoInserció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
| Componente | Cambio | Impacto |
|---|---|---|
| TipoComprobanteValidator.php | REMOVER unique:comprob,codigo | CRÍTICO - Permite tipos personalizados |
| TipoComprobanteValidator.php | AGREGAR unique:comprob,descri | ALTO - Nombres únicos |
subdicom.tipcom | decimal(2) → integer (ID) | CRÍTICO - Requiere migración de datos |
ordcte.id_tipo | integer (código) → integer (ID) | CRÍTICO - Requiere migración de datos |
TipoComprobante.php | Nuevos métodos: existsByNombre(), getAllByCodigo(), getById(), getCodigoById(), exists(), checkUsage() | ALTO - API pública del modelo |
LibroDigital.php | JOIN con comprob para obtener código | MEDIO - Cambio en query SQL |
ComprobanteValidator.php | Validación: exists:comprob,id | ALTO - Validación obligatoria |
| Frontend forms | value="" en dropdowns | BAJO - Cambio visual mínimo |
| Multi-tenancy | Sin FK físicas, validación en backend | CRÍ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