Skip to content

Arquitectura Backend Legacy

Estado: ⚠️ LEGACY - Solo mantenimiento

ARQUITECTURA DEPRECADA

Esta arquitectura está obsoleta y no debe usarse para nuevos desarrollos. Solo se mantiene para dar soporte al código existente. Para nuevos endpoints, consulte Arquitectura Moderna Slim Framework.

1. Visión General

Contexto Histórico

El backend legacy de Sistema Bautista se construyó con endpoints basados en archivos PHP individuales antes de la adopción de Slim Framework. Esta arquitectura se mantiene activa para:

  • ✅ Mantenimiento de endpoints existentes
  • ✅ Corrección de bugs en funcionalidades legacy
  • NO para nuevos endpoints o refactorizaciones mayores

Características Principales

  • Routing file-based: URL mapped directamente a archivos PHP
  • Switch HTTP manual: Cada archivo maneja métodos HTTP con switch
  • Sin middleware global: Validación y autenticación por archivo
  • Controllers legacy: Lógica de negocio + orquestación (responsabilidad excesiva)
  • Models legacy: Validaciones + acceso a DB (acoplamiento alto)
  • Query directa: Algunos endpoints sin controllers/models

Stack Tecnológico

ComponenteTecnologíaVersión
RuntimePHP8.2+
DatabasePostgreSQL12+
PDONative-
AuthJWT (RSA)Custom
ValidationRakit Validation1.x

2. Estructura de Archivos

Paths Completos desde Root

Todos los paths se especifican desde la raíz del repositorio bautista-backend/:

bautista-backend/
├── backend/                     # ⚠️ ENDPOINTS LEGACY (DEPRECADO)
│   ├── carteras.php            # Endpoint de carteras (con controller)
│   ├── dolar.php               # Cotización dólar (query directa)
│   ├── cliente.php             # Clientes (query directa)
│   ├── proveedor.php           # Proveedores (query directa)
│   │
│   ├── mod-compras/            # Módulo de compras
│   │   ├── comprobante.php     # Comprobantes de compra
│   │   ├── concepto.php        # Conceptos de compra
│   │   ├── tipo-comprobante.php # Tipos de comprobante
│   │   └── libro-digital.php   # Libro digital de compras
│   │
│   ├── mod-contabilidad/       # Módulo de contabilidad
│   │   └── ...
│   │
│   ├── mod-ventas/             # Módulo de ventas
│   │   └── ...
│   │
│   ├── mod-ctacte/             # Cuenta corriente
│   │   └── ...
│   │
│   ├── mod-tesoreria/          # Tesorería
│   │   └── ...
│   │
│   ├── mod-stock/              # Stock
│   │   └── ...
│   │
│   ├── mod-crm/                # CRM
│   │   └── ...
│   │
│   └── legacy/                 # Código legacy muy antiguo
│       └── ...

├── controller/                 # Controllers legacy (parcialmente)
│   ├── general/
│   │   └── CarteraController.php  # Controller de carteras
│   ├── Compra/
│   │   ├── ComprobanteController.php
│   │   └── ConceptoController.php
│   └── ...

├── models/                     # Models legacy (parcialmente)
│   ├── Cartera.php
│   ├── Cliente.php
│   └── ...

├── auth/                       # Autenticación compartida
│   ├── JwtHandler.php          # Manejo JWT (legacy + moderno)
│   └── ...

├── helper/                     # Helpers compartidos
│   ├── exceptions.php          # Excepciones custom
│   ├── exceptionHandler.php    # Manejo global de excepciones
│   ├── success.php             # Respuestas de éxito
│   ├── methods.php             # Métodos de utilidad
│   └── validator.php           # Validador Rakit

├── connection/                 # Conexión base de datos
│   ├── connect.php             # Inicialización de conexión
│   ├── Database.php            # Clase Database (PDO wrapper)
│   └── ConnectionManager.php   # Multi-tenant manager (legacy + moderno)

└── Routes/                     # ✅ ENDPOINTS MODERNOS (Slim Framework)
    ├── Compras/
    │   └── ComprobanteRoutes.php
    └── ...

Convención de Naming

TipoPatternEjemploPath
Endpoint genérico{entidad}.phpcarteras.phpbackend/
Endpoint de módulo{entidad}.phpcomprobante.phpbackend/mod-{modulo}/
Controller legacy{Entidad}Controller.phpCarteraController.phpcontroller/general/
Model legacy{Entidad}.phpCartera.phpmodels/

3. Patrones de Implementación

3.1. Endpoint con Controller/Model

Patrón: Endpoint delega a controller, que usa model para DB.

Ubicación:

  • Endpoint: bautista-backend/backend/{entidad}.php
  • Controller: bautista-backend/controller/general/{Entidad}Controller.php
  • Model: bautista-backend/models/{Entidad}.php

Ejemplo completo:

Endpoint: bautista-backend/backend/carteras.php

php
<?php

use App\controller\general\CarteraController;

require_once('../auth/JwtHandler.php');
require_once('../helper/exceptions.php');
require_once('../helper/exceptionHandler.php');
require_once('../helper/success.php');
require_once('../helper/methods.php');
require_once('../helper/validator.php');
require_once('../connection/connect.php');

header('content-type: application/json; charset=utf-8');

