Skip to content

Sistema de Configuración - Documentación Técnica

Conceptual Foundation: Para entender los CONCEPTOS de multi-tenancy con configuración de niveles, consulte Database Architecture: Multi-Tenant. Este documento describe la implementación técnica del sistema de configuración.

Este documento describe la implementación técnica del sistema de configuración data_config con soporte para claves anidadas usando notación de punto.

Tabla de Contenidos


Descripción General

La tabla data_config proporciona un sistema de almacenamiento de configuración clave-valor flexible para Sistema Bautista. Soporta:

  • Pares clave-valor simples: Almacenamiento de configuración plana tradicional
  • Claves anidadas con notación de punto: Organización jerárquica usando formato modulo.entidad.propiedad
  • Aislamiento multi-tenant: Configuraciones a nivel EMPRESA y SUCURSAL
  • Compatibilidad hacia atrás: Las claves legacy continúan funcionando sin modificación

Archivos Clave:

  • Migration: migrations/migrations/tenancy/20240823200714_new_table_data_config.php
  • Model: models/general/Config.php
  • Seeds: migrations/seeds/tenancy/DataConfig.php
  • Tests: Tests/Unit/General/ConfigModelTest.php

Estructura de Base de Datos

Definición de Tabla

sql
CREATE TABLE data_config (
    id      SERIAL PRIMARY KEY,
    clave   VARCHAR(100),
    valor   VARCHAR(500),
    help    VARCHAR(500)
);

Descripción de Columnas

ColumnaTipoDescripción
idSERIALClave primaria auto-incremental
claveVARCHAR(100)Clave de configuración (soporta notación de punto)
valorVARCHAR(500)Valor de configuración (almacenado como string)
helpVARCHAR(500)Descripción/documentación opcional para la clave

Índices

Considerar agregar un índice en clave para rendimiento:

sql
CREATE INDEX idx_data_config_clave ON data_config(clave);

Soporte Multi-Tenant

La tabla data_config se crea en dos niveles de tenancy:

NivelPatrón de SchemaDescripción
LEVEL_EMPRESA (1)publicConfiguraciones por defecto de toda la empresa
LEVEL_SUCURSAL (2)suc{XXXX}Configuraciones específicas de sucursal que sobrescriben las de empresa

Esto se configura en la migration usando ConfigurableMigration:

php
protected function getDefaultLevels(): array
{
    return [self::LEVEL_EMPRESA, self::LEVEL_SUCURSAL];
}

Aislamiento por Schema

Cada schema de tenant tiene su propia tabla data_config, permitiendo:

  • Valores por defecto de toda la empresa en public.data_config
  • Sobrescrituras específicas por sucursal en suc0001.data_config, suc0002.data_config, etc.

API del Modelo Config

Implementación Actual

Ubicada en models/general/Config.php:

php
namespace App\models\general;

use App\models\Model;
use PDO;

class Config extends Model
{
    public function __construct(PDO $conn)
    {
        parent::__construct($conn, 'data_config');
    }

    /**
     * Obtiene todas las configuraciones como array clave-valor
     * @return array<string, string>
     */
    public function getAll(): array;

    /**
     * Obtiene un valor de configuración individual por clave
     * @return mixed Valor o false si no se encuentra
     */
    public function getOneByKey(string $key): mixed;
}

Métodos Propuestos para Claves Anidadas

php
/**
 * Obtiene valor de configuración con resolución automática de claves anidadas
 *
 * @param string $key Clave en notación de punto (ej: 'membresia.facturacion.plazo_dias')
 * @return mixed Valor o false si no se encuentra
 */
public function getMapped(string $key): mixed;

/**
 * Obtiene todas las configuraciones que coincidan con un prefijo como array anidado
 *
 * @param string $prefix Prefijo de clave (ej: 'membresia.facturacion')
 * @return array Array asociativo anidado de configuraciones coincidentes
 */
public function getByPrefix(string $prefix): array;

/**
 * Transforma filas planas clave-valor en array asociativo anidado
 *
 * @param array $rows Filas de base de datos con columnas 'clave' y 'valor'
 * @return array Estructura anidada
 * @internal
 */
