Implement Run all rules (#582)

This commit is contained in:
soky srm
2026-01-08 15:20:14 +01:00
committed by GitHub
parent c315e08a6e
commit e37c03d1d4
11 changed files with 239 additions and 0 deletions

View File

@@ -104,6 +104,30 @@ class RulesController < ApplicationController
redirect_to rules_path, notice: "All rules deleted"
end
def confirm_all
@rules = Current.family.rules
@total_affected_count = Rule.total_affected_resource_count(@rules)
# Compute AI cost estimation if any rule has auto_categorize action
if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } }
llm_provider = Provider::Registry.get_provider(:openai)
if llm_provider
@selected_model = Provider::Openai.effective_model
@estimated_cost = LlmUsage.estimate_auto_categorize_cost(
transaction_count: @total_affected_count,
category_count: Current.family.categories.count,
model: @selected_model
)
end
end
end
def apply_all
ApplyAllRulesJob.perform_later(Current.family)
redirect_back_or_to rules_path, notice: t("rules.apply_all.success")
end
private
def set_rule
@rule = Current.family.rules.find(params[:id])

View File

@@ -0,0 +1,9 @@
class ApplyAllRulesJob < ApplicationJob
queue_as :medium_priority
def perform(family, execution_type: "manual")
family.rules.find_each do |rule|
RuleJob.perform_now(rule, ignore_attribute_locks: true, execution_type: execution_type)
end
end
end

View File

@@ -40,6 +40,20 @@ class Rule < ApplicationRecord
matching_resources_scope.count
end
# Calculates total unique resources affected across multiple rules
# This handles overlapping rules by deduplicating transaction IDs
def self.total_affected_resource_count(rules)
return 0 if rules.empty?
# Collect all unique transaction IDs matched by any rule
transaction_ids = Set.new
rules.each do |rule|
transaction_ids.merge(rule.send(:matching_resources_scope).pluck(:id))
end
transaction_ids.size
end
def apply(ignore_attribute_locks: false, rule_run: nil)
total_modified = 0
total_async_jobs = 0

View File

@@ -0,0 +1,48 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t("rules.apply_all.confirm_title")) %>
<% dialog.with_body do %>
<p class="text-secondary text-sm mb-4">
<%= t("rules.apply_all.confirm_message",
count: @rules.count,
transactions: @total_affected_count) %>
</p>
<% if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } } %>
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-start gap-2">
<%= icon "info", class: "w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" %>
<div class="text-xs">
<p class="font-medium text-blue-900 mb-1"><%= t("rules.apply_all.ai_cost_title") %></p>
<% if @estimated_cost.present? %>
<p class="text-blue-700">
<%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %>
<%= t("rules.apply_all.estimated_cost", cost: sprintf("%.4f", @estimated_cost)) %>
</p>
<% else %>
<p class="text-blue-700">
<%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %>
<% if @selected_model.present? %>
<span class="font-semibold"><%= t("rules.apply_all.cost_unavailable_model", model: @selected_model) %></span>
<% else %>
<span class="font-semibold"><%= t("rules.apply_all.cost_unavailable_no_provider") %></span>
<% end %>
<%= t("rules.apply_all.cost_warning") %>
</p>
<% end %>
<p class="text-blue-600 mt-1">
<%= link_to t("rules.apply_all.view_usage"), settings_llm_usage_path, class: "underline hover:text-blue-800" %>
</p>
</div>
</div>
</div>
<% end %>
<%= render DS::Button.new(
text: t("rules.apply_all.confirm_button"),
href: apply_all_rules_path,
method: :post,
full_width: true,
data: { turbo_frame: "_top" }) %>
<% end %>
<% end %>

View File