try {
    // JWT ya validado en JwtHandler.php
    $payload = $GLOBALS['payload'];
    ['db' => $db, 'schema' => $schema] = $payload;

    // Parsear body JSON
    $array = json_decode(file_get_contents('php://input'), true);

    // Conexión a base de datos
    $database = new Database($db, $schema);
    $conn = $database->getConnection();

    // Inicializar controller
    $carteraController = new CarteraController($conn);

    // Switch manual de HTTP methods
    switch ($_SERVER['REQUEST_METHOD']) {
        case 'GET':
            $result = $carteraController->getAll($array ?? $_GET);

            http_response_code(200);
            echo getSuccessResponse(200, $result);
            break;

        case 'POST':
            $result = $carteraController->insert($array);

            if ($result) {
                http_response_code(201);
                echo getSuccessResponse(201, ['id' => $result]);
            } else {
                throw new InsertError("Error al insertar una nueva cartera");
            }
            break;

        case 'PUT':
            $result = $carteraController->update($array);

            if ($result) {
                http_response_code(204);
                echo getSuccessResponse(204);
            } else {
                throw new UpdateError("Error al modificar la cartera");
            }
            break;

        case 'DELETE':
            $result = $carteraController->delete($array);

            if ($result) {
                http_response_code(204);
                echo getSuccessResponse(204);
            } else {
                throw new DeleteError("Error al eliminar la cartera");
            }
            break;

        default:
            http_response_code(404);
            echo json_encode(['error' => "Recurso no encontrado"]);
            break;
    }

} catch (Exception $e) {
    ExceptionHandler::handle($e);
}

Características del patrón:

  1. ✅ JWT validado en JwtHandler.php (require)
  2. ✅ Payload extraído de $GLOBALS['payload']
  3. ✅ Switch manual para HTTP methods
  4. ✅ Controller inicializado con conexión PDO
  5. ✅ Response con getSuccessResponse()
  6. ✅ Manejo de excepciones con ExceptionHandler

Controller Legacy: bautista-backend/controller/general/CarteraController.php

php
<?php

namespace App\controller\general;

use App\models\Cartera;
use PDO;

class CarteraController
{
    private PDO $conn;

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

    /**
     * Obtener todas las carteras
     *
     * @param array $params Parámetros de filtrado
     * @return array
     */
    public function getAll(array $params = []): array
    {
        $model = new Cartera($this->conn);

        // Filtros opcionales
        if (isset($params['tipo'])) {
            $model->setTipo($params['tipo']);
        }

        if (isset($params['filter'])) {
            $model->setFilter($params['filter']);
        }

        return $model->getAll();
    }

    /**
     * Insertar nueva cartera
     *
     * @param array $data Datos de cartera
     * @return int|false ID de cartera insertada o false
     */
    public function insert(array $data): int|false
    {
        $model = new Cartera($this->conn);

        // Validar datos
        if (!$this->validate($data, 'insert')) {
            throw new \InvalidArgumentException("Datos de cartera inválidos");
        }

        // Asignar datos al model
        $model->setNombre($data['nombre']);
        $model->setTipo($data['tipo']);

        if (isset($data['descripcion'])) {
            $model->setDescripcion($data['descripcion']);
        }

        // Insertar
        return $model->insert();
    }

    /**
     * Actualizar cartera existente
     *
     * @param array $data Datos de cartera
     * @return bool
     */
    public function update(array $data): bool
    {
        $model = new Cartera($this->conn);

        // Validar datos
        if (!$this->validate($data, 'update')) {
            throw new \InvalidArgumentException("Datos de cartera inválidos");
        }

        // Asignar datos
        $model->setId($data['id']);
        $model->setNombre($data['nombre']);
        $model->setTipo($data['tipo']);

        if (isset($data['descripcion'])) {
            $model->setDescripcion($data['descripcion']);
        }

        // Actualizar
        return $model->update();
    }

    /**
     * Eliminar cartera
     *
     * @param array $data Datos con ID de cartera
     * @return bool
     */
    public function delete(array $data): bool
    {
        if (!isset($data['id'])) {
            throw new \InvalidArgumentException("ID de cartera requerido");
        }

        $model = new Cartera($this->conn);
        $model->setId($data['id']);

        return $model->delete();
    }

    /**
     * Validar datos de cartera
     *
     * @param array $data Datos a validar
     * @param string $operation Operación (insert|update)
     * @return bool
     */
    private function validate(array $data, string $operation): bool
    {
        // Validación básica
        if ($operation === 'insert') {
            if (empty($data['nombre']) || empty($data['tipo'])) {
                return false;
            }
        }

        if ($operation === 'update') {
            if (empty($data['id']) || empty($data['nombre']) || empty($data['tipo'])) {
                return false;
            }
        }

        return true;
    }
}

Problemas del Controller Legacy:

  • ⚠️ Demasiada responsabilidad (validación + orquestación + lógica de negocio)
  • ⚠️ Validación manual (no usa Validator middleware)
  • ⚠️ Sin separación de service layer
  • ⚠️ Lógica acoplada al model

Model Legacy: bautista-backend/models/Cartera.php

php
<?php

namespace App\models;

use PDO;

class Cartera
{
    private PDO $conn;

    private ?int $id = null;
    private ?string $nombre = null;
    private ?string $tipo = null;
    private ?string $descripcion = null;
    private ?string $filter = null;

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

    // Getters y Setters
    public function setId(int $id): void
    {
        $this->id = $id;
    }

    public function setNombre(string $nombre): void
    {
        $this->nombre = $nombre;
    }

    public function setTipo(string $tipo): void
    {
        $this->tipo = $tipo;
    }

    public function setDescripcion(?string $descripcion): void
    {
        $this->descripcion = $descripcion;
    }

    public function setFilter(string $filter): void
    {
        $this->filter = $filter;
    }

