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 (actualizado 2026-03-27 — migración DDD completada)


Referencia de Negocio


Arquitectura Implementada

Estado Actual: 5-Layer DDD (Slim 4)

Este recurso fue migrado al patrón 5-layer DDD dentro de Modules/Ventas/. El patrón legacy coexiste pero los nuevos endpoints van por Slim routes.

HTTP Request (X-Schema)


VentaRoutes (/mod-ventas/*) → VentaConfigRoutes


RubroController / LineaController  [PSR-7, sin herencia]


RubroService / LineaService  [ModelFactory + ConnectionManager + ProductoService]


App\models\Venta\Rubro / Linea  [legacy models reutilizados]


PostgreSQL (schema via ConnectionManager search_path)

Archivos del módulo DDD:

LayerRubro (Agrupación)Línea
RouteModules/Ventas/Infrastructure/Http/Routes/VentaConfigRoutes.phpídem (misma clase)
ControllerModules/Ventas/Infrastructure/Http/Controllers/RubroController.phpModules/Ventas/Infrastructure/Http/Controllers/LineaController.php
ServiceModules/Ventas/Application/Services/RubroService.phpModules/Ventas/Application/Services/LineaService.php
ValidatorModules/Ventas/Application/Validators/RubroInsertValidator.phpModules/Ventas/Application/Validators/LineaInsertValidator.php
DTO RequestModules/Ventas/Presentation/DTOs/Rubro/RubroRequest.phpModules/Ventas/Presentation/DTOs/Linea/LineaRequest.php
DTO ResponseModules/Ventas/Presentation/DTOs/Rubro/RubroResponse.phpModules/Ventas/Presentation/DTOs/Linea/LineaResponse.php
Model (legacy)models/modulo-venta/Rubro.phpmodels/modulo-venta/Linea.php
DTO legacyResources/Venta/Rubro.phpResources/Venta/Linea.php

Patrón de DTOs (patrón Membresia):

  • RubroRequest / LineaRequest: extienden FullDTO, sin validación, solo propiedades tipadas
  • RubroResponse / LineaResponse: extienden FullDTO, propiedades de salida tipadas
  • La validación está solo en Application/Validators/ (via ValidationMiddleware)

Namespace: Ventas\... (sin prefijo App\), consistente con Crm\... y Membresia\...

Patrón Legacy (Pre-5-Layer, coexiste)

El patrón legacy sigue activo para backward compatibility con frontend JS antiguo:

Backend PHP (legacy) → Controller → Model → Database
LayerRubro (Agrupación)Línea
Route/backend/agrupacion.php/backend/linea.php
Controllercontroller/modulo-venta/RubroController.php (eliminado en Fase 5)controller/modulo-venta/LineaController.php (eliminado en Fase 5)
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 delegaba directamente en Model (ya eliminados los legacy controllers)
  • Sin Service Layer (lógica en Model)
  • Sin Auditoría automática

API Endpoints (5-Layer DDD — Slim 4)

Endpoints actuales registrados bajo /mod-ventas/rubro y /mod-ventas/lrubro (alias).

Rubro

MétodoPathHandlerDescripción
GET/mod-ventas/rubroRubroController::getAllLista todos los rubros. Soporta filter, first, last via QueryParamsMiddleware
GET/mod-ventas/rubro/{id}RubroController::getByIdObtiene un rubro por ID. 404 si no existe
POST/mod-ventas/rubroRubroController::insertCrea un rubro. Validación via RubroInsertValidator. ID = MAX+1 con transacción
PUT/mod-ventas/rubro/{id}RubroController::updateActualiza concepto. Validación via RubroInsertValidator. 404 si no existe
DELETE/mod-ventas/rubro/{id}RubroController::deleteElimina un rubro. Valida sin líneas y sin productos. 400 si tiene dependencias

Alias: /mod-ventas/lrubro apunta al mismo VentaConfigRoutes (backward compat con TS hooks de Stock).

Línea

MétodoPathHandlerDescripción
GET/mod-ventas/rubro/{rubro}/lineaLineaController::getAllLista líneas del rubro. Soporta filter
GET/mod-ventas/rubro/{rubro}/linea/{linea}LineaController::getByIdObtiene línea por PK compuesta. 404 si rubro o línea no existe
POST/mod-ventas/rubro/{rubro}/lineaLineaController::insertCrea una línea. Valida rubro padre existe. ID = MAX(linea WHERE rubro)+1 con transacción
PUT/mod-ventas/rubro/{rubro}/linea/{linea}LineaController::updateActualiza descri. 404 si rubro o línea no existe
DELETE/mod-ventas/rubro/{rubro}/linea/{linea}LineaController::deleteElimina una línea. Valida sin productos. 400 si tiene dependencias

Validación de DELETE — dependencias:

  • RubroService::delete(): verifica líneas via Linea model propio + productos via ProductoService::existsByRubro(int $rubroId)
  • LineaService::delete(): verifica productos via ProductoService::existsByLinea(int $rubroId, int $lineaId)
  • Ambos métodos en service/Venta/ProductoService.php y models/modulo-venta/Producto.php
  • Error de negocio: BadRequest (HTTP 400), no RuntimeException

Response DTOs:

  • RubroResponse: { int $id, string $concepto }
  • LineaResponse: { int $id, string $descri, int $rubro }

API Endpoints (Legacy — coexiste)

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

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)
  • 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),
    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 de Request (5-layer DDD)

Ubicación: Modules/Ventas/Application/Validators/ via ValidationMiddleware

Método: Validators como middleware Slim, ejecutados antes del Controller. Los DTOs RubroRequest / LineaRequest no validan — son solo tipado.

Reglas implementadas:

RubroInsertValidator (aplica a POST y PUT):

  • concepto: required, max:50

LineaInsertValidator (aplica a POST y PUT):

  • descri: required, max:50

Respuesta en error: HTTP 422 Unprocessable Entity


Nivel 1 (Legacy): Validación Estructural en DTOs

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

Nota: Solo aplica al patrón legacy. El patrón 5-layer usa Validators, no DTOs con validación.

RubroDTO (legacy):

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

LineaDTO (legacy):

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

Divergencia resuelta: El patrón legacy usaba max:30. El patrón 5-layer corrigió a max:50 (consistente con la columna real de la BD: VARCHAR(50)).


Nivel 2: Validación de Negocio (5-layer DDD)

Ubicación: Services (RubroService.php y LineaService.php)

Validaciones implementadas:

RubroService:

  • create(): transacción explícita para MAX+1
  • delete(): verifica que no tenga líneas asociadas (vía Linea model)
  • delete(): verifica que no tenga productos (vía ProductoService::existsByRubro())

LineaService:

  • create(): verifica existencia de rubro padre antes de insertar (lanza BadRequest si no existe)
  • create(): transacción explícita para MAX(linea WHERE rubro)+1
  • delete(): verifica que no tenga productos (vía ProductoService::existsByLinea())

Tipo de error: BadRequest (HTTP 400) — no RuntimeException


Nivel 2 (Legacy): Validación de Negocio en Models

Ubicación: Models legacy (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 legacy: 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 RubroService y LineaService (5-layer DDD) para expandir campos rubro y linea
  • La hydration ocurre en ProductoController antes de delegar al Model (Fase 3 del change)
  • Llamadas en: ProductoController::getById(), getByArticulo(), getAll()
  • Los productos requieren obligatoriamente rubro y línea válidos
  • ProductoService expone existsByRubro(int $rubroId): bool y existsByLinea(int $rubroId, int $lineaId): bool para la validación de dependencias en DELETE de rubros/líneas

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: Ambos patrones coexisten. El patrón 5-layer DDD está implementado en Modules/Ventas/ y es el patrón activo para todos los nuevos consumers. El legacy coexiste para backward compatibility.

AspectoLegacy5-Layer DDD (actual)
RoutingPHP directo (/backend/*.php)Slim 4 routes (/mod-ventas/rubro)
ControllerPass-through simplePSR-7 puro, sin herencia
ServiceAusenteRubroService / LineaService
ValidaciónDTOs con max:30ValidationMiddleware con max:50
TransaccionesSin transacción en insertbeginTransaction/commit/rollback
DELETENo disponibleDisponible con validación de dependencias
AuditoríaSin auditoríaSin auditoría (pendiente)
DTOs requestDTO con validaciónFullDTO sin validación (RubroRequest/LineaRequest)
DTOs responseResources/Venta/Rubro.phpRubroResponse/LineaResponse en Presentation/DTOs/

Operación DELETE

Implementado en 5-layer DDD: Los endpoints DELETE /mod-ventas/rubro/{id} y DELETE /mod-ventas/rubro/{rubro}/linea/{linea} están disponibles con validación de dependencias.

No implementado en legacy: El patrón legacy (/backend/agrupacion.php, /backend/linea.php) no soporta DELETE.

Lógica de validación antes de DELETE:

  • RubroService::delete(int $id):

    1. Verifica que el rubro existe (lanza BadRequest si no)
    2. Verifica que no tenga líneas: Linea::getAll(['rubro' => $id]) (lanza BadRequest si hay líneas)
    3. Verifica que no tenga productos: ProductoService::existsByRubro($id) (lanza BadRequest si hay productos)
  • LineaService::delete(int $rubroId, int $lineaId):

    1. Verifica que la línea existe (lanza BadRequest si no)
    2. Verifica que no tenga productos: ProductoService::existsByLinea($rubroId, $lineaId) (lanza BadRequest)

Los métodos existsByRubro(int $rubroId): bool y existsByLinea(int $rubroId, int $lineaId): bool están en service/Venta/ProductoService.php y models/modulo-venta/Producto.php.


Concurrencia en Auto-Generación de IDs

Estado en patrón legacy ⚠️: Los métodos getNewId() usan MAX(id) + 1 sin transacción que lo envuelva, lo que puede causar duplicados en alta concurrencia.

Estado en patrón 5-layer DDD ✅: RubroService::create() y LineaService::create() envuelven el insert en ConnectionManager::beginTransaction('principal') / commit / rollback, mitigando el riesgo de race condition.

Riesgo residual en legacy (patrón legacy sin transacción):

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

Soluciones alternativas (para considerar en el futuro):

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 en legacy: No implementado, pero colisiones son raras en volumen bajo de datos típico.

Estado en 5-layer DDD: ✅ RubroService::create() y LineaService::create() envuelven el insert en beginTransaction/commit, mitigando el riesgo.


Campos Sin Uso

Campos en tabla lrubro:

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

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


Divergencia DTO vs BD (resuelta)

Línea - Campo descri:

  • DTO legacy (Resources/Venta/Linea.php): max:30 (valor original)
  • Validator 5-layer (LineaInsertValidator.php): max:50 ✅ (corregido)
  • Base de datos: VARCHAR(50)

La divergencia fue resuelta en la migración 5-layer DDD: el LineaInsertValidator usa max:50, consistente con la columna real de la BD. El DTO legacy conserva max:30 por backward compat.


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