private function mapNestedKeys(array $rows): array;

Sistema de Claves Anidadas

Formato de Notación de Punto

Las claves usan puntos (.) como separadores de nivel:

{modulo}.{entidad}.{propiedad}

Ejemplos

ClaveMóduloEntidadPropiedad
membresia.facturacion.plazo_vencimiento_diasmembresiafacturacionplazo_vencimiento_dias
membresia.facturacion.genera_recibomembresiafacturaciongenera_recibo
ventas.impresion.copias_defectoventasimpresioncopias_defecto
ctacte.notificaciones.email_habilitadoctactenotificacionesemail_habilitado

Ejemplos de Uso

Obtener un Valor Anidado Individual

php
// Enfoque legacy (sigue funcionando)
$plazo = $config->getOneByKey('membresia.facturacion.plazo_vencimiento_dias');
// Retorna: "30"

// Nuevo enfoque mapeado (propuesto)
$plazo = $config->getMapped('membresia.facturacion.plazo_vencimiento_dias');
// Retorna: "30"

Obtener un Grupo de Configuraciones

php
$facturacionConfig = $config->getByPrefix('membresia.facturacion');

// Retorna array anidado:
// [
//     'plazo_vencimiento_dias' => '30',
//     'genera_recibo' => '1',
//     'tipo_comprobante_defecto' => 'FC',
// ]

Obtener Todas las Configuraciones de un Módulo

php
$membresiaConfig = $config->getByPrefix('membresia');

// Retorna array completamente anidado:
// [
//     'facturacion' => [
//         'plazo_vencimiento_dias' => '30',
//         'genera_recibo' => '1',
//     ],
//     'notificaciones' => [
//         'email_aviso_vencimiento' => '1',
//         'dias_anticipacion' => '7',
//     ],
// ]

Compatibilidad Hacia Atrás

Las claves legacy sin puntos continúan funcionando exactamente como antes:

php
// Clave legacy (sin puntos)
$config->getOneByKey('PermisosEmpresa');     // Funciona sin cambios
$config->getOneByKey('fact-defecto');        // Funciona sin cambios
$config->getOneByKey('CantCajas');           // Funciona sin cambios

// Nueva clave anidada
$config->getOneByKey('membresia.facturacion.plazo_dias');  // También funciona

Convenciones de Nomenclatura

Estructura de Clave

{nivel_1}.{nivel_2}.{nivel_3}
NivelContenidoEjemplos
Módulo/Áreamembresia, ventas, ctacte, tesoreria, stock, crm
Entidad/Funcionalidadfacturacion, reportes, notificaciones, impresion
Propiedad específicaplazo_vencimiento_dias, email_habilitado, copias_defecto

Reglas de Formato

  1. Todo en minúsculas: Usar membresia no Membresia
  2. Snake_case: Usar plazo_vencimiento_dias no plazoVencimientoDias
  3. Nombres descriptivos: Usar genera_recibo_automatico no gra
  4. Máximo 3 niveles: Evitar a.b.c.d.e - reestructurar si es necesario

Claves de Primer Nivel Reservadas

Prefijo de ClaveMódulo
membresiaMódulo de membresías
ventasMódulo de ventas
comprasMódulo de compras
ctacteCuentas corrientes
tesoreriaTesorería
stockInventario
crmCRM
contabilidadContabilidad
sistemaConfiguraciones del sistema

Resolución de Prioridad Multi-Tenant

Orden de Búsqueda

Al recuperar valores de configuración:

  1. Nivel SUCURSAL (más específico) - Verificar suc{XXXX}.data_config
  2. Nivel EMPRESA (fallback) - Verificar public.data_config

Patrón de Implementación

php
public function getWithFallback(string $key): mixed
{
    // 1. Intentar nivel SUCURSAL primero (schema actual)
    $value = $this->getOneByKey($key);

    if ($value !== false) {
        return $value;
    }

    // 2. Fallback a nivel EMPRESA
    // Requiere conexión secundaria a schema public
    return $this->getFromEmpresaLevel($key);
}

Ejemplo de Caso de Uso

