Skip to content

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:

LayerArchivoUbicación
RoutesListaPrecioRoute.phpRoutes/Venta/
ControllerListaPrecioController.phpcontroller/modulo-venta/
ServiceListaPrecioService.phpservice/Venta/
ModelListaPrecio.phpmodels/modulo-venta/
DTOListaPrecio.phpResources/Venta/
EnumTipoPrecio.phpResources/Venta/Enums/
DomainItem.php, Ajuste.phpDomain/Ventas/Facturacion/
Migration20240823200743_new_table_precios.phpmigrations/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:

CampoTipoRequeridoDescripción
methodstringDebe ser "ganancia-margen" para esta operación
data.agrupacionDesdeintID del rubro inicial del rango
data.agrupacionHastaintID del rubro final del rango
data.porcentajefloat/nullCondicionalPorcentaje fijo. Null = usar porcentaje del artículo
data.precioFinalbooleantrue = calcular precio final con impuestos, false = precio neto
data.listaintNú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:

CodeCondición
201Operación exitosa (al menos un producto procesado)
201Error de negocio (ningún producto procesado)
400Bad Request (faltan datos requeridos)
500Error 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 method en body
  • Verifica existencia de campos requeridos para método 'ganancia-margen'
  • Si $result es false, 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 datos
  • ListaPrecio $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:

  1. Obtener productos del rango de rubros
  2. 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
  3. 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 constructor
  • Item (Domain) - Instanciado para cálculo de precio final
  • Ajuste (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ónTipoDescripciónSQL
productointFiltrar por un producto específicoWHERE numero = :numero
productoarrayFiltrar por rango de productosWHERE numero BETWEEN :desde AND :hasta
listaintFiltrar por lista específicaWHERE 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 = :numero

Clave 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 = :numero

Nota: 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 producto
  • tipoPrecio: TipoPrecio enum - NETO o FINAL
  • cantidad: float - Cantidad (siempre 1 en este caso)
  • ajustes: array - Colección de objetos Ajuste

Métodos utilizados:

  • setPrecio(float $precio): Asignar precio base
  • setTipoPrecio(TipoPrecio $tipo): Asignar tipo (NETO)
  • setCantidad(float $cantidad): Asignar cantidad (1)
  • addAjuste(Ajuste $ajuste): Agregar impuesto o descuento
  • calculate(): Ejecutar cálculo final
  • getPrecioFinal(): float: Obtener precio calculado

Lógica de cálculo:

  1. Parte de precio base * cantidad
  2. Aplica cada ajuste secuencialmente:
    • Si es PORCENTAJE: valor = base * (porcentaje / 100)
    • Si es FIJO: valor = monto_fijo
  3. Suma o resta según tipo de ajuste (IMPUESTO suma, DESCUENTO resta)
  4. 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 precio
  • TipoAjuste::DESCUENTO - Resta al precio
  • TipoValor::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:

ColumnaTipoNullDescripción
listaVARCHAR(3)NOIdentificador de la lista de precios
numeroDECIMAL(6,0)NOCódigo del producto (FK a producto.numero)
precioDECIMAL(16,5)Precio del producto en esta lista
tippreVARCHAR(1)Tipo de precio: 'F' (Final) o 'N' (Neto)
prefinDECIMAL(16,5)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_Articulo en columna numero - 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:
    1. Query para verificar existencia
    2. 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:

CampoReglasDescripción
preciorequired, numericDebe ser numérico
tipo_preciorequired, max:1, enumSolo 'N' o 'F'
listarequired, integerNúmero de lista
id_productointegerCó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 method en 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:

  1. Producto con costo válido:

    php
    if ($costo <= 0) {
        continue; // Omite producto sin mensaje
    }
  2. Producto con porcentaje de ganancia (solo en modo "por artículo"):

    php
    if (is_null($producto['porc_ganancia'])) {
        continue; // Omite producto sin mensaje
    }
  3. Al menos un producto procesado:

    php
    return $productos_cargados; // true o false

Validaciones ausentes:

  • No valida que agrupacion_desde <= agrupacion_hasta
  • No valida que lista sea 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, bonfija
  • imintipo, aimi, costo, descripcion, rubro
  • linea, refcon, maneja_stock, categoria_iva
  • punped, proveedor, sincroniza_web, stock_web
  • manejo_precios_facturacion, comision, activo
  • porc_ganancia, ubicacion

Relaciones con otras entidades

Producto (tabla: producto):

  • Relación: Lista de precios pertenece a un producto
  • FK: precios.numeroproducto.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):

  1. 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
  2. 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
  3. Test: Calcular precio final con IVA

    • Given: Producto con costo 100, IVA 21%
    • When: Se ejecuta con precioFinal = true
    • Then: Precio final ≈ 121
  4. 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
  5. 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()
  6. 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):

  1. 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'
  2. 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
  3. 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:

  1. Obtener todos los productos existentes de la lista en una sola query
  2. Indexar por numero en memoria
  3. Decidir INSERT vs UPDATE sin queries adicionales
  4. Usar batch INSERT para nuevos productos
  5. 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 updates

Nota: 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_Articulo en precios(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.