From ad80bb91a5f5c06cf7293fae8621fac4187f075b Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Fri, 15 May 2026 16:07:09 +0800 Subject: [PATCH 1/3] feat(stockAlerts): restore inventory_snapshot ETL after transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores Phase 2 of OBPIH-3280. PR #2018 replaced inventory_snapshot with product_availability as the live qty source but deleted RefreshInventorySnapshotAfterTransactionJob with an explicit deferred follow-up "need to ETL product availability records to inventory snapshot" — never delivered, leaving stock-alert reports empty for 5+ years. This service listens to RefreshProductAvailabilityEvent (already fires on every Transaction.afterInsert), synchronously refreshes product_availability for the affected products, and copies the rows into inventory_snapshot with date = current_date + 1 — the exact shape the stock-alert compare view expects. INSERT column list and ON DUPLICATE KEY UPDATE behavior match the pre-2020 generateInsertInventorySnapshotStatement; data source is the new event-driven product_availability table, finishing the architectural pivot OBPIH-3280 started. Preserves activity-gated alerting: snapshot rows only appear when transactions happen, so the view's BETWEEN current_date AND current_date + 1 filter still acts as the no-spam gate. Gated by openboxes.jobs.refreshInventorySnapshotAfterTransactionJob.enabled (truthy-check matching the deleted job's pattern). Upstream application.yml still ships this key at true, so default behavior is on. Refs: openboxes/openboxes#4742, openboxes/openboxes#2018 --- .../InventorySnapshotEtlService.groovy | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 grails-app/services/org/pih/warehouse/custom/stockAlertRestore/InventorySnapshotEtlService.groovy diff --git a/grails-app/services/org/pih/warehouse/custom/stockAlertRestore/InventorySnapshotEtlService.groovy b/grails-app/services/org/pih/warehouse/custom/stockAlertRestore/InventorySnapshotEtlService.groovy new file mode 100644 index 0000000000..607791aa3d --- /dev/null +++ b/grails-app/services/org/pih/warehouse/custom/stockAlertRestore/InventorySnapshotEtlService.groovy @@ -0,0 +1,111 @@ +package org.pih.warehouse.custom.stockAlertRestore + +import grails.gorm.transactions.Transactional +import grails.util.Holders +import groovy.sql.Sql +import org.pih.warehouse.inventory.RefreshProductAvailabilityEvent +import org.springframework.context.ApplicationListener +import org.springframework.core.Ordered + +import javax.sql.DataSource + +/** + * Restores Phase 2 of OBPIH-3280 — copies product_availability rows into inventory_snapshot + * with date = current_date + 1, so the stock-alert view chain (which still reads from + * inventory_snapshot) has data to report on. + * + * Upstream PR #2018 deleted RefreshInventorySnapshotAfterTransactionJob with the explicit + * deferred follow-up "need to ETL product availability records to inventory snapshot" — never + * delivered. Issue openboxes/openboxes#4742 tracks the symptom. + */ +@Transactional +class InventorySnapshotEtlService implements ApplicationListener, Ordered { + + def productAvailabilityService + DataSource dataSource + + @Override + int getOrder() { + return Ordered.LOWEST_PRECEDENCE + } + + void onApplicationEvent(RefreshProductAvailabilityEvent event) { + if (!isEnabled()) { + return + } + if (event?.disableRefresh) { + return + } + if (!event?.locationId) { + return + } + + log.info "InventorySnapshotEtlService received event locationId=${event.locationId} productIds=${event.productIds?.size() ?: 0}" + + // The upstream RefreshProductAvailabilityEventService may have triggered an async + // refresh job. Force a synchronous refresh here so the data we read is current. + productAvailabilityService.refreshProductsAvailability( + event.locationId, event.productIds, event.forceRefresh ?: Boolean.FALSE) + + copySnapshotFromProductAvailability(event.locationId, event.productIds) + } + + /** + * Enabled only when openboxes.jobs.refreshInventorySnapshotAfterTransactionJob.enabled + * is explicitly true. Mirrors the truthy-check the deleted + * RefreshInventorySnapshotAfterTransactionJob used (`if (enabled) { ... }`). + * + * Upstream application.yml still ships this key at true, so default behavior is on. + * Per-client deploys (e.g. docker/openboxes.yml) should set it explicitly so the + * feature survives an upstream housekeeping pass that drops the orphan default. + */ + private boolean isEnabled() { + return Holders.config.openboxes.jobs.refreshInventorySnapshotAfterTransactionJob.enabled == true + } + + private void copySnapshotFromProductAvailability(String locationId, List productIds) { + Date tomorrow = new Date() + 1 + tomorrow.clearTime() + String tomorrowString = tomorrow.format("yyyy-MM-dd HH:mm:ss") + + boolean filterByProducts = productIds && !productIds.isEmpty() + String productFilter = filterByProducts + ? "AND pa.product_id IN (${productIds.collect { "'${it}'" }.join(',')})" + : "" + + String sqlText = """ + INSERT INTO inventory_snapshot + (id, version, date, location_id, product_id, product_code, + inventory_item_id, lot_number, expiration_date, + bin_location_id, bin_location_name, + quantity_on_hand, date_created, last_updated) + SELECT UUID(), 0, '${tomorrowString}', + pa.location_id, pa.product_id, pa.product_code, + pa.inventory_item_id, + COALESCE(pa.lot_number, 'DEFAULT'), + ii.expiration_date, + pa.bin_location_id, + COALESCE(pa.bin_location_name, 'DEFAULT'), + pa.quantity_on_hand, + NOW(), NOW() + FROM product_availability pa + LEFT JOIN inventory_item ii ON ii.id = pa.inventory_item_id + WHERE pa.location_id = '${locationId}' + ${productFilter} + ON DUPLICATE KEY UPDATE + quantity_on_hand = VALUES(quantity_on_hand), + version = inventory_snapshot.version + 1, + last_updated = NOW(); + """ + + Sql sql = new Sql(dataSource) + try { + int rows = sql.executeUpdate(sqlText) + log.info "InventorySnapshotEtlService wrote/updated ${rows} inventory_snapshot rows for location ${locationId} dated ${tomorrowString}" + } catch (Exception e) { + log.error "InventorySnapshotEtlService failed to copy snapshot rows for location ${locationId}: ${e.message}", e + } finally { + sql.close() + } + } +} From 14940fc39aa046e2397df00fdd930b0a6ead85ff Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Fri, 15 May 2026 16:07:39 +0800 Subject: [PATCH 2/3] chore(tjk): pin refreshInventorySnapshotAfterTransactionJob enabled flag Defensive override so the stock-alert ETL keeps running on TJK even if upstream eventually removes the orphan openboxes.jobs.refreshInventorySnapshotAfterTransactionJob.enabled default from application.yml during a future housekeeping pass. The key is read by our InventorySnapshotEtlService (custom code) but the deployment-side override here is what guarantees we don't depend on upstream's default staying around. --- docker/openboxes.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker/openboxes.yml b/docker/openboxes.yml index b31f909e1e..b1143bf819 100644 --- a/docker/openboxes.yml +++ b/docker/openboxes.yml @@ -31,3 +31,11 @@ openboxes: enabled: true daysUntilExpiry: 90 cronExpression: 0 0 0 * * ? + + # Gates our InventorySnapshotEtlService — restores Phase 2 of OBPIH-3280 + # so stock alerts actually have data (openboxes/openboxes#4742). + # Already defaults to true in upstream application.yml; set explicitly + # here so the fix survives if upstream eventually removes the orphan key + # during housekeeping. + refreshInventorySnapshotAfterTransactionJob: + enabled: true From 2b21c0f03014712c5a85667f7643a8e79e274af8 Mon Sep 17 00:00:00 2001 From: gqcorneby <185756521+gqcorneby@users.noreply.github.com> Date: Fri, 22 May 2026 12:32:03 +0800 Subject: [PATCH 3/3] fix(stockAlerts): fix stale QOH in inventory_snapshot ETL - defer ETL to afterCommit so transaction_entry rows are visible before product_availability is recomputed - use forceRefresh=true to purge stale pa rows before reinserting - replace ON DUPLICATE KEY UPDATE with DELETE+INSERT to remove stale (lot, bin) combinations from the snapshot --- .../InventorySnapshotEtlService.groovy | 80 ++++++++++++------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/grails-app/services/org/pih/warehouse/custom/stockAlertRestore/InventorySnapshotEtlService.groovy b/grails-app/services/org/pih/warehouse/custom/stockAlertRestore/InventorySnapshotEtlService.groovy index 607791aa3d..8cc1564d44 100644 --- a/grails-app/services/org/pih/warehouse/custom/stockAlertRestore/InventorySnapshotEtlService.groovy +++ b/grails-app/services/org/pih/warehouse/custom/stockAlertRestore/InventorySnapshotEtlService.groovy @@ -6,17 +6,28 @@ import groovy.sql.Sql import org.pih.warehouse.inventory.RefreshProductAvailabilityEvent import org.springframework.context.ApplicationListener import org.springframework.core.Ordered +import org.springframework.transaction.support.TransactionSynchronizationAdapter +import org.springframework.transaction.support.TransactionSynchronizationManager import javax.sql.DataSource /** * Restores Phase 2 of OBPIH-3280 — copies product_availability rows into inventory_snapshot * with date = current_date + 1, so the stock-alert view chain (which still reads from - * inventory_snapshot) has data to report on. + * inventory_snapshot) sees the current quantities a transaction just produced. * * Upstream PR #2018 deleted RefreshInventorySnapshotAfterTransactionJob with the explicit - * deferred follow-up "need to ETL product availability records to inventory snapshot" — never - * delivered. Issue openboxes/openboxes#4742 tracks the symptom. + * deferred follow-up "need to ETL product availability records to inventory snapshot" — + * never delivered. This service finishes that pivot rather than rolling it back: pa stays + * the live source of truth; inventory_snapshot is a derived projection. + * + * Wired via RefreshProductAvailabilityEvent (Transaction.afterInsert / afterUpdate). Work + * is registered inside an afterCommit synchronization so the originating DB transaction + * has committed before pa is refreshed and copied — otherwise the recompute would race + * the still-open transaction_entry insert and read stale state. + * + * Gated by openboxes.jobs.refreshInventorySnapshotAfterTransactionJob.enabled (reuses the + * config key from the pre-2020 deleted job; already shipped at true in application.yml). */ @Transactional class InventorySnapshotEtlService implements ApplicationListener, Ordered { @@ -40,25 +51,31 @@ class InventorySnapshotEtlService implements ApplicationListener