    /**
     * Obtener todas las carteras
     *
     * @return array
     */
    public function getAll(): array
    {
        $sql = "SELECT * FROM carteras WHERE 1=1";
        $params = [];

        // Filtro por tipo
        if ($this->tipo !== null) {
            $sql .= " AND tipo = :tipo";
            $params[':tipo'] = $this->tipo;
        }

        // Filtro de búsqueda
        if ($this->filter !== null) {
            $sql .= " AND (nombre ILIKE :filter OR descripcion ILIKE :filter)";
            $params[':filter'] = "%{$this->filter}%";
        }

        $sql .= " ORDER BY nombre ASC";

        $stmt = $this->conn->prepare($sql);
        $stmt->execute($params);

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

    /**
     * Insertar nueva cartera
     *
     * @return int|false ID de cartera insertada
     */
    public function insert(): int|false
    {
        $sql = "INSERT INTO carteras (nombre, tipo, descripcion)
                VALUES (:nombre, :tipo, :descripcion)
                RETURNING id";

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

        if ($stmt->execute()) {
            return (int)$stmt->fetchColumn();
        }

        return false;
    }

    /**
     * Actualizar cartera existente
     *
     * @return bool
     */
    public function update(): bool
    {
        $sql = "UPDATE carteras
                SET nombre = :nombre,
                    tipo = :tipo,
                    descripcion = :descripcion
                WHERE id = :id";

        $stmt = $this->conn->prepare($sql);
        $stmt->bindParam(':id', $this->id);
        $stmt->bindParam(':nombre', $this->nombre);
        $stmt->bindParam(':tipo', $this->tipo);
        $stmt->bindParam(':descripcion', $this->descripcion);

        return $stmt->execute();
    }

    /**
     * Eliminar cartera (soft delete)
     *
     * @return bool
     */
    public function delete(): bool
    {
        $sql = "UPDATE carteras SET eliminado = 1 WHERE id = :id";

        $stmt = $this->conn->prepare($sql);
        $stmt->bindParam(':id', $this->id);

        return $stmt->execute();
    }
}

Problemas del Model Legacy:

  • ⚠️ Mezcla validación con acceso a DB
  • ⚠️ Sin DTOs (arrays asociativos directos)
  • ⚠️ Lógica de filtrado en model (debería estar en service)
  • ⚠️ Sin separación de responsabilidades

3.2. Endpoint con Query Directa (Sin Controller/Model)

Patrón: Endpoint ejecuta queries SQL directamente sin capas intermedias.

Ubicación: bautista-backend/backend/{entidad}.php

Ejemplo completo:

Endpoint: bautista-backend/backend/dolar.php

php
<?php

require_once('../auth/JwtHandler.php');
require_once('../helper/exceptions.php');
require_once('../helper/exceptionHandler.php');
require_once('../helper/success.php');
require_once('../helper/methods.php');
require_once('../helper/validator.php');
require_once('../connection/connect.php');

header('content-type: application/json; charset=utf-8');

try {
    $payload = $GLOBALS['payload'];
    ['db' => $db, 'schema' => $schema] = $payload;

    switch ($_SERVER['REQUEST_METHOD']) {
        case 'GET':
            // Conexión directa
            $database = new Database($db, $schema);
            $conn = $database->getConnection();

            // Query directa
            $sql = "SELECT * FROM dolar ORDER BY fecha DESC LIMIT 1";

            $stmt = $conn->prepare($sql);
            $stmt->execute();

            if ($stmt->rowCount() === 1) {
                $result = $stmt->fetch(PDO::FETCH_ASSOC);
                $result['valor'] = (float)$result['valor'];
            }

            http_response_code(200);
            echo getSuccessResponse(200, $result ?? []);
            break;

        case 'POST':
            $array = json_decode(file_get_contents('php://input'), true);

            $database = new Database($db, $schema);
            $conn = $database->getConnection();
            $values = [];

            // Verificar si existe cotización del día
            $sql = "SELECT * FROM dolar WHERE fecha = :fecha";
            $stmt = $conn->prepare($sql);
            $stmt->bindParam(':fecha', $array['fecha']);
            $stmt->execute();

            if ($stmt->rowCount() > 0) {
                // Actualizar cotización existente
                $sql = "UPDATE dolar SET valor = :valor WHERE fecha = :fecha";
            } else {
                // Insertar nueva cotización
                $sql = "INSERT INTO dolar (fecha, valor) VALUES (:fecha, :valor)";
            }

            $values[':fecha'] = $array['fecha'];
            $values[':valor'] = $array['valor'];

            $stmt = $conn->prepare($sql);
            $result = $stmt->execute($values);

            if ($result) {
                http_response_code(204);
                echo getSuccessResponse(204);
            } else {
                throw new UpdateError("Error al actualizar la cotización del dólar");
            }
            break;

        default:
            http_response_code(404);
            echo json_encode(['error' => "Recurso no encontrado"]);
            break;
    }

} catch (Exception $e) {
    ExceptionHandler::handle($e);
}

Características del patrón:

  1. ✅ Query SQL directa en endpoint
  2. ✅ Sin controller/model intermedios
  3. ✅ Lógica de negocio en endpoint
  4. ⚠️ No reutilizable
  5. ⚠️ Difícil de testear
  6. ⚠️ Violación de SRP (Single Responsibility Principle)

3.3. Endpoint con ConnectionManager (Multi-tenant)

Patrón: Endpoint usa ConnectionManager para manejar múltiples conexiones (oficial/prueba).

Ubicación: bautista-backend/backend/mod-{modulo}/{entidad}.php

Ejemplo completo:

Endpoint: bautista-backend/backend/mod-compras/comprobante.php

php
<?php

use App\connection\ConnectionManager;
use App\controller\Compra\ComprobanteController;
use App\Factories\ModelFactory;
use App\service\auditLog\AuditLogger;

require_once('../../auth/JwtHandler.php');
require_once('../../helper/exceptions.php');
require_once('../../helper/exceptionHandler.php');
require_once('../../helper/success.php');
require_once('../../helper/methods.php');
require_once('../../helper/validator.php');
require_once('../../connection/connect.php');

header('content-type: application/json; charset=utf-8');

try {
    $payload = $GLOBALS['payload'];
    ['db' => $db, 'schema' => $schema] = $payload;

    $array = json_decode(file_get_contents('php://input'), true);

    // Inicializar ConnectionManager
    $connectionManager = new ConnectionManager();

    // Configurar conexiones (multi-database para oficial/prueba)
    $connectionManager->setConfig('oficial', [
        'database' => $db,
        'schema' => $schema
    ]);

    $connectionManager->setConfig('prueba', [
        'database' => $db . '_p', // Base de datos de prueba con sufijo _p
        'schema' => $schema
    ]);

    // Conexión principal según parámetro 'prueba'
    $connectionManager->setConfig('principal', [
        'database' => isset($array['prueba']) && $array['prueba'] ? $db . '_p' : $db,
        'schema' => $schema
    ]);

    // Alias para compatibilidad
    $connectionManager->setAlias('oficial_dbal', 'oficial');
    $connectionManager->setAlias('prueba_dbal', 'prueba');

    // Inicializar dependencias
    $modelFactory = $container->get(ModelFactory::class);
    $auditLogger = new AuditLogger($modelFactory);

    // Controller con ConnectionManager
    $comprobanteController = new ComprobanteController($connectionManager, $auditLogger);

    switch ($_SERVER['REQUEST_METHOD']) {
        case 'GET':
            $response = $comprobanteController->getOne($array);
            http_response_code(200);
            echo getSuccessResponse(200, $response);
            break;

        case 'POST':
            $response = $comprobanteController->insert($array);
            http_response_code(201);
            echo getSuccessResponse(201, $response);
            break;

        case 'PUT':
            $response = $comprobanteController->update($array);
            http_response_code(200);
            echo getSuccessResponse(200, $response);
            break;

        case 'DELETE':
            $response = $comprobanteController->delete($array);
            http_response_code(200);
            echo getSuccessResponse(200, $response);
            break;

        default:
            http_response_code(404);
            echo json_encode(['error' => "Recurso no encontrado"]);
            break;
    }

} catch (Exception $e) {
    ExceptionHandler::handle($e);
}

Características del patrón:

  1. ✅ ConnectionManager para multi-tenant
  2. ✅ Conexiones: oficial, prueba, principal
  3. ✅ Base de datos de prueba con sufijo _p
  4. ✅ Parámetro prueba determina DB activa
  5. ✅ Alias para compatibilidad con código moderno

3.4. Autenticación JWT

Archivo: bautista-backend/auth/JwtHandler.php

Todos los endpoints legacy requieren JwtHandler.php para validar JWT.

php
<?php

require_once('../auth/JwtHandler.php');

// JWT ya validado automáticamente
$payload = $GLOBALS['payload'];
['db' => $db, 'schema' => $schema] = $payload;

Payload JWT:

php
[
    'id' => 123,              // ID de usuario (desde v3.9.1)
    'user' => 'admin',        // Username
    'db' => 'suc0001',        // Nombre de base de datos
    'schema' => 'suc0001',    // Schema multi-tenant
    'exp' => 1738584000,      // Timestamp de expiración
    'permissions' => [...]    // Permisos del usuario
]

3.5. Helpers Compartidos

Helper de Éxito: helper/success.php

php
<?php

/**
 * Generar respuesta de éxito estándar
 *
 * @param int $code Código HTTP
 * @param mixed $data Datos a retornar
 * @return string JSON
 */
function getSuccessResponse(int $code, $data = null): string
{
    $response = [
        'success' => true,
        'code' => $code
    ];

    if ($data !== null) {
        $response['data'] = $data;
    }

    return json_encode($response);
}

Uso:

php
http_response_code(200);
echo getSuccessResponse(200, ['id' => 123, 'nombre' => 'Cartera 1']);

Helper de Excepciones: helper/exceptionHandler.php

php
<?php

class ExceptionHandler
{
    public static function handle(Exception $e): void
    {
        $code = $e->getCode() ?: 500;

        http_response_code($code);

        echo json_encode([
            'error' => true,
            'code' => $code,
            'message' => $e->getMessage(),
            'trace' => DEBUG ? $e->getTraceAsString() : null
        ]);
    }
}

Excepciones Custom (helper/exceptions.php):

php
<?php

class InsertError extends Exception
{
    public function __construct(string $message = "Error al insertar registro")
    {
        parent::__construct($message, 500);
    }
}

class UpdateError extends Exception
{
    public function __construct(string $message = "Error al actualizar registro")
    {
        parent::__construct($message, 500);
    }
}

class DeleteError extends Exception
{
    public function __construct(string $message = "Error al eliminar registro")
    {
        parent::__construct($message, 500);
    }
}

class MissingSession extends Exception
{
    public function __construct(string $message = "Sesión no válida")
    {
        parent::__construct($message, 401);
    }
}

4. Problemas de Arquitectura

4.1. Routing Basado en Archivos

Problema: URL mappea directamente a archivos PHP físicos.

❌ URL física determina endpoint
POST /backend/mod-compras/comprobante.php
  ↓ Mapea a archivo
  bautista-backend/backend/mod-compras/comprobante.php

Consecuencias:

  • ⚠️ Estructura de directorios expuesta
  • ⚠️ Refactoring difícil (cambiar path = cambiar URL)
  • ⚠️ Sin versioning de API
  • ⚠️ Sin parámetros de ruta (/comprobantes/:id)

Solución: Slim Framework con routing declarativo.

php
// ✅ Arquitectura moderna
$app->get('/comprobantes/{id}', [ComprobanteController::class, 'getOne']);

4.2. Switch Manual HTTP

Problema: Cada archivo maneja métodos HTTP con switch manual.

php
// ❌ Switch manual por archivo
switch ($_SERVER['REQUEST_METHOD']) {
    case 'GET':
        // Lógica GET
        break;
    case 'POST':
        // Lógica POST
        break;
    case 'PUT':
        // Lógica PUT
        break;
    case 'DELETE':
        // Lógica DELETE
        break;
}

Consecuencias:

