Auditoría del cálculo de precios de supplier_products¶
Resumen¶
SupplierProduct::calculateAndStorePrices() es el único punto donde el Integrador
convierte el precio que envía un proveedor en los precios de venta por canal que
consumen Compulandia (Medusa), Tienda Naranja, Contimarket y UMarket. No existía
documentación del algoritmo; este documento lo reconstruye a partir del código
(app/Models/SupplierProduct.php:887) y de una lectura read-only de la base de
desarrollo el 2026-09-15.
Hallazgo principal (bug reportado, corregido el 2026-09-15 según ADR-0006). El
margen mínimo se aplicaba como piso siempre, con la fórmula
max(precio × factor, precio + margen). Cuando el factor es menor a 1 (proveedor que
envía precio sugerido de venta y se quiere vender al 89 %, 90 %, etc.) el piso
precio + margen es siempre mayor que precio × factor, así que el factor nunca
tenía efecto y el producto se publicaba más caro que el precio sugerido. Afecta a
NGO (id 888): 6 categorías activas con factor 0,90, 101 productos, 26 de ellos
seleccionados y publicados. El estado de la corrección está en la sección 7.
Además del bug se detectaron once puntos ciegos, ordenados por severidad en la sección 6. Los más relevantes: la configuración de pricing (factor, margen, escala, factor de canal, IVA del proveedor) no dispara recálculo al editarse; los productos nuevos suelen quedar sin precio por una carrera entre la creación y la asignación de categoría; y los precios calculados nunca se borran cuando dejan de ser válidos.
1. Alcance y método¶
- Se auditó el método y todo lo que lo rodea: disparadores (observer, estrategia de
evaluación, workflow, job, controladores), modelos y tablas involucradas, y los
consumidores de la tabla
prices. - Datos de referencia: consultas de solo lectura con
tinkersobre la base dev (sin mutarproduct_itemsnisupplier_products, según la regla del proyecto). Las cifras del documento corresponden a ese momento y se listan en el anexo. - No se modificó código. La corrección se propone en la sección 7.
2. Modelo de datos involucrado¶
| Tabla / campo | Tipo | Rol en el cálculo |
|---|---|---|
supplier_products.regular_price |
decimal(20,2) nullable | Precio base que envía el proveedor (costo o precio sugerido, según proveedor) |
supplier_products.special_price |
decimal(20,2) nullable | Precio de oferta del proveedor |
supplier_products.offer_start / offer_end |
datetime nullable | Vigencia de la oferta; se copian tal cual a prices |
suppliers.iva_incl |
boolean, default 0 | Si el precio del proveedor ya incluye IVA |
categories.factor |
double(8,2) nullable, default 1 | Multiplicador de la categoría de proveedor |
categories.margin_min |
double(8,2) nullable, default 0 | Margen mínimo absoluto en guaraníes |
categories.scale_id |
FK nullable a price_scales |
Escala por tramo de precio; si existe reemplaza factor y margen |
price_scale_levels.lower_limit / upper_limit |
unsigned bigint | Tramos de la escala (enteros) |
price_scale_levels.factor / margin_min |
decimal(8,2) | Parámetros del tramo |
partners.factor |
double nullable | Multiplicador del canal de salida |
partners.active |
boolean | Sólo se calculan canales activos |
price_lists.active |
boolean | Sólo listas activas (hoy existe una: PYG) |
prices |
— | Resultado: una fila por (supplier_product, partner, price_list) |
La relación producto ↔ categoría es la pivote category_product (product_id,
category_id). Sólo hay categorías de tipo Supplier asociadas a productos (0 filas
de tipo Partner), pero el código no filtra por tipo.
Constantes: TaxConstants::IVA_RATE = 1.1, SupplierConstants::CL = 1.
Estado actual de la configuración (dev, 2026-09-15):
| Canal (partner) | activo | factor |
|---|---|---|
| Tienda Naranja (1) | sí | 1,07 |
| Compulandia (2) | sí | 1,00 |
| Contimarket (3) | sí | 1,05 |
| UMarket (4) | sí | 1,00 |
| Proveedor | iva_incl | Categorías activas | factor mín – máx | margen mín – máx |
|---|---|---|---|---|
| Compulandia (1) | 1 | 123 | 1,00 – 1,20 | 0 – 100.000 |
| Fastrax (2) | 0 | 114 | 1,00 – 1,50 | 0 – 500.000 |
| NGO (888) | 0 | 80 | 0,85 – 1,50 | 0 – 250.000 |
| Compras Paraguai (999) | 0 | 86 | 1,00 – 1,50 (1 con escala) | 0 – 350.000 |
| Xiaomizone (10) | 1 | 1 | 1,00 | 0 |
| J.W Enterprises (9) | 1 | 0 | — | — |
| Servicio (777) | 0 | 1 | 1,00 | 5.000 |
3. Cuándo se ejecuta (disparadores)¶
flowchart TD
A[Sync de proveedor escribe supplier_products] --> B{evento Eloquent}
B -- created --> C[SupplierProductObserver::created]
C --> D[CalculatePricesJob\ncola pricing]
B -- updated --> E[SupplierProductEvaluationStrategy]
E -- flag WORKFLOW_SUPPLIER_PRODUCT_CHANGE=true --> F[SupplierProductChangeWorkflow]
F -- solo si cambió precio u oferta --> G[CalculatePricesActivity]
E -- flag=false --> H[CalculatePricesJob]
I[ConfirmationModal\nconfirmación manual] --> D
J[PendingProductsController\nhandlePendingProducts] -- llamada síncrona --> K
D --> K[calculateAndStorePrices]
G --> K
K --> L[(prices)]
D --> M[EvaluateSelectedSupplierProductJob]
Detalles que importan:
- Al crear un
SupplierProductel observer despachaCalculatePricesJobinmediatamente (app/Observers/SupplierProductObserver.php:23). El job esShouldBeUniquepor 60 s con 3 intentos. - Al actualizar, la estrategia detecta cambio de precio comparando la parte
entera de
regular_priceyspecial_price, o cualquier cambio enoffer_start/offer_end(SupplierProductEvaluationStrategy::detectChanges). El flag del workflow está activo en dev, así que correCalculatePricesActivity(colaworkflows, 3 intentos con backoff 15/30/60 s). - Al editar una categoría de proveedor (
factor,margin_min,scale_idoactive) o una escala de precios, los observersCategoryObserveryPriceScaleObserverdespachanRecalculateCategoryProductPricesJob, que arranca un workflow de cambio de precio por cada producto de la categoría. - No dispara recálculo ningún otro cambio de configuración:
partners.factor, activación de un canal,suppliers.iva_incl, ni asociar o quitar una categoría a un producto (ver H2). - La confirmación manual de pendientes despacha el job de forma explícita, y el
controlador legado
PendingProductsControllerlo llama de forma síncrona dentro de una transacción; ambos pueden solaparse con el workflow. - Después del cálculo (sólo por la ruta del job) se despacha
EvaluateSelectedSupplierProductJob; por la ruta del workflow la evaluación es el paso 3 del mismo workflow.
4. Algoritmo paso a paso¶
Referencia: app/Models/SupplierProduct.php:887-1013.
4.1 Precondiciones (retorno temprano, sin tocar prices)¶
| # | Condición | Log | Efecto |
|---|---|---|---|
| P1 | regular_price vacío o < 0,01 |
info | Se ignora el producto. Las filas previas de prices quedan como estaban |
| P2 | Ninguna categoría asociada con active = 1 |
warning | Ídem |
| P3 | Ninguna categoría activa con factor no nulo y > 0 |
warning | Ídem. Aplica también a CL, aunque CL no usa el factor |
Para P3 se toma la primera categoría que cumpla, con first() y sin ORDER BY
(getSupplierPricingCategory(), línea 596). Si el producto tiene varias categorías
activas con parámetros distintos, la elección depende del orden físico de la pivote.
4.2 Obtención de parámetros¶
si categoría.scale_id existe:
nivel = price_scale_levels donde lower_limit <= regular_price
y (upper_limit >= regular_price o upper_limit es null)
ordenado por lower_limit desc, primero
si hay nivel: factor = nivel.factor ; margen = nivel.margin_min ?? 0
si no: factor = categoría.factor ; margen = categoría.margin_min ?? 0 (warning "Escala no aplica")
si no:
factor = categoría.factor ; margen = categoría.margin_min ?? 0
La escala se evalúa una sola vez sobre regular_price; el precio especial usa el
mismo tramo aunque su monto caiga en otro.
4.3 Cálculo por canal y lista¶
Para cada partner activo × cada price_list activa (hoy 4 × 1 = 4 filas):
Proveedor CL (supplier_id = 1): no se aplica factor, margen ni IVA.
regular = regular_price × partner.factor
special = special_price × partner.factor (null si no hay special)
Proveedores no-CL (regla vigente desde ADR-0006; applyPricingFactor()):
si factor >= 1: regular = max(regular_price × factor, regular_price + margen)
si factor < 1: regular = regular_price × factor ← el margen no aplica
regular = regular × (iva_incl ? 1 : 1,1)
regular = regular × partner.factor
si special_price > 0: misma regla sobre special_price, luego IVA y factor de canal
si no: special = null
Antes de ADR-0006 el piso regular_price + margen se aplicaba sin condición (con
> para regular y >= para especial), que es lo que anulaba el factor menor a 1.
Un canal con partners.factor nulo o ≤ 0 ahora se omite con un log de error en
lugar de guardar precio 0 (mitigación de H6).
4.4 Persistencia¶
Por cada combinación se busca la fila existente (supplier_product_id, partner_id,
price_list_id) y se hace update; si no existe, create. Se guarda con
round(x, 2); special_from_date/special_to_date se copian de offer_start/
offer_end sin validación. No hay transacción ni índice único.
4.5 Ejemplo real (NGO-T2681, categoría VISICOOLERS, factor 0,90, margen 5.000, iva_incl 0)¶
| Paso | Esperado por negocio | Lo que hace el código |
|---|---|---|
| base = 6.710.000 × 0,90 | 6.039.000 | 6.039.000 |
| piso = 6.710.000 + 5.000 | (no debería aplicar) | 6.715.000 |
| max(base, piso) | 6.039.000 | 6.715.000 |
| × IVA 1,1 | 6.642.900 (si el precio sugerido no incluye IVA) | 7.386.500 |
| × canal Compulandia 1,00 | 6.642.900 | 7.386.500 (valor real en prices) |
El producto se publicaba un 10 % por encima del precio sugerido en vez de un 10 % por debajo. Con la regla nueva y NGO marcado con IVA incluido, el resultado es 6.039.000.
5. Consumo aguas abajo¶
product_item_viewhaceLEFT JOIN pricesporsupplier_product_idypartnersporpartner_id, filtrandoc.active = 1. Expone una fila por canal conregular_price,special_price,offer_start,offer_end. Cualquier fila obsoleta enpricesaparece en la vista como precio vigente.ProductPriceService::getLocalPriceInfolee la vista con canalCompulandiay combina con Medusa (regular siempre local; special puede venir de Medusa).- Medusa (
computeVariantPriceAmount): tomaspecial > 0 ? special : regularde la fila Compulandia de la vista. - Tienda Naranja: regular desde la fila TN de la vista (ya con factor 1,07);
special desde
ProductPriceService(fila Compulandia) y le aplica otra vezpartners.factorde TN. Es consistente sólo porque Compulandia tiene factor 1. - Contimarket:
pricesconpartner_id = 3, redondeado a entero. - UMarket:
pricesconpartner_id = 4, redondeado hacia arriba a múltiplo de 500.
Existe además ProductPrincingService::getSalePrice() (legado, todos sus usos están
comentados) con otra fórmula: toma el menor entre regular y special, usa
max(factor) entre categorías, aplica el margen como diferencia y siempre suma IVA.
No debe usarse como referencia.
6. Hallazgos¶
Severidad: crítico = precio publicado incorrecto o ausente; alto = datos inconsistentes con impacto probable; medio = fragilidad o deuda; bajo = calidad.
H1 · Crítico · El margen mínimo anula cualquier factor < 1 (bug reportado)¶
Con margen ≥ 0 y factor < 1 siempre se cumple precio + margen > precio × factor,
así que el piso gana y el factor no participa. La fórmula asume que regular_price es
un costo al que hay que agregarle ganancia; para un proveedor que envía precio
sugerido de venta esa premisa es falsa.
Impacto medido: 7 categorías NGO con factor < 1 (6 activas), 101 productos, 26 seleccionados y publicados. Precio publicado ≈ (sugerido + 5.000) × 1,1, es decir +10 % en lugar de −10 %.
Punto ciego asociado: IVA. NGO está con iva_incl = 0, por lo que al precio
sugerido se le suma 10 %. Si el precio sugerido ya incluye IVA (lo habitual para un
precio de venta al público), incluso corrigiendo el margen el resultado sería
0,90 × 1,10 = 0,99 del sugerido, no 0,90. Hay que confirmar con negocio qué es
exactamente el price de NGO (ver decisiones D1–D3 en la sección 7).
H2 · Medio · Recálculo por configuración: cubre la categoría y la escala, no el resto¶
Corregido el 2026-09-15. La primera versión de este documento afirmaba que ningún cambio de configuración recalculaba. Es falso para las categorías y las escalas: el mecanismo existe desde 2025-04-04 y se reforzó el 2026-03-06. El error fue mío, por haber buscado el disparador en los controladores y no en los observers.
Sí recalcula. CategoryObserver::updated despacha
RecalculateCategoryProductPricesJob cuando cambia factor, margin_min, scale_id
o active de una categoría. PriceScaleObserver hace lo mismo para todas las
categorías de una escala al actualizarla o borrarla. El job recorre los productos de la
categoría y arranca un SupplierProductChangeWorkflow por producto con el cambio
price, así que además de recalcular reevalúa el seleccionado y sincroniza los canales.
No recalcula. Quedan fuera:
partners.factory la activación de un canal. No hay observer dePartner, y el factor de canal multiplica todas las filas deprices.suppliers.iva_incl. No hay observer deSupplier. Relevante ahora mismo: marcar NGO con IVA incluido no dispara nada por sí solo.- Asociar o quitar una categoría a un producto. El observer registrado
(
ProductCategoryObserver) es del modeloProductCategory(tabla deprecada por RFC 003), no de la pivotecategory_productque usa el cálculo.
Fragilidades del camino que sí funciona. El job carga $this->category->products
entero en memoria, sin chunk, y arranca un workflow por producto sin deduplicar. Una
categoría con miles de productos genera esa misma cantidad de workflows y de syncs de
salida. Además ignora el feature flag supplier_product_change_workflow: usa el
workflow aunque el flag esté apagado.
H3 · Crítico · Carrera creación → categoría: productos nuevos sin precio¶
Los syncs crean el producto (create/updateOrCreate) y después asocian la
categoría con syncWithoutDetaching. El job despachado en created corre en cuanto
Horizon lo toma; si llega antes del attach, falla P2/P3 y sale con un warning. Como
un sync posterior con el mismo precio no es "cambio", el producto nunca recibe
precio. Productos con regular_price > 0 y sin fila en prices:
| Proveedor | Productos |
|---|---|
| Compulandia (1) | 335 (317 de ellos sin categoría activa con factor, ver H10) |
| Fastrax (2) | 997 |
| Gametec (3) | 103 |
| Todegol (8) | 46 |
| J.W (9) | 4 |
| NGO (888) | 113 |
| Compras Paraguai (999) | 6 |
Además, 3.685 productos tienen una cantidad de filas en prices distinta de 4
(canales que se activaron después del último cálculo).
H4 · Alto · Los precios calculados nunca se invalidan¶
Los retornos tempranos (P1–P3) dejan las filas anteriores intactas, y no hay limpieza al descartar un producto ni al desactivar una categoría.
| Situación | Productos con filas en prices |
|---|---|
regular_price nulo o 0 |
5.475 |
| Sin ninguna categoría activa | 67 |
Descartados (discarded_at) |
3 |
La vista los muestra como precio vigente; sólo la selección (que exige precio y stock) evita que lleguen a los canales, y esa evaluación corre por otro camino.
H5 · Alto · Elección no determinista de la categoría de pricing¶
5.230 productos tienen más de una categoría activa con factor o margen distintos.
first() sin orden hace que el precio dependa del orden físico de la pivote y pueda
cambiar tras un re-attach. El método legado usaba max(factor), así que además hay
dos criterios históricos distintos.
H6 · Alto · partners.factor nulo produce precio 0¶
La columna es nullable, sin default ni validación. precio × null = 0 y se guardaría
regular_price = 0 para todos los productos en ese canal en el próximo recálculo.
Hoy los 4 canales tienen factor, pero crear un partner nuevo sin factor (o borrarlo)
es una vía directa a publicar precios en cero.
H7 · Medio · Precio especial y ofertas sin validación¶
- No se valida
special < regular: 22 productos en origen y 88 filas enpricescon special ≥ regular. Los consumidores lo mitigan cada uno a su manera (ProductPriceServiceanula, Medusa toma special si > 0 sin comparar). - 33 productos con
special_pricey sinoffer_end; las fechas se copian sin chequear coherencia.ClearExpiredOfferslimpia en origen y, como cambia la parte entera, sí dispara recálculo. - Comparación del piso inconsistente:
>para regular,>=para special. - Para CL,
special_pricepasa crudo aunque sea mayor que el regular.
H8 · Medio · Escala de precios: huecos y tramo único¶
Los límites son enteros (0–500000, 500001–800000, …). Un regular_price con
decimales entre 500.000,01 y 500.000,99 no cae en ningún tramo y el código vuelve al
factor de la categoría con un warning. Compras Paraguai, único proveedor con escala,
tiene 386 productos con decimales. El tramo se elige sólo con regular_price y se
reutiliza para el especial.
H9 · Medio · Integridad de prices y concurrencia¶
- Sin índice único en
(supplier_product_id, partner_id, price_list_id); el patrónfirst()→update/createno es atómico. Hoy hay 0 duplicados, pero conviven tres rutas concurrentes (job encreated, activity del workflow, llamada síncrona del controlador) y el job explícito del modal de confirmación. price_list_ides una dimensión sin efecto: el cálculo no distingue listas, sólo multiplica filas.
H10 · Medio · Validaciones divergentes entre ABM, confirmación y cálculo¶
| Regla | ABM (UpdateSupplierCategoryRequest) |
Confirmación (validateCategoryPricingConfig) |
Cálculo |
|---|---|---|---|
| factor | min:0.01 |
> 0 |
> 0 |
| margin_min | min:0 |
> 0 (obligatorio) |
?? 0 (opcional) |
| CL | igual que el resto | exento | exige categoría con factor aunque no lo use |
Consecuencias: 47 categorías activas no-CL con factor 1 y margen 0 (precio = costo + IVA, sin ganancia); 7 categorías activas con factor o margen nulo; 317 productos CL sin precio por exigirles un factor que el algoritmo ignora.
Category::changeStatus() intenta bloquear factor < 1 sólo para CL, pero la condición
tiene una asignación ($this->categorizable_type = Supplier::class) en lugar de
comparación, así que aplica a cualquier categorizable_id === 1 (incluye el partner
Tienda Naranja) y sólo si el estado se cambia por ese método. El ABM no explica la
semántica de un factor < 1 y hoy es la única forma de expresar "precio sugerido".
H11 · Bajo · Calidad del código y observabilidad¶
- El docblock describe una validación de categoría CL que ya no existe.
$this->supplier_id !== SupplierConstants::CLes comparación estricta con entero; funciona porque Eloquent devuelveint, pero un modelo hidratado con string trataría a CL como no-CL (aplicaría factor, margen e IVA).$this->supplier->iva_inclsin null-check: si el proveedor no existe, error fatal.- Se loguea en cada iteración y en nivel
infosobre un stack que incluye Sentry; no se usa el formato obligatorio[{CANAL}:{product_id}:{sku}]nilogTag(). - IVA hardcodeado en 1,1 y
iva_inclcomo booleano global por proveedor (no admite proveedores con productos exentos o con distinta alícuota). - Se guardan 2 decimales en guaraníes; cada canal trunca o redondea distinto.
- Doble camino de precio especial en TN (fila Compulandia × factor TN) versus fila TN de la vista para el regular.
Seguridad¶
No hay entrada de usuario en el cálculo, por lo que no se identificaron
vulnerabilidades de inyección o de autorización en este flujo. Los riesgos son de
integridad de precios publicados: un factor mal tipeado (0,09 en vez de 0,90),
un partners.factor nulo (H6) o un margen 0 pasan sin alerta y se propagan a todos
los canales. Se recomienda validación de rangos y registro de actividad
(activity()) sobre factor, margin_min, scale_id y partners.factor, que hoy
no tienen traza de quién los cambió.
7. Corrección de H1: decisiones tomadas y estado de implementación¶
Decisiones (2026-09-15, hquintero)¶
- D1 · Aceptada. Un factor menor a 1 significa "
regular_pricees precio sugerido de venta; vender aprecio × factor" y el margen mínimo no aplica. Registrada en ADR-0006. - D2 · Resuelta por configuración. El IVA se gobierna sólo con
suppliers.iva_incl; el cálculo lo respeta (multiplica por 1,1 únicamente cuando está en 0). Pendiente operativo: al escribir esto NGO sigue coniva_incl = 0en dev; hay que marcarlo desde el ABM de proveedores antes del recálculo masivo, si no el precio publicado será 0,99 del sugerido. - D3 · Aceptada. La misma regla se aplica al
special_price.
Implementado (misma rama, sin correr tests)¶
| Pieza | Archivo | Qué hace |
|---|---|---|
| Regla del factor | SupplierProduct::applyPricingFactor() |
Helper estático puro: max(precio × f, precio + margen) si f ≥ 1; precio × f si f < 1 |
| Cálculo puro | SupplierProduct::computePrices() |
Devuelve las filas por canal × lista sin escribir; expone motivo de omisión, factor, margen, origen (categoría o tramo de escala) e IVA |
| Persistencia | SupplierProduct::calculateAndStorePrices() |
Sólo persiste lo que devuelve computePrices(); un log por producto con el formato [PRICE:id:sku] |
| Canal sin factor | computePrices() |
Se omite el canal con log de error en vez de guardar precio 0 (mitiga H6) |
| Proveedor inexistente | computePrices() |
Se omite con motivo sin_proveedor en vez de error fatal |
| Rango del factor | UpdateSupplierCategoryRequest, PriceScaleForm, supplier-form-fields.blade.php |
between:0.5,3 con mensaje y texto de ayuda que explica la semántica de cada lado del 1 |
| Recálculo masivo | php artisan prices:recalculate |
Filtros --supplier, --category, --sku, --missing, --limit; --dry-run compara actual vs. esperado sin escribir ni encolar; sin --dry-run encola CalculatePricesJob por producto |
| Test | tests/Unit/SupplierProductPricingFactorTest.php |
Casos de la regla (no ejecutado, por la restricción del proyecto) |
Verificación hecha en dev, toda de lectura, con computePrices():
- NGO-T2681: esperado Compulandia 6.642.900 (con
iva_incl = 0) contra 7.386.500 actual; TN 7.107.903, Conti 6.975.045, UM 6.642.900. - 300 productos recientes de cualquier proveedor con factor ≥ 1 (incluye Fastrax, Compras Paraguai con escala y CL con especial): 300 iguales al precio almacenado, 0 diferencias. La regla nueva no altera a nadie con factor ≥ 1.
prices:recalculate --supplier=NGO --category=810 --dry-run: 68 filas cambian, 0 iguales (17 productos × 4 canales).prices:recalculate --supplier=NGO --missing --dry-run: los 113 NGO sin precio están omitidos porsin_categoria_activa(la categoría se crea inactiva en el sync y nadie la activó todavía). El comando los recalculará cuando se activen.
Puesta en producción sugerida¶
- Marcar NGO con IVA incluido en el ABM de proveedores (D2).
php artisan prices:recalculate --supplier=NGO --dry-runy revisar la tabla.php artisan prices:recalculate --supplier=NGO(encola; Horizon procesa la colapricingy cada job reevalúa el seleccionado, que dispara los syncs de salida).- Confirmar en
storage/logs/jobs/PRICING/pricing.logy en la vistaproduct_item_viewpara 3–5 SKUs.
Nota: marcar el IVA del proveedor no dispara recálculo por sí solo (H2), de ahí que
el paso 3 sea necesario. Tocar el factor de las categorías de NGO sí lo dispararía, por
el CategoryObserver, pero el comando es el camino controlable y con vista previa.
Pendiente (fuera de esta corrección)¶
- H2: extender el recálculo automático a lo que hoy queda fuera, sobre todo
partners.factorysuppliers.iva_incl, y darle chunk al job por categoría. Para esos casos el comandoprices:recalculatees el camino por ahora. - H3: la carrera creación → categoría. Opciones:
delayen el job decreated, attach de categoría antes delcreateen los syncs, o recálculo al cambiar la pivotecategory_product.--missingsirve de red mientras tanto. - H4: borrar o invalidar filas de
pricesen los retornos tempranos y al descartar un producto. - H6 (esquema):
partners.factor NOT NULL DEFAULT 1y validación en el ABM. - H5: criterio explícito de categoría de pricing y
ORDER BYen la query. - H9: índice único
(supplier_product_id, partner_id, price_list_id). - H7/H8/H10/H11: validaciones alineadas,
changeStatuscorregido, tramos de escala continuos.
8. Cómo verificar sin correr tests¶
Los tests no se ejecutan en el servidor de desarrollo compartido. Verificación de lectura:
php artisan prices:recalculate --sku=<SKU> --dry-runmuestra actual vs. esperado por canal con factor, margen y origen del parámetro.- Tras un recálculo real, consulta de control (tinker read-only):
DB::table('prices')->where('supplier_product_id', 13833)->get(['partner_id','regular_price','special_price']);
- Revisar
storage/logs/jobs/PRICING/pricing.log: un logPrecios calculadospor producto con las filas, oCálculo de precios omitidocon el motivo.
Anexo · Consultas usadas (read-only, dev, 2026-09-15)¶
- Categorías de proveedor con factor < 1:
categories where categorizable_type = Supplier and factor < 1→ 7 (6 activas), todas de NGO. - Productos NGO en esas categorías: 101; seleccionados activos: 26.
- Productos con
regular_price > 0sin fila enprices: 1.604 (desglose en H3). - Productos con más de una categoría activa con factor o margen distintos: 5.230.
- Productos con
regular_pricenulo/0 y filas enprices: 5.475. - Filas en
pricesconspecial_price >= regular_price: 88. pricessin índice único; duplicados actuales: 0; total filas: 89.023.- Flag
WORKFLOW_SUPPLIER_PRODUCT_CHANGE:true(definido dos veces en.env, líneas 213 y 216).