diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 24927b295..cd8a6eb6c 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -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]) diff --git a/app/jobs/apply_all_rules_job.rb b/app/jobs/apply_all_rules_job.rb new file mode 100644 index 000000000..f14da7091 --- /dev/null +++ b/app/jobs/apply_all_rules_job.rb @@ -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 diff --git a/app/models/rule.rb b/app/models/rule.rb index 13087f93b..cd18deadc 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -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 diff --git a/app/views/rules/confirm_all.html.erb b/app/views/rules/confirm_all.html.erb new file mode 100644 index 000000000..c88778767 --- /dev/null +++ b/app/views/rules/confirm_all.html.erb @@ -0,0 +1,48 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("rules.apply_all.confirm_title")) %> + + <% dialog.with_body do %> +
+ <%= t("rules.apply_all.confirm_message", + count: @rules.count, + transactions: @total_affected_count) %> +
+ + <% if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } } %> +<%= t("rules.apply_all.ai_cost_title") %>
+ <% if @estimated_cost.present? %> ++ <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %> + <%= t("rules.apply_all.estimated_cost", cost: sprintf("%.4f", @estimated_cost)) %> +
+ <% else %> ++ <%= t("rules.apply_all.ai_cost_message", transactions: @total_affected_count) %> + <% if @selected_model.present? %> + <%= t("rules.apply_all.cost_unavailable_model", model: @selected_model) %> + <% else %> + <%= t("rules.apply_all.cost_unavailable_no_provider") %> + <% end %> + <%= t("rules.apply_all.cost_warning") %> +
+ <% end %> ++ <%= link_to t("rules.apply_all.view_usage"), settings_llm_usage_path, class: "underline hover:text-blue-800" %> +
+