  • ⚠️ Código repetitivo en cada endpoint
  • ⚠️ Sin middleware HTTP global
  • ⚠️ Difícil agregar logging/metricas
  • ⚠️ Validación manual por método

Solución: Slim Framework con routing por método.

php
// ✅ Routing declarativo
$app->get('/comprobantes', [ComprobanteController::class, 'getAll']);
$app->post('/comprobantes', [ComprobanteController::class, 'create']);
$app->put('/comprobantes/{id}', [ComprobanteController::class, 'update']);
$app->delete('/comprobantes/{id}', [ComprobanteController::class, 'delete']);

4.3. Lógica Dispersa

Problema: Controllers legacy mezclan validación + orquestación + lógica de negocio.

php
// ❌ Controller con demasiada responsabilidad
class CarteraController
{
    public function insert(array $data): int|false
    {
        // Validación
        if (empty($data['nombre'])) {
            throw new \InvalidArgumentException("Nombre requerido");
        }

        // Orquestación
        $model = new Cartera($this->conn);
        $model->setNombre($data['nombre']);

        // Lógica de negocio
        if ($this->existeCartera($data['nombre'])) {
            throw new \Exception("Cartera duplicada");
        }

        // Acceso a DB (delegado a model)
        return $model->insert();
    }
}

Consecuencias:

  • ⚠️ Difícil de testear (múltiples responsabilidades)
  • ⚠️ Código duplicado entre controllers
  • ⚠️ Lógica de negocio acoplada a infraestructura

Solución: Arquitectura 5 capas moderna.

php
// ✅ Separación de responsabilidades
Route Validation Middleware Controller Service Model

4.4. Sin DTOs

Problema: Arrays asociativos en lugar de objetos tipados.

php
// ❌ Arrays sin tipo
public function insert(array $data): int|false
{
    $nombre = $data['nombre']; // No type-safe
    $tipo = $data['tipo'];     // Posibles errores en runtime
}

Consecuencias:

  • ⚠️ Sin type hints (errores en runtime)
  • ⚠️ Sin validación estática (IDE no detecta errores)
  • ⚠️ Difícil refactorizar
  • ⚠️ Documentación implícita (no explícita)

Solución: DTOs con tipos estrictos.

php
// ✅ DTOs tipados
class CarteraInputDTO extends FullDTO
{
    public function __construct(
        public readonly string $nombre,
        public readonly string $tipo,
        public readonly ?string $descripcion = null
    ) {}
}

public function insert(CarteraInputDTO $dto): int
{
    // Type-safe
}

4.5. Sin Testing

Problema: Endpoints legacy no diseñados para testing.

Consecuencias:

  • ⚠️ Sin tests unitarios (lógica mezclada)
  • ⚠️ Sin tests de integración (dependencia de archivos físicos)
  • ⚠️ Refactoring riesgoso
  • ⚠️ Regresiones frecuentes

Solución: Arquitectura testeable con DI.

php
// ✅ Testeable con DI
class ComprobanteService
{
    public function __construct(
        private ConnectionManager $connectionManager,
        private AuditLogger $auditLogger
    ) {}

    public function create(ComprobanteInputDTO $dto): ComprobanteOutputDTO
    {
        // Lógica testeable
    }
}

// Test unitario
$service = new ComprobanteService(
    $this->createMock(ConnectionManager::class),
    $this->createMock(AuditLogger::class)
);

5. Migración a Slim Framework (Paso a Paso)

Estrategia de Migración

ESTRATEGIA PROGRESIVA

La migración a Slim Framework se hace gradualmente refactorizando endpoints de alto impacto primero.

Paso 1: Identificar Endpoint a Migrar

Priorizar endpoints con:

  • ✅ Alto uso (frecuencia de llamadas)
  • ✅ Lógica compleja (difícil de mantener)
  • ✅ Requisitos de testing
  • ✅ Necesidad de refactoring

Ejemplo: Migrar backend/mod-compras/comprobante.php


Paso 2: Crear Route en Slim

Archivo: bautista-backend/Routes/Compras/ComprobanteRoutes.php

php
<?php

declare(strict_types=1);

namespace App\Routes\Compras;

use App\controller\Compra\ComprobanteController;
use App\middleware\ValidationMiddleware;
use Slim\Routing\RouteCollectorProxy;

return function (RouteCollectorProxy $group) {
    $group->group('/comprobantes', function (RouteCollectorProxy $comprobantes) {

        // GET /comprobantes?filter=...
        $comprobantes->get('', [ComprobanteController::class, 'getAll']);

        // GET /comprobantes/{id}
        $comprobantes->get('/{id:[0-9]+}', [ComprobanteController::class, 'getOne']);

        // POST /comprobantes
        $comprobantes->post('', [ComprobanteController::class, 'create'])
            ->add(new ValidationMiddleware('comprobante', 'create'));

        // PUT /comprobantes/{id}
        $comprobantes->put('/{id:[0-9]+}', [ComprobanteController::class, 'update'])
            ->add(new ValidationMiddleware('comprobante', 'update'));

        // DELETE /comprobantes/{id}
        $comprobantes->delete('/{id:[0-9]+}', [ComprobanteController::class, 'delete']);
    });
};

Paso 3: Crear Controller Moderno

Archivo: bautista-backend/controller/Compra/ComprobanteController.php

php
<?php

declare(strict_types=1);

namespace App\controller\Compra;

use App\controller\Controller;
use App\service\Compra\ComprobanteService;
use App\Resources\Compra\ComprobanteInputDTO;
use App\Resources\Compra\ComprobanteOutputDTO;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

class ComprobanteController extends Controller
{
    public function __construct(
        private ComprobanteService $comprobanteService
    ) {}

    /**
     * Crear nuevo comprobante
     */
    public function create(Request $request, Response $response): Response
    {
        // Parsear body validado
        $data = $request->getParsedBody();

        // Crear DTO de entrada
        $dto = ComprobanteInputDTO::fromArray($data);

        // Delegar a service
        $result = $this->comprobanteService->create($dto);

        // Responder con DTO de salida
        return $this->respondWithData($response, $result->toArray(), 201);
    }

    /**
     * Actualizar comprobante existente
     */
    public function update(Request $request, Response $response, array $args): Response
    {
        $id = (int)$args['id'];
        $data = $request->getParsedBody();

        $dto = ComprobanteInputDTO::fromArray($data);

        $result = $this->comprobanteService->update($id, $dto);

        return $this->respondWithData($response, $result->toArray(), 200);
    }

    /**
     * Obtener comprobante por ID
     */
    public function getOne(Request $request, Response $response, array $args): Response
    {
        $id = (int)$args['id'];

        $result = $this->comprobanteService->getById($id);

        return $this->respondWithData($response, $result->toArray(), 200);
    }

    /**
     * Eliminar comprobante
     */
    public function delete(Request $request, Response $response, array $args): Response
    {
        $id = (int)$args['id'];

        $this->comprobanteService->delete($id);

        return $response->withStatus(204);
    }
}

Paso 4: Crear Service Layer

Archivo: bautista-backend/service/Compra/ComprobanteService.php

php
<?php

declare(strict_types=1);

namespace App\service\Compra;

use App\connection\ConnectionManager;
use App\models\Compra\Comprobante;
use App\Resources\Compra\ComprobanteInputDTO;
use App\Resources\Compra\ComprobanteOutputDTO;
use App\service\auditLog\AuditLogger;

class ComprobanteService
{
    use \App\Traits\Conectable;

    public function __construct(
        private ConnectionManager $connectionManager,
        private AuditLogger $auditLogger
    ) {}

    /**
     * Crear nuevo comprobante
     */
    public function create(ComprobanteInputDTO $dto): ComprobanteOutputDTO
    {
        // Obtener conexión principal
        $conn = $this->getConnection('principal');

        // Iniciar transacción
        $this->beginTransaction('principal');

        try {
            // Crear model
            $model = new Comprobante($conn);

            // Asignar datos del DTO
            $model->setNumeroComprobante($dto->numeroComprobante);
            $model->setFecha($dto->fecha);
            $model->setProveedor($dto->proveedorId);
            $model->setImporte($dto->importe);

            // Insertar
            $id = $model->insert();

            // Audit log
            $this->auditLogger->log('comprobante', $id, 'INSERT');

            // Commit
            $this->commit('principal');

            // Obtener comprobante creado
            return $this->getById($id);

        } catch (\Exception $e) {
            $this->rollback('principal');
            throw $e;
        }
    }

    /**
     * Obtener comprobante por ID
     */
    public function getById(int $id): ComprobanteOutputDTO
    {
        $conn = $this->getConnection('principal');

        $model = new Comprobante($conn);
        $data = $model->getById($id);

        if (empty($data)) {
            throw new \Exception("Comprobante no encontrado", 404);
        }

        return ComprobanteOutputDTO::fromArray($data);
    }

    /**
     * Actualizar comprobante
     */
    public function update(int $id, ComprobanteInputDTO $dto): ComprobanteOutputDTO
    {
        $conn = $this->getConnection('principal');

        $this->beginTransaction('principal');

        try {
            $model = new Comprobante($conn);
            $model->setId($id);
            $model->setNumeroComprobante($dto->numeroComprobante);
            $model->setFecha($dto->fecha);
            $model->setProveedor($dto->proveedorId);
            $model->setImporte($dto->importe);

            $model->update();

            $this->auditLogger->log('comprobante', $id, 'UPDATE');

            $this->commit('principal');

            return $this->getById($id);

        } catch (\Exception $e) {
            $this->rollback('principal');
            throw $e;
        }
    }

    /**
     * Eliminar comprobante (soft delete)
     */
    public function delete(int $id): void
    {
        $conn = $this->getConnection('principal');

        $this->beginTransaction('principal');

        try {
            $model = new Comprobante($conn);
            $model->setId($id);
            $model->delete();

            $this->auditLogger->log('comprobante', $id, 'DELETE');

            $this->commit('principal');

        } catch (\Exception $e) {
            $this->rollback('principal');
            throw $e;
        }
    }
}

Paso 5: Crear DTOs

Input DTO: bautista-backend/Resources/Compra/ComprobanteInputDTO.php

php
<?php

declare(strict_types=1);

namespace App\Resources\Compra;

use App\Resources\FullDTO;

class ComprobanteInputDTO extends FullDTO
{
    public function __construct(
        public readonly string $numeroComprobante,
        public readonly string $fecha,
        public readonly int $proveedorId,
        public readonly float $importe,
        public readonly ?string $comentario = null
    ) {}
}

Output DTO: bautista-backend/Resources/Compra/ComprobanteOutputDTO.php

php
<?php

declare(strict_types=1);

namespace App\Resources\Compra;

use App\Resources\FullDTO;

class ComprobanteOutputDTO extends FullDTO
{
    public function __construct(
        public readonly int $id,
        public readonly string $numeroComprobante,
        public readonly string $fecha,
        public readonly int $proveedorId,
        public readonly string $proveedorNombre,
        public readonly float $importe,
        public readonly ?string $comentario,
        public readonly string $fechaCreacion
    ) {}
}

Paso 6: Crear Validadores

Archivo: bautista-backend/validators/comprobante.php

php
<?php

return [
    'create' => [
        'numeroComprobante' => 'required|string|max:50',
        'fecha' => 'required|date:Y-m-d',
        'proveedorId' => 'required|integer|min:1',
        'importe' => 'required|numeric|min:0',
        'comentario' => 'nullable|string|max:500'
    ],

    'update' => [
        'numeroComprobante' => 'required|string|max:50',
        'fecha' => 'required|date:Y-m-d',
        'proveedorId' => 'required|integer|min:1',
        'importe' => 'required|numeric|min:0',
        'comentario' => 'nullable|string|max:500'
    ]
];

Paso 7: Registrar Route en index.php

Archivo: bautista-backend/index.php

php
<?php

// Registrar routes de Compras
$app->group('/compras', function (RouteCollectorProxy $group) {
    // Comprobantes
    require __DIR__ . '/Routes/Compras/ComprobanteRoutes.php';
})->add($authMiddleware);

Paso 8: Deprecar Endpoint Legacy

Opción A: Comentar código legacy

php
<?php

// ⚠️ DEPRECADO - Migrado a Routes/Compras/ComprobanteRoutes.php
// Este endpoint será eliminado en v4.0.0
// Por favor, usar: POST /compras/comprobantes

die(json_encode([
    'error' => 'Endpoint deprecado',
    'message' => 'Use POST /compras/comprobantes en su lugar'
]));

Opción B: Proxy temporal

php
<?php

// ⚠️ PROXY TEMPORAL - Migrado a Slim Framework
// Redirigir al nuevo endpoint

header('Location: /compras/comprobantes', true, 301);
exit;

Paso 9: Testing

Test Unitario: bautista-backend/Tests/Unit/Compras/ComprobanteServiceTest.php

php
<?php

namespace Tests\Unit\Compras;

use App\service\Compra\ComprobanteService;
use App\Resources\Compra\ComprobanteInputDTO;
use PHPUnit\Framework\TestCase;

class ComprobanteServiceTest extends TestCase
{
    private ComprobanteService $service;