@@ -11,6 +11,13 @@
method: :delete,
confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %>
<% end %>
<%= render DS::Link.new(
text: t("rules.apply_all.button"),
variant: "secondary",
href: confirm_all_rules_path,
icon: "play",
frame: :modal
) %>
<% end %>
<%= render DS::Link.new(
text: "New rule",

View File

@@ -4,6 +4,19 @@ en:
no_action: No Action
actions:
value_placeholder: Enter a value
apply_all:
button: Apply All
confirm_title: Apply All Rules
confirm_message: You are about to apply %{count} rules affecting %{transactions} unique transactions. Please confirm if you wish to proceed.
confirm_button: Confirm and Apply All
success: All rules have been queued for execution
ai_cost_title: AI Cost Estimation
ai_cost_message: This will use AI to categorize up to %{transactions} transactions.
estimated_cost: "Estimated cost: ~$%{cost}"
cost_unavailable_model: Cost estimation unavailable for model "%{model}".
cost_unavailable_no_provider: Cost estimation unavailable (no LLM provider configured).
cost_warning: You may incur costs, please check with the model provider for the most up-to-date prices.
view_usage: View usage history
recent_runs:
title: Recent Runs
description: View the execution history of your rules including success/failure status and transaction counts.

View File

@@ -219,6 +219,8 @@ Rails.application.routes.draw do
collection do
delete :destroy_all
get :confirm_all
post :apply_all
end
end

33
lib/tasks/rules.rake Normal file
View File

@@ -0,0 +1,33 @@
namespace :rules do
desc "Apply all rules for a family"
task :apply_all, [ :family_id ] => :environment do |_t, args|
family_id = args[:family_id]
if family_id.blank?
puts "Usage: bin/rails rules:apply_all[family_id]"
exit 1
end
family = Family.find(family_id)
rules = family.rules
if rules.empty?
puts "No rules found for family #{family_id}"
exit 0
end
puts "Applying #{rules.count} rules for family #{family_id}..."
rules.find_each do |rule|
print " Applying rule '#{rule.name || rule.id}'... "
begin
RuleJob.perform_now(rule, ignore_attribute_locks: true, execution_type: "manual")
puts "done"
rescue => e
puts "failed: #{e.message}"
end
end
puts "Finished applying all rules"
end
end

View File

@@ -179,4 +179,17 @@ class RulesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to rules_url
end
test "should get confirm_all" do
get confirm_all_rules_url
assert_response :success
end
test "apply_all enqueues job and redirects" do
assert_enqueued_with(job: ApplyAllRulesJob) do
post apply_all_rules_url
end
assert_redirected_to rules_url
end
end

View File

@@ -0,0 +1,41 @@
require "test_helper"
class ApplyAllRulesJobTest < ActiveJob::TestCase
include EntriesTestHelper
setup do
@family = families(:empty)
@account = @family.accounts.create!(name: "Test Account", balance: 1000, currency: "USD", accountable: Depository.new)
@groceries_category = @family.categories.create!(name: "Groceries")
end
test "applies all rules for a family" do
# Create a rule
rule = Rule.create!(
family: @family,
resource_type: "transaction",
effective_date: 1.day.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: @groceries_category.id) ]
)
# Mock RuleJob to verify it gets called for each rule
RuleJob.expects(:perform_now).with(rule, ignore_attribute_locks: true, execution_type: "manual").once
ApplyAllRulesJob.perform_now(@family)
end
test "applies all rules with custom execution type" do
rule = Rule.create!(
family: @family,
resource_type: "transaction",
effective_date: 1.day.ago.to_date,
conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Test") ],
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
)
RuleJob.expects(:perform_now).with(rule, ignore_attribute_locks: true, execution_type: "scheduled").once
ApplyAllRulesJob.perform_now(@family, execution_type: "scheduled")
end
end

View File

@@ -201,4 +201,39 @@ class RuleTest < ActiveSupport::TestCase
assert_equal business_category, transaction_entry.transaction.category, "Transaction with 'business' in notes should be categorized"
assert_nil transaction_entry2.transaction.category, "Transaction without 'business' in notes should not be categorized"
end
test "total_affected_resource_count deduplicates overlapping rules" do
# Create transactions
transaction_entry1 = create_transaction(date: Date.current, account: @account, name: "Whole Foods", amount: 50)
transaction_entry2 = create_transaction(date: Date.current, account: @account, name: "Whole Foods", amount: 100)
transaction_entry3 = create_transaction(date: Date.current, account: @account, name: "Target", amount: 75)
# Rule 1: Match transactions with name "Whole Foods" (matches txn 1 and 2)
rule1 = Rule.create!(
family: @family,
resource_type: "transaction",
effective_date: 1.day.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: @groceries_category.id) ]
)
# Rule 2: Match transactions with amount > 60 (matches txn 2 and 3)
rule2 = Rule.create!(
family: @family,
resource_type: "transaction",
effective_date: 1.day.ago.to_date,
conditions: [ Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 60) ],
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
)
# Rule 1 affects 2 transactions, Rule 2 affects 2 transactions
# But transaction_entry2 is matched by both, so total unique should be 3
assert_equal 2, rule1.affected_resource_count
assert_equal 2, rule2.affected_resource_count
assert_equal 3, Rule.total_affected_resource_count([ rule1, rule2 ])
end
test "total_affected_resource_count returns zero for empty rules" do
assert_equal 0, Rule.total_affected_resource_count([])
end
end