acts_as_calculator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +12 -0
- data/app/controllers/acts_as_calculator/application_controller.rb +76 -0
- data/app/controllers/acts_as_calculator/formula_versions_controller.rb +50 -0
- data/app/controllers/acts_as_calculator/formulas_controller.rb +58 -0
- data/app/controllers/acts_as_calculator/imports_controller.rb +33 -0
- data/app/controllers/acts_as_calculator/templates_controller.rb +66 -0
- data/app/models/acts_as_calculator/formula.rb +21 -0
- data/app/models/acts_as_calculator/formula_version.rb +88 -0
- data/app/models/acts_as_calculator/lookup_table.rb +25 -0
- data/app/models/acts_as_calculator/lookup_table_entry.rb +26 -0
- data/app/models/acts_as_calculator/record.rb +12 -0
- data/app/models/acts_as_calculator/run.rb +24 -0
- data/app/models/acts_as_calculator/template.rb +56 -0
- data/app/models/acts_as_calculator/variable.rb +20 -0
- data/config/routes.rb +36 -0
- data/lib/acts_as_calculator/aggregate_results.rb +57 -0
- data/lib/acts_as_calculator/aggregation.rb +9 -0
- data/lib/acts_as_calculator/apportion_amount.rb +62 -0
- data/lib/acts_as_calculator/apportionment.rb +57 -0
- data/lib/acts_as_calculator/build_calculator.rb +24 -0
- data/lib/acts_as_calculator/build_lookups.rb +37 -0
- data/lib/acts_as_calculator/calculable.rb +84 -0
- data/lib/acts_as_calculator/calculator_cache.rb +36 -0
- data/lib/acts_as_calculator/cast_date.rb +16 -0
- data/lib/acts_as_calculator/cast_decimal.rb +15 -0
- data/lib/acts_as_calculator/cast_json_safe.rb +37 -0
- data/lib/acts_as_calculator/cast_liquid_value.rb +58 -0
- data/lib/acts_as_calculator/cast_variable_attributes.rb +17 -0
- data/lib/acts_as_calculator/configuration.rb +31 -0
- data/lib/acts_as_calculator/distribute_remainder.rb +49 -0
- data/lib/acts_as_calculator/divide_proportionally.rb +27 -0
- data/lib/acts_as_calculator/engine.rb +19 -0
- data/lib/acts_as_calculator/errors.rb +23 -0
- data/lib/acts_as_calculator/evaluate_expression.rb +49 -0
- data/lib/acts_as_calculator/evaluate_formula.rb +53 -0
- data/lib/acts_as_calculator/find_lookup_table_references.rb +68 -0
- data/lib/acts_as_calculator/find_owned_record.rb +38 -0
- data/lib/acts_as_calculator/find_tier.rb +25 -0
- data/lib/acts_as_calculator/formula_version_drop.rb +55 -0
- data/lib/acts_as_calculator/function_registry.rb +83 -0
- data/lib/acts_as_calculator/import_definitions.rb +70 -0
- data/lib/acts_as_calculator/import_formula.rb +102 -0
- data/lib/acts_as_calculator/import_lookup_table.rb +94 -0
- data/lib/acts_as_calculator/import_outcome.rb +20 -0
- data/lib/acts_as_calculator/import_summary.rb +29 -0
- data/lib/acts_as_calculator/import_template.rb +57 -0
- data/lib/acts_as_calculator/liquid_filters.rb +70 -0
- data/lib/acts_as_calculator/persist_run.rb +31 -0
- data/lib/acts_as_calculator/promote_template.rb +21 -0
- data/lib/acts_as_calculator/publish_formula_version.rb +54 -0
- data/lib/acts_as_calculator/publish_template.rb +18 -0
- data/lib/acts_as_calculator/read_import_file.rb +23 -0
- data/lib/acts_as_calculator/render_liquid.rb +113 -0
- data/lib/acts_as_calculator/render_template.rb +43 -0
- data/lib/acts_as_calculator/resolve_formula_version.rb +57 -0
- data/lib/acts_as_calculator/resolve_import_owner.rb +38 -0
- data/lib/acts_as_calculator/resolve_template.rb +41 -0
- data/lib/acts_as_calculator/resolve_variables.rb +105 -0
- data/lib/acts_as_calculator/result.rb +32 -0
- data/lib/acts_as_calculator/result_drop.rb +46 -0
- data/lib/acts_as_calculator/serialize_formula.rb +18 -0
- data/lib/acts_as_calculator/serialize_formula_version.rb +26 -0
- data/lib/acts_as_calculator/serialize_import_summary.rb +21 -0
- data/lib/acts_as_calculator/serialize_template.rb +13 -0
- data/lib/acts_as_calculator/supersede_formula_versions.rb +83 -0
- data/lib/acts_as_calculator/tier.rb +31 -0
- data/lib/acts_as_calculator/variable_spec.rb +33 -0
- data/lib/acts_as_calculator/version.rb +5 -0
- data/lib/acts_as_calculator.rb +78 -0
- data/lib/generators/acts_as_calculator/import/import_generator.rb +26 -0
- data/lib/generators/acts_as_calculator/install/install_generator.rb +31 -0
- data/lib/generators/acts_as_calculator/install/templates/create_acts_as_calculator_tables.rb.tt +133 -0
- data/lib/tasks/acts_as_calculator.rake +21 -0
- data/sig/acts_as_calculator.rbs +4 -0
- metadata +251 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class FormulaVersion < Record
|
|
5
|
+
self.table_name = "calculator_formula_versions"
|
|
6
|
+
|
|
7
|
+
DRAFT = "draft"
|
|
8
|
+
ACTIVE = "active"
|
|
9
|
+
RETIRED = "retired"
|
|
10
|
+
STATUSES = [DRAFT, ACTIVE, RETIRED].freeze
|
|
11
|
+
|
|
12
|
+
# `effective_to` and `status` stay writable so an active version can be closed out or
|
|
13
|
+
# retired; rewriting what was in force on a date already calculated is what's banned.
|
|
14
|
+
IMMUTABLE_ONCE_ACTIVE = %w[formula_id version_number expression effective_from].freeze
|
|
15
|
+
|
|
16
|
+
belongs_to :formula, inverse_of: :versions
|
|
17
|
+
|
|
18
|
+
has_many :variables,
|
|
19
|
+
class_name: "ActsAsCalculator::Variable",
|
|
20
|
+
foreign_key: :formula_version_id,
|
|
21
|
+
inverse_of: :formula_version,
|
|
22
|
+
dependent: :destroy
|
|
23
|
+
|
|
24
|
+
has_many :runs,
|
|
25
|
+
class_name: "ActsAsCalculator::Run",
|
|
26
|
+
foreign_key: :formula_version_id,
|
|
27
|
+
inverse_of: :formula_version,
|
|
28
|
+
dependent: :restrict_with_error
|
|
29
|
+
|
|
30
|
+
validates :expression, presence: true
|
|
31
|
+
validates :effective_from, presence: true
|
|
32
|
+
validates :status, inclusion: { in: STATUSES }
|
|
33
|
+
validates :version_number,
|
|
34
|
+
presence: true,
|
|
35
|
+
numericality: { only_integer: true, greater_than: 0 },
|
|
36
|
+
uniqueness: { scope: :formula_id }
|
|
37
|
+
|
|
38
|
+
validate :effective_range_ordered
|
|
39
|
+
validate :no_overlapping_active_version
|
|
40
|
+
validate :active_content_unchanged, on: :update
|
|
41
|
+
|
|
42
|
+
scope :active, -> { where(status: ACTIVE) }
|
|
43
|
+
scope :open_ended_or_ending_on_or_after, lambda { |date|
|
|
44
|
+
where("#{quoted_table_name}.effective_to IS NULL OR #{quoted_table_name}.effective_to >= :date", date:)
|
|
45
|
+
}
|
|
46
|
+
scope :covering, ->(date) { where(effective_from: ..date).open_ended_or_ending_on_or_after(date) }
|
|
47
|
+
|
|
48
|
+
def active?
|
|
49
|
+
status == ACTIVE
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def covers?(date)
|
|
53
|
+
return false if effective_from.nil? || effective_from > date
|
|
54
|
+
|
|
55
|
+
effective_to.nil? || effective_to >= date
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def effective_range_ordered
|
|
61
|
+
return if effective_from.nil? || effective_to.nil? || effective_to >= effective_from
|
|
62
|
+
|
|
63
|
+
errors.add(:effective_to, "must be on or after effective_from")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def no_overlapping_active_version
|
|
67
|
+
return unless active?
|
|
68
|
+
return if formula_id.nil? || effective_from.nil?
|
|
69
|
+
|
|
70
|
+
errors.add(:effective_from, "overlaps an active version of this formula") if overlapping_siblings.exists?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def overlapping_siblings
|
|
74
|
+
siblings = self.class.active.where(formula_id:)
|
|
75
|
+
siblings = siblings.where.not(id:) if id
|
|
76
|
+
siblings = siblings.where(effective_from: ..effective_to) if effective_to
|
|
77
|
+
siblings.open_ended_or_ending_on_or_after(effective_from)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def active_content_unchanged
|
|
81
|
+
return unless status_was == ACTIVE
|
|
82
|
+
|
|
83
|
+
(changed & IMMUTABLE_ONCE_ACTIVE).each do |attribute|
|
|
84
|
+
errors.add(attribute, "cannot be changed on an active version — create a new version instead")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class LookupTable < Record
|
|
5
|
+
self.table_name = "calculator_lookup_tables"
|
|
6
|
+
|
|
7
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
8
|
+
|
|
9
|
+
has_many :entries,
|
|
10
|
+
class_name: "ActsAsCalculator::LookupTableEntry",
|
|
11
|
+
foreign_key: :lookup_table_id,
|
|
12
|
+
inverse_of: :lookup_table,
|
|
13
|
+
dependent: :destroy
|
|
14
|
+
|
|
15
|
+
validates :key, presence: true, uniqueness: { scope: %i[scope owner_type owner_id] }
|
|
16
|
+
validates :scope, presence: true
|
|
17
|
+
|
|
18
|
+
scope :owned_by, ->(owner) { where(owner_type: owner&.class&.polymorphic_name, owner_id: owner&.id) }
|
|
19
|
+
scope :global, -> { where(owner_type: nil, owner_id: nil) }
|
|
20
|
+
|
|
21
|
+
def tiers
|
|
22
|
+
entries.ordered.map { |entry| Tier.new(from: entry.from, to: entry.to, value: entry.value) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class LookupTableEntry < Record
|
|
5
|
+
self.table_name = "calculator_lookup_table_entries"
|
|
6
|
+
|
|
7
|
+
belongs_to :lookup_table, inverse_of: :entries
|
|
8
|
+
|
|
9
|
+
validates :value, presence: true
|
|
10
|
+
validates :position, presence: true, numericality: { only_integer: true }
|
|
11
|
+
validate :bounds_ordered
|
|
12
|
+
|
|
13
|
+
# `from`/`to` are half-open and nullable on both ends, so neither is orderable in SQL
|
|
14
|
+
# in a way that puts an unbounded floor first across every adapter — position is the
|
|
15
|
+
# explicit, adapter-independent answer.
|
|
16
|
+
scope :ordered, -> { order(:position, :id) }
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def bounds_ordered
|
|
21
|
+
return if from.nil? || to.nil? || to > from
|
|
22
|
+
|
|
23
|
+
errors.add(:to, "must be greater than from")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class Record < ::ActiveRecord::Base
|
|
5
|
+
self.abstract_class = true
|
|
6
|
+
|
|
7
|
+
# Set here rather than inherited from the host: `belongs_to_required_by_default` only
|
|
8
|
+
# becomes true via `config.load_defaults`, and whether a run may be saved without the
|
|
9
|
+
# formula version it applied is not the host's decision to make.
|
|
10
|
+
self.belongs_to_required_by_default = true
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class Run < Record
|
|
5
|
+
self.table_name = "calculator_runs"
|
|
6
|
+
|
|
7
|
+
belongs_to :calculable, polymorphic: true
|
|
8
|
+
belongs_to :formula_version, inverse_of: :runs
|
|
9
|
+
|
|
10
|
+
validates :as_of_date, presence: true
|
|
11
|
+
validates :result, presence: true
|
|
12
|
+
|
|
13
|
+
scope :recent_first, -> { order(as_of_date: :desc, id: :desc) }
|
|
14
|
+
scope :for_formula_key, lambda { |key|
|
|
15
|
+
joins(formula_version: :formula).where(calculator_formulas: { key: key.to_s })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# The audit trail is append-only: a correction is a new run, never an edit of the row
|
|
19
|
+
# that recorded what was actually applied.
|
|
20
|
+
def readonly?
|
|
21
|
+
persisted?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class Template < Record
|
|
5
|
+
self.table_name = "calculator_templates"
|
|
6
|
+
|
|
7
|
+
HTML = "html"
|
|
8
|
+
TEXT = "text"
|
|
9
|
+
FORMATS = [HTML, TEXT].freeze
|
|
10
|
+
|
|
11
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
12
|
+
|
|
13
|
+
validates :key, presence: true, uniqueness: { scope: %i[scope owner_type owner_id version_number] }
|
|
14
|
+
validates :scope, presence: true
|
|
15
|
+
validates :body, presence: true
|
|
16
|
+
validates :format, inclusion: { in: FORMATS }
|
|
17
|
+
validates :version_number, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
18
|
+
|
|
19
|
+
# Only on update: a create demotes its siblings in before_create, which runs *after*
|
|
20
|
+
# validation, so running this on create would flag the version it is about to replace.
|
|
21
|
+
validate :single_current_version, on: :update
|
|
22
|
+
|
|
23
|
+
before_create :demote_other_versions, if: :current?
|
|
24
|
+
|
|
25
|
+
scope :owned_by, ->(owner) { where(owner_type: owner&.class&.polymorphic_name, owner_id: owner&.id) }
|
|
26
|
+
scope :global, -> { where(owner_type: nil, owner_id: nil) }
|
|
27
|
+
scope :latest_first, -> { order(version_number: :desc, id: :desc) }
|
|
28
|
+
scope :current, -> { where(current: true) }
|
|
29
|
+
scope :version_siblings_of, lambda { |template|
|
|
30
|
+
where(key: template.key, scope: template.scope,
|
|
31
|
+
owner_type: template.owner_type, owner_id: template.owner_id)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def next_version_number
|
|
35
|
+
(self.class.version_siblings_of(self).maximum(:version_number) || 0) + 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def other_versions
|
|
41
|
+
siblings = self.class.version_siblings_of(self)
|
|
42
|
+
persisted? ? siblings.where.not(id:) : siblings
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def demote_other_versions
|
|
46
|
+
other_versions.current.update_all(current: false, updated_at: Time.current)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def single_current_version
|
|
50
|
+
return unless current?
|
|
51
|
+
return unless other_versions.current.exists?
|
|
52
|
+
|
|
53
|
+
errors.add(:current, "another version of this template is already current — use PromoteTemplate")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class Variable < Record
|
|
5
|
+
self.table_name = "calculator_variables"
|
|
6
|
+
|
|
7
|
+
SOURCE_TYPES = ResolveVariables::SOURCE_TYPES.map(&:to_s).freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :formula_version, inverse_of: :variables
|
|
10
|
+
|
|
11
|
+
validates :name, presence: true, uniqueness: { scope: :formula_version_id }
|
|
12
|
+
validates :source_type, inclusion: { in: SOURCE_TYPES }
|
|
13
|
+
|
|
14
|
+
def lookup_table_key
|
|
15
|
+
return nil unless source_type == "lookup"
|
|
16
|
+
|
|
17
|
+
(source_config || {}).fetch("table", name).to_s
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The API ships off (docs/PLAN.md, "Authoring paths"): a host opts in with
|
|
4
|
+
# `ActsAsCalculator.configure { |c| c.enable_api = true }`.
|
|
5
|
+
#
|
|
6
|
+
# The gate is a routing constraint rather than a `before_action` on purpose. A controller
|
|
7
|
+
# guard leaves the route registered — the router matches it, instantiates the controller,
|
|
8
|
+
# and the controller declines — which means the endpoints exist, answer HEAD/OPTIONS, show
|
|
9
|
+
# up in `rails routes`, and are one forgotten `skip_before_action` away from being live.
|
|
10
|
+
# A constraint that returns false makes the route not match at all: the engine's router
|
|
11
|
+
# passes, the host's router finds nothing, and the request 404s because there is no such
|
|
12
|
+
# path. Off means absent, not refused.
|
|
13
|
+
#
|
|
14
|
+
# The lambda is evaluated per request, so the flag is read at match time, not captured when
|
|
15
|
+
# the routes were drawn.
|
|
16
|
+
ActsAsCalculator::Engine.routes.draw do
|
|
17
|
+
constraints(->(_request) { ActsAsCalculator.configuration.enable_api }) do
|
|
18
|
+
resources :formulas, only: %i[index show create update destroy] do
|
|
19
|
+
resources :versions, only: %i[index show create], controller: "formula_versions"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# No `update`: a template body is versioned for rollback, so a change publishes a new
|
|
23
|
+
# version (POST) and going back promotes an old one — editing a row in place would
|
|
24
|
+
# rewrite the history that makes rollback possible.
|
|
25
|
+
resources :templates, only: %i[index show create destroy] do
|
|
26
|
+
member do
|
|
27
|
+
post :preview
|
|
28
|
+
post :promote
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# docs/PLAN.md's JSON import, reachable over HTTP — the same ImportDefinitions the
|
|
33
|
+
# generator and the rake task call, handed a parsed body instead of a file.
|
|
34
|
+
post "import", to: "imports#create"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculator
|
|
6
|
+
class AggregateResults
|
|
7
|
+
def self.call(...)
|
|
8
|
+
new(...).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(records:, formula:, as_of: nil, group_by: nil)
|
|
12
|
+
@records = records
|
|
13
|
+
@formula = formula
|
|
14
|
+
@as_of = as_of
|
|
15
|
+
@group_by = group_by
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return total(rows) if group_by.nil?
|
|
20
|
+
|
|
21
|
+
rows.group_by { |record| group_key(record) }.transform_values { |group| total(group) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :records, :formula, :as_of, :group_by
|
|
27
|
+
|
|
28
|
+
def rows
|
|
29
|
+
@rows ||= records.to_a
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def total(group)
|
|
33
|
+
group.sum(BigDecimal(0)) { |record| CastDecimal.(value_of(record)) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def value_of(record)
|
|
37
|
+
outcome = calculate(record)
|
|
38
|
+
return outcome.value if outcome.respond_to?(:value)
|
|
39
|
+
return outcome if outcome.is_a?(Numeric)
|
|
40
|
+
|
|
41
|
+
raise AggregationError, "expected a Result or Numeric from #{record.inspect}, got #{outcome.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def calculate(record)
|
|
45
|
+
return formula.call(record) if formula.respond_to?(:call)
|
|
46
|
+
raise AggregationError, "#{record.class} does not respond to #calculate" unless record.respond_to?(:calculate)
|
|
47
|
+
|
|
48
|
+
as_of.nil? ? record.calculate(formula) : record.calculate(formula, as_of:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def group_key(record)
|
|
52
|
+
return group_by.call(record) if group_by.respond_to?(:call)
|
|
53
|
+
|
|
54
|
+
record.public_send(group_by)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculator
|
|
6
|
+
class ApportionAmount
|
|
7
|
+
def self.call(...)
|
|
8
|
+
new(...).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(amount:, among:, by: nil, strategy: :proportional,
|
|
12
|
+
precision: Apportionment::DEFAULT_PRECISION)
|
|
13
|
+
@amount = amount
|
|
14
|
+
@among = among
|
|
15
|
+
@by = by
|
|
16
|
+
@strategy = strategy
|
|
17
|
+
@precision = precision
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
raise ApportionmentError, "cannot apportion among an empty collection" if members.empty?
|
|
22
|
+
|
|
23
|
+
shares = Apportionment.strategy(strategy).call(amount: total, weights:, precision:)
|
|
24
|
+
|
|
25
|
+
members.zip(weights, shares).map do |member, weight, share|
|
|
26
|
+
Apportionment::Share.new(member:, weight:, amount: share)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :amount, :among, :by, :strategy, :precision
|
|
33
|
+
|
|
34
|
+
def total
|
|
35
|
+
@total ||= CastDecimal.(amount)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def members
|
|
39
|
+
@members ||= among.to_a
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def weights
|
|
43
|
+
@weights ||= members.map { |member| weight_for(member) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def weight_for(member)
|
|
47
|
+
return BigDecimal(1) if by.nil? || strategy.to_sym == :equal
|
|
48
|
+
|
|
49
|
+
weight = CastDecimal.(raw_weight(member))
|
|
50
|
+
raise ApportionmentError, "negative weight #{weight.to_f} from #{by.inspect}" if weight.negative?
|
|
51
|
+
|
|
52
|
+
weight
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def raw_weight(member)
|
|
56
|
+
value = by.respond_to?(:call) ? by.call(member) : member.public_send(by)
|
|
57
|
+
raise ApportionmentError, "#{by.inspect} returned nil for #{member.inspect}" if value.nil?
|
|
58
|
+
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculator
|
|
6
|
+
module Apportionment
|
|
7
|
+
Share = Data.define(:member, :weight, :amount)
|
|
8
|
+
|
|
9
|
+
DEFAULT_PRECISION = 2
|
|
10
|
+
|
|
11
|
+
PROPORTIONAL = lambda { |amount:, weights:, **|
|
|
12
|
+
DivideProportionally.(amount:, weights:)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
EQUAL = lambda { |amount:, weights:, **|
|
|
16
|
+
Array.new(weights.size) { amount / weights.size }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
LARGEST_REMAINDER = lambda { |amount:, weights:, precision:|
|
|
20
|
+
DistributeRemainder.(amount:, weights:, precision:)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def self.split(amount:, among:, by: nil, strategy: :proportional, precision: DEFAULT_PRECISION)
|
|
24
|
+
ApportionAmount.(amount:, among:, by:, strategy:, precision:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.register_strategy(name, strategy)
|
|
28
|
+
registry[name.to_sym] = strategy
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.unregister_strategy(name)
|
|
33
|
+
registry.delete(name.to_sym)
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.strategy(name)
|
|
38
|
+
registry.fetch(name.to_sym) do
|
|
39
|
+
raise UnknownStrategyError,
|
|
40
|
+
"unknown apportionment strategy #{name.inspect} (known: #{known_strategies.join(", ")})"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.known_strategies
|
|
45
|
+
registry.keys
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.registry
|
|
49
|
+
@registry ||= {}
|
|
50
|
+
end
|
|
51
|
+
private_class_method :registry
|
|
52
|
+
|
|
53
|
+
register_strategy(:proportional, PROPORTIONAL)
|
|
54
|
+
register_strategy(:equal, EQUAL)
|
|
55
|
+
register_strategy(:largest_remainder, LARGEST_REMAINDER)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dentaku"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculator
|
|
6
|
+
class BuildCalculator
|
|
7
|
+
def self.call(...)
|
|
8
|
+
new(...).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(functions: FunctionRegistry.default, case_sensitive: false)
|
|
12
|
+
@functions = functions
|
|
13
|
+
@case_sensitive = case_sensitive
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
functions.install(Dentaku::Calculator.new(case_sensitive:))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :functions, :case_sensitive
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class BuildLookups
|
|
5
|
+
def self.call(...)
|
|
6
|
+
new(...).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(formula_version:, owner: nil)
|
|
10
|
+
@formula_version = formula_version
|
|
11
|
+
@owner = owner
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
table_keys.to_h { |key| [key, tiers_for(key)] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :formula_version, :owner
|
|
21
|
+
|
|
22
|
+
def table_keys
|
|
23
|
+
formula_version.variables.filter_map(&:lookup_table_key).uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tiers_for(key)
|
|
27
|
+
table = FindOwnedRecord.(relation: LookupTable.all, key:, scope: formula.scope, owner: owner || formula.owner)
|
|
28
|
+
raise MissingLookupTableError, "no lookup table #{key.inspect} in scope #{formula.scope.inspect}" if table.nil?
|
|
29
|
+
|
|
30
|
+
table.tiers
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def formula
|
|
34
|
+
@formula ||= formula_version.formula
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/core_ext/class/attribute"
|
|
5
|
+
|
|
6
|
+
module ActsAsCalculator
|
|
7
|
+
module Calculable
|
|
8
|
+
extend ::ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
# No `dependent:` on purpose. Whether an audit trail outlives the record it audited
|
|
12
|
+
# is the host app's retention policy, and Run#readonly? makes `:destroy` raise.
|
|
13
|
+
has_many :calculator_runs,
|
|
14
|
+
class_name: "ActsAsCalculator::Run",
|
|
15
|
+
as: :calculable,
|
|
16
|
+
inverse_of: :calculable
|
|
17
|
+
|
|
18
|
+
class_attribute :calculator_scope_name, instance_writer: false, default: DEFAULT_SCOPE
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
def calculator_scope(name)
|
|
23
|
+
self.calculator_scope_name = name.to_s
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Leftover keywords splat into the calculation context, which makes `as_of`, `scope`,
|
|
28
|
+
# `owner` and `dry_run` unusable as Dentaku variable names; `context:` is the escape
|
|
29
|
+
# hatch for a formula that genuinely declares one, and wins over the splat.
|
|
30
|
+
def calculate(key, as_of: nil, scope: nil, owner: nil, dry_run: false, context: {}, **extra)
|
|
31
|
+
EvaluateFormula.(
|
|
32
|
+
calculable: self,
|
|
33
|
+
key:,
|
|
34
|
+
scope: scope || calculator_scope_name,
|
|
35
|
+
owner: owner || calculator_owner,
|
|
36
|
+
as_of:,
|
|
37
|
+
context: extra.merge(context),
|
|
38
|
+
dry_run:
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def calculate_as_of(key, as_of, **context)
|
|
43
|
+
calculate(key, as_of:, **context)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def calculation_history(key = nil, limit: nil)
|
|
47
|
+
runs = calculator_runs.recent_first
|
|
48
|
+
runs = runs.for_formula_key(key) unless key.nil?
|
|
49
|
+
limit.nil? ? runs : runs.limit(limit)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def calculator_owner
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Chains a calculation and a render in one call — `calculate:` names one formula key
|
|
57
|
+
# (assigned as `result`) or several (assigned as `results`) — or renders a Result the
|
|
58
|
+
# caller already has. Leftover keywords splat into the template's assigns *and* into
|
|
59
|
+
# the calculation's context, so one call can feed both.
|
|
60
|
+
def render(key, calculate: nil, result: nil, results: {}, as_of: nil, scope: nil,
|
|
61
|
+
owner: nil, version_number: nil, dry_run: false, context: {}, **extra)
|
|
62
|
+
assigns = extra.merge(context)
|
|
63
|
+
computed = calculator_render_results(calculate, as_of:, scope:, owner:, dry_run:, context: assigns)
|
|
64
|
+
|
|
65
|
+
RenderTemplate.(
|
|
66
|
+
key:, version_number:, context: assigns,
|
|
67
|
+
scope: scope || calculator_scope_name,
|
|
68
|
+
owner: owner || calculator_owner,
|
|
69
|
+
result: result || (computed.values.first unless calculate.is_a?(Array)),
|
|
70
|
+
results: results.merge(computed)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Forwards the assigns as `context:` rather than splatting them, so a key named
|
|
77
|
+
# `scope` reaches the formula instead of redirecting its lookup.
|
|
78
|
+
def calculator_render_results(keys, as_of:, scope:, owner:, dry_run:, context:)
|
|
79
|
+
Array(keys).to_h do |formula_key|
|
|
80
|
+
[formula_key.to_s, calculate(formula_key, as_of:, scope:, owner:, dry_run:, context:)]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class CalculatorCache
|
|
5
|
+
def self.default
|
|
6
|
+
@default ||= new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(functions: FunctionRegistry.default)
|
|
10
|
+
@functions = functions
|
|
11
|
+
@key = :"acts_as_calculator_calculators_#{object_id}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def fetch(formula_version_id)
|
|
15
|
+
raise ArgumentError, "a calculator cache key must be a formula_version_id" if formula_version_id.nil?
|
|
16
|
+
|
|
17
|
+
store[formula_version_id] ||= BuildCalculator.(functions:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def clear
|
|
21
|
+
store.clear
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :functions, :key
|
|
27
|
+
|
|
28
|
+
# Keyed by formula_version_id because version content is immutable, so an entry can
|
|
29
|
+
# never go stale. Held per thread because Dentaku::Calculator mutates its own memory
|
|
30
|
+
# while evaluating — one shared instance across a Puma thread pool would interleave
|
|
31
|
+
# two requests' variables.
|
|
32
|
+
def store
|
|
33
|
+
Thread.current[key] ||= {}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculator
|
|
6
|
+
class CastDate
|
|
7
|
+
def self.call(value)
|
|
8
|
+
return value if value.instance_of?(Date)
|
|
9
|
+
return value.to_date if value.respond_to?(:to_date)
|
|
10
|
+
|
|
11
|
+
Date.parse(value.to_s)
|
|
12
|
+
rescue ArgumentError, TypeError, RangeError
|
|
13
|
+
raise Error, "cannot cast #{value.inspect} to a date"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|