Escenario: El período de facturación por defecto de la empresa es 30 días, pero la sucursal suc0001 necesita 45 días.

SchemaClaveValor
publicmembresia.facturacion.plazo_dias30
suc0001membresia.facturacion.plazo_dias45
suc0002(no configurado)-

Resultado:

  • suc0001 recupera 45 (sobrescritura de sucursal)
  • suc0002 recupera 30 (fallback de empresa)

Consideraciones de Implementación

Parseo de Claves

php
private function parseKey(string $key): array
{
    return explode('.', $key);
}

Construcción de Arrays Anidados (Iterativo)

php
private function mapNestedKeys(array $rows): array
{
    $result = [];

    foreach ($rows as $row) {
        $keys = explode('.', $row['clave']);
        $current = &$result;

        foreach ($keys as $i => $key) {
            if ($i === count($keys) - 1) {
                // Última clave - asignar valor
                $current[$key] = $row['valor'];
            } else {
                // Clave intermedia - asegurar que existe array
                if (!isset($current[$key])) {
                    $current[$key] = [];
                }
                $current = &$current[$key];
            }
        }
    }

    return $result;
}

Construcción de Arrays Anidados (Recursivo)

php
private function setNestedValue(array &$array, array $keys, string $value): void
{
    $key = array_shift($keys);

    if (empty($keys)) {
        $array[$key] = $value;
    } else {
        if (!isset($array[$key]) || !is_array($array[$key])) {
            $array[$key] = [];
        }
        $this->setNestedValue($array[$key], $keys, $value);
    }
}

Query por Prefijo

php
public function getByPrefix(string $prefix): array
{
    $sql = "SELECT clave, valor FROM {$this->table}
            WHERE clave LIKE :prefix";

    $stmt = $this->conn->prepare($sql);
    $stmt->execute([':prefix' => $prefix . '.%']);

    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

    // Eliminar prefijo de claves antes de mapear
    $stripped = array_map(function($row) use ($prefix) {
        $row['clave'] = substr($row['clave'], strlen($prefix) + 1);
        return $row;
    }, $rows);

    return $this->mapNestedKeys($stripped);
}

Consideraciones de Rendimiento

  1. Índice en clave: Esencial para queries LIKE con prefijo
  2. Estrategia de Cache: Considerar cachear todas las configs al inicio del request
  3. Carga Perezosa: Cargar por prefijo solo cuando se necesite
  4. Operaciones por Lote: Usar getByPrefix() en lugar de múltiples llamadas a getOneByKey()

Invalidación de Cache

Al implementar caching:

php
class Config extends Model
{
    private static ?array $cache = null;

    public function getOneByKey(string $key): mixed
    {
        if (self::$cache === null) {
            self::$cache = $this->getAll();
        }

        return self::$cache[$key] ?? false;
    }

    public static function clearCache(): void
    {
        self::$cache = null;
    }
}

Triggers de invalidación:

  • Después de INSERT/UPDATE/DELETE en data_config
  • Al cambiar de schema (multi-tenant)
  • Por request (para simplicidad)

Guía de Migración

Configuraciones Existentes

No se requiere migración. Las claves planas existentes continúan funcionando:

php
// Estas siguen funcionando exactamente como antes
$config->getOneByKey('fact-defecto');
$config->getOneByKey('CantCajas');
$config->getOneByKey('ModMovimientosAsociados');

Agregando Nuevas Configuraciones Anidadas

  1. Elegir prefijo apropiado basado en el módulo
  2. Seguir convenciones de nomenclatura (snake_case, descriptivo)
  3. Documentar en columna help
  4. Agregar a seeds si es necesario

Ejemplo de seed para nueva configuración anidada:

php
$configurations = [
    [
        'clave' => 'membresia.facturacion.plazo_vencimiento_dias',
        'valor' => '30',
        'help' => 'Cantidad de dias para el vencimiento de facturas de membresia',
    ],
    [
        'clave' => 'membresia.facturacion.genera_recibo',
        'valor' => '1',
        'help' => 'Si se genera recibo automaticamente al facturar (0=No, 1=Si)',
    ],
];

