From 04549d80bf652f422a5911959b85ea46add10b9c Mon Sep 17 00:00:00 2001 From: Himank Dave <93311724+steadyfall@users.noreply.github.com> Date: Thu, 14 May 2026 15:56:49 -0400 Subject: [PATCH] fix(rules): count blocked rule transactions (#1782) * Add blocked count to rule run summary * test(rules): cover rule run blocked counts * fix(rules): derive blocked count from modified rows Blocked rule transactions are the processed rows that were not modified. This keeps the displayed queued / processed / modified / blocked summary aligned when a run has already processed all matching rows but some were skipped by enrichment locks. * fix(rules): count processed rows for rule jobs Synchronous rule actions return the number of rows they modified, but rule-run processed counts should represent the number of matched transactions the job attempted to process. Using queued matches for processed preserves the distinction between processed and modified rows, which lets locked manual edits appear as blocked instead of making processed collapse to modified. This changes RuleJob counter semantics, so it was committed separately from the derived blocked-count display change. --- app/jobs/rule_job.rb | 2 +- app/models/rule_run.rb | 4 ++ app/views/rules/index.html.erb | 3 +- config/locales/views/rules/en.yml | 1 + test/controllers/rules_controller_test.rb | 20 +++++++++ test/jobs/rule_job_test.rb | 54 +++++++++++++++++++++++ test/system/rules_test.rb | 37 ++++++++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 test/jobs/rule_job_test.rb create mode 100644 test/system/rules_test.rb diff --git a/app/jobs/rule_job.rb b/app/jobs/rule_job.rb index 92a23c086..c70c79fa9 100644 --- a/app/jobs/rule_job.rb +++ b/app/jobs/rule_job.rb @@ -40,7 +40,7 @@ class RuleJob < ApplicationJob status = "pending" elsif result.is_a?(Integer) # Only synchronous actions were executed - transactions_processed = result + transactions_processed = transactions_queued transactions_modified = result status = "success" else diff --git a/app/models/rule_run.rb b/app/models/rule_run.rb index 8f0ac759f..3028de44a 100644 --- a/app/models/rule_run.rb +++ b/app/models/rule_run.rb @@ -27,6 +27,10 @@ class RuleRun < ApplicationRecord status == "failed" end + def transactions_blocked + [ transactions_processed - transactions_modified, 0 ].max + end + # Thread-safe method to complete a job and update the run def complete_job!(modified_count: 0) with_lock do diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 49282af42..00d0dfb94 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -128,6 +128,7 @@
<%= t("rules.recent_runs.columns.transactions_counts.queued") %>
<%= t("rules.recent_runs.columns.transactions_counts.processed") %>
<%= t("rules.recent_runs.columns.transactions_counts.modified") %>
+
<%= t("rules.recent_runs.columns.transactions_counts.blocked", default: "Blocked") %>
@@ -169,7 +170,7 @@ <%= run.rule_name.presence || run.rule&.name.presence || t("rules.recent_runs.unnamed_rule") %> - <%= "#{number_with_delimiter(run.transactions_queued)} / #{number_with_delimiter(run.transactions_processed)} / #{number_with_delimiter(run.transactions_modified)}" %> + <%= "#{number_with_delimiter(run.transactions_queued)} / #{number_with_delimiter(run.transactions_processed)} / #{number_with_delimiter(run.transactions_modified)} / #{number_with_delimiter(run.transactions_blocked)}" %> <% end %> diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml index 81eccc7e8..5c6fac3bf 100644 --- a/config/locales/views/rules/en.yml +++ b/config/locales/views/rules/en.yml @@ -31,6 +31,7 @@ en: queued: Queued processed: Processed modified: Modified + blocked: Blocked execution_types: manual: Manual scheduled: Scheduled diff --git a/test/controllers/rules_controller_test.rb b/test/controllers/rules_controller_test.rb index baa30a1f3..cdbc49134 100644 --- a/test/controllers/rules_controller_test.rb +++ b/test/controllers/rules_controller_test.rb @@ -210,6 +210,26 @@ class RulesControllerTest < ActionDispatch::IntegrationTest end end + test "index shows blocked count in recent runs summary" do + rule = rules(:one) + RuleRun.create!( + rule: rule, + execution_type: "manual", + status: "success", + transactions_queued: 10, + transactions_processed: 7, + transactions_modified: 4, + pending_jobs_count: 0, + executed_at: Time.current + ) + + get rules_url + + assert_response :success + assert_select "th", text: /Queued\s+Processed\s+Modified\s+Blocked/ + assert_select "td", text: "10 / 7 / 4 / 3" + end + test "should get confirm_all" do get confirm_all_rules_url assert_response :success diff --git a/test/jobs/rule_job_test.rb b/test/jobs/rule_job_test.rb new file mode 100644 index 000000000..c7d1c462a --- /dev/null +++ b/test/jobs/rule_job_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class RuleJobTest < ActiveJob::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + @account = @family.accounts.create!(name: "Rule job test", balance: 1000, currency: "USD", accountable: Depository.new) + @food_and_dining = @family.categories.create!(name: "Food & Dining") + @groceries = @family.categories.create!(name: "Groceries") + end + + test "records manually locked matching transactions as blocked" do + 20.times do |index| + create_transaction( + account: @account, + name: "Whole Foods #{index}", + date: Date.current - index.days + ) + end + + manually_locked_transactions = @family.transactions + .joins(:entry) + .where("entries.name LIKE ?", "Whole Foods%") + .order("entries.date DESC") + .limit(10) + + manually_locked_transactions.each do |transaction| + transaction.update!(category: @groceries) + transaction.lock_attr!(:category_id) + transaction.entry.mark_user_modified! + end + + rule = @family.rules.create!( + name: "Whole Foods Testing", + resource_type: "transaction", + effective_date: 1.year.ago.to_date, + conditions: [ + Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Whole Foods") + ], + actions: [ + Rule::Action.new(action_type: "set_transaction_category", value: @food_and_dining.id) + ] + ) + + RuleJob.perform_now(rule) + + rule_run = rule.rule_runs.order(:created_at).last + assert_equal 20, rule_run.transactions_queued + assert_equal 20, rule_run.transactions_processed + assert_equal 10, rule_run.transactions_modified + assert_equal 10, rule_run.transactions_blocked + end +end diff --git a/test/system/rules_test.rb b/test/system/rules_test.rb new file mode 100644 index 000000000..49f4601bc --- /dev/null +++ b/test/system/rules_test.rb @@ -0,0 +1,37 @@ +require "application_system_test_case" + +class RulesTest < ApplicationSystemTestCase + setup do + sign_in @user = users(:family_admin) + end + + test "shows queued processed modified and blocked counts for recent rule runs" do + rule = @user.family.rules.create!( + name: "Whole Foods Testing", + resource_type: "transaction", + effective_date: 1.year.ago.to_date, + conditions: [ + Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Whole Foods") + ], + actions: [ + Rule::Action.new(action_type: "set_transaction_category", value: categories(:food_and_drink).id) + ] + ) + + rule.rule_runs.create!( + rule_name: rule.name, + execution_type: "manual", + status: "success", + transactions_queued: 20, + transactions_processed: 20, + transactions_modified: 10, + pending_jobs_count: 0, + executed_at: Time.current + ) + + visit rules_path + + assert_selector "th", text: /queued\s+processed\s+modified\s+blocked/i + assert_selector "td", text: "20 / 20 / 10 / 10" + end +end