Skip to content

Agrupación y Línea de Artículos - Documentación Técnica Backend

Módulo: ventas Feature: Agrupación (Rubro) y Línea Fecha: 2026-02-11

⚠️ DOCUMENTACIÓN RETROSPECTIVA - Generada a partir de código implementado el 2026-02-11


Referencia de Negocio


Arquitectura Implementada

Patrón Legacy (Pre-5-Layer)

Este recurso utiliza el patrón legacy del sistema (anterior a la adopción de 5-layer DDD):

Backend PHP (legacy) → Controller → Model → Database

Archivos implementados:

LayerRubro (Agrupación)Línea
Route/backend/agrupacion.php/backend/linea.php
Controllercontroller/modulo-venta/RubroController.phpcontroller/modulo-venta/LineaController.php
Modelmodels/modulo-venta/Rubro.phpmodels/modulo-venta/Linea.php
DTOResources/Venta/Rubro.phpResources/Venta/Linea.php

Características del patrón legacy:

  • Entrada PHP directa (no usa Slim Framework routes)
  • Controller delega directamente en Model
  • Sin Service Layer (lógica en Model)
  • Sin Domain Layer
  • Sin Auditoría automática
  • Sin Validador middleware estructurado

API Endpoints

Rubro (Agrupación)

GET /backend/agrupacion.php (Sin ID)

Descripción: Obtiene todas las agrupaciones con opciones de filtrado

Método HTTP: GET

Request Body (JSON):

json
{
  "filter": "string (opcional)",
  "first": true | false (opcional),
  "last": true | false (opcional)
}

Parámetros:

  • filter - Filtro para búsqueda por nombre (ILIKE) o código (LIKE). Limita a 10 resultados cuando se usa (autocomplete)
  • first - Si es true, retorna solo el primer rubro ordenado por código ASC
  • last - Si es true, retorna solo el último rubro ordenado por código DESC

Response DTO:

json
{
  "status": 200,
  "message": "Datos recibidos correctamente",
  "data": [
    {
      "id": 1,
      "concepto": "Alimentos"
    }
  ]
}

Status codes: 200 (éxito), 500 (error de servidor)


GET /backend/agrupacion.php (Con ID)

Descripción: Obtiene una agrupación específica por su ID

Método HTTP: GET

Request Body (JSON):

json
{
  "id": 1
}

Response DTO:

json
{
  "status": 200,
  "message": "Datos recibidos correctamente",
  "data": {
    "id": 1,
    "concepto": "Alimentos"
  }
}

Status codes: 200 (éxito), 400 (ID inválido), 500 (error)


POST /backend/agrupacion.php

Descripción: Crea una nueva agrupación con código auto-generado

Método HTTP: POST

Request Body (JSON):

json
{
  "concepto": "Bebidas"
}

Validaciones (estructurales):

  • concepto: requerido, string, máximo 30 caracteres

Lógica de negocio:

  1. Obtiene el siguiente código disponible: MAX(rubro) + 1 (o 1 si no hay registros)
  2. Inserta el nuevo rubro con código auto-generado
  3. Retorna el DTO del rubro creado

Response DTO:

json
{
  "status": 201,
  "message": "Datos recibidos correctamente",
  "data": {
    "id": 5,
    "concepto": "Bebidas"
  }
}

Status codes: 201 (creado), 400 (datos inválidos), 500 (error)


PUT /backend/agrupacion.php

Descripción: Actualiza el concepto de una agrupación existente

Método HTTP: PUT

Request Body (JSON):

json
{
  "id": 1,
  "concepto": "Alimentos y Bebidas"
}

Validaciones (estructurales):

  • id: requerido, integer
  • concepto: requerido, string, máximo 30 caracteres

Lógica de negocio:

  • Actualiza solo el campo concepto
  • El código (rubro) no se puede modificar

Response:

json
{
  "status": 204,
  "message": "No Content"
}

Status codes: 204 (actualizado), 400 (datos inválidos), 500 (error)


Línea

GET /backend/linea.php (Sin ID)

Descripción: Obtiene todas las líneas con opciones de filtrado

Método HTTP: GET

Request Body (JSON):

json
{
  "filter": "string (opcional)",
  "rubro": 1 (opcional)
}

Parámetros:

  • filter - Filtro para búsqueda por descripción (ILIKE) o código (LIKE). Limita a 10 resultados cuando se usa
  • rubro - Filtra líneas por agrupación específica (ID del rubro)

Response DTO:

json
{
  "status": 200,
  "message": "Datos recibidos correctamente",
  "data": [
    {
      "id": 1,
      "rubro": 1,
      "descri": "Lácteos"
    }
  ]
}

Status codes: 200 (éxito), 500 (error de servidor)


GET /backend/linea.php (Con ID)

Descripción: Obtiene una línea específica por su ID y rubro

Método HTTP: GET

Request Body (JSON):

json
{
  "id": 1,
  "rubro": 1
}

Nota: Si se construye el Controller con rubro (opcional), ese valor se usa para el filtro automáticamente.

Response DTO:

json
{
  "status": 200,
  "message": "Datos recibidos correctamente",
  "data": {
    "id": 1,
    "rubro": 1,
    "descri": "Lácteos"
  }
}

Status codes: 200 (éxito), 400 (ID/rubro inválido), 500 (error)


POST /backend/linea.php

Descripción: Crea una nueva línea con código auto-generado dentro de la agrupación

Método HTTP: POST

Request Body (JSON):

json
{
  "rubro": 1,
  "descri": "Panadería"
}

Validaciones (estructurales):

  • rubro: requerido, integer
  • descri: requerido, string, máximo 30 caracteres (DTO) / 50 caracteres (BD)

Lógica de negocio:

  1. Obtiene el siguiente código de línea dentro del rubro: MAX(linea) WHERE rubro = :rubro + 1
  2. Inserta la nueva línea con código auto-generado
  3. Retorna el DTO de la línea creada

Response DTO:

json
{
  "status": 201,
  "message": "Datos recibidos correctamente",
  "data": {
    "id": 3,
    "rubro": 1,
    "descri": "Panadería"
  }
}

Status codes: 201 (creado), 400 (datos inválidos), 500 (error)


PUT /backend/linea.php

Descripción: Actualiza la descripción de una línea existente

Método HTTP: PUT

Request Body (JSON):

json
{
  "id": 1,
  "rubro": 1,
  "descri": "Lácteos y Derivados"
}

Validaciones (estructurales):

  • id: requerido, integer
  • rubro: requerido, integer
  • descri: requerido, string, máximo 30 caracteres (DTO) / 50 caracteres (BD)

Lógica de negocio:

  • Actualiza solo el campo descri
  • Los códigos (linea, rubro) no se pueden modificar

Response:

json
{
  "status": 204,
  "message": "No Content"
}

Status codes: 204 (actualizado), 400 (datos inválidos), 500 (error)


Esquema de Base de Datos

Tabla: lrubro (Agrupación)

Nivel Multi-Tenancy: EMPRESA y SUCURSAL

Descripción: Categorías principales para clasificar artículos

Estructura:

CampoTipoConstraintsDescripción
rubroSERIALPRIMARY KEYCódigo único auto-incremental
conceptoVARCHAR(30)NOT NULLNombre de la agrupación
urlimgVARCHAR(200)NULLURL de imagen (sin uso)
activowebCHAR(1)NULLActivo en web (sin uso)

Indexes:

  • PRIMARY KEY: rubro

Foreign Keys: Ninguna

Constraints: Ninguna adicional

Comentarios:

  • Campos urlimg y activoweb están en la estructura pero sin uso actual
  • Tabla lrubro sigue convención de nombre legacy (prefijo 'l')

SQL de Migración:

sql
-- Ver: migrations/tenancy/20240823200731_new_table_rubro.php
CREATE TABLE lrubro (
    rubro SERIAL PRIMARY KEY,
    concepto VARCHAR(30) NOT NULL,
    urlimg VARCHAR(200),
    activoweb CHAR(1)
);

Tabla: linea (Línea)

