2024-05-17 09:09:32 -04:00
|
|
|
class Import < ApplicationRecord
|
|
|
|
belongs_to :account
|
|
|
|
|
|
|
|
validate :raw_csv_must_be_parsable
|
|
|
|
|
|
|
|
before_save :initialize_csv, if: :should_initialize_csv?
|
|
|
|
|
|
|
|
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
|
|
|
|
|
|
|
|
store_accessor :column_mappings, :define_column_mapping_keys
|
|
|
|
|
|
|
|
scope :ordered, -> { order(created_at: :desc) }
|
|
|
|
|
2024-05-22 14:12:56 +02:00
|
|
|
FALLBACK_TRANSACTION_NAME = "Imported transaction"
|
|
|
|
|
2024-05-17 09:09:32 -04:00
|
|
|
def publish_later
|
|
|
|
ImportJob.perform_later(self)
|
|
|
|
end
|
|
|
|
|
|
|
|
def loaded?
|
|
|
|
raw_csv_str.present?
|
|
|
|
end
|
|
|
|
|
|
|
|
def configured?
|
|
|
|
csv.present?
|
|
|
|
end
|
|
|
|
|
|
|
|
def cleaned?
|
|
|
|
loaded? && configured? && csv.valid?
|
|
|
|
end
|
|
|
|
|
|
|
|
def csv
|
|
|
|
get_normalized_csv_with_validation
|
|
|
|
end
|
|
|
|
|
|
|
|
def available_headers
|
|
|
|
get_raw_csv.table.headers
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_selected_header_for_field(field)
|
2024-06-24 11:31:21 -03:00
|
|
|
column_mappings&.dig(field.key) || field.key
|
2024-05-17 09:09:32 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def update_csv!(row_idx:, col_idx:, value:)
|
|
|
|
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
|
|
|
|
update! normalized_csv_str: updated_csv.to_s
|
|
|
|
end
|
|
|
|
|
|
|
|
# Type-specific methods (potential STI inheritance in future when more import types added)
|
|
|
|
def publish
|
|
|
|
update!(status: "importing")
|
|
|
|
|
|
|
|
transaction do
|
|
|
|
generate_transactions.each do |txn|
|
|
|
|
txn.save!
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-05-30 22:06:32 -04:00
|
|
|
self.account.sync
|
|
|
|
|
2024-05-17 09:09:32 -04:00
|
|
|
update!(status: "complete")
|
|
|
|
rescue => e
|
|
|
|
update!(status: "failed")
|
|
|
|
Rails.logger.error("Import with id #{id} failed: #{e}")
|
|
|
|
end
|
|
|
|
|
|
|
|
def dry_run
|
|
|
|
generate_transactions
|
|
|
|
end
|
|
|
|
|
|
|
|
def expected_fields
|
|
|
|
@expected_fields ||= create_expected_fields
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
def get_normalized_csv_with_validation
|
|
|
|
return nil if normalized_csv_str.nil?
|
|
|
|
|
|
|
|
csv = Import::Csv.new(normalized_csv_str)
|
|
|
|
|
|
|
|
expected_fields.each do |field|
|
|
|
|
csv.define_validator(field.key, field.validator) if field.validator
|
|
|
|
end
|
|
|
|
|
|
|
|
csv
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_raw_csv
|
|
|
|
return nil if raw_csv_str.nil?
|
|
|
|
Import::Csv.new(raw_csv_str)
|
|
|
|
end
|
|
|
|
|
|
|
|
def should_initialize_csv?
|
|
|
|
raw_csv_str_changed? || column_mappings_changed?
|
|
|
|
end
|
|
|
|
|
|
|
|
def initialize_csv
|
|
|
|
generated_csv = generate_normalized_csv(raw_csv_str)
|
|
|
|
self.normalized_csv_str = generated_csv.table.to_s
|
|
|
|
end
|
|
|
|
|
|
|
|
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
|
|
|
|
def generate_normalized_csv(csv_str)
|
|
|
|
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings)
|
|
|
|
end
|
|
|
|
|
|
|
|
def update_csv(row_idx, col_idx, value)
|
|
|
|
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
|
|
|
|
update! normalized_csv_str: updated_csv.to_s
|
|
|
|
end
|
|
|
|
|
|
|
|
def generate_transactions
|
2024-07-01 10:49:43 -04:00
|
|
|
transaction_entries = []
|
2024-05-22 10:02:03 -04:00
|
|
|
category_cache = {}
|
2024-05-23 08:09:33 -04:00
|
|
|
tag_cache = {}
|
2024-05-17 09:09:32 -04:00
|
|
|
|
|
|
|
csv.table.each do |row|
|
2024-05-23 08:09:33 -04:00
|
|
|
category_name = row["category"].presence
|
|
|
|
tag_strings = row["tags"].presence&.split("|") || []
|
|
|
|
tags = []
|
2024-05-22 10:02:03 -04:00
|
|
|
|
2024-05-23 08:09:33 -04:00
|
|
|
tag_strings.each do |tag_string|
|
|
|
|
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
|
|
|
|
end
|
|
|
|
|
2024-06-20 08:15:09 -04:00
|
|
|
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
|
2024-05-22 10:02:03 -04:00
|
|
|
|
2024-07-01 10:49:43 -04:00
|
|
|
entry = account.entries.build \
|
2024-05-22 14:12:56 +02:00
|
|
|
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
2024-05-17 09:09:32 -04:00
|
|
|
date: Date.iso8601(row["date"]),
|
2024-07-01 10:49:43 -04:00
|
|
|
currency: account.currency,
|
|
|
|
amount: BigDecimal(row["amount"]) * -1,
|
|
|
|
entryable: Account::Transaction.new(category: category, tags: tags)
|
2024-05-17 09:09:32 -04:00
|
|
|
|
2024-07-01 10:49:43 -04:00
|
|
|
transaction_entries << entry
|
2024-05-17 09:09:32 -04:00
|
|
|
end
|
|
|
|
|
2024-07-01 10:49:43 -04:00
|
|
|
transaction_entries
|
2024-05-17 09:09:32 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def create_expected_fields
|
|
|
|
date_field = Import::Field.new \
|
|
|
|
key: "date",
|
|
|
|
label: "Date",
|
|
|
|
validator: ->(value) { Import::Field.iso_date_validator(value) }
|
|
|
|
|
|
|
|
name_field = Import::Field.new \
|
|
|
|
key: "name",
|
2024-06-11 18:46:44 -04:00
|
|
|
label: "Name",
|
|
|
|
is_optional: true
|
2024-05-17 09:09:32 -04:00
|
|
|
|
|
|
|
category_field = Import::Field.new \
|
|
|
|
key: "category",
|
2024-06-11 18:46:44 -04:00
|
|
|
label: "Category",
|
|
|
|
is_optional: true
|
2024-05-17 09:09:32 -04:00
|
|
|
|
2024-05-23 08:09:33 -04:00
|
|
|
tags_field = Import::Field.new \
|
|
|
|
key: "tags",
|
2024-06-11 18:46:44 -04:00
|
|
|
label: "Tags",
|
|
|
|
is_optional: true
|
2024-05-23 08:09:33 -04:00
|
|
|
|
2024-05-17 09:09:32 -04:00
|
|
|
amount_field = Import::Field.new \
|
|
|
|
key: "amount",
|
|
|
|
label: "Amount",
|
|
|
|
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
|
|
|
|
2024-05-23 08:09:33 -04:00
|
|
|
[ date_field, name_field, category_field, tags_field, amount_field ]
|
2024-05-17 09:09:32 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def define_column_mapping_keys
|
|
|
|
expected_fields.each do |field|
|
|
|
|
field.key.to_sym
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def raw_csv_must_be_parsable
|
|
|
|
begin
|
|
|
|
CSV.parse(raw_csv_str || "")
|
|
|
|
rescue CSV::MalformedCSVError
|
2024-08-13 01:38:58 +01:00
|
|
|
# i18n-tasks-use t('activerecord.errors.models.import.attributes.raw_csv_str.invalid_csv_format')
|
|
|
|
errors.add(:raw_csv_str, :invalid_csv_format)
|
2024-05-17 09:09:32 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|