Reglas de Coexistencia

EscenarioComportamiento
Clave legacy existeFunciona sin cambios
Nueva clave anidada agregadaFunciona con ambas APIs
Mismo prefijo, diferentes profundidadesTodas coexisten de forma segura
Colisión de claves (ej: a.b y a.b.c)Evitar - a.b sería hoja, no puede ser también rama

Testing

Ejemplo de Test Unitario

php
public function testGetByPrefixReturnsNestedArray(): void
{
    // Arrange
    $rows = [
        ['clave' => 'membresia.facturacion.plazo_dias', 'valor' => '30'],
        ['clave' => 'membresia.facturacion.genera_recibo', 'valor' => '1'],
        ['clave' => 'membresia.notificaciones.email', 'valor' => '1'],
    ];

    // Mock database para retornar estas filas para query de prefijo
    // ...

    // Act
    $result = $this->model->getByPrefix('membresia');

    // Assert
    $this->assertIsArray($result);
    $this->assertArrayHasKey('facturacion', $result);
    $this->assertArrayHasKey('notificaciones', $result);
    $this->assertEquals('30', $result['facturacion']['plazo_dias']);
    $this->assertEquals('1', $result['facturacion']['genera_recibo']);
}

public function testMapNestedKeysBuildsCorrectStructure(): void
{
    // Arrange
    $rows = [
        ['clave' => 'a.b.c', 'valor' => '1'],
        ['clave' => 'a.b.d', 'valor' => '2'],
        ['clave' => 'a.e', 'valor' => '3'],
    ];

    // Act
    $result = $this->invokePrivateMethod('mapNestedKeys', [$rows]);

    // Assert
    $expected = [
        'a' => [
            'b' => [
                'c' => '1',
                'd' => '2',
            ],
            'e' => '3',
        ],
    ];
    $this->assertEquals($expected, $result);
}

Casos de Test a Cubrir

  1. Camino Feliz: Recuperación válida de clave anidada
  2. Nivel Único: Clave sin puntos (legacy)
  3. Prefijo Vacío: Sin configuraciones coincidentes
  4. Anidamiento Profundo: 4+ niveles (caso extremo)
  5. Caracteres Especiales: Claves con unicode, números
  6. Inyección SQL: Patrones de prefijo maliciosos
  7. Comportamiento de Cache: Invalidación correcta
  8. Multi-Tenant: Resolución correcta de schema

Solución de Problemas

Problemas Comunes

Configuración No Encontrada

Síntoma: getOneByKey() retorna false

Checklist:

  1. Verificar ortografía de clave (sensible a mayúsculas/minúsculas)
  2. Verificar que el schema correcto está activo (multi-tenant)
  3. Confirmar que el registro existe: SELECT * FROM data_config WHERE clave = 'tu.clave'
  4. Verificar espacios al inicio/final en la clave

Estructura Anidada No se Construye

Síntoma: getByPrefix() retorna array plano en lugar de anidado

Checklist:

  1. Asegurar que las claves usan puntos (.) no guiones (-) o guiones bajos (_) como separadores
  2. Verificar que mapNestedKeys() se está llamando en los resultados
  3. Verificar colisiones de claves (misma clave como hoja y rama)

Problemas de Rendimiento

Síntoma: Cargas de página lentas con muchas llamadas a configuración

Soluciones:

  1. Agregar índice: CREATE INDEX idx_data_config_clave ON data_config(clave)
  2. Implementar caching (ver sección Cache)
  3. Usar getByPrefix() para recuperación por lote en lugar de múltiples llamadas a getOneByKey()

Sobrescritura Multi-Tenant No Funciona

Síntoma: Sobrescritura de sucursal ignorada, siempre retorna el valor por defecto de empresa

Checklist:

  1. Verificar que la conexión es al schema correcto (suc{XXXX})
  2. Verificar que el registro existe en el schema de la sucursal
  3. Verificar que search_path está configurado correctamente en la conexión

Referencias


Última Actualización: 2026-01-28 Versión: 1.0.0 Estado: Propuesto (Funcionalidad de Claves Anidadas pendiente de implementación)