Nivel Multi-Tenancy: EMPRESA y SUCURSAL

Descripción: Sub-categorías dentro de cada agrupación

Estructura:

CampoTipoConstraintsDescripción
lineaSERIALPRIMARY KEY (compuesta)Código de línea (auto-incremental dentro del rubro)
rubroINTEGERPRIMARY KEY (compuesta), NOT NULLReferencia a la agrupación padre
descriVARCHAR(50)NULLDescripción de la línea
urlimgVARCHAR(200)NULLURL de imagen (sin uso)
activowebCHAR(1)NULLActivo en web (sin uso)

Indexes:

  • PRIMARY KEY (compuesta): (linea, rubro)

Foreign Keys:

  • Lógica a lrubro(rubro) - NO implementada en BD (validación en aplicación)

Constraints: Ninguna adicional

Comentarios:

  • Clave primaria compuesta permite códigos de línea duplicados entre diferentes rubros
  • El código linea es auto-incremental dentro de cada rubro (no globalmente)
  • Campos urlimg y activoweb sin uso actual
  • La relación con lrubro no tiene FK física (solo lógica)

SQL de Migración:

sql
-- Ver: migrations/tenancy/20240823200732_new_table_linea.php
CREATE TABLE linea (
    linea SERIAL NOT NULL,
    rubro INTEGER NOT NULL,
    descri VARCHAR(50),
    urlimg VARCHAR(200),
    activoweb CHAR(1),
    PRIMARY KEY (linea, rubro)
);

Capa de Datos (Models)

RubroController

Archivo: controller/modulo-venta/RubroController.php

Responsabilidades:

  • Delegar operaciones CRUD al modelo
  • Actuar como capa de paso entre endpoint y modelo

Métodos:

getById(int $id): ?RubroDTO

  • Obtiene un rubro por ID
  • Delega a Rubro::getById()

getAll(array $options): array

  • Obtiene todos los rubros con opciones de filtrado
  • Parámetros: filter, first, last
  • Delega a Rubro::getAll()

insert(array|RubroDTO $data): RubroDTO

  • Crea un nuevo rubro
  • Delega a Rubro::insert()

update(array|RubroDTO $data): bool

  • Actualiza un rubro existente
  • Delega a Rubro::update()

Dependencias:

  • Rubro (Model)

Rubro (Model)

Archivo: models/modulo-venta/Rubro.php

Responsabilidades:

  • Acceso a datos de la tabla lrubro
  • Generación de códigos auto-incrementales
  • Mapeo de resultados a DTOs

Métodos:

getById(int $id): ?RubroDTO

  • Query: SELECT rubro::int as id, concepto FROM lrubro WHERE rubro = :id
  • Validaciones: ID requerido y tipo integer
  • Retorno: RubroDTO o null si no existe

getAll(array $options): array

  • Query base: SELECT rubro::int as id, concepto FROM lrubro
  • Opciones de filtrado:
    • filter: Búsqueda por concepto (ILIKE) o código (LIKE), limita a 10 resultados
    • first: Retorna solo el primer registro (ORDER BY rubro ASC LIMIT 1)
    • last: Retorna solo el último registro (ORDER BY rubro DESC LIMIT 1)
  • Retorno: Array de RubroDTO[]

getNewId(): int

  • Query: SELECT CASE WHEN MAX(rubro::int) IS NULL THEN 1 ELSE MAX(rubro::int) + 1 END AS rubro FROM lrubro
  • Retorno: Siguiente código disponible
  • ⚠️ Riesgo de concurrencia: Usa MAX + 1 sin transacción explícita

insert(array|RubroDTO $data): RubroDTO

  • Obtiene nuevo ID mediante getNewId()
  • Query: INSERT INTO lrubro (rubro, concepto) VALUES (:rubro, :concepto)
  • Retorno: RubroDTO con ID asignado

update(array|RubroDTO $data): bool

  • Query: UPDATE lrubro SET concepto = :concepto WHERE rubro = :id
  • Retorno: bool indicando éxito

Tabla: lrubro


LineaController

Archivo: controller/modulo-venta/LineaController.php

