diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 41a5cce64..65e02ded1 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -12,6 +12,16 @@ class RulesController < ApplicationController @direction = "asc" unless [ "asc", "desc" ].include?(@direction) @rules = Current.family.rules.order(@sort_by => @direction) + + # Fetch recent rule runs with pagination + recent_runs_scope = RuleRun + .joins(:rule) + .where(rules: { family_id: Current.family.id }) + .recent + .includes(:rule) + + @pagy, @recent_runs = pagy(recent_runs_scope, limit: params[:per_page] || 20, page_param: :runs_page) + render layout: "settings" end diff --git a/app/jobs/auto_categorize_job.rb b/app/jobs/auto_categorize_job.rb index c31917a83..f168351bd 100644 --- a/app/jobs/auto_categorize_job.rb +++ b/app/jobs/auto_categorize_job.rb @@ -1,7 +1,13 @@ class AutoCategorizeJob < ApplicationJob queue_as :medium_priority - def perform(family, transaction_ids: []) - family.auto_categorize_transactions(transaction_ids) + def perform(family, transaction_ids: [], rule_run_id: nil) + modified_count = family.auto_categorize_transactions(transaction_ids) + + # If this job was part of a rule run, report back the modified count + if rule_run_id.present? + rule_run = RuleRun.find_by(id: rule_run_id) + rule_run&.complete_job!(modified_count: modified_count) + end end end diff --git a/app/jobs/auto_detect_merchants_job.rb b/app/jobs/auto_detect_merchants_job.rb index eb3713a4e..cdb4ecb7c 100644 --- a/app/jobs/auto_detect_merchants_job.rb +++ b/app/jobs/auto_detect_merchants_job.rb @@ -1,7 +1,13 @@ class AutoDetectMerchantsJob < ApplicationJob queue_as :medium_priority - def perform(family, transaction_ids: []) - family.auto_detect_transaction_merchants(transaction_ids) + def perform(family, transaction_ids: [], rule_run_id: nil) + modified_count = family.auto_detect_transaction_merchants(transaction_ids) + + # If this job was part of a rule run, report back the modified count + if rule_run_id.present? + rule_run = RuleRun.find_by(id: rule_run_id) + rule_run&.complete_job!(modified_count: modified_count) + end end end diff --git a/app/jobs/rule_job.rb b/app/jobs/rule_job.rb index fe446d0ec..1dbcccbd0 100644 --- a/app/jobs/rule_job.rb +++ b/app/jobs/rule_job.rb @@ -1,7 +1,93 @@ class RuleJob < ApplicationJob queue_as :medium_priority - def perform(rule, ignore_attribute_locks: false) - rule.apply(ignore_attribute_locks: ignore_attribute_locks) + def perform(rule, ignore_attribute_locks: false, execution_type: "manual") + executed_at = Time.current + transactions_queued = 0 + transactions_processed = 0 + transactions_modified = 0 + pending_jobs_count = 0 + status = "unknown" + error_message = nil + rule_run = nil + + begin + # Count matching transactions before processing (queued count) + transactions_queued = rule.affected_resource_count + + # Create the RuleRun record first with pending status + # We'll update it after we know if there are async jobs + # Store the rule name at execution time so it persists even if the rule name changes later + rule_run = RuleRun.create!( + rule: rule, + rule_name: rule.name, + execution_type: execution_type, + status: "pending", # Start as pending, will be updated + transactions_queued: transactions_queued, + transactions_processed: 0, + transactions_modified: 0, + pending_jobs_count: 0, + executed_at: executed_at + ) + + # Apply the rule and get the result + result = rule.apply(ignore_attribute_locks: ignore_attribute_locks, rule_run: rule_run) + + if result.is_a?(Hash) && result[:async] + # Async actions were executed + transactions_processed = result[:modified_count] || 0 + pending_jobs_count = result[:jobs_count] || 0 + status = "pending" + elsif result.is_a?(Integer) + # Only synchronous actions were executed + transactions_processed = result + transactions_modified = result + status = "success" + else + # Unexpected result type - log and default to 0 + Rails.logger.warn("RuleJob: Unexpected result type from rule.apply: #{result.class} for rule #{rule.id}") + transactions_processed = 0 + transactions_modified = 0 + status = "unknown" + end + + # Update the rule run with final counts + rule_run.update!( + status: status, + transactions_processed: transactions_processed, + transactions_modified: transactions_modified, + pending_jobs_count: pending_jobs_count + ) + rescue => e + status = "failed" + error_message = "#{e.class}: #{e.message}" + Rails.logger.error("RuleJob failed for rule #{rule.id}: #{error_message}") + + # Update the rule run as failed if it was created + if rule_run + rule_run.update(status: "failed", error_message: error_message) + else + # Create a failed rule run if we hadn't created one yet + # Store the rule name at execution time so it persists even if the rule name changes later + begin + RuleRun.create!( + rule: rule, + rule_name: rule.name, + execution_type: execution_type, + status: "failed", + transactions_queued: transactions_queued, + transactions_processed: 0, + transactions_modified: 0, + pending_jobs_count: 0, + executed_at: executed_at, + error_message: error_message + ) + rescue => e + Rails.logger.error("RuleJob: Failed to create RuleRun for rule #{rule.id}: #{create_error.message}") + end + end + + raise # Re-raise to mark job as failed in Sidekiq + end end end diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index be813066d..f305a4faa 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -31,11 +31,36 @@ module Enrichable # - Are not locked # - Are not ignored # - Have changed value from the last saved value + # Returns true if any attributes were actually changed, false otherwise def enrich_attributes(attrs, source:, metadata: {}) + # Track current values before modification for virtual attributes (like tag_ids) + current_values = {} enrichable_attrs = Array(attrs).reject do |attr_key, attr_value| - locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) || self[attr_key.to_s] == attr_value + if locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) + true + else + # For virtual attributes (like tag_ids), use the getter method + # For regular attributes, use self[attr_key] + current_value = if respond_to?(attr_key.to_sym) + send(attr_key.to_sym) + else + self[attr_key.to_s] + end + + # Normalize arrays for comparison (sort them) + if current_value.is_a?(Array) && attr_value.is_a?(Array) + current_values[attr_key] = current_value + current_value.sort == attr_value.sort + else + current_values[attr_key] = current_value + current_value == attr_value + end + end end + return false if enrichable_attrs.empty? + + was_modified = false ActiveRecord::Base.transaction do enrichable_attrs.each do |attr, value| self.send("#{attr}=", value) @@ -47,7 +72,34 @@ module Enrichable end save + + # For virtual attributes (like tag_ids), previous_changes won't track them + # So we need to check if the value actually changed by comparing before/after + if previous_changes.any? + was_modified = true + else + # Check if any virtual attributes changed by comparing current value with what we set + enrichable_attrs.each do |attr, new_value| + # Get the current value after save (for virtual attributes, this reflects the change) + current_value = if respond_to?(attr.to_sym) + send(attr.to_sym) + else + self[attr.to_s] + end + + old_value = current_values[attr] + if old_value.is_a?(Array) && new_value.is_a?(Array) && current_value.is_a?(Array) + was_modified = true if old_value.sort != current_value.sort + elsif old_value != current_value + was_modified = true + end + break if was_modified + end + end end + + # Return whether any attributes were actually saved + was_modified end def locked?(attr) diff --git a/app/models/family.rb b/app/models/family.rb index deee144ee..1df25797f 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -45,16 +45,16 @@ class Family < ApplicationRecord Merchant.where(id: merchant_ids) end - def auto_categorize_transactions_later(transactions) - AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id)) + def auto_categorize_transactions_later(transactions, rule_run_id: nil) + AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id) end def auto_categorize_transactions(transaction_ids) AutoCategorizer.new(self, transaction_ids: transaction_ids).auto_categorize end - def auto_detect_transaction_merchants_later(transactions) - AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id)) + def auto_detect_transaction_merchants_later(transactions, rule_run_id: nil) + AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id) end def auto_detect_transaction_merchants(transaction_ids) diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 9a57df28b..1efb76c58 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -11,7 +11,7 @@ class Family::AutoCategorizer if scope.none? Rails.logger.info("No transactions to auto-categorize for family #{family.id}") - return + return 0 else Rails.logger.info("Auto-categorizing #{scope.count} transactions for family #{family.id}") end @@ -20,7 +20,7 @@ class Family::AutoCategorizer if categories_input.empty? Rails.logger.error("Cannot auto-categorize transactions for family #{family.id}: no categories available") - return + return 0 end result = llm_provider.auto_categorize( @@ -31,23 +31,28 @@ class Family::AutoCategorizer unless result.success? Rails.logger.error("Failed to auto-categorize transactions for family #{family.id}: #{result.error.message}") - return + return 0 end + modified_count = 0 scope.each do |transaction| auto_categorization = result.data.find { |c| c.transaction_id == transaction.id } category_id = categories_input.find { |c| c[:name] == auto_categorization&.category_name }&.dig(:id) if category_id.present? - transaction.enrich_attribute( + was_modified = transaction.enrich_attribute( :category_id, category_id, source: "ai" ) transaction.lock_attr!(:category_id) + # enrich_attribute returns true if the transaction was actually modified + modified_count += 1 if was_modified end end + + modified_count end private diff --git a/app/models/family/auto_merchant_detector.rb b/app/models/family/auto_merchant_detector.rb index be1cbcebb..2877d5c38 100644 --- a/app/models/family/auto_merchant_detector.rb +++ b/app/models/family/auto_merchant_detector.rb @@ -11,7 +11,7 @@ class Family::AutoMerchantDetector if scope.none? Rails.logger.info("No transactions to auto-detect merchants for family #{family.id}") - return + return 0 else Rails.logger.info("Auto-detecting merchants for #{scope.count} transactions for family #{family.id}") end @@ -24,9 +24,10 @@ class Family::AutoMerchantDetector unless result.success? Rails.logger.error("Failed to auto-detect merchants for family #{family.id}: #{result.error.message}") - return + return 0 end + modified_count = 0 scope.each do |transaction| auto_detection = result.data.find { |c| c.transaction_id == transaction.id } @@ -45,15 +46,19 @@ class Family::AutoMerchantDetector merchant_id = merchant_id || ai_provider_merchant&.id if merchant_id.present? - transaction.enrich_attribute( + was_modified = transaction.enrich_attribute( :merchant_id, merchant_id, source: "ai" ) # We lock the attribute so that this Rule doesn't try to run again transaction.lock_attr!(:merchant_id) + # enrich_attribute returns true if the transaction was actually modified + modified_count += 1 if was_modified end end + + modified_count end private diff --git a/app/models/rule.rb b/app/models/rule.rb index 9632cdff2..4ffb8caa2 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -4,6 +4,7 @@ class Rule < ApplicationRecord belongs_to :family has_many :conditions, dependent: :destroy has_many :actions, dependent: :destroy + has_many :rule_runs, dependent: :destroy accepts_nested_attributes_for :conditions, allow_destroy: true accepts_nested_attributes_for :actions, allow_destroy: true @@ -39,9 +40,30 @@ class Rule < ApplicationRecord matching_resources_scope.count end - def apply(ignore_attribute_locks: false) + def apply(ignore_attribute_locks: false, rule_run: nil) + total_modified = 0 + total_async_jobs = 0 + has_async = false + actions.each do |action| - action.apply(matching_resources_scope, ignore_attribute_locks: ignore_attribute_locks) + result = action.apply(matching_resources_scope, ignore_attribute_locks: ignore_attribute_locks, rule_run: rule_run) + + if result.is_a?(Hash) && result[:async] + has_async = true + total_async_jobs += result[:jobs_count] || 0 + total_modified += result[:modified_count] || 0 + elsif result.is_a?(Integer) + total_modified += result + else + # Log unexpected result type but don't fail + Rails.logger.warn("Rule#apply: Unexpected result type from action #{action.id}: #{result.class} (value: #{result.inspect})") + end + end + + if has_async + { modified_count: total_modified, async: true, jobs_count: total_async_jobs } + else + total_modified end end diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb index 5945503ef..c415c59eb 100644 --- a/app/models/rule/action.rb +++ b/app/models/rule/action.rb @@ -3,8 +3,8 @@ class Rule::Action < ApplicationRecord validates :action_type, presence: true - def apply(resource_scope, ignore_attribute_locks: false) - executor.execute(resource_scope, value: value, ignore_attribute_locks: ignore_attribute_locks) + def apply(resource_scope, ignore_attribute_locks: false, rule_run: nil) + executor.execute(resource_scope, value: value, ignore_attribute_locks: ignore_attribute_locks, rule_run: rule_run) || 0 end def options diff --git a/app/models/rule/action_executor.rb b/app/models/rule/action_executor.rb index 4a9a1185d..37f1d2541 100644 --- a/app/models/rule/action_executor.rb +++ b/app/models/rule/action_executor.rb @@ -21,7 +21,7 @@ class Rule::ActionExecutor nil end - def execute(scope, value: nil, ignore_attribute_locks: false) + def execute(scope, value: nil, ignore_attribute_locks: false, rule_run: nil) raise NotImplementedError, "Action executor #{self.class.name} must implement #execute" end @@ -34,6 +34,35 @@ class Rule::ActionExecutor } end + protected + # Helper method to track modified count during enrichment + # The block should return true if the resource was modified, false otherwise + # If the block doesn't return a value, we'll check previous_changes as a fallback + def count_modified_resources(scope) + modified_count = 0 + scope.each do |resource| + # Yield the resource and capture the return value if the block returns one + block_result = yield resource + + # If the block explicitly returned a boolean, use that + if block_result == true || block_result == false + was_modified = block_result + else + # Otherwise, check previous_changes as fallback + was_modified = resource.previous_changes.any? + + # For Transaction resources, check the entry if the transaction itself wasn't modified + if !was_modified && resource.respond_to?(:entry) + entry = resource.entry + was_modified = entry&.previous_changes&.any? || false + end + end + + modified_count += 1 if was_modified + end + modified_count + end + private attr_reader :rule diff --git a/app/models/rule/action_executor/auto_categorize.rb b/app/models/rule/action_executor/auto_categorize.rb index 3ea40b70c..a3701089d 100644 --- a/app/models/rule/action_executor/auto_categorize.rb +++ b/app/models/rule/action_executor/auto_categorize.rb @@ -29,17 +29,31 @@ class Rule::ActionExecutor::AutoCategorize < Rule::ActionExecutor end end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) enrichable_transactions = transaction_scope.enrichable(:category_id) if enrichable_transactions.empty? Rails.logger.info("No transactions to auto-categorize for #{rule.id}") - return + return 0 end - enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx| + batch_size = 20 + jobs_count = 0 + + enrichable_transactions.in_batches(of: batch_size).each_with_index do |transactions, idx| Rails.logger.info("Scheduling auto-categorization for batch #{idx + 1} of #{enrichable_transactions.count}") - rule.family.auto_categorize_transactions_later(transactions) + rule.family.auto_categorize_transactions_later(transactions, rule_run_id: rule_run&.id) + jobs_count += 1 end + + # Return metadata about async jobs + # Note: modified_count is set to queued_count here because we don't know + # the actual modified count until the async jobs complete + # The actual modified count will be reported back via rule_run.complete_job! + { + async: true, + modified_count: enrichable_transactions.count, + jobs_count: jobs_count + } end end diff --git a/app/models/rule/action_executor/auto_detect_merchants.rb b/app/models/rule/action_executor/auto_detect_merchants.rb index 87c046825..15151b31c 100644 --- a/app/models/rule/action_executor/auto_detect_merchants.rb +++ b/app/models/rule/action_executor/auto_detect_merchants.rb @@ -7,17 +7,31 @@ class Rule::ActionExecutor::AutoDetectMerchants < Rule::ActionExecutor end end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) enrichable_transactions = transaction_scope.enrichable(:merchant_id) if enrichable_transactions.empty? Rails.logger.info("No transactions to auto-detect merchants for #{rule.id}") - return + return 0 end - enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx| + batch_size = 20 + jobs_count = 0 + + enrichable_transactions.in_batches(of: batch_size).each_with_index do |transactions, idx| Rails.logger.info("Scheduling auto-merchant-enrichment for batch #{idx + 1} of #{enrichable_transactions.count}") - rule.family.auto_detect_transaction_merchants_later(transactions) + rule.family.auto_detect_transaction_merchants_later(transactions, rule_run_id: rule_run&.id) + jobs_count += 1 end + + # Return metadata about async jobs + # Note: modified_count is set to queued_count here because we don't know + # the actual modified count until the async jobs complete + # The actual modified count will be reported back via rule_run.complete_job! + { + async: true, + modified_count: enrichable_transactions.count, + jobs_count: jobs_count + } end end diff --git a/app/models/rule/action_executor/set_transaction_category.rb b/app/models/rule/action_executor/set_transaction_category.rb index 2da95bbac..e14bf33a7 100644 --- a/app/models/rule/action_executor/set_transaction_category.rb +++ b/app/models/rule/action_executor/set_transaction_category.rb @@ -7,7 +7,7 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor family.categories.alphabetically.pluck(:name, :id) end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) category = family.categories.find_by_id(value) scope = transaction_scope @@ -16,7 +16,8 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor scope = scope.enrichable(:category_id) end - scope.each do |txn| + count_modified_resources(scope) do |txn| + # enrich_attribute returns true if the transaction was actually modified, false otherwise txn.enrich_attribute( :category_id, category.id, diff --git a/app/models/rule/action_executor/set_transaction_merchant.rb b/app/models/rule/action_executor/set_transaction_merchant.rb index f9693ddac..0b0d43ad3 100644 --- a/app/models/rule/action_executor/set_transaction_merchant.rb +++ b/app/models/rule/action_executor/set_transaction_merchant.rb @@ -7,16 +7,17 @@ class Rule::ActionExecutor::SetTransactionMerchant < Rule::ActionExecutor family.merchants.alphabetically.pluck(:name, :id) end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) merchant = family.merchants.find_by_id(value) - return unless merchant + return 0 unless merchant scope = transaction_scope unless ignore_attribute_locks scope = scope.enrichable(:merchant_id) end - scope.each do |txn| + count_modified_resources(scope) do |txn| + # enrich_attribute returns true if the transaction was actually modified, false otherwise txn.enrich_attribute( :merchant_id, merchant.id, diff --git a/app/models/rule/action_executor/set_transaction_name.rb b/app/models/rule/action_executor/set_transaction_name.rb index 1dd89fa30..4d4c84d8a 100644 --- a/app/models/rule/action_executor/set_transaction_name.rb +++ b/app/models/rule/action_executor/set_transaction_name.rb @@ -7,15 +7,20 @@ class Rule::ActionExecutor::SetTransactionName < Rule::ActionExecutor nil end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) - return if value.blank? + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) + return 0 if value.blank? - scope = transaction_scope + scope = transaction_scope.with_entry unless ignore_attribute_locks - scope = scope.enrichable(:name) + # Filter by entry's locked_attributes, not transaction's + # Since name is on Entry, not Transaction, we need to check entries.locked_attributes + scope = scope.where.not( + Arel.sql("entries.locked_attributes ? 'name'") + ) end - scope.each do |txn| + count_modified_resources(scope) do |txn| + # enrich_attribute returns true if the entry was actually modified, false otherwise txn.entry.enrich_attribute( :name, value, diff --git a/app/models/rule/action_executor/set_transaction_tags.rb b/app/models/rule/action_executor/set_transaction_tags.rb index f317f9602..c1dbc30a2 100644 --- a/app/models/rule/action_executor/set_transaction_tags.rb +++ b/app/models/rule/action_executor/set_transaction_tags.rb @@ -7,7 +7,7 @@ class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor family.tags.alphabetically.pluck(:name, :id) end - def execute(transaction_scope, value: nil, ignore_attribute_locks: false) + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) tag = family.tags.find_by_id(value) scope = transaction_scope @@ -16,7 +16,7 @@ class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor scope = scope.enrichable(:tag_ids) end - rows = scope.each do |txn| + count_modified_resources(scope) do |txn| txn.enrich_attribute( :tag_ids, [ tag.id ], diff --git a/app/models/rule_run.rb b/app/models/rule_run.rb new file mode 100644 index 000000000..8f0ac759f --- /dev/null +++ b/app/models/rule_run.rb @@ -0,0 +1,42 @@ +class RuleRun < ApplicationRecord + belongs_to :rule + + validates :execution_type, inclusion: { in: %w[manual scheduled] } + validates :status, inclusion: { in: %w[pending success failed] } + validates :executed_at, presence: true + validates :transactions_queued, numericality: { greater_than_or_equal_to: 0 } + validates :transactions_processed, numericality: { greater_than_or_equal_to: 0 } + validates :transactions_modified, numericality: { greater_than_or_equal_to: 0 } + validates :pending_jobs_count, numericality: { greater_than_or_equal_to: 0 } + + scope :recent, -> { order(executed_at: :desc) } + scope :for_rule, ->(rule) { where(rule: rule) } + scope :successful, -> { where(status: "success") } + scope :failed, -> { where(status: "failed") } + scope :pending, -> { where(status: "pending") } + + def pending? + status == "pending" + end + + def success? + status == "success" + end + + def failed? + status == "failed" + end + + # Thread-safe method to complete a job and update the run + def complete_job!(modified_count: 0) + with_lock do + increment!(:transactions_modified, modified_count) + decrement!(:pending_jobs_count) + + # If all jobs are done, mark as success + if pending_jobs_count <= 0 + update!(status: "success") + end + end + end +end diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 49062f691..23613b959 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -81,3 +81,89 @@ <% end %> + + +<% if @recent_runs.any? %> +
+
+

<%= t("rules.recent_runs.title") %>

+

<%= t("rules.recent_runs.description") %>

+
+ +
+ + + + + + + + + + + + <% @recent_runs.each do |run| %> + + + + + + + + <% end %> + +
+ <%= t("rules.recent_runs.columns.date_time") %> + + <%= t("rules.recent_runs.columns.execution_type") %> + + <%= t("rules.recent_runs.columns.status") %> + + <%= t("rules.recent_runs.columns.rule_name") %> + +
+
<%= t("rules.recent_runs.columns.transactions_counts.queued") %>
+
<%= t("rules.recent_runs.columns.transactions_counts.processed") %>
+
<%= t("rules.recent_runs.columns.transactions_counts.modified") %>
+
+
+ <%= run.executed_at.strftime("%b %d, %Y %I:%M %p") %> + + + <%= t("rules.recent_runs.execution_types.#{run.execution_type}") %> + + +
+ <% if run.pending? %> + + <%= t("rules.recent_runs.statuses.#{run.status}") %> + + <% elsif run.success? %> + + <%= t("rules.recent_runs.statuses.#{run.status}") %> + + <% else %> + + <%= t("rules.recent_runs.statuses.#{run.status}") %> + + <% end %> + <% if run.failed? && run.error_message.present? %> +
+ <%= icon("info", size: "sm", class: "text-red-500") %> +
+ <% end %> +
+
+ <%= 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)}" %> +
+
+ + <% if @pagy.pages > 1 %> +
+ <%= render "shared/pagination", pagy: @pagy %> +
+ <% end %> +
+<% end %> diff --git a/config/locales/views/rules/ca.yml b/config/locales/views/rules/ca.yml new file mode 100644 index 000000000..33843b8f4 --- /dev/null +++ b/config/locales/views/rules/ca.yml @@ -0,0 +1,24 @@ +--- +ca: + rules: + recent_runs: + title: Execucions Recents + description: Visualitza l'historial d'execució de les teves regles incloent l'estat d'èxit/fallada i els recomptes de transaccions. + unnamed_rule: Regla Sense Nom + columns: + date_time: Data/Hora + execution_type: Tipus + status: Estat + rule_name: Nom de la Regla + transactions_counts: + queued: En Cua + processed: Processades + modified: Modificades + execution_types: + manual: Manual + scheduled: Programada + statuses: + pending: Pendent + success: Èxit + failed: Fallada + diff --git a/config/locales/views/rules/de.yml b/config/locales/views/rules/de.yml new file mode 100644 index 000000000..d86ceebd5 --- /dev/null +++ b/config/locales/views/rules/de.yml @@ -0,0 +1,24 @@ +--- +de: + rules: + recent_runs: + title: Letzte Ausführungen + description: Zeige die Ausführungsgeschichte deiner Regeln einschließlich Erfolgs-/Fehlerstatus und Transaktionsanzahlen. + unnamed_rule: Unbenannte Regel + columns: + date_time: Datum/Uhrzeit + execution_type: Typ + status: Status + rule_name: Regelname + transactions_counts: + queued: In Warteschlange + processed: Verarbeitet + modified: Geändert + execution_types: + manual: Manuell + scheduled: Geplant + statuses: + pending: Ausstehend + success: Erfolgreich + failed: Fehlgeschlagen + diff --git a/config/locales/views/rules/en.yml b/config/locales/views/rules/en.yml new file mode 100644 index 000000000..7a1837e45 --- /dev/null +++ b/config/locales/views/rules/en.yml @@ -0,0 +1,23 @@ +--- +en: + rules: + recent_runs: + title: Recent Runs + description: View the execution history of your rules including success/failure status and transaction counts. + unnamed_rule: Unnamed Rule + columns: + date_time: Date/Time + execution_type: Type + status: Status + rule_name: Rule Name + transactions_counts: + queued: Queued + processed: Processed + modified: Modified + execution_types: + manual: Manual + scheduled: Scheduled + statuses: + pending: Pending + success: Success + failed: Failed diff --git a/config/locales/views/rules/es.yml b/config/locales/views/rules/es.yml new file mode 100644 index 000000000..701038479 --- /dev/null +++ b/config/locales/views/rules/es.yml @@ -0,0 +1,24 @@ +--- +es: + rules: + recent_runs: + title: Ejecuciones Recientes + description: Ver el historial de ejecución de tus reglas incluyendo el estado de éxito/fallo y los conteos de transacciones. + unnamed_rule: Regla Sin Nombre + columns: + date_time: Fecha/Hora + execution_type: Tipo + status: Estado + rule_name: Nombre de Regla + transactions_counts: + queued: En Cola + processed: Procesadas + modified: Modificadas + execution_types: + manual: Manual + scheduled: Programada + statuses: + pending: Pendiente + success: Éxito + failed: Fallido + diff --git a/config/locales/views/rules/nb.yml b/config/locales/views/rules/nb.yml new file mode 100644 index 000000000..a1b7aeb06 --- /dev/null +++ b/config/locales/views/rules/nb.yml @@ -0,0 +1,24 @@ +--- +nb: + rules: + recent_runs: + title: Siste Kjøringer + description: Se kjøringsloggen for reglene dine inkludert suksess/feil-status og transaksjonsantall. + unnamed_rule: Navnløs Regel + columns: + date_time: Dato/Tid + execution_type: Type + status: Status + rule_name: Regelnavn + transactions_counts: + queued: I Kø + processed: Behandlet + modified: Endret + execution_types: + manual: Manuell + scheduled: Planlagt + statuses: + pending: Ventende + success: Vellykket + failed: Mislyktes + diff --git a/config/locales/views/rules/ro.yml b/config/locales/views/rules/ro.yml new file mode 100644 index 000000000..d4e7df3a5 --- /dev/null +++ b/config/locales/views/rules/ro.yml @@ -0,0 +1,24 @@ +--- +ro: + rules: + recent_runs: + title: Rulări Recente + description: Vezi istoricul de execuție al regulilor tale incluzând statusul de succes/eșec și numărul de tranzacții. + unnamed_rule: Regulă Fără Nume + columns: + date_time: Dată/Ora + execution_type: Tip + status: Status + rule_name: Nume Regulă + transactions_counts: + queued: În Așteptare + processed: Procesate + modified: Modificate + execution_types: + manual: Manual + scheduled: Programată + statuses: + pending: În Așteptare + success: Succes + failed: Eșuat + diff --git a/config/locales/views/rules/tr.yml b/config/locales/views/rules/tr.yml new file mode 100644 index 000000000..8318415b1 --- /dev/null +++ b/config/locales/views/rules/tr.yml @@ -0,0 +1,24 @@ +--- +tr: + rules: + recent_runs: + title: Son Çalıştırmalar + description: Başarı/başarısızlık durumu ve işlem sayıları dahil olmak üzere kurallarınızın yürütme geçmişini görüntüleyin. + unnamed_rule: İsimsiz Kural + columns: + date_time: Tarih/Saat + execution_type: Tür + status: Durum + rule_name: Kural Adı + transactions_counts: + queued: Kuyruğa Alındı + processed: İşlendi + modified: Değiştirildi + execution_types: + manual: Manuel + scheduled: Zamanlanmış + statuses: + pending: Beklemede + success: Başarılı + failed: Başarısız + diff --git a/db/migrate/20251124000000_create_rule_runs.rb b/db/migrate/20251124000000_create_rule_runs.rb new file mode 100644 index 000000000..40ed831fa --- /dev/null +++ b/db/migrate/20251124000000_create_rule_runs.rb @@ -0,0 +1,21 @@ +class CreateRuleRuns < ActiveRecord::Migration[7.2] + def change + create_table :rule_runs, id: :uuid do |t| + t.references :rule, null: false, foreign_key: true, type: :uuid + t.string :rule_name + t.string :execution_type, null: false + t.string :status, null: false + t.integer :transactions_queued, null: false, default: 0 + t.integer :transactions_processed, null: false, default: 0 + t.integer :transactions_modified, null: false, default: 0 + t.integer :pending_jobs_count, null: false, default: 0 + t.datetime :executed_at, null: false + t.text :error_message + + t.timestamps + end + + add_index :rule_runs, :executed_at + add_index :rule_runs, [ :rule_id, :executed_at ] + end +end diff --git a/db/schema.rb b/db/schema.rb index bd35087c4..bec5fc441 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_26_094446) do +ActiveRecord::Schema[7.2].define(version: 2025_12_06_131244) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -800,6 +800,24 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_094446) do t.index ["family_id"], name: "index_rules_on_family_id" end + create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "rule_id", null: false + t.string "rule_name" + t.string "execution_type", null: false + t.string "status", null: false + t.integer "transactions_queued", default: 0, null: false + t.integer "transactions_processed", default: 0, null: false + t.integer "transactions_modified", default: 0, null: false + t.integer "pending_jobs_count", default: 0, null: false + t.datetime "executed_at", null: false + t.text "error_message" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["executed_at"], name: "index_rule_runs_on_executed_at" + t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" + t.index ["rule_id"], name: "index_rule_runs_on_rule_id" + end + create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ticker", null: false t.string "name" @@ -1106,6 +1124,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_094446) do add_foreign_key "rule_actions", "rules" add_foreign_key "rule_conditions", "rule_conditions", column: "parent_id" add_foreign_key "rule_conditions", "rules" + add_foreign_key "rule_runs", "rules" add_foreign_key "rules", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" diff --git a/test/models/rule/action_test.rb b/test/models/rule/action_test.rb index f71bb2cbe..db3933ea0 100644 --- a/test/models/rule/action_test.rb +++ b/test/models/rule/action_test.rb @@ -84,7 +84,7 @@ class Rule::ActionTest < ActiveSupport::TestCase new_name = "Renamed Transaction" # Does not modify transactions that are locked (user edited them) - @txn1.lock_attr!(:name) + @txn1.entry.lock_attr!(:name) action = Rule::Action.new( rule: @transaction_rule,