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.
This commit is contained in:
Himank Dave
2026-05-14 15:56:49 -04:00
committed by GitHub
parent 0ad1e59165
commit 04549d80bf
7 changed files with 119 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -128,6 +128,7 @@
<div><%= t("rules.recent_runs.columns.transactions_counts.queued") %></div>
<div><%= t("rules.recent_runs.columns.transactions_counts.processed") %></div>
<div><%= t("rules.recent_runs.columns.transactions_counts.modified") %></div>
<div><%= t("rules.recent_runs.columns.transactions_counts.blocked", default: "Blocked") %></div>
</div>
</th>
</tr>
@@ -169,7 +170,7 @@
<%= run.rule_name.presence || run.rule&.name.presence || t("rules.recent_runs.unnamed_rule") %>
</td>
<td class="px-4 py-3 text-sm text-primary text-center tabular-nums">
<%= "#{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)}" %>
</td>
</tr>
<% end %>

View File

@@ -31,6 +31,7 @@ en:
queued: Queued
processed: Processed
modified: Modified
blocked: Blocked
execution_types:
manual: Manual
scheduled: Scheduled

View File

@@ -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

View File

@@ -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

37
test/system/rules_test.rb Normal file
View File

@@ -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