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
⚠️ 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 → DatabaseArchivos implementados:
| Layer | Rubro (Agrupación) | Línea |
|---|---|---|
| Route | /backend/agrupacion.php | /backend/linea.php |
| Controller | controller/modulo-venta/RubroController.php | controller/modulo-venta/LineaController.php |
| 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 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 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 |
| urlimg | VARCHAR(200) | NULL | URL de imagen (sin uso) |
| activoweb | CHAR(1) | NULL | Activo 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
lineaes auto-incremental dentro de cadarubro(no globalmente) - Campos
urlimgyactivowebsin uso actual - 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),
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:
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 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, integerdescri: 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
RubroControlleryLineaControllerpara expandir camposrubroylinea - 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
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 (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 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 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 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