Skip to content

Rendimiento: agrupar el cálculo de movimientos de stock en lotes - #50

Open
daniel89fg wants to merge 2 commits into
masterfrom
rendimiento-movimientos-stock
Open

daniel89fg wants to merge 2 commits into
masterfrom
rendimiento-movimientos-stock

Conversation

@daniel89fg

Copy link
Copy Markdown
Member

Requiere NeoRazorX/facturascripts#2040. Ese PR añade al core los pipes de DocumentStitcher que usa la extensión incluida aquí; sin él, la agrupación de documentos no queda cubierta y este PR no debe aprobarse.

Problema

Aprobar un albarán, facturar o agrupar documentos podía superar el max_execution_time (60 s) y cortar el proceso, en dos situaciones:

  • el documento repite la misma referencia en muchas líneas,
  • los productos implicados tienen un histórico largo de movimientos de stock.

Un cliente lo reprodujo agrupando 18 albaranes de proveedor (45 líneas, referencias repetidas) en una factura desde DocumentStitcher: timeout de 60 s. Sin el plugin activo, la misma operación era instantánea.

Causas encontradas en Lib/StockMovementManager.php:

  • addLineBusinessDocument() llamaba a updateReferenceSaldos() en cada invocación, y ese método releía, reordenaba en PHP y regrababa todo el histórico de (almacén, referencia).
  • Se invocaba 4-5 veces por línea en el ciclo aprobar → generar: al guardar la línea y dos veces por línea clonada desde cloneLine().
  • getBusinessDocumentMovementQuantityForReference() recorría $doc->getLines() y por cada línea con la misma referencia recargaba el documento entero y consultaba doc_transformations: coste cuadrático.
  • cleanResidualMovements() consultaba las líneas dentro del bucle de movimientos.
  • Al agrupar documentos, cada albarán creaba y borraba sus movimientos por separado, recalculando los saldos una y otra vez.
  • stocks_movimientos no tenía índices compuestos, y el worker recalculaba todas las variantes por todos los almacenes, con un evento por línea guardada.

Solución

  • Lotes reentrantes en StockMovementManager (beginBatch() / flushBatch() / inBatch()): dentro de un lote sólo se anota el par (documento, referencia) afectado y, al cerrarlo, se calcula una sola vez cada movimiento y una sola vez el saldo de cada (almacén, referencia). 200 líneas de la misma referencia colapsan en una entrada.
  • Caché por documento de líneas y transformaciones (dos consultas por documento en lugar de cuatro por línea), que elimina el coste cuadrático. Se abre y cierra siempre dentro de la misma operación, así que nunca lee datos obsoletos.
  • Los saldos se recalculan sólo desde la fecha afectada, partiendo del saldo anterior, y no se recalculan si el movimiento no ha cambiado.
  • cleanResidualMovements(): agrupa las líneas por referencia fuera del bucle y, dentro de un lote, delega la decisión en el cierre del lote en vez de borrar y recrear.
  • Extensiones que abren y cierran el lote en cada flujo: modelo de documento (saveBefore / saveUpdate / saveInsert / save), generador de documentos (generateBefore / generateTrue / generateFalse) y controlador DocumentStitcher, para que los cambios de estado de todos los documentos y la generación del nuevo compartan un único lote.
  • Worker UpdateStockMovements: recorre sólo los pares (almacén, referencia) que existen y se envía un único evento por producto en lugar de uno por línea.
  • Índices nuevos en stocks_movimientos: (codalmacen, referencia), (docid, docmodel) e (idproducto, codalmacen, referencia).

El cálculo de la cantidad de cada movimiento no cambia de fórmula: sólo cambia cuándo y cuántas veces se ejecuta, y cada cierre de lote recalcula a partir del estado ya persistido.

Tiempos

Medidos con los tests nuevos, sobre MySQL y PostgreSQL:

Escenario Antes Después
Agrupar 120 albaranes (2.971 líneas, referencias repetidas, productos con 0-500 movimientos previos) en una factura 133-183 s 43-71 s
Albarán con 200 líneas de la misma referencia → factura minutos (llegaba a agotar el tiempo de ejecución) 2,3-5,4 s
Albarán con 50 productos de 200 a 1.000 movimientos previos cada uno → factura — 0,8-1,2 s

En el caso de la agrupación, el tiempo restante lo consume casi por completo el core al clonar las 2.971 líneas: el coste del propio plugin pasó a ser prácticamente cero (de 107 s recalculando saldos a un recálculo único por referencia al final).

Tests

Tres casos nuevos en Test/main/DeliveryNoteTest.php, con umbral de tiempo configurable mediante STOCKAVANZADO_PERF_BUDGET y aserciones de corrección (número exacto de movimientos, cantidades, saldos acumulados y stock final):

  • albarán con 200 líneas del mismo producto → factura,
  • albarán con 50 productos con entre 200 y 1.000 movimientos previos → factura,
  • 120 albaranes con entre 1 y 50 líneas cada uno, sobre 40 productos con entre 0 y 500 movimientos previos, agrupados en una sola factura reproduciendo el flujo de DocumentStitcher.

Suite completa en verde: 86 tests en PHP 8.1, 8.2, 8.3 y 8.4, con MySQL y PostgreSQL.

🤖 Generated with Claude Code

Daniel Fernández Giménez and others added 2 commits September 17, 2026 18:59
Aprobar un albarán, facturar o agrupar documentos podía superar el
max_execution_time cuando el documento repetía la misma referencia en
muchas líneas o los productos tenían un histórico largo de movimientos.

Cada llamada a addLineBusinessDocument() recalculaba los saldos de toda
la referencia, y se invocaba 4-5 veces por línea. Además el cálculo de
la cantidad recorría las líneas del documento recargándolo de la base de
datos en cada una, con un coste cuadrático.

Cambios:

- StockMovementManager: lotes reentrantes (beginBatch/flushBatch). Dentro
  de un lote sólo se anota el par (documento, referencia) afectado y al
  cerrarlo se calcula una sola vez cada movimiento y cada saldo.
- StockMovementManager: caché por documento de líneas y transformaciones,
  que elimina el coste cuadrático y las recargas repetidas.
- StockMovementManager: los saldos se recalculan sólo desde la fecha
  afectada, y no se recalculan si el movimiento no ha cambiado.
- cleanResidualMovements(): las líneas se agrupan por referencia fuera del
  bucle y, dentro de un lote, delega la decisión en el cierre del lote.
- Extensiones de BusinessDocument, BusinessDocumentLine, del generador de
  documentos y del controlador DocumentStitcher para abrir y cerrar el
  lote en cada flujo.
- Worker UpdateStockMovements: recorre sólo los pares (almacén,
  referencia) con movimientos, en lugar de todas las variantes por todos
  los almacenes, y se envía un único evento por producto.
- Índices en stocks_movimientos por (codalmacen, referencia),
  (docid, docmodel) e (idproducto, codalmacen, referencia).
- Tests de rendimiento en DeliveryNoteTest: albarán de 200 líneas de la
  misma referencia, 50 productos con histórico y agrupación de 120
  albaranes en una sola factura.

La extensión del controlador necesita los pipes añadidos en el core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant