-
Notifications
You must be signed in to change notification settings - Fork 146
/
article.rb
271 lines (245 loc) · 10.2 KB
/
article.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
class Article < ApplicationRecord
include LocalizeInput
include PriceCalculation
# @!attribute name
# @return [String] Article name
# @!attribute unit
# @return [String] Unit, e.g. +kg+, +2 L+ or +5 pieces+.
# @!attribute note
# @return [String] Short line with optional extra article information.
# @!attribute availability
# @return [Boolean] Whether this article is available within the Foodcoop.
# @!attribute manufacturer
# @return [String] Original manufacturer.
# @!attribute origin
# Where the article was produced.
# ISO 3166-1 2-letter country code, optionally prefixed with region.
# E.g. +NL+ or +Sicily, IT+ or +Berlin, DE+.
# @return [String] Production origin.
# @see http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements
# @!attribute price
# @return [Number] Net price
# @see ArticlePrice#price
# @!attribute tax
# @return [Number] VAT percentage (10 is 10%).
# @see ArticlePrice#tax
# @!attribute deposit
# @return [Number] Deposit
# @see ArticlePrice#deposit
# @!attribute unit_quantity
# @return [Number] Number of units in wholesale package (box).
# @see ArticlePrice#unit_quantity
# @!attribute order_number
# Order number, this can be used by the supplier to identify articles.
# This is required when using the shared database functionality.
# @return [String] Order number.
# @!attribute article_category
# @return [ArticleCategory] Category this article is in.
belongs_to :article_category
# @!attribute supplier
# @return [Supplier] Supplier this article belongs to.
belongs_to :supplier
# @!attribute article_prices
# @return [Array<ArticlePrice>] Price history (current price first).
has_many :article_prices, -> { order('created_at DESC') }
# @!attribute order_articles
# @return [Array<OrderArticle>] Order articles for this article.
has_many :order_articles
# @!attribute order
# @return [Array<Order>] Orders this article appears in.
has_many :orders, through: :order_articles
# Replace numeric seperator with database format
localize_input_of :price, :tax, :deposit
# Get rid of unwanted whitespace. {Unit#new} may even bork on whitespace.
normalize_attributes :name, :unit, :note, :manufacturer, :origin, :order_number
scope :undeleted, -> { where(deleted_at: nil) }
scope :available, -> { undeleted.where(availability: true) }
scope :not_in_stock, -> { where(type: nil) }
# Validations
validates :name, :unit, :price, :tax, :deposit, :unit_quantity, :supplier_id, :article_category, presence: true
validates :name, length: { in: 4..60 }
validates :unit, length: { in: 1..15 }
validates :note, length: { maximum: 255 }
validates :origin, length: { maximum: 255 }
validates :manufacturer, length: { maximum: 255 }
validates :order_number, length: { maximum: 255 }
validates :price, numericality: { greater_than_or_equal_to: 0 }
validates :unit_quantity, numericality: { greater_than: 0 }
validates :deposit, :tax, numericality: true
# validates_uniqueness_of :name, :scope => [:supplier_id, :deleted_at, :type], if: Proc.new {|a| a.supplier.shared_sync_method.blank? or a.supplier.shared_sync_method == 'import' }
# validates_uniqueness_of :name, :scope => [:supplier_id, :deleted_at, :type, :unit, :unit_quantity]
validate :uniqueness_of_name
# Callbacks
before_save :update_price_history
before_destroy :check_article_in_use
def self.ransackable_attributes(_auth_object = nil)
%w[id name supplier_id article_category_id unit note manufacturer origin unit_quantity order_number]
end
def self.ransackable_associations(_auth_object = nil)
%w[article_category supplier order_articles orders]
end
# Returns true if article has been updated at least 2 days ago
def recently_updated
updated_at > 2.days.ago
end
# If the article is used in an open Order, the Order will be returned.
def in_open_order
@in_open_order ||= begin
order_articles = OrderArticle.where(order_id: Order.open.collect(&:id))
order_article = order_articles.detect { |oa| oa.article_id == id }
order_article&.order
end
end
# Returns true if the article has been ordered in the given order at least once
def ordered_in_order?(order)
order.order_articles.where(article_id: id).where('quantity > 0').one?
end
# this method checks, if the shared_article has been changed
# unequal attributes will returned in array
# if only the timestamps differ and the attributes are equal,
# false will returned and self.shared_updated_on will be updated
def shared_article_changed?(supplier = self.supplier)
# skip early if the timestamp hasn't changed
shared_article = self.shared_article(supplier)
return if shared_article.nil? || shared_updated_on == shared_article.updated_on
attrs = unequal_attributes(shared_article)
if attrs.empty?
# when attributes not changed, update timestamp of article
update_attribute(:shared_updated_on, shared_article.updated_on)
false
else
attrs
end
end
# Return article attributes that were changed (incl. unit conversion)
# @param new_article [Article] New article to update self
# @option options [Boolean] :convert_units Omit or set to +true+ to keep current unit and recompute unit quantity and price.
# @return [Hash<Symbol, Object>] Attributes with new values
def unequal_attributes(new_article, options = {})
# try to convert different units when desired
if options[:convert_units] == false
new_price = nil
new_unit_quantity = nil
else
new_price, new_unit_quantity = convert_units(new_article)
end
if new_price && new_unit_quantity
new_unit = unit
else
new_price = new_article.price
new_unit_quantity = new_article.unit_quantity
new_unit = new_article.unit
end
Article.compare_attributes(
{
name: [name, new_article.name],
manufacturer: [manufacturer, new_article.manufacturer.to_s],
origin: [origin, new_article.origin],
unit: [unit, new_unit],
price: [price.to_f.round(2), new_price.to_f.round(2)],
tax: [tax, new_article.tax],
deposit: [deposit.to_f.round(2), new_article.deposit.to_f.round(2)],
# take care of different num-objects.
unit_quantity: [unit_quantity.to_s.to_f, new_unit_quantity.to_s.to_f],
note: [note.to_s, new_article.note.to_s]
}
)
end
# Compare attributes from two different articles.
#
# This is used for auto-synchronization
# @param attributes [Hash<Symbol, Array>] Attributes with old and new values
# @return [Hash<Symbol, Object>] Changed attributes with new values
def self.compare_attributes(attributes)
unequal_attributes = attributes.select do |_name, values|
values[0] != values[1] && !(values[0].blank? && values[1].blank?)
end
unequal_attributes.to_a.map { |a| [a[0], a[1].last] }.to_h
end
# to get the correspondent shared article
def shared_article(supplier = self.supplier)
order_number.blank? and return nil
@shared_article ||= begin
supplier.shared_supplier.find_article_by_number(order_number)
rescue StandardError
nil
end
end
# convert units in foodcoop-size
# uses unit factors in app_config.yml to calc the price/unit_quantity
# returns new price and unit_quantity in array, when calc is possible => [price, unit_quanity]
# returns false if units aren't foodsoft-compatible
# returns nil if units are eqal
def convert_units(new_article = shared_article)
return unless unit != new_article.unit
return false if new_article.unit.include?(',')
# legacy, used by foodcoops in Germany
if new_article.unit == 'KI' && unit == 'ST' # 'KI' means a box, with a different amount of items in it
# try to match the size out of its name, e.g. "banana 10-12 St" => 10
new_unit_quantity = /[0-9\-\s]+(St)/.match(new_article.name).to_s.to_i
if new_unit_quantity && new_unit_quantity > 0
new_price = (new_article.price / new_unit_quantity.to_f).round(2)
[new_price, new_unit_quantity]
else
false
end
else # use ruby-units to convert
fc_unit = begin
::Unit.new(unit)
rescue StandardError
nil
end
supplier_unit = begin
::Unit.new(new_article.unit)
rescue StandardError
nil
end
if fc_unit != 0 && supplier_unit != 0 && fc_unit && supplier_unit && fc_unit =~ supplier_unit
conversion_factor = (supplier_unit / fc_unit).to_base.to_r
new_price = new_article.price / conversion_factor
new_unit_quantity = new_article.unit_quantity * conversion_factor
[new_price, new_unit_quantity]
else
false
end
end
end
def deleted?
deleted_at.present?
end
def mark_as_deleted
check_article_in_use
update_column :deleted_at, Time.now
end
protected
# Checks if the article is in use before it will deleted
def check_article_in_use
raise I18n.t('articles.model.error_in_use', article: name.to_s) if in_open_order
end
# Create an ArticlePrice, when the price-attr are changed.
def update_price_history
return unless price_changed?
article_prices.build(
price: price,
tax: tax,
deposit: deposit,
unit_quantity: unit_quantity
)
end
def price_changed?
changed.detect { |attr| attr == 'price' || 'tax' || 'deposit' || 'unit_quantity' } ? true : false
end
# We used have the name unique per supplier+deleted_at+type. With the addition of shared_sync_method all,
# this came in the way, and we now allow duplicate names for the 'all' methods - expecting foodcoops to
# make their own choice among products with different units by making articles available/unavailable.
def uniqueness_of_name
matches = Article.where(name: name, supplier_id: supplier_id, deleted_at: deleted_at, type: type)
matches = matches.where.not(id: id) unless new_record?
# supplier should always be there - except, perhaps, on initialization (on seeding)
if supplier && (supplier.shared_sync_method.blank? || supplier.shared_sync_method == 'import')
errors.add :name, :taken if matches.any?
elsif matches.where(unit: unit, unit_quantity: unit_quantity).any?
errors.add :name, :taken_with_unit
end
end
end