Saltar a contenido

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 tinker sobre la base dev (sin mutar product_items ni supplier_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:

  1. Al crear un SupplierProduct el observer despacha CalculatePricesJob inmediatamente (app/Observers/SupplierProductObserver.php:23). El job es ShouldBeUnique por 60 s con 3 intentos.
  2. Al actualizar, la estrategia detecta cambio de precio comparando la parte entera de regular_price y special_price, o cualquier cambio en offer_start/offer_end (SupplierProductEvaluationStrategy::detectChanges). El flag del workflow está activo en dev, así que corre CalculatePricesActivity (cola workflows, 3 intentos con backoff 15/30/60 s).
  3. Al editar una categoría de proveedor (factor, margin_min, scale_id o active) o una escala de precios, los observers CategoryObserver y PriceScaleObserver despachan RecalculateCategoryProductPricesJob, que arranca un workflow de cambio de precio por cada producto de la categoría.
  4. 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).
  5. La confirmación manual de pendientes despacha el job de forma explícita, y el controlador legado PendingProductsController lo llama de forma síncrona dentro de una transacción; ambos pueden solaparse con el workflow.
  6. 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_view hace LEFT JOIN prices por supplier_product_id y partners por partner_id, filtrando c.active = 1. Expone una fila por canal con regular_price, special_price, offer_start, offer_end. Cualquier fila obsoleta en prices aparece en la vista como precio vigente.
  • ProductPriceService::getLocalPriceInfo lee la vista con canal Compulandia y combina con Medusa (regular siempre local; special puede venir de Medusa).
  • Medusa (computeVariantPriceAmount): toma special > 0 ? special : regular de 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 vez partners.factor de TN. Es consistente sólo porque Compulandia tiene factor 1.
  • Contimarket: prices con partner_id = 3, redondeado a entero.
  • UMarket: prices con partner_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.factor y la activación de un canal. No hay observer de Partner, y el factor de canal multiplica todas las filas de prices.
  • suppliers.iva_incl. No hay observer de Supplier. 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 modelo ProductCategory (tabla deprecada por RFC 003), no de la pivote category_product que 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 en prices con special ≥ regular. Los consumidores lo mitigan cada uno a su manera (ProductPriceService anula, Medusa toma special si > 0 sin comparar).
  • 33 productos con special_price y sin offer_end; las fechas se copian sin chequear coherencia. ClearExpiredOffers limpia en origen y, como cambia la parte entera, sí dispara recálculo.
  • Comparación del piso inconsistente: > para regular, >= para special.
  • Para CL, special_price pasa 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ón first() → update/create no es atómico. Hoy hay 0 duplicados, pero conviven tres rutas concurrentes (job en created, activity del workflow, llamada síncrona del controlador) y el job explícito del modal de confirmación.
  • price_list_id es 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::CL es comparación estricta con entero; funciona porque Eloquent devuelve int, pero un modelo hidratado con string trataría a CL como no-CL (aplicaría factor, margen e IVA).
  • $this->supplier->iva_incl sin null-check: si el proveedor no existe, error fatal.
  • Se loguea en cada iteración y en nivel info sobre un stack que incluye Sentry; no se usa el formato obligatorio [{CANAL}:{product_id}:{sku}] ni logTag().
  • IVA hardcodeado en 1,1 y iva_incl como 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_price es precio sugerido de venta; vender a precio × 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 con iva_incl = 0 en 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 por sin_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

  1. Marcar NGO con IVA incluido en el ABM de proveedores (D2).
  2. php artisan prices:recalculate --supplier=NGO --dry-run y revisar la tabla.
  3. php artisan prices:recalculate --supplier=NGO (encola; Horizon procesa la cola pricing y cada job reevalúa el seleccionado, que dispara los syncs de salida).
  4. Confirmar en storage/logs/jobs/PRICING/pricing.log y en la vista product_item_view para 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)

  1. H2: extender el recálculo automático a lo que hoy queda fuera, sobre todo partners.factor y suppliers.iva_incl, y darle chunk al job por categoría. Para esos casos el comando prices:recalculate es el camino por ahora.
  2. H3: la carrera creación → categoría. Opciones: delay en el job de created, attach de categoría antes del create en los syncs, o recálculo al cambiar la pivote category_product. --missing sirve de red mientras tanto.
  3. H4: borrar o invalidar filas de prices en los retornos tempranos y al descartar un producto.
  4. H6 (esquema): partners.factor NOT NULL DEFAULT 1 y validación en el ABM.
  5. H5: criterio explícito de categoría de pricing y ORDER BY en la query.
  6. H9: índice único (supplier_product_id, partner_id, price_list_id).
  7. H7/H8/H10/H11: validaciones alineadas, changeStatus corregido, 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:

  1. php artisan prices:recalculate --sku=<SKU> --dry-run muestra actual vs. esperado por canal con factor, margen y origen del parámetro.
  2. 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']);
  1. Revisar storage/logs/jobs/PRICING/pricing.log: un log Precios calculados por producto con las filas, o Cálculo de precios omitido con 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 > 0 sin fila en prices: 1.604 (desglose en H3).
  • Productos con más de una categoría activa con factor o margen distintos: 5.230.
  • Productos con regular_price nulo/0 y filas en prices: 5.475.
  • Filas en prices con special_price >= regular_price: 88.
  • prices sin í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).