Responsabilidades:

  • Delegar operaciones CRUD al modelo
  • Soportar filtrado por rubro (constructor opcional)

Constructor:

php
__construct(PDO $conn, ?int $rubro = null)
  • Parámetro rubro opcional: Si se provee, filtra automáticamente las consultas por ese rubro

Métodos:

getById(int $id): ?LineaDTO

  • Obtiene una línea por ID
  • Si el controller tiene rubro configurado, aplica filtro automático
  • Delega a Linea::getById()

getAll(array $options): array

  • Obtiene todas las líneas con opciones de filtrado
  • Parámetros: filter, rubro
  • Delega a Linea::getAll()

insert(array|LineaDTO $data): LineaDTO

  • Crea una nueva línea
  • Delega a Linea::insert()

update(array|LineaDTO $data): bool

  • Actualiza una línea existente
  • Delega a Linea::update()

Dependencias:

  • Linea (Model)

Linea (Model)

Archivo: models/modulo-venta/Linea.php

Responsabilidades:

  • Acceso a datos de la tabla linea
  • Generación de códigos auto-incrementales por rubro
  • Mapeo de resultados a DTOs
  • Soporte de filtrado por rubro en todas las operaciones

Constructor:

php
__construct(PDO $conn, ?int $rubro = null)
  • Parámetro rubro opcional: Aplica filtro automático en getById()

Métodos:

getById(int $id): ?LineaDTO

  • Query base: SELECT linea::int as id, rubro, TRIM(descri) AS descri FROM linea WHERE linea = :id
  • Si rubro está configurado en el constructor: Agrega AND rubro = :rubro
  • Validaciones: ID requerido y tipo integer
  • Retorno: LineaDTO o null si no existe

getAll(array $options): array

  • Query base: SELECT linea::int as id, rubro, TRIM(descri) AS descri FROM linea
  • Opciones de filtrado:
    • filter: Búsqueda por descri (ILIKE) o código linea (LIKE), limita a 10 resultados
    • rubro: Filtra por rubro específico (rubro::int = :rubro)
  • Retorno: Array de LineaDTO[]

getNewId(int $rubro): int

  • Query: SELECT CASE WHEN MAX(linea::int) IS NULL THEN 1 ELSE MAX(linea::int) + 1 END AS linea FROM linea WHERE rubro = :rubro
  • Retorno: Siguiente código de línea dentro del rubro
  • ⚠️ Riesgo de concurrencia: Usa MAX + 1 sin transacción explícita

insert(array|LineaDTO $data): LineaDTO

  • Obtiene nuevo ID mediante getNewId($data->rubro)
  • Query: INSERT INTO linea (linea, rubro, descri) VALUES (:linea, :rubro, :descri)
  • Retorno: LineaDTO con ID asignado

update(array|LineaDTO $data): bool

  • Query: UPDATE linea SET descri = :descri WHERE rubro = :rubro AND linea = :id
  • Retorno: bool indicando éxito
  • Nota: Requiere tanto linea como rubro para identificar el registro (PK compuesta)

Tabla: linea


DTOs (Data Transfer Objects)

RubroDTO

Archivo: Resources/Venta/Rubro.php

Propiedades:

php
public ?int $id;        // Código del rubro (auto-generado)
public string $concepto; // Nombre de la agrupación

Validaciones (en constructor):

  • id: opcional, integer
  • concepto: requerido, string, máximo 30 caracteres

Métodos heredados:

  • fromArray(array $data): self - Crea instancia desde array
  • toArray(): array - Convierte a array asociativo

LineaDTO

Archivo: Resources/Venta/Linea.php

Propiedades:

php
public ?int $id;       // Código de la línea (auto-generado dentro del rubro)
public int $rubro;     // Código de la agrupación padre
public string $descri; // Descripción de la línea

Validaciones (en constructor):

  • id: opcional, integer
  • rubro: requerido, integer
  • descri: requerido, string, máximo 30 caracteres

Métodos heredados:

  • fromArray(array $data): self - Crea instancia desde array
  • toArray(): array - Convierte a array asociativo

