Appearance
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:
| Layer | Rubro (Agrupación) | Línea |
|---|---|---|
| Route | Modules/Ventas/Infrastructure/Http/Routes/VentaConfigRoutes.php | ídem (misma clase) |
| Controller | Modules/Ventas/Infrastructure/Http/Controllers/RubroController.php | Modules/Ventas/Infrastructure/Http/Controllers/LineaController.php |
| Service | Modules/Ventas/Application/Services/RubroService.php | Modules/Ventas/Application/Services/LineaService.php |
| Validator | Modules/Ventas/Application/Validators/RubroInsertValidator.php | Modules/Ventas/Application/Validators/LineaInsertValidator.php |
| DTO Request | Modules/Ventas/Presentation/DTOs/Rubro/RubroRequest.php | Modules/Ventas/Presentation/DTOs/Linea/LineaRequest.php |
| DTO Response | Modules/Ventas/Presentation/DTOs/Rubro/RubroResponse.php | Modules/Ventas/Presentation/DTOs/Linea/LineaResponse.php |
| Model (legacy) | models/modulo-venta/Rubro.php | models/modulo-venta/Linea.php |
| DTO legacy | Resources/Venta/Rubro.php | Resources/Venta/Linea.php |
Patrón de DTOs (patrón Membresia):
RubroRequest/LineaRequest: extiendenFullDTO, sin validación, solo propiedades tipadasRubroResponse/LineaResponse: extiendenFullDTO, propiedades de salida tipadas- La validación está solo en
Application/Validators/(viaValidationMiddleware)
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| Layer | Rubro (Agrupación) | Línea |
|---|---|---|
| Route | /backend/agrupacion.php | /backend/linea.php |
| Controller | controller/modulo-venta/RubroController.php (eliminado en Fase 5) | controller/modulo-venta/LineaController.php (eliminado en Fase 5) |
| Model | models/modulo-venta/Rubro.php | models/modulo-venta/Linea.php |
| DTO | Resources/Venta/Rubro.php | Resources/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étodo | Path | Handler | Descripción |
|---|---|---|---|
| GET | /mod-ventas/rubro | RubroController::getAll | Lista todos los rubros. Soporta filter, first, last via QueryParamsMiddleware |
| GET | /mod-ventas/rubro/{id} | RubroController::getById | Obtiene un rubro por ID. 404 si no existe |
| POST | /mod-ventas/rubro | RubroController::insert | Crea un rubro. Validación via RubroInsertValidator. ID = MAX+1 con transacción |
| PUT | /mod-ventas/rubro/{id} | RubroController::update | Actualiza concepto. Validación via RubroInsertValidator. 404 si no existe |
| DELETE | /mod-ventas/rubro/{id} | RubroController::delete | Elimina 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étodo | Path | Handler | Descripción |
|---|---|---|---|
| GET | /mod-ventas/rubro/{rubro}/linea | LineaController::getAll | Lista líneas del rubro. Soporta filter |
| GET | /mod-ventas/rubro/{rubro}/linea/{linea} | LineaController::getById | Obtiene línea por PK compuesta. 404 si rubro o línea no existe |
| POST | /mod-ventas/rubro/{rubro}/linea | LineaController::insert | Crea una línea. Valida rubro padre existe. ID = MAX(linea WHERE rubro)+1 con transacción |
| PUT | /mod-ventas/rubro/{rubro}/linea/{linea} | LineaController::update | Actualiza descri. 404 si rubro o línea no existe |
| DELETE | /mod-ventas/rubro/{rubro}/linea/{linea} | LineaController::delete | Elimina una línea. Valida sin productos. 400 si tiene dependencias |
Validación de DELETE — dependencias:
RubroService::delete(): verifica líneas viaLineamodel propio + productos viaProductoService::existsByRubro(int $rubroId)LineaService::delete(): verifica productos viaProductoService::existsByLinea(int $rubroId, int $lineaId)- Ambos métodos en
service/Venta/ProductoService.phpymodels/modulo-venta/Producto.php - Error de negocio:
BadRequest(HTTP 400), noRuntimeException
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 ASClast- 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:
- Obtiene el siguiente código disponible:
MAX(rubro) + 1(o 1 si no hay registros) - Inserta el nuevo rubro con código auto-generado
- 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, integerconcepto: 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 usarubro- 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, integerdescri: requerido, string, máximo 30 caracteres (DTO) / 50 caracteres (BD)
Lógica de negocio:
- Obtiene el siguiente código de línea dentro del rubro:
MAX(linea) WHERE rubro = :rubro + 1 - Inserta la nueva línea con código auto-generado
- 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, integerrubro: requerido, integerdescri: 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:
| Campo | Tipo | Constraints | Descripción |
|---|---|---|---|
| rubro | SERIAL | PRIMARY KEY | Código único auto-incremental |
| concepto | VARCHAR(30) | NOT NULL | Nombre de la agrupación |
| urlimg | VARCHAR(200) | NULL | URL de imagen (sin uso) |
| activoweb | CHAR(1) | NULL | Activo en web (sin uso) |
Indexes:
- PRIMARY KEY:
rubro
Foreign Keys: Ninguna
Constraints: Ninguna adicional
Comentarios:
- Campos
urlimgyactivowebestán en la estructura pero sin uso actual - Tabla
lrubrosigue 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:
| Campo | Tipo | Constraints | Descripción |
|---|---|---|---|
| linea | SERIAL | PRIMARY KEY (compuesta) | Código de línea (auto-incremental dentro del rubro) |
| rubro | INTEGER | PRIMARY KEY (compuesta), NOT NULL | Referencia a la agrupación padre |
| descri | VARCHAR(50) | NULL | Descripció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
lineaes auto-incremental dentro de cadarubro(no globalmente) - La relación con
lrubrono 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:
RubroDTOonullsi 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 resultadosfirst: 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:
RubroDTOcon ID asignado
update(array|RubroDTO $data): bool
- Query:
UPDATE lrubro SET concepto = :concepto WHERE rubro = :id - Retorno:
boolindicando é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
rubroopcional: 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
rubroconfigurado, 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
rubroopcional: Aplica filtro automático engetById()
Métodos:
getById(int $id): ?LineaDTO
- Query base:
SELECT linea::int as id, rubro, TRIM(descri) AS descri FROM linea WHERE linea = :id - Si
rubroestá configurado en el constructor: AgregaAND rubro = :rubro - Validaciones: ID requerido y tipo integer
- Retorno:
LineaDTOonullsi 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 resultadosrubro: 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:
LineaDTOcon ID asignado
update(array|LineaDTO $data): bool
- Query:
UPDATE linea SET descri = :descri WHERE rubro = :rubro AND linea = :id - Retorno:
boolindicando éxito - Nota: Requiere tanto
lineacomorubropara 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ónValidaciones (en constructor):
id: opcional, integerconcepto: requerido, string, máximo 30 caracteres
Métodos heredados:
fromArray(array $data): self- Crea instancia desde arraytoArray(): 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íneaValidaciones (en constructor):
id: opcional, integerrubro: requerido, integerdescri: requerido, string, máximo 30 caracteres
Métodos heredados:
fromArray(array $data): self- Crea instancia desde arraytoArray(): 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, integerdescri: 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+1delete(): verifica que no tenga líneas asociadas (víaLineamodel)delete(): verifica que no tenga productos (víaProductoService::existsByRubro())
LineaService:
create(): verifica existencia de rubro padre antes de insertar (lanzaBadRequestsi no existe)create(): transacción explícita para MAX(linea WHERE rubro)+1delete(): verifica que no tenga productos (víaProductoService::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
RubroServiceyLineaService(5-layer DDD) para expandir camposrubroylinea - La hydration ocurre en
ProductoControllerantes de delegar al Model (Fase 3 del change) - Llamadas en:
ProductoController::getById(),getByArticulo(),getAll() - Los productos requieren obligatoriamente rubro y línea válidos
ProductoServiceexponeexistsByRubro(int $rubroId): boolyexistsByLinea(int $rubroId, int $lineaId): boolpara 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
Lrubroprimero - 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.
| Aspecto | Legacy | 5-Layer DDD (actual) |
|---|---|---|
| Routing | PHP directo (/backend/*.php) | Slim 4 routes (/mod-ventas/rubro) |
| Controller | Pass-through simple | PSR-7 puro, sin herencia |
| Service | Ausente | RubroService / LineaService |
| Validación | DTOs con max:30 | ValidationMiddleware con max:50 |
| Transacciones | Sin transacción en insert | beginTransaction/commit/rollback |
| DELETE | No disponible | Disponible con validación de dependencias |
| Auditoría | Sin auditoría | Sin auditoría (pendiente) |
| DTOs request | DTO con validación | FullDTO sin validación (RubroRequest/LineaRequest) |
| DTOs response | Resources/Venta/Rubro.php | RubroResponse/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):- Verifica que el rubro existe (lanza
BadRequestsi no) - Verifica que no tenga líneas:
Linea::getAll(['rubro' => $id])(lanzaBadRequestsi hay líneas) - Verifica que no tenga productos:
ProductoService::existsByRubro($id)(lanzaBadRequestsi hay productos)
- Verifica que el rubro existe (lanza
LineaService::delete(int $rubroId, int $lineaId):- Verifica que la línea existe (lanza
BadRequestsi no) - Verifica que no tenga productos:
ProductoService::existsByLinea($rubroId, $lineaId)(lanzaBadRequest)
- Verifica que la línea existe (lanza
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 estructuraOpció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 lineaPropó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
conceptopara búsquedas ILIKE (Rubro) - Agregar índice GIN en
descripara 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
dbyschemapara 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