    protected function setUp(): void
    {
        $connectionManager = $this->createMock(\App\connection\ConnectionManager::class);
        $auditLogger = $this->createMock(\App\service\auditLog\AuditLogger::class);

        $this->service = new ComprobanteService($connectionManager, $auditLogger);
    }

    public function testCreateComprobante(): void
    {
        $dto = new ComprobanteInputDTO(
            numeroComprobante: 'FC-0001-00000001',
            fecha: '2026-02-03',
            proveedorId: 1,
            importe: 10000.00
        );

        $result = $this->service->create($dto);

        $this->assertInstanceOf(\App\Resources\Compra\ComprobanteOutputDTO::class, $result);
        $this->assertEquals('FC-0001-00000001', $result->numeroComprobante);
    }
}

Test de Integración: bautista-backend/Tests/Integration/Compras/ComprobanteIntegrationTest.php

php
<?php

namespace Tests\Integration\Compras;

use Tests\Integration\BaseIntegrationTestCase;

class ComprobanteIntegrationTest extends BaseIntegrationTestCase
{
    public function testCreateComprobanteEndpoint(): void
    {
        $data = [
            'numeroComprobante' => 'FC-0001-00000001',
            'fecha' => '2026-02-03',
            'proveedorId' => 1,
            'importe' => 10000.00
        ];

        $response = $this->post('/compras/comprobantes', $data);

        $this->assertEquals(201, $response->getStatusCode());

        $body = json_decode((string)$response->getBody(), true);
        $this->assertEquals('FC-0001-00000001', $body['data']['numeroComprobante']);
    }
}

Paso 10: Documentar Cambio

CHANGELOG.md:

markdown
## [v4.0.0] - 2026-02-15

### Features
- Endpoint `/compras/comprobantes` migrado a Slim Framework

### Deprecated
- `backend/mod-compras/comprobante.php` deprecado (eliminado en v5.0.0)

### Migration Guide
- Cambiar URL de `POST /backend/mod-compras/comprobante.php` a `POST /compras/comprobantes`
- Validación ahora en ValidationMiddleware (respuestas 422 en lugar de 400)
- DTOs tipados en lugar de arrays asociativos

6. Helpers Compartidos

6.1. Autenticación

Archivo: bautista-backend/auth/JwtHandler.php

  • ✅ Validación automática de JWT
  • ✅ Payload extraído en $GLOBALS['payload']
  • ✅ Usado tanto en legacy como en Slim Framework

6.2. Conexión Base de Datos

Archivo: bautista-backend/connection/Database.php

php
<?php

class Database
{
    private PDO $conn;

    public function __construct(string $db, string $schema)
    {
        $dsn = "pgsql:host=" . DB_HOST . ";port=" . DB_PORT . ";dbname={$db}";

        $this->conn = new PDO($dsn, DB_USER, DB_PASS);
        $this->conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        // Configurar schema multi-tenant
        $this->conn->exec("SET search_path TO {$schema}, public");
    }

    public function getConnection(): PDO
    {
        return $this->conn;
    }
}

6.3. ConnectionManager

Archivo: bautista-backend/connection/ConnectionManager.php

  • ✅ Manejo de múltiples conexiones (oficial/prueba/principal)
  • ✅ Transacciones coordinadas
  • ✅ Schema-based multi-tenancy
  • ✅ Usado en legacy y arquitectura moderna

Uso en legacy:

php
$connectionManager = new ConnectionManager();

$connectionManager->setConfig('oficial', [
    'database' => $db,
    'schema' => $schema
]);

$connectionManager->setConfig('prueba', [
    'database' => $db . '_p',
    'schema' => $schema
]);

$conn = $connectionManager->getConnection('oficial');

6.4. Comunicación Frontend → Backend

IMPORTANTE: API.js vs api.ts

Diferencia crítica en el frontend para comunicación con backend:

  • API.js (Legacy): Solo para endpoints legacy (backend/**/*.php)
  • api.ts con Axios (Moderno): Para endpoints Slim Framework (Routes/**/*.php)

❌ API.js (Legacy) - Solo para Endpoints Legacy

Archivo: bautista-app/js/middleware/API.js

Uso EXCLUSIVO con endpoints legacy:

javascript
// ❌ Solo para endpoints legacy (backend/*.php)
import { ApiRequest } from '../../middleware/API.js';

const request = new ApiRequest();

// Legacy endpoint: backend/carteras.php
const response = await request.get('/backend/carteras.php', {
    tipo: 'cliente'
});

// Legacy endpoint: backend/mod-compras/comprobante.php
const comprobante = await request.post('/backend/mod-compras/comprobante.php', {
    numero: 'FC-0001-00000001',
    importe: 10000
});

Características:

  • ⚠️ URL incluye path completo al archivo PHP
  • ⚠️ Response format custom (no estándar)
  • ⚠️ Sin type safety (JavaScript vanilla)

✅ api.ts con Axios (Moderno) - Para Endpoints Slim Framework

Archivo: bautista-app/ts/api/api.ts

Uso REQUERIDO con endpoints Slim modernos:

typescript
// ✅ Para endpoints Slim Framework modernos
import axios from '../api/api';

// Endpoint moderno: GET /compras/comprobantes
const response = await axios.get('/compras/comprobantes', {
    params: {
        filter: 'proveedor1'
    }
});

// Endpoint moderno: POST /compras/comprobantes
const comprobante = await axios.post('/compras/comprobantes', {
    numeroComprobante: 'FC-0001-00000001',
    fecha: '2026-02-03',
    proveedorId: 1,
    importe: 10000.00
});

// Endpoint moderno: PUT /compras/comprobantes/123
const updated = await axios.put('/compras/comprobantes/123', {
    importe: 12000.00
});

// Endpoint moderno: DELETE /compras/comprobantes/123
await axios.delete('/compras/comprobantes/123');

Características:

  • ✅ URL RESTful sin archivos físicos
  • ✅ Response estándar (PSR-7)
  • ✅ Type safety con TypeScript
  • ✅ Axios interceptors (X-Schema header automático)
  • ✅ Error handling consistente

Migración de API.js a api.ts

Antes (Legacy):

javascript
// ❌ API.js legacy
import { ApiRequest } from '../../middleware/API.js';

const request = new ApiRequest();

const response = await request.get('/backend/mod-compras/comprobante.php', {
    id: 123
});

Después (Moderno):

typescript
// ✅ api.ts con Axios
import axios from '../api/api';

const response = await axios.get('/compras/comprobantes/123');

Tabla de Migración:

AspectoAPI.js (Legacy)api.ts (Moderno)
Endpointsbackend/**/*.phpSlim Routes (/compras/comprobantes)
Type Safety❌ No (JavaScript)✅ Sí (TypeScript)
URL PatternFile-based (/backend/file.php)RESTful (/resource/{id})
HeadersManualAutomático (interceptors)
Error HandlingCustomEstándar (HTTP status)
Response FormatCustomPSR-7 estándar

REGLA DE ORO

Si el endpoint está en:

  • bautista-backend/backend/*.php → Usar API.js (legacy)
  • bautista-backend/Routes/**/*.php → Usar api.ts (axios)

7. Troubleshooting

7.1. Error 401: Token inválido

Síntomas: {"error": "Token inválido"}

Causas:

  1. JWT expirado
  2. JWT malformado
  3. Private key incorrecta
  4. Header Authorization faltante

Solución:

bash
# Verificar header en request
Authorization: Bearer <token>

# Verificar expiración en JWT debugger (jwt.io)
# Regenerar token si expiró

7.2. Error 500: Schema no encontrado

Síntomas: ERROR: schema "suc0001" does not exist

Causas:

  1. Schema no creado en base de datos
  2. Payload JWT con schema incorrecto
  3. Migration no ejecutada

Solución:

bash
# Verificar schemas existentes
psql -d database -c "\dn"

# Ejecutar migrations
php migrations/migrate-db-command.php --migrate

# Verificar payload JWT
echo $GLOBALS['payload'];

7.3. Error: Método HTTP no soportado

Síntomas: {"error": "Recurso no encontrado"}

Causas: Switch no maneja método HTTP enviado

Solución:

php
// Verificar que switch incluya el método
switch ($_SERVER['REQUEST_METHOD']) {
    case 'GET':
        // ...
        break;
    case 'POST':
        // ...
        break;
    // ⚠️ Agregar PUT/DELETE si es necesario
    default:
        http_response_code(405); // Method Not Allowed
        echo json_encode(['error' => "Método no soportado"]);
        break;
}

7.4. Error: Connection refused

Síntomas: Connection refused al conectar a PostgreSQL

Causas:

  1. PostgreSQL no iniciado
  2. Host/port incorrecto en constants.php
  3. Firewall bloqueando conexión

Solución:

bash
# Verificar PostgreSQL activo
sudo systemctl status postgresql

# Verificar configuración
grep -A 5 "DB_" constants.php

# Test de conexión
psql -h localhost -p 5432 -U postgres -d database

7.5. Error: Multi-tenant no funciona

Síntomas: Datos de otra sucursal aparecen en respuesta

Causas:

  1. search_path no configurado correctamente
  2. Schema incorrecto en payload JWT
  3. ConnectionManager mal configurado

Solución:

php
// Verificar search_path
$stmt = $conn->query("SHOW search_path");
$searchPath = $stmt->fetchColumn();
echo "Search path: " . $searchPath; // Debe ser: suc0001, public

// Verificar payload
var_dump($GLOBALS['payload']);
// ['db' => 'suc0001', 'schema' => 'suc0001']

// Verificar ConnectionManager
$conn = $connectionManager->getConnection('oficial');
$stmt = $conn->query("SELECT current_schema()");
echo "Current schema: " . $stmt->fetchColumn();

8. Referencias

Documentación Relacionada

Ejemplos Reales en Código

MóduloEndpoint LegacyEndpoint Moderno (Slim)
Carterasbackend/carteras.phpRoutes/General/CarteraRoutes.php
Comprasbackend/mod-compras/comprobante.phpRoutes/Compras/ComprobanteRoutes.php
Dólarbackend/dolar.php(Pendiente migración)

Tecnologías

Migración a Slim


Resumen

RECORDATORIO FINAL

Esta arquitectura está DEPRECADA. Solo para mantenimiento de código existente.

Para nuevos desarrollos:

  • ✅ Usar Arquitectura Slim Framework
  • ✅ Seguir patrones de 5 capas (Route → Middleware → Controller → Service → Model)
  • ✅ Implementar DTOs tipados
  • ✅ Escribir tests (PHPUnit)

Puntos clave:

  1. ✅ Routing file-based (URL → archivo PHP)
  2. ✅ Switch HTTP manual en cada endpoint
  3. ✅ Controllers legacy con lógica mezclada
  4. ✅ Models legacy sin DTOs
  5. ✅ ConnectionManager para multi-tenant
  6. ✅ JWT compartido (JwtHandler.php)
  7. ✅ Migración gradual a Slim Framework siguiendo 10 pasos
  8. ✅ Testing crítico en migración