Validaciones

Nivel 1: Validación Estructural

Ubicación: DTOs (Resources/Venta/Rubro.php y Resources/Venta/Linea.php)

Método: Constructor de DTO con validación interna usando self::validate()

Reglas implementadas:

RubroDTO:

  • id: integer (opcional)
  • concepto: required, max:30

LineaDTO:

  • id: integer (opcional)
  • rubro: required, integer
  • descri: required, max:30

Respuesta en error: Exception con mensaje descriptivo


Nivel 2: Validación de Negocio

Ubicación: Models (Rubro.php y Linea.php)

Validaciones implementadas:

Rubro:

  • ID requerido y tipo correcto en getById()
  • Código auto-generado garantiza unicidad

Línea:

  • ID requerido y tipo correcto en getById()
  • Rubro requerido para obtener nuevo ID
  • Código auto-generado garantiza unicidad dentro del rubro

⚠️ Limitación: No valida existencia de rubro padre al crear línea (sin FK en BD)


Puntos de Integración

Módulos que consumen este recurso

Producto (Artículo):

  • Usa RubroController y LineaController para expandir campos rubro y linea
  • Llamadas en: Producto::getById(), Producto::getByArticulo(), Producto::getAll()
  • Los productos requieren obligatoriamente rubro y línea válidos

Listas de Precios:

  • Filtrado de productos por rango de rubros
  • Usado en generación de lista por margen de ganancia

Informes de Ventas:

  • Agrupación de datos por rubro (ejemplo: ventas-provincias-rubros)

Seeds de Datos

Rubro (Lrubro)

Archivo: migrations/seeds/tenancy/Lrubro.php

Nivel: EMPRESA y SUCURSAL

Datos insertados:

php
['rubro' => 1, 'concepto' => 'Varios']

Lógica:

  • Si no existe ningún rubro, crea uno con código 1 y concepto "Varios"
  • Si ya existen rubros, omite el seed

Línea

Archivo: migrations/seeds/tenancy/Linea.php

Nivel: EMPRESA y SUCURSAL

Datos insertados:

php
['linea' => 1, 'rubro' => 1, 'descri' => 'Varios']

Lógica:

  • Si no existe ninguna línea, busca el primer rubro existente
  • Si no hay rubros, ejecuta el seed de Lrubro primero
  • Crea línea con código 1 asociada al primer rubro
  • Si ya existen líneas, omite el seed

Dependencias: Seed de Lrubro (se ejecuta automáticamente si no hay rubros)


Consideraciones Técnicas

Patrón Legacy vs 5-Layer DDD

Estado actual (Legacy):

  • Endpoint PHP directo (no Slim routes)
  • Controller como pass-through simple
  • Lógica de negocio en Models
  • Sin Service Layer
  • Sin Auditoría

Migración futura a 5-Layer:

Si se decide migrar este recurso al patrón moderno:

Slim Routes (Routes/Venta/LineaRoute.php):

php
$group->get('', [LineaController::class, 'getAll']);
$group->get('/{id}', [LineaController::class, 'getById']);
$group->post('', [LineaController::class, 'insert']);
$group->put('/{id}', [LineaController::class, 'update']);

Controller (solo HTTP handling):

php
public function insert(Request $request, Response $response): Response
{
    $data = $request->getParsedBody();
    $result = $this->service->insert($data);
    return $response->withJson(['data' => $result], 201);
}

Service Layer (lógica de negocio + transacciones + audit):

