mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
Implement Run all rules (#582)
This commit is contained in:
@@ -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])
|
||||
|
||||
9
app/jobs/apply_all_rules_job.rb
Normal file
9
app/jobs/apply_all_rules_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
48
app/views/rules/confirm_all.html.erb
Normal file
48
app/views/rules/confirm_all.html.erb
Normal 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 %>
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
33
lib/tasks/rules.rake
Normal 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
|
||||
@@ -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
|
||||
|
||||
41
test/jobs/apply_all_rules_job_test.rb
Normal file
41
test/jobs/apply_all_rules_job_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user