mirror of
https://github.com/rickbarrette/redmine_qbo.git
synced 2026-04-02 08:21:57 -04:00
Compare commits
2 Commits
eb6954ddf1
...
2bcb1840a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bcb1840a4 | |||
| c87e18810b |
@@ -19,6 +19,7 @@ class Customer < QboBaseModel
|
|||||||
validates_presence_of :name
|
validates_presence_of :name
|
||||||
before_validation :normalize_phone_numbers
|
before_validation :normalize_phone_numbers
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
qbo_sync push: true
|
||||||
|
|
||||||
acts_as_searchable columns: %w[name phone_number mobile_phone_number ],
|
acts_as_searchable columns: %w[name phone_number mobile_phone_number ],
|
||||||
scope: ->(_context) { left_joins(:project) },
|
scope: ->(_context) { left_joins(:project) },
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ class Employee < QboBaseModel
|
|||||||
has_many :users
|
has_many :users
|
||||||
validates_presence_of :id, :name
|
validates_presence_of :id, :name
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
qbo_sync push: false
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -14,6 +14,7 @@ class Estimate < QboBaseModel
|
|||||||
belongs_to :customer
|
belongs_to :customer
|
||||||
validates_presence_of :doc_number, :id
|
validates_presence_of :doc_number, :id
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
qbo_sync push: false
|
||||||
|
|
||||||
# returns a human readable string
|
# returns a human readable string
|
||||||
def to_s
|
def to_s
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class Invoice < QboBaseModel
|
|||||||
validates :id, presence: true, uniqueness: true
|
validates :id, presence: true, uniqueness: true
|
||||||
validates :doc_number, :txn_date, presence: true
|
validates :doc_number, :txn_date, presence: true
|
||||||
self.primary_key = :id
|
self.primary_key = :id
|
||||||
|
qbo_sync push: false
|
||||||
|
|
||||||
# Return the invoice's document number as its string representation
|
# Return the invoice's document number as its string representation
|
||||||
def to_s
|
def to_s
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ class QboBaseModel < ActiveRecord::Base
|
|||||||
|
|
||||||
self.abstract_class = true
|
self.abstract_class = true
|
||||||
validates_presence_of :id
|
validates_presence_of :id
|
||||||
|
class_attribute :qbo_push_enabled, default: true
|
||||||
attr_accessor :skip_qbo_push
|
attr_accessor :skip_qbo_push
|
||||||
before_validation :push_to_qbo, on: :create
|
before_validation :push_to_qbo, on: :create, if: :push_to_qbo?
|
||||||
after_commit :push_to_qbo, on: :update, unless: :skip_qbo_push?
|
after_commit :push_to_qbo, on: :update, if: :push_to_qbo?
|
||||||
|
|
||||||
# Returns the details of the entity.
|
# Returns the details of the entity.
|
||||||
# If the details have already been fetched, it returns the cached version.
|
# If the details have already been fetched, it returns the cached version.
|
||||||
@@ -55,6 +55,13 @@ class QboBaseModel < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def push_to_qbo?
|
||||||
|
log "qbo_push_enabled #{self.class.qbo_push_enabled}"
|
||||||
|
log "skip_qbo_push #{skip_qbo_push}"
|
||||||
|
|
||||||
|
self.class.qbo_push_enabled && skip_qbo_push != true
|
||||||
|
end
|
||||||
|
|
||||||
# Repsonds to missing methods by delegating to the QBO entity calss if the method is defined there.
|
# Repsonds to missing methods by delegating to the QBO entity calss if the method is defined there.
|
||||||
# This allows for dynamic access to any attributes or methods of the QBO customer without having to explicitly define them in the Subclass model, providing flexibility and reducing boilerplate code.
|
# This allows for dynamic access to any attributes or methods of the QBO customer without having to explicitly define them in the Subclass model, providing flexibility and reducing boilerplate code.
|
||||||
def respond_to_missing?(method_name, include_private = false)
|
def respond_to_missing?(method_name, include_private = false)
|
||||||
@@ -76,7 +83,11 @@ class QboBaseModel < ActiveRecord::Base
|
|||||||
# Flag used to update local without pushing to QBO.
|
# Flag used to update local without pushing to QBO.
|
||||||
# This is used to prevent loops with the webhook
|
# This is used to prevent loops with the webhook
|
||||||
def skip_qbo_push?
|
def skip_qbo_push?
|
||||||
!!skip_qbo_push
|
!!skip_qbo_push
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.qbo_sync(push: true)
|
||||||
|
self.qbo_push_enabled = push
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class CustomerSyncService < SyncServiceBase
|
|||||||
!remote.active?
|
!remote.active?
|
||||||
end
|
end
|
||||||
|
|
||||||
map_attribute :name, ->(remote) { remote.display_name }
|
map_attribute :name, :display_name
|
||||||
map_attribute :phone_number, ->(remote) { remote.primary_phone&.free_form_number&.gsub(/\D/, '') }
|
map_phone :phone_number, :primary_phone
|
||||||
map_attribute :mobile_phone_number, ->(remote) { remote.mobile_phone&.free_form_number&.gsub(/\D/, '') }
|
map_phone :mobile_phone_number, :mobile_phone
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -17,6 +17,7 @@ class EstimateSyncService < SyncServiceBase
|
|||||||
Estimate
|
Estimate
|
||||||
end
|
end
|
||||||
|
|
||||||
map_attribute :customer, ->(remote) { Customer.find_by(id: remote.customer_ref&.value) }
|
map_attributes :doc_number, :txn_date
|
||||||
|
map_belongs_to :customer
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -22,7 +22,9 @@ class InvoiceSyncService < SyncServiceBase
|
|||||||
InvoiceAttachmentService.new(local, remote).attach
|
InvoiceAttachmentService.new(local, remote).attach
|
||||||
end
|
end
|
||||||
|
|
||||||
map_attribute :customer, ->(remote) { Customer.find_by(id: remote.customer_ref&.value) }
|
map_attributes :balance, :doc_number, :due_date, :txn_date
|
||||||
map_attribute :total_amount, ->(remote) { remote.total }
|
map_attribute :total_amount, :total
|
||||||
map_attribute :qbo_updated_at, ->(remote) { remote.meta_data&.last_updated_time }
|
map_attribute :qbo_updated_at, "meta_data.last_updated_time"
|
||||||
|
map_belongs_to :customer
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -90,14 +90,111 @@ class SyncServiceBase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Attribute Mapping DSL
|
||||||
|
#
|
||||||
|
# This DSL defines how attributes from a QuickBooks Online (QBO) entity
|
||||||
|
# are mapped onto a local ActiveRecord model during synchronization.
|
||||||
|
#
|
||||||
|
# Each mapping registers a lambda in `attribute_map`. When a remote QBO
|
||||||
|
# object is processed, the lambda is executed to extract and transform
|
||||||
|
# the value that will be assigned to the local model attribute.
|
||||||
|
#
|
||||||
|
# The DSL supports several mapping patterns:
|
||||||
|
#
|
||||||
|
# 1. Direct attribute mapping (same name)
|
||||||
|
#
|
||||||
|
# map_attribute :doc_number
|
||||||
|
#
|
||||||
|
# Equivalent to:
|
||||||
|
#
|
||||||
|
# local.doc_number = remote.doc_number
|
||||||
|
#
|
||||||
|
# 2. Renamed attribute mapping
|
||||||
|
#
|
||||||
|
# map_attribute :total_amount, :total
|
||||||
|
#
|
||||||
|
# Equivalent to:
|
||||||
|
#
|
||||||
|
# local.total_amount = remote.total
|
||||||
|
#
|
||||||
|
# 3. Custom transformation logic
|
||||||
|
#
|
||||||
|
# map_attribute :qbo_updated_at do |remote|
|
||||||
|
# remote.meta_data&.last_updated_time
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# Useful for nested fields or computed values.
|
||||||
|
#
|
||||||
|
# 4. Bulk attribute mapping
|
||||||
|
#
|
||||||
|
# map_attributes :doc_number, :txn_date, :due_date
|
||||||
|
#
|
||||||
|
# Convenience helper that maps multiple attributes with identical names.
|
||||||
|
#
|
||||||
|
# 5. Foreign key / reference mapping
|
||||||
|
#
|
||||||
|
# map_belongs_to :customer
|
||||||
|
#
|
||||||
|
# Resolves a QBO reference object (e.g. `customer_ref.value`) and finds
|
||||||
|
# the associated local ActiveRecord model.
|
||||||
|
#
|
||||||
|
# 6. Specialized helpers
|
||||||
|
#
|
||||||
|
# map_phone :phone_number, :primary_phone
|
||||||
|
#
|
||||||
|
# Extracts and normalizes phone numbers by stripping non-digit characters.
|
||||||
|
#
|
||||||
|
# Internally, the mappings are stored in `attribute_map` and executed by the
|
||||||
|
# SyncService during `process_attributes`, which iterates through each mapping
|
||||||
|
# and assigns the computed value to the local record.
|
||||||
|
#
|
||||||
|
# This design keeps synchronization services declarative, readable, and easy
|
||||||
|
# to extend while centralizing transformation logic in a single DSL.
|
||||||
class << self
|
class << self
|
||||||
def map_attribute(local, remote = nil, &block)
|
|
||||||
attribute_map[local] = block || remote
|
def map_attributes(*attrs)
|
||||||
|
attrs.each do |attr|
|
||||||
|
map_attribute(attr)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def map_attribute(local_attr, remote_attr = nil, &block)
|
||||||
|
attribute_map[local_attr] =
|
||||||
|
if block_given?
|
||||||
|
block
|
||||||
|
elsif remote_attr
|
||||||
|
->(remote) do
|
||||||
|
remote_attr.to_s.split('.').reduce(remote) do |obj, method|
|
||||||
|
obj&.public_send(method)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
->(remote) { remote.public_send(local_attr) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def attribute_map
|
def attribute_map
|
||||||
@attribute_map ||= {}
|
@attribute_map ||= {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def map_belongs_to(local_attr, ref: nil, model: nil)
|
||||||
|
ref ||= "#{local_attr}_ref"
|
||||||
|
model ||= local_attr.to_s.classify.constantize
|
||||||
|
|
||||||
|
attribute_map[local_attr] = lambda do |remote|
|
||||||
|
ref_obj = remote.public_send(ref)
|
||||||
|
id = ref_obj&.value
|
||||||
|
id ? model.find_by(id: id) : nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def map_phone(local_attr, remote_attr)
|
||||||
|
attribute_map[local_attr] = lambda do |remote|
|
||||||
|
phone = remote.public_send(remote_attr)
|
||||||
|
phone&.free_form_number&.gsub(/\D/, '')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Log messages with the entity type for better traceability
|
# Log messages with the entity type for better traceability
|
||||||
@@ -108,7 +205,7 @@ class SyncServiceBase
|
|||||||
# Create or update a local entity record based on the QBO remote data
|
# Create or update a local entity record based on the QBO remote data
|
||||||
def persist(remote)
|
def persist(remote)
|
||||||
local = @entity.find_or_initialize_by(id: remote.id)
|
local = @entity.find_or_initialize_by(id: remote.id)
|
||||||
|
|
||||||
if destroy_local?(remote)
|
if destroy_local?(remote)
|
||||||
if local.persisted?
|
if local.persisted?
|
||||||
local.destroy
|
local.destroy
|
||||||
@@ -118,11 +215,12 @@ class SyncServiceBase
|
|||||||
end
|
end
|
||||||
|
|
||||||
process_attributes(local, remote)
|
process_attributes(local, remote)
|
||||||
|
|
||||||
if local.changed?
|
if local.new_record? || local.changed?
|
||||||
|
was_new = local.new_record?
|
||||||
local.skip_qbo_push = true
|
local.skip_qbo_push = true
|
||||||
local.save
|
local.save!
|
||||||
log "Updated #{@entity.name} #{remote.id}"
|
log "#{was_new ? 'Created' : 'Updated'} #{@entity.name} #{remote.id}"
|
||||||
attach_documents(local, remote)
|
attach_documents(local, remote)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -133,9 +231,13 @@ class SyncServiceBase
|
|||||||
# Maps remote attributes to local model
|
# Maps remote attributes to local model
|
||||||
def process_attributes(local, remote)
|
def process_attributes(local, remote)
|
||||||
log "Processing #{@entity} ##{remote.id}"
|
log "Processing #{@entity} ##{remote.id}"
|
||||||
self.class.attribute_map.each do |local_attr, remote_attr|
|
|
||||||
value = extract_value(remote, remote_attr)
|
self.class.attribute_map.each do |local_attr, mapper|
|
||||||
local.public_send("#{local_attr}=", value)
|
value = mapper.call(remote)
|
||||||
|
|
||||||
|
if local.public_send(local_attr) != value
|
||||||
|
local.public_send("#{local_attr}=", value)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user