php
public function insert(LineaRequest $data): LineaDTO
{
    $this->connections->beginTransaction('oficial');
    try {
        // Validar rubro existe (nuevo)
        if (!$this->rubroModel->exists($data->rubro)) {
            throw new ServerException("Rubro no existe");
        }

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

        // Auditoría (nuevo)
        $this->registrarAuditoria("INSERT", "VENTAS", "linea", $result->id);

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

Model (solo data access):

  • Mantener queries actuales
  • Remover validaciones de negocio

Operación DELETE Ausente

No implementado: Este recurso no soporta eliminación física ni soft delete

Razón: Integridad referencial con productos (artículos que referencian rubros y líneas)

Consideración: Si se agrega DELETE en el futuro:

  • Validar que no existan productos asociados
  • O implementar soft delete con deleted_at
  • O implementar CASCADE delete (con precaución)

Concurrencia en Auto-Generación de IDs

⚠️ Problema identificado:

Los métodos getNewId() usan MAX(id) + 1 sin transacción que lo envuelva, lo que puede causar duplicados en escenarios de alta concurrencia:

Rubro:

php
// Thread 1: Lee MAX = 5, asigna 6
// Thread 2: Lee MAX = 5, asigna 6 (DUPLICADO!)
$newId = $this->getNewId();
$this->insert(['id' => $newId, ...]);

Línea (mismo riesgo dentro de cada rubro):

php
// Thread 1: Lee MAX(linea WHERE rubro=1) = 3, asigna 4
// Thread 2: Lee MAX(linea WHERE rubro=1) = 3, asigna 4 (DUPLICADO!)
$newId = $this->getNewId($rubro);
$this->insert(['id' => $newId, ...]);

Soluciones recomendadas (para migración futura):

Opción 1: Usar secuencias nativas de PostgreSQL

sql
-- Rubro (ya usa SERIAL, aprovechar)
INSERT INTO lrubro (concepto) VALUES (:concepto) RETURNING rubro;

-- Linea (complejo por clave compuesta + contador por rubro)
-- Requiere rediseño de estructura

Opción 2: Transacción con SELECT FOR UPDATE

php
$this->conn->beginTransaction();
$newId = $this->getNewIdWithLock($rubro); // SELECT MAX ... FOR UPDATE
$this->insert(['id' => $newId, ...]);
$this->conn->commit();

Opción 3: Manejo de duplicados con retry

php
try {
    $this->insert(['id' => $this->getNewId(), ...]);
} catch (PDOException $e) {
    if ($this->isDuplicateKeyError($e)) {
        // Retry con nuevo ID
    }
}

Estado actual: No implementado, pero colisiones son raras en volumen bajo de datos típico


Campos Sin Uso

Campos en ambas tablas:

  • urlimg (VARCHAR(200))
  • activoweb (CHAR(1))

Estado: Sin uso actual, disponibles para funcionalidad futura (integración con tienda web)

Recomendación:

  • Documentar si se activan
  • O remover en migración futura si no se planea usar

Divergencia DTO vs BD

Línea - Campo descri:

  • DTO valida: max:30
  • Base de datos: VARCHAR(50)

Impacto:

  • Frontend/Proxy permite hasta 50 caracteres
  • DTO legacy rechazaría valores > 30
  • Base de datos soporta hasta 50

Recomendación: Unificar límites (preferible 50 en todos los niveles)


Uso de TRIM en Queries

Línea:

sql
SELECT linea::int as id, rubro, TRIM(descri) AS descri FROM linea

Propósito: Eliminar espacios en blanco de datos legacy (posible migración desde sistema anterior)

Rubro: No usa TRIM (campo concepto)

Inconsistencia: Aplicar TRIM en ambas tablas o en ninguna


Performance

Índices Existentes

Rubro:

  • PRIMARY KEY en rubro (índice automático)

Línea:

  • PRIMARY KEY compuesta en (linea, rubro) (índice automático)

Queries Comunes

Rubro:

  • SELECT ... WHERE rubro = :id - Usa PK index ✅
  • SELECT ... WHERE concepto ILIKE :filter - Sin índice ⚠️ (pero volumen bajo)
  • SELECT MAX(rubro) - Full scan ⚠️ (pero ejecución rara, solo en inserts)

Línea:

  • SELECT ... WHERE linea = :id AND rubro = :rubro - Usa PK index ✅
  • SELECT ... WHERE rubro = :rubro - Usa PK index (primer componente) ✅
  • SELECT ... WHERE descri ILIKE :filter - Sin índice ⚠️ (pero volumen bajo)
  • SELECT MAX(linea) WHERE rubro = :rubro - Scan filtrado ⚠️ (ejecución rara)

Recomendaciones

Sin acción inmediata: Estos recursos manejan volúmenes bajos de datos (típicamente < 100 rubros, < 500 líneas)

Si crece el volumen:

  • Agregar índice GIN en concepto para búsquedas ILIKE (Rubro)
  • Agregar índice GIN en descri para búsquedas ILIKE (Línea)
  • Considerar caché en memoria para listados completos

Seguridad

Autenticación

Mecanismo: JWT token validado en archivo legacy (auth/JwtHandler.php)

Payload JWT:

php
[
    'db' => 'nombre_base_datos',
    'schema' => 'suc0001',
    'user_id' => 123,
    // ... otros campos
]

Validación:

  • Token debe ser válido y no expirado
  • Se extrae db y schema para conexión multi-tenant

Permisos

Permiso requerido: VENTAS_BASES_AGR-LIN (nivel 3)

Cobertura:

  • Listado de rubros
  • Alta de rubros
  • Modificación de rubros
  • Listado de líneas
  • Alta de líneas
  • Modificación de líneas

Validación: En frontend (no implementada en backend legacy)

⚠️ Limitación: Backend no valida permisos, confía en frontend


Sanitización de Inputs

PDO Prepared Statements: ✅ Usado en todas las queries

Validación de tipos: ✅ DTOs validan tipos básicos

SQL Injection: ✅ Protegido mediante prepared statements

XSS: N/A (API backend, responsabilidad del frontend)


Multi-Tenancy

Nivel de datos: EMPRESA y SUCURSAL (ambas tablas)

Mecanismo: PostgreSQL search_path configurado por Database class según schema del JWT

Aislamiento: ✅ Cada tenant tiene sus propios rubros y líneas en su schema

Validación: Backend confía en la validación del JWT y configuración de search_path


Testing

Estado Actual

Tests implementados: ❌ No existen tests para estos recursos

Razón: Código legacy anterior a adopción de PHPUnit en el proyecto


Estrategia de Testing Recomendada (Para Migración Futura)

Tests Unitarios (PHPUnit)

RubroModel:

php
// Tests/Unit/Venta/RubroModelTest.php

public function testGetByIdRetornaRubroCuandoExiste(): void
{
    // Arrange: Mock PDO
    $mockStmt = $this->createMock(PDOStatement::class);
    $mockStmt->method('rowCount')->willReturn(1);
    $mockStmt->method('fetch')->willReturn([
        'id' => 1,
        'concepto' => 'Alimentos'
    ]);

    $mockConn = $this->createMock(PDO::class);
    $mockConn->method('prepare')->willReturn($mockStmt);

    $model = new Rubro($mockConn);

    // Act
    $result = $model->getById(1);

    // Assert
    $this->assertInstanceOf(RubroDTO::class, $result);
    $this->assertEquals(1, $result->id);
    $this->assertEquals('Alimentos', $result->concepto);
}

public function testGetByIdRetornaNullCuandoNoExiste(): void { ... }

public function testGetAllConFiltroLimitaA10Resultados(): void { ... }

public function testGetNewIdRetorna1CuandoTablaVacia(): void { ... }

public function testGetNewIdRetornaMaxMas1(): void { ... }

public function testInsertAsignaIdAutomaticamente(): void { ... }

public function testUpdateModificaSoloConcepto(): void { ... }

LineaModel:

php
// Tests/Unit/Venta/LineaModelTest.php

public function testGetByIdConRubroFiltrado(): void { ... }

public function testGetAllConRubro(): void { ... }

public function testGetNewIdPorRubro(): void { ... }

public function testInsertAsignaIdDentroDeRubro(): void { ... }

public function testUpdateRequierePrimaryKeyCompuesta(): void { ... }

public function testDosLineasPuedenTenerMismoIdEnDiferentesRubros(): void { ... }

Tests de Integración (Base de Datos Real)

php
// Tests/Integration/Venta/RubroIntegrationTest.php
// Extiende BaseIntegrationTestCase (con Docker PostgreSQL)

public function testFlujoCRUDCompletoRubro(): void
{
    // 1. Insert
    $data = ['concepto' => 'Test Rubro'];
    $created = $this->rubroModel->insert($data);
    $this->assertNotNull($created->id);

    // 2. GetById
    $retrieved = $this->rubroModel->getById($created->id);
    $this->assertEquals('Test Rubro', $retrieved->concepto);

    // 3. Update
    $updated = ['id' => $created->id, 'concepto' => 'Updated'];
    $this->rubroModel->update($updated);
    $retrieved = $this->rubroModel->getById($created->id);
    $this->assertEquals('Updated', $retrieved->concepto);

    // 4. GetAll incluye el nuevo
    $all = $this->rubroModel->getAll([]);
    $ids = array_column($all, 'id');
    $this->assertContains($created->id, $ids);
}

public function testConcurrenciaEnGetNewId(): void
{
    // Simular inserts simultáneos
    // Verificar duplicados o manejo correcto
}
php
// Tests/Integration/Venta/LineaIntegrationTest.php

public function testLineasPuedenTenerMismoCodigoEnDiferentesRubros(): void
{
    $rubro1 = $this->rubroModel->insert(['concepto' => 'Rubro 1']);
    $rubro2 = $this->rubroModel->insert(['concepto' => 'Rubro 2']);

    $linea1 = $this->lineaModel->insert(['rubro' => $rubro1->id, 'descri' => 'Línea 1']);
    $linea2 = $this->lineaModel->insert(['rubro' => $rubro2->id, 'descri' => 'Línea 2']);

    // Ambas pueden tener id=1 si son las primeras en sus rubros
    $this->assertEquals(1, $linea1->id);
    $this->assertEquals(1, $linea2->id);
    $this->assertNotEquals($linea1->rubro, $linea2->rubro);
}

Tests E2E (Endpoints)

php
// Tests/E2E/Venta/RubroEndpointTest.php

public function testCrearRubroViaAPI(): void
{
    $response = $this->post('/backend/agrupacion.php', [
        'concepto' => 'Nuevo Rubro'
    ], $this->getAuthHeaders());

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

    $data = json_decode($response->getBody(), true);
    $this->assertArrayHasKey('data', $data);
    $this->assertArrayHasKey('id', $data['data']);
    $this->assertEquals('Nuevo Rubro', $data['data']['concepto']);
}

public function testAutenticacionRequerida(): void
{
    $response = $this->post('/backend/agrupacion.php', [
        'concepto' => 'Test'
    ]); // Sin headers de autenticación

    $this->assertEquals(401, $response->getStatusCode());
}

Auditoría

Estado Actual

Implementado: ❌ No hay auditoría de operaciones

Razón: Código legacy sin Service Layer (donde se implementa auditoría en patrón 5-layer)


Implementación Futura (Migración a 5-Layer)

Al migrar a 5-layer DDD, agregar auditoría en Service:

php
// service/Venta/LineaService.php

public function insert(LineaRequest $data): LineaDTO
{
    $this->connections->beginTransaction('oficial');
    try {
        $result = $this->model->insert($data);

        // Auditoría
        $this->registrarAuditoria(
            "INSERT",
            "VENTAS",
            "linea",
            $result->id
        );

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

public function update(LineaRequest $data): bool
{
    $this->connections->beginTransaction('oficial');
    try {
        $result = $this->model->update($data);

        // Auditoría
        $this->registrarAuditoria(
            "UPDATE",
            "VENTAS",
            "linea",
            $data->id
        );

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

Registro en tabla auditoria:

  • usuario_id (del JWT payload)
  • fecha y hora
  • operacion (INSERT/UPDATE)
  • tabla (linea/lrubro)
  • registro_id
  • modulo (VENTAS)

Referencias


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

Consideración de Migración: Este recurso es candidato a migración al patrón 5-layer DDD para obtener:

  • Service Layer con lógica de negocio y transacciones
  • Auditoría automática
  • Validación de permisos en backend
  • Slim Framework routes con middleware estructurado
  • Foreign Keys en base de datos (validación de integridad)
  • Soft deletes si se requiere eliminación en el futuro