Appearance
Lista de Precios - Costo por Margen de Ganancia - Documentación Técnica Backend
⚠️ DOCUMENTACIÓN RETROSPECTIVA - Generada a partir de código implementado el 2026-02-11
Módulo: Ventas Feature: Lista de Precios - Costo por Margen de Ganancia Fecha: 2026-02-11
Referencia de Negocio
Arquitectura Implementada
Patrón: 3-Layer (sin Domain Layer explícita)
API Layer (Routes + Controller)
↓
Service Layer (Business Logic + Orchestration)
↓
Model Layer (Data Access + DTO Mapping)
↓
Database Layer (PostgreSQL - tabla precios)Archivos involucrados:
| Layer | Archivo | Ubicación |
|---|---|---|
| Routes | ListaPrecioRoute.php | Routes/Venta/ |
| Controller | ListaPrecioController.php | controller/modulo-venta/ |
| Service | ListaPrecioService.php | service/Venta/ |
| Model | ListaPrecio.php | models/modulo-venta/ |
| DTO | ListaPrecio.php | Resources/Venta/ |
| Enum | TipoPrecio.php | Resources/Venta/Enums/ |
| Domain | Item.php, Ajuste.php | Domain/Ventas/Facturacion/ |
| Migration | 20240823200743_new_table_precios.php | migrations/migrations/tenancy/ |
API Endpoints
POST /api/mod-ventas/lista-precio
Descripción: Endpoint multiuso que maneja tres operaciones distintas según el parámetro method.
Request Body:
json
{
"method": "ganancia-margen",
"data": {
"agrupacionDesde": 1,
"agrupacionHasta": 50,
"porcentaje": 30.5,
"precioFinal": true,
"lista": 2
}
}Parámetros del body:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
method | string | Sí | Debe ser "ganancia-margen" para esta operación |
data.agrupacionDesde | int | Sí | ID del rubro inicial del rango |
data.agrupacionHasta | int | Sí | ID del rubro final del rango |
data.porcentaje | float/null | Condicional | Porcentaje fijo. Null = usar porcentaje del artículo |
data.precioFinal | boolean | Sí | true = calcular precio final con impuestos, false = precio neto |
data.lista | int | Sí | Número de la lista de precios destino |
Response Success (201 Created):
json
{
"status": 201,
"message": "Datos recibidos correctamente.",
"data": true
}Response Error (201 con Exception específica):
json
{
"error": "Faltan datos para generar la lista de precios automática."
}Status Codes:
| Code | Condición |
|---|---|
| 201 | Operación exitosa (al menos un producto procesado) |
| 201 | Error de negocio (ningún producto procesado) |
| 400 | Bad Request (faltan datos requeridos) |
| 500 | Error interno del servidor |
Nota crítica: El endpoint retorna 201 incluso cuando falla por no procesar productos. La distinción está en el contenido del response (data=true vs Exception).
Capa de Controlador
ListaPrecioController::insert()
Responsabilidades:
- Parsear el body de la request
- Validar la presencia de parámetros según el método
- Enrutar a los métodos del servicio apropiados
- Retornar respuesta HTTP
Método específico para costo + ganancia:
php
case 'ganancia-margen':
$agrupacion_desde = $body['data']['agrupacionDesde'];
$agrupacion_hasta = $body['data']['agrupacionHasta'];
$porcentaje = $body['data']['porcentaje'];
$calcula_precio_final = $body['data']['precioFinal'];
$lista = $body['data']['lista'];
$result = $service->generarListaMargenGanancia(
$agrupacion_desde,
$agrupacion_hasta,
$porcentaje,
$calcula_precio_final,
$lista
);
if (!$result) {
throw new Exception('Faltan datos para generar la lista de precios automática.', 201);
}Validaciones en Controller:
- Verifica existencia de
methoden body - Verifica existencia de campos requeridos para método 'ganancia-margen'
- Si
$resultesfalse, lanza Exception con código 201
Issues observados:
- No hay validador middleware aplicado antes del controller
- Validaciones son manuales con
isset() - El código de error en Exception (201) es un status code de éxito, no de error
Capa de Servicio
ListaPrecioService
Constructor:
php
public function __construct(PDO $conn)
{
$this->conn = $conn;
$this->model = new ListaPrecio($conn);
}Dependencias:
PDO $conn- Conexión a base de datosListaPrecio $model- Model para acceso a datos
Sin traits:
- NO implementa
Conectable(no usa ConnectionManager) - NO implementa
Auditable(no registra auditoría) - Usa transacción manual con
PDO::beginTransaction()
Método: generarListaMargenGanancia()
Firma:
php
public function generarListaMargenGanancia(
$agrupacion_desde,
$agrupacion_hasta,
$porcentaje,
$calcula_precio_final,
$lista
)Responsabilidades:
- Obtener productos del rango de rubros
- Para cada producto:
- Verificar si tiene costo
- Calcular precio base (con porcentaje individual o fijo)
- Opcionalmente calcular precio final con impuestos
- Verificar si el producto ya existe en la lista
- Insertar o actualizar según existencia
- Retornar true si procesó al menos un producto, false si ninguno
Flujo detallado:
1. Preparar options para ProductoController
options = ['rubro' => [$agrupacion_desde, $agrupacion_hasta]]
2. Obtener productos del rango
productos = ProductoController->getAll(options, 'max')
3. Inicializar flags
$productos_cargados = false
4. Para cada producto:
a. Verificar si ya existe en la lista destino
b. Obtener costo del producto
Si costo <= 0 → SKIP (continuar con siguiente)
c. Calcular precio base:
SI porcentaje != null:
precio_base = costo + costo * (porcentaje / 100)
productos_cargados = true
SI NO (usar porcentaje del artículo):
SI producto.porc_ganancia es null → SKIP
precio_base = costo + costo * (producto.porc_ganancia / 100)
productos_cargados = true
d. SI calcula_precio_final = true:
- Crear Item de Domain Layer
- Agregar Ajuste de IVA (si existe categoria_iva.porcentaje)
- Agregar Ajuste de impuesto interno (si existe imp_interno)
- Calcular precio final con Item->calculate()
- tipo_precio = "F"
SI NO:
- precio_final = precio_base
- tipo_precio = "N"
e. Crear ListaPrecioDTO con los datos calculados
f. SI producto NO existe en lista:
model->insert(lista_precio)
SI NO:
model->update(lista_precio)
5. Retornar $productos_cargados (true/false)Lógica de cálculo de precio final (usa Domain Layer):
php
$item = new Item();
$item->setPrecio($precio_base);
$item->setTipoPrecio(TipoPrecio::NETO);
$item->setCantidad(1);
// Agregar IVA
if (isset($producto['categoria_iva']->porcentaje)) {
$iva = (float)$producto['categoria_iva']->porcentaje;
$item->addAjuste(new Ajuste(
TipoAjuste::IMPUESTO,
$iva,
TipoValor::PORCENTAJE
));
}
// Agregar impuesto interno
if (isset($producto['imp_interno'])) {
$valor_imp = (float)$producto['imp_interno'];
$tipo_valor = ($producto['tipo_imp'] ?? '') === 'P'
? TipoValor::PORCENTAJE
: TipoValor::FIJO;
$item->addAjuste(new Ajuste(
TipoAjuste::IMPUESTO,
$valor_imp,
$tipo_valor,
));
}
$item->calculate();
$precio_final = $item->getPrecioFinal();Dependencias del Service:
ProductoController- Instanciado directamente en el método (anti-pattern)ListaPrecio Model- Inyectado vía constructorItem(Domain) - Instanciado para cálculo de precio finalAjuste(Domain) - Instanciado para cada impuesto
Issues observados:
- NO usa transacciones en este método (a diferencia de
generarListaPrecioPorRango) - Instancia ProductoController directamente en lugar de inyectarlo
- NO registra auditoría (no implementa
Auditable) - Sin manejo de excepciones (si falla INSERT/UPDATE, propaga excepción sin rollback)
- N+1 Query Problem: Verifica existencia producto por producto en loop
Capa de Modelo
ListaPrecio Model
Tabla: precios
Constructor:
php
public function __construct(PDO $conn)
{
parent::__construct($conn, 'precios');
}Método: getAll(array $options)
Propósito: Obtener listas de precios con filtros opcionales
Parámetros soportados:
| Opción | Tipo | Descripción | SQL |
|---|---|---|---|
producto | int | Filtrar por un producto específico | WHERE numero = :numero |
producto | array | Filtrar por rango de productos | WHERE numero BETWEEN :desde AND :hasta |
lista | int | Filtrar por lista específica | WHERE lista = :lista |
Query SQL:
sql
SELECT
lista::int,
precio,
tippre as tipo_precio
FROM precios
WHERE [condiciones opcionales]Retorno: ListaPrecioDTO[]
Método: insert(ListaPrecioDTO $data)
Query SQL:
sql
INSERT INTO precios (lista, numero, precio, tippre)
VALUES(:lista, :numero, :precio, :tipo_precio)Binding:
:lista→$data->lista:numero→$data->id_producto:precio→$data->precio:tipo_precio→$data->tipo_precio
Retorno: ListaPrecioDTO | null
Método: update(ListaPrecioDTO $data)
Query SQL:
sql
UPDATE precios
SET precio = :precio, tippre = :tipo_precio
WHERE lista = :lista AND numero = :numeroClave primaria compuesta: (lista, numero)
Retorno: ListaPrecioDTO | null
Método: delete(int $producto, int $lista)
Query SQL:
sql
DELETE FROM precios
WHERE lista = :lista AND numero = :numeroNota: Es eliminación física (hard delete), no soft delete
Retorno: bool
Domain Layer
Item (Domain/Ventas/Facturacion/Item.php)
Propósito: Representar un ítem de facturación con cálculo de precio final aplicando ajustes (impuestos, descuentos).
Atributos relevantes:
precio: float - Precio base del productotipoPrecio: TipoPrecio enum - NETO o FINALcantidad: float - Cantidad (siempre 1 en este caso)ajustes: array - Colección de objetos Ajuste
Métodos utilizados:
setPrecio(float $precio): Asignar precio basesetTipoPrecio(TipoPrecio $tipo): Asignar tipo (NETO)setCantidad(float $cantidad): Asignar cantidad (1)addAjuste(Ajuste $ajuste): Agregar impuesto o descuentocalculate(): Ejecutar cálculo finalgetPrecioFinal(): float: Obtener precio calculado
Lógica de cálculo:
- Parte de precio base * cantidad
- Aplica cada ajuste secuencialmente:
- Si es PORCENTAJE:
valor = base * (porcentaje / 100) - Si es FIJO:
valor = monto_fijo
- Si es PORCENTAJE:
- Suma o resta según tipo de ajuste (IMPUESTO suma, DESCUENTO resta)
- Retorna precio final
Ajuste (Domain/Ventas/Facturacion/Ajuste.php)
Propósito: Representar un ajuste (impuesto/descuento) a aplicar sobre un ítem.
Constructor:
php
public function __construct(
TipoAjuste $tipo, // IMPUESTO, DESCUENTO
float $valor, // Monto o porcentaje
TipoValor $tipoValor // PORCENTAJE, FIJO
)Enums relacionados:
TipoAjuste::IMPUESTO- Suma al precioTipoAjuste::DESCUENTO- Resta al precioTipoValor::PORCENTAJE- Valor es porcentaje (ej: 21)TipoValor::FIJO- Valor es monto fijo (ej: 150.50)
Database Schema
Tabla: precios
Nivel: EMPRESA y SUCURSAL (configurables vía ConfigurableMigration)
Descripción: Almacena precios de productos para diferentes listas de precios.
Schema SQL:
sql
CREATE TABLE precios (
lista VARCHAR(3) NOT NULL,
numero DECIMAL(6,0) NOT NULL,
precio DECIMAL(16,5) NULL,
tippre VARCHAR(1) NULL,
prefin DECIMAL(16,5) NULL -- Sin uso
);
CREATE INDEX fki_Articulo ON precios(numero);Columnas:
| Columna | Tipo | Null | Descripción |
|---|---|---|---|
lista | VARCHAR(3) | NO | Identificador de la lista de precios |
numero | DECIMAL(6,0) | NO | Código del producto (FK a producto.numero) |
precio | DECIMAL(16,5) | SÍ | Precio del producto en esta lista |
tippre | VARCHAR(1) | SÍ | Tipo de precio: 'F' (Final) o 'N' (Neto) |
prefin | DECIMAL(16,5) | SÍ | Sin uso en el código actual |
Clave primaria compuesta: No definida explícitamente en migración, pero se comporta como (lista, numero)
Índices:
fki_Articuloen columnanumero- Para joins con tabla producto
Foreign Keys: No definidas en migración actual
Constraints: Ninguna
Valores posibles de tippre:
'N'- Precio Neto (sin impuestos incluidos) -TipoPrecio::NETO'F'- Precio Final (con impuestos incluidos) -TipoPrecio::FINAL
Comportamiento UPSERT:
- No hay UNIQUE constraint en
(lista, numero) - El service implementa UPSERT manualmente:
- Query para verificar existencia
- INSERT si no existe, UPDATE si existe
Data Layer (DTOs)
ListaPrecio DTO
Ubicación: Resources/Venta/ListaPrecio.php
Atributos:
php
public float $precio;
public string $tipo_precio; // 'N' o 'F'
public ?int $id_producto;
public int $lista;Constructor:
php
public function __construct(
$lista,
$precio,
$id_producto = null,
$tipo_precio = null // Default: TipoPrecio::NETO
)Validaciones en constructor:
| Campo | Reglas | Descripción |
|---|---|---|
precio | required, numeric | Debe ser numérico |
tipo_precio | required, max:1, enum | Solo 'N' o 'F' |
lista | required, integer | Número de lista |
id_producto | integer | Código del producto |
Mapeo array → DTO:
php
ListaPrecio::fromArray([
'lista' => 2,
'precio' => 150.50,
'tipo_precio' => 'N',
'id_producto' => 123
])Mapeo SQL → DTO (en Model):
php
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
$listas = array_map(fn($ls) => ListaPrecioDTO::fromArray($ls), $result);Validaciones
Nivel 1: Validación Estructural (Controller manual)
Ubicación: ListaPrecioController::insert()
php
if (!isset($body['destino'], $body['origen'], $body['listas'])) {
throw new BadRequest('Faltan datos para generar la lista de precios automática.');
}Campos validados manualmente:
- Presencia de
methoden body (switch) - Para método 'ganancia-margen': extrae directamente sin validar presencia
Issues:
- NO hay ValidatorMiddleware aplicado en la ruta
- Validaciones son inconsistentes (algunas con isset, otras asumen existencia)
- No valida tipos de datos (int, float, bool)
Nivel 2: Validación de Negocio (Service)
Ubicación: ListaPrecioService::generarListaMargenGanancia()
Validaciones implementadas:
Producto con costo válido:
phpif ($costo <= 0) { continue; // Omite producto sin mensaje }Producto con porcentaje de ganancia (solo en modo "por artículo"):
phpif (is_null($producto['porc_ganancia'])) { continue; // Omite producto sin mensaje }Al menos un producto procesado:
phpreturn $productos_cargados; // true o false
Validaciones ausentes:
- No valida que
agrupacion_desde <= agrupacion_hasta - No valida que
listasea un número positivo - No valida que
porcentaje(si provisto) sea un número válido - No verifica que la lista de precios destino exista previamente
Integration Points
Dependencias internas
ProductoController (anti-pattern):
php
$producto_controller = new ProductoController($this->conn);
$productos = $producto_controller->getAll($options, 'max');Issue: Instancia el controller directamente en lugar de usar inyección de dependencias o un servicio intermedio.
Scope 'max' del Producto: Obtiene campos:
id,nombre,codigo_comercial,stock,bonfijaimintipo,aimi,costo,descripcion,rubrolinea,refcon,maneja_stock,categoria_ivapunped,proveedor,sincroniza_web,stock_webmanejo_precios_facturacion,comision,activoporc_ganancia,ubicacion
Relaciones con otras entidades
Producto (tabla: producto):
- Relación: Lista de precios pertenece a un producto
- FK:
precios.numero→producto.numero - Usado para: Obtener costo, porcentaje ganancia, impuestos
Rubro (tabla: rubro):
- Relación: Productos se filtran por rango de rubros
- Usado para: Delimitar qué productos procesar
Categoría IVA:
- Relación: Producto tiene una categoría IVA con porcentaje
- Usado para: Calcular impuesto IVA en precio final
Testing Strategy
Estado actual: No se encontraron tests específicos para generarListaMargenGanancia().
Archivo de tests existente: Tests/Unit/models/Venta/ListaPrecioTest.php
Test factory existente: Tests/Factories/Venta/ListaPrecioFactory.php
Tests recomendados
Unit Tests (con mocks):
Test: Calcular precio con porcentaje fijo
- Given: Producto con costo 100, porcentaje fijo 30
- When: Se ejecuta generarListaMargenGanancia
- Then: Precio base = 130
- Mock: ProductoController, ListaPrecio Model
Test: Calcular precio con porcentaje del artículo
- Given: Producto con costo 100, porc_ganancia 25
- When: Se ejecuta con porcentaje = null
- Then: Precio base = 125
Test: Calcular precio final con IVA
- Given: Producto con costo 100, IVA 21%
- When: Se ejecuta con precioFinal = true
- Then: Precio final ≈ 121
Test: Omitir productos sin costo
- Given: Productos con costo 0 y costo -10
- When: Se ejecuta generarListaMargenGanancia
- Then: No se insertan registros para esos productos
Test: Actualizar precio existente
- Given: Producto ya tiene precio en lista destino
- When: Se ejecuta con nuevo precio calculado
- Then: Se llama a model->update(), no insert()
Test: Retornar false si ningún producto procesado
- Given: Todos los productos sin costo o sin porcentaje
- When: Se ejecuta generarListaMargenGanancia
- Then: Retorna false
Integration Tests (con base de datos real):
Test: Generar lista completa con fixtures
- Given: 10 productos con costo en rubro 1-3
- When: Se ejecuta para lista 2, rubros 1-3, porcentaje 40
- Then: Se crean 10 registros en precios con tipo_precio 'N'
Test: Precio final con múltiples impuestos
- Given: Producto con IVA 21% + impuesto interno 5%
- When: Se ejecuta con precioFinal = true
- Then: Precio final calculado correctamente con ambos impuestos
Test: Manejo de transacciones (actualmente NO implementado)
- Given: Productos válidos
- When: Falla el insert del 5to producto
- Then: Debería hacer rollback, pero actualmente no lo hace
Performance Considerations
N+1 Query Problem
Issue crítico: Para cada producto del rango, se ejecutan 2 queries adicionales:
php
foreach ($productos as $producto) {
// Query 1: Verificar existencia
$producto_existe = $this->model->getAll([
'producto' => (int)$producto['id'],
'lista' => $lista
]);
// Query 2: INSERT o UPDATE
if (empty($producto_existe)) {
$this->model->insert($lista_precio);
} else {
$this->model->update($lista_precio);
}
}Impacto: Si el rango contiene 100 productos, se ejecutan ~200 queries adicionales.
Solución recomendada:
- Obtener todos los productos existentes de la lista en una sola query
- Indexar por
numeroen memoria - Decidir INSERT vs UPDATE sin queries adicionales
- Usar batch INSERT para nuevos productos
- Usar batch UPDATE para productos existentes
Ejemplo de optimización:
php
// 1 query para todos los productos del rango
$precios_existentes = $this->model->getByProductosYLista($producto_ids, $lista);
foreach ($productos as $producto) {
$existe = isset($precios_existentes[$producto['id']]);
if (!$existe) {
$batch_inserts[] = $lista_precio;
} else {
$batch_updates[] = $lista_precio;
}
}
// 1 query para todos los inserts
// 1 query para todos los updatesNota: Ya existe el método getByProductosYLista() en el Model (líneas 115-159), pero no es utilizado por el Service.
Índices necesarios
Índice existente:
fki_Articuloenprecios(numero)- Para joins con producto
Índices recomendados adicionales:
sql
-- Para optimizar verificación de existencia
CREATE INDEX idx_precios_lista_numero ON precios(lista, numero);
-- Para optimizar queries por lista
CREATE INDEX idx_precios_lista ON precios(lista);Transacciones
Issue crítico: El método NO usa transacciones.
Riesgo: Si falla a mitad del proceso, quedan precios parcialmente actualizados sin posibilidad de rollback.
Comparación: El método generarListaPrecioPorRango() SÍ usa transacciones:
php
try {
$this->conn->beginTransaction();
// ... lógica ...
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollBack();
throw $e;
}Recomendación: Envolver generarListaMargenGanancia() con el mismo patrón de transacción.
Security Considerations
Inyección SQL
Estado: Protegido mediante prepared statements en Model.
Ejemplo seguro:
php
$stmt = $this->conn->prepare($sql);
$stmt->bindValue(':lista', $data->lista);
$stmt->execute();Validación de entrada
Issues:
- No hay ValidatorMiddleware en la ruta
- Validaciones manuales inconsistentes en Controller
- Falta validación de tipos (int, float, bool)
Permisos
Requerido en frontend: VENTAS_BASES_LISTA-PRECIO_COSTO
Estado backend: No se detecta validación de permisos explícita en el endpoint.
Asunción: AuthMiddleware valida JWT y permisos globales, pero no permisos específicos de este endpoint.
Auditoría
Issue crítico: No se registra auditoría de las operaciones masivas de INSERT/UPDATE.
Recomendación: Implementar Auditable trait en ListaPrecioService y registrar:
- Operación: "GENERACION_LISTA_COSTO_GANANCIA"
- Módulo: "VENTAS"
- Detalles: lista destino, rango de rubros, cantidad de productos procesados
Preguntas Técnicas Pendientes
⚠️ Aclaraciones Requeridas: Hay aspectos técnicos que requieren validación. Ver: Preguntas sobre Lista de Precios Costo Ganancia
Referencias
⚠️ NOTA IMPORTANTE: Esta documentación fue generada automáticamente analizando el código implementado. Validar cambios futuros contra este baseline.