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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# The one place import happens. Both authoring paths — the generator and the rake task —
|
|
5
|
+
# are argument parsing and printing around this call, so neither can drift into having
|
|
6
|
+
# its own idea of what "already imported" means.
|
|
7
|
+
class ImportDefinitions
|
|
8
|
+
# Lookup tables first: a formula version's variables name tables that have to resolve
|
|
9
|
+
# before a calculation using them can run.
|
|
10
|
+
SECTIONS = {
|
|
11
|
+
"lookup_tables" => ImportLookupTable,
|
|
12
|
+
"formulas" => ImportFormula,
|
|
13
|
+
"templates" => ImportTemplate
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def self.call(...)
|
|
17
|
+
new(...).call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(path: nil, data: nil)
|
|
21
|
+
raise ImportError, "give ImportDefinitions either a path or data, not both" if path && data
|
|
22
|
+
raise ImportError, "ImportDefinitions needs a path or data" if path.nil? && data.nil?
|
|
23
|
+
|
|
24
|
+
@path = path
|
|
25
|
+
@document = (data || ReadImportFile.(path)).transform_keys(&:to_s)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
reject_unknown_sections
|
|
30
|
+
|
|
31
|
+
ImportSummary.new(source: path&.to_s || "inline data", outcomes:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :path, :document
|
|
37
|
+
|
|
38
|
+
def outcomes
|
|
39
|
+
SECTIONS.flat_map do |section, decree|
|
|
40
|
+
Array(document[section]).map { |attributes| import(decree, attributes, section) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reject_unknown_sections
|
|
45
|
+
unknown = document.keys - SECTIONS.keys
|
|
46
|
+
return if unknown.empty?
|
|
47
|
+
|
|
48
|
+
raise ImportError, "unknown import section(s) #{unknown.join(", ")} (known: #{SECTIONS.keys.join(", ")})"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One savepoint per entry, not one transaction per file: the summary reports what each
|
|
52
|
+
# entry did, so a half-written formula must not survive its own failure, and the twenty
|
|
53
|
+
# entries that imported cleanly must not be rolled back by the twenty-first.
|
|
54
|
+
def import(decree, attributes, section)
|
|
55
|
+
raise ImportError, "#{section} must be a list of objects, got #{attributes.inspect}" unless attributes.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
Record.transaction(requires_new: true) { decree.(attributes:) }
|
|
58
|
+
rescue Error, ::ActiveRecord::ActiveRecordError => e
|
|
59
|
+
failure(attributes, section, e)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def failure(attributes, section, error)
|
|
63
|
+
attributes = attributes.is_a?(Hash) ? attributes.transform_keys(&:to_s) : {}
|
|
64
|
+
|
|
65
|
+
ImportOutcome.build(kind: section.delete_suffix("s"), status: :failed,
|
|
66
|
+
key: attributes["key"] || "(no key)", scope: attributes["scope"] || DEFAULT_SCOPE,
|
|
67
|
+
detail: error.message)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class ImportFormula
|
|
5
|
+
def self.call(...)
|
|
6
|
+
new(...).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(attributes:)
|
|
10
|
+
@attributes = attributes.transform_keys(&:to_s)
|
|
11
|
+
@key = @attributes.fetch("key") { raise ImportError, "a formulas entry has no key" }.to_s
|
|
12
|
+
@scope = (@attributes["scope"] || DEFAULT_SCOPE).to_s
|
|
13
|
+
@owner = ResolveImportOwner.(@attributes["owner"])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return outcome(:created, add_version(Formula.create!(key:, scope:, owner:))) if formula.nil?
|
|
18
|
+
return outcome(:skipped, matching_version) if matching_version
|
|
19
|
+
|
|
20
|
+
outcome(:updated, add_version(formula))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :attributes, :key, :scope, :owner
|
|
26
|
+
|
|
27
|
+
# The exact [key, scope, owner] triple, with none of `FindOwnedRecord`'s fallback to the
|
|
28
|
+
# global row: an owner-scoped import that quietly rewrote the global formula would be
|
|
29
|
+
# the opposite of what the file asked for.
|
|
30
|
+
def formula
|
|
31
|
+
return @formula if defined?(@formula)
|
|
32
|
+
|
|
33
|
+
@formula = Formula.owned_by(owner).find_by(key:, scope:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Content, not status: a file that flips a version from draft to active is asking to
|
|
37
|
+
# publish the version it already declared, not to write a second copy of it.
|
|
38
|
+
def matching_version
|
|
39
|
+
@matching_version ||= formula.versions.includes(:variables).find { |version| same_content?(version) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def same_content?(version)
|
|
43
|
+
version.expression == expression &&
|
|
44
|
+
version.effective_from == effective_from &&
|
|
45
|
+
version.effective_to == effective_to &&
|
|
46
|
+
specs(version.variables) == declared_specs
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def add_version(target)
|
|
50
|
+
PublishFormulaVersion.(formula: target, expression:, effective_from:, effective_to:, status:,
|
|
51
|
+
change_note: attributes["change_note"], variables: declared_variables)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def expression
|
|
55
|
+
@expression ||= required_attribute("expression").to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def effective_from
|
|
59
|
+
@effective_from ||= CastDate.(required_attribute("effective_from"))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def effective_to
|
|
63
|
+
return @effective_to if defined?(@effective_to)
|
|
64
|
+
|
|
65
|
+
@effective_to = attributes["effective_to"] && CastDate.(attributes["effective_to"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def status
|
|
69
|
+
@status ||= (attributes["status"] || FormulaVersion::ACTIVE).to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def required_attribute(name)
|
|
73
|
+
attributes.fetch(name) { raise ImportError, "formula #{key.inspect} has no #{name}" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def declared_variables
|
|
77
|
+
@declared_variables ||= Array(attributes["variables"]).map { |variable| declared_variable(variable) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def declared_variable(variable)
|
|
81
|
+
variable = variable.transform_keys(&:to_s)
|
|
82
|
+
variable.fetch("name") { raise ImportError, "formula #{key.inspect} has a variable with no name" }
|
|
83
|
+
|
|
84
|
+
CastVariableAttributes.(variable)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def declared_specs
|
|
88
|
+
@declared_specs ||= specs(declared_variables)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `VariableSpec` already normalises a Hash and an AR `Variable` onto the same shape, so
|
|
92
|
+
# comparing through it is the same reading `ResolveVariables` gets at calculation time.
|
|
93
|
+
# Declaration order carries no meaning — a reordered file is the same formula.
|
|
94
|
+
def specs(variables)
|
|
95
|
+
variables.map { |variable| VariableSpec.build(variable) }.sort_by(&:name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def outcome(result, version)
|
|
99
|
+
ImportOutcome.build(kind: :formula, status: result, key:, scope:, detail: "version #{version.version_number}")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class ImportLookupTable
|
|
5
|
+
def self.call(...)
|
|
6
|
+
new(...).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(attributes:)
|
|
10
|
+
@attributes = attributes.transform_keys(&:to_s)
|
|
11
|
+
@key = @attributes.fetch("key") { raise ImportError, "a lookup_tables entry has no key" }.to_s
|
|
12
|
+
@scope = (@attributes["scope"] || DEFAULT_SCOPE).to_s
|
|
13
|
+
@owner = ResolveImportOwner.(@attributes["owner"])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return create if existing.nil?
|
|
18
|
+
return outcome(:skipped) if entries_unchanged?
|
|
19
|
+
|
|
20
|
+
guard_against_rewriting_audited_brackets
|
|
21
|
+
replace_entries
|
|
22
|
+
outcome(:updated)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :attributes, :key, :scope, :owner
|
|
28
|
+
|
|
29
|
+
def existing
|
|
30
|
+
return @existing if defined?(@existing)
|
|
31
|
+
|
|
32
|
+
@existing = LookupTable.owned_by(owner).find_by(key:, scope:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create
|
|
36
|
+
table = LookupTable.create!(key:, scope:, owner:)
|
|
37
|
+
write_entries(table)
|
|
38
|
+
outcome(:created)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def replace_entries
|
|
42
|
+
existing.entries.destroy_all
|
|
43
|
+
write_entries(existing)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def write_entries(table)
|
|
47
|
+
declared_entries.each_with_index do |entry, position|
|
|
48
|
+
table.entries.create!(position:, from: entry[:from], to: entry[:to], value: entry[:value])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def declared_entries
|
|
53
|
+
@declared_entries ||= Array(attributes["entries"]).map { |entry| declared_entry(entry.transform_keys(&:to_s)) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def declared_entry(entry)
|
|
57
|
+
value = entry.fetch("value") { raise ImportError, "lookup table #{key.inspect} has an entry with no value" }
|
|
58
|
+
|
|
59
|
+
{ from: cast_bound(entry["from"]), to: cast_bound(entry["to"]), value: CastDecimal.(value) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cast_bound(value)
|
|
63
|
+
value.nil? ? nil : CastDecimal.(value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def entries_unchanged?
|
|
67
|
+
existing.entries.ordered.map { |entry| { from: entry.from, to: entry.to, value: entry.value } } ==
|
|
68
|
+
declared_entries
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Entries a draft version merely mentions are still free to change; entries an active
|
|
72
|
+
# or retired version resolved are not, because a run has been audited against them.
|
|
73
|
+
def guard_against_rewriting_audited_brackets
|
|
74
|
+
statuses = [FormulaVersion::ACTIVE, FormulaVersion::RETIRED]
|
|
75
|
+
references = FindLookupTableReferences.(lookup_table: existing, statuses:)
|
|
76
|
+
return if references.empty?
|
|
77
|
+
|
|
78
|
+
raise LookupTableInUseError, in_use_message(references)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def in_use_message(references)
|
|
82
|
+
used_by = references.map { |version| "#{version.formula.key}##{version.version_number} (#{version.status})" }
|
|
83
|
+
|
|
84
|
+
"lookup table #{key.inspect} in scope #{scope.inspect} already backs #{used_by.join(", ")}; " \
|
|
85
|
+
"changing its entries would retroactively change what those calculations were audited " \
|
|
86
|
+
"against. Import a new table key instead (e.g. #{key.inspect} → \"#{key}_v2\") and a new " \
|
|
87
|
+
"formula version pointing at it."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def outcome(status)
|
|
91
|
+
ImportOutcome.build(kind: :lookup_table, status:, key:, scope:)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# One line of an import report: what was touched, and what happened to it.
|
|
5
|
+
IMPORT_STATUSES = %i[created updated skipped failed].freeze
|
|
6
|
+
|
|
7
|
+
ImportOutcome = Data.define(:kind, :status, :key, :scope, :detail) do
|
|
8
|
+
def self.build(kind:, status:, key:, scope:, detail: nil)
|
|
9
|
+
new(kind: kind.to_sym, status: status.to_sym, key: key.to_s, scope: scope.to_s, detail:)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def failed?
|
|
13
|
+
status == :failed
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s
|
|
17
|
+
"#{status} #{kind} #{key.inspect} (scope #{scope.inspect})#{detail && " — #{detail}"}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
ImportSummary = Data.define(:source, :outcomes) do
|
|
5
|
+
def success?
|
|
6
|
+
failures.empty?
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def failures
|
|
10
|
+
outcomes.select(&:failed?)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def counts
|
|
14
|
+
IMPORT_STATUSES.to_h { |status| [status, outcomes.count { |outcome| outcome.status == status }] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_s
|
|
18
|
+
[headline, *outcomes.map { |outcome| " #{outcome}" }].join("\n")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def headline
|
|
24
|
+
tally = counts.map { |status, count| "#{count} #{status}" }.join(", ")
|
|
25
|
+
|
|
26
|
+
"acts_as_calculator: imported #{source} — #{tally}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class ImportTemplate
|
|
5
|
+
def self.call(...)
|
|
6
|
+
new(...).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(attributes:)
|
|
10
|
+
@attributes = attributes.transform_keys(&:to_s)
|
|
11
|
+
@key = @attributes.fetch("key") { raise ImportError, "a templates entry has no key" }.to_s
|
|
12
|
+
@scope = (@attributes["scope"] || DEFAULT_SCOPE).to_s
|
|
13
|
+
@owner = ResolveImportOwner.(@attributes["owner"])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# An import publishes. The rake task's job is to make a deploy's templates the live
|
|
17
|
+
# ones; a version that imported but stayed staged would look like it had worked and
|
|
18
|
+
# render the previous body forever. Rollback stays `PromoteTemplate.(template: older)`,
|
|
19
|
+
# which the next import then reverses — the file is the source of truth.
|
|
20
|
+
def call
|
|
21
|
+
return outcome(:skipped, current) if current && current.body == body && current.format == format
|
|
22
|
+
|
|
23
|
+
outcome(versions.exists? ? :updated : :created, publish)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :attributes, :key, :scope, :owner
|
|
29
|
+
|
|
30
|
+
def versions
|
|
31
|
+
Template.owned_by(owner).where(key:, scope:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current
|
|
35
|
+
return @current if defined?(@current)
|
|
36
|
+
|
|
37
|
+
@current = versions.current.first
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def publish
|
|
41
|
+
PublishTemplate.(key:, scope:, owner:, body:, format:)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def body
|
|
45
|
+
@body ||= attributes.fetch("body") { raise ImportError, "template #{key.inspect} has no body" }.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def format
|
|
49
|
+
@format ||= (attributes["format"] || Template::HTML).to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def outcome(result, template)
|
|
53
|
+
ImportOutcome.build(kind: :template, status: result, key:, scope:,
|
|
54
|
+
detail: "version #{template.version_number}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module ActsAsCalculator
|
|
7
|
+
# Registered on this gem's own Liquid::Environment, never on Liquid's global default, so
|
|
8
|
+
# a host app's other Liquid usage neither gains these nor loses its own `date` filter.
|
|
9
|
+
# Only public methods here become filters; the helpers below stay private on purpose.
|
|
10
|
+
module LiquidFilters
|
|
11
|
+
DELIMITER = ","
|
|
12
|
+
ISO_DATE = "%Y-%m-%d"
|
|
13
|
+
|
|
14
|
+
def currency(input, unit = "", precision = 2)
|
|
15
|
+
return "" if blank_input?(input)
|
|
16
|
+
|
|
17
|
+
places = Integer(precision)
|
|
18
|
+
"#{unit}#{delimited(FunctionRegistry::ROUND_CURRENCY.(numeric(input), places), places)}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Takes a rate expressed as a fraction of one — the form this gem's bracket tiers and
|
|
22
|
+
# lookup tables hold — so 0.2205 renders as "22.05%".
|
|
23
|
+
def percentage(input, precision = 2)
|
|
24
|
+
return "" if blank_input?(input)
|
|
25
|
+
|
|
26
|
+
places = Integer(precision)
|
|
27
|
+
"#{delimited(FunctionRegistry::ROUND_CURRENCY.(numeric(input) * 100, places), places)}%"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def date(input, format = ISO_DATE)
|
|
31
|
+
point = time_like(input)
|
|
32
|
+
|
|
33
|
+
point.nil? ? "" : point.strftime(format.to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def blank_input?(input)
|
|
39
|
+
input.nil? || input == ""
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def numeric(input)
|
|
43
|
+
CastDecimal.(input.is_a?(ResultDrop) ? input.value : input)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def delimited(decimal, places)
|
|
47
|
+
whole, fraction = decimal.abs.to_s("F").split(".")
|
|
48
|
+
grouped = whole.reverse.scan(/\d{1,3}/).join(DELIMITER).reverse
|
|
49
|
+
sign = decimal.negative? ? "-" : ""
|
|
50
|
+
|
|
51
|
+
places.positive? ? "#{sign}#{grouped}.#{fraction.to_s.ljust(places, "0")[0, places]}" : "#{sign}#{grouped}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def time_like(input)
|
|
55
|
+
case input
|
|
56
|
+
when nil, "" then nil
|
|
57
|
+
when ::Date, ::Time then input
|
|
58
|
+
when ::Integer then ::Time.at(input)
|
|
59
|
+
when ::String then parsed_date(input)
|
|
60
|
+
else input if input.respond_to?(:strftime)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parsed_date(input)
|
|
65
|
+
::Date.parse(input)
|
|
66
|
+
rescue ::Date::Error
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class PersistRun
|
|
5
|
+
def self.call(...)
|
|
6
|
+
new(...).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(calculable:, formula_version:, result:, as_of: nil)
|
|
10
|
+
@calculable = calculable
|
|
11
|
+
@formula_version = formula_version
|
|
12
|
+
@result = result
|
|
13
|
+
@as_of = CastDate.(as_of || result.as_of || Date.current)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
Run.create!(
|
|
18
|
+
calculable:,
|
|
19
|
+
formula_version:,
|
|
20
|
+
as_of_date: as_of,
|
|
21
|
+
inputs: CastJsonSafe.(result.inputs),
|
|
22
|
+
breakdown: CastJsonSafe.(result.breakdown),
|
|
23
|
+
result: result.value
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :calculable, :formula_version, :result, :as_of
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# Rollback. Pointing `current` at an older row leaves every version in place, which is
|
|
5
|
+
# what "simple version history for rollback" (docs/PLAN.md) buys over deleting the row
|
|
6
|
+
# that went wrong or racing MAX(version_number).
|
|
7
|
+
class PromoteTemplate
|
|
8
|
+
def self.call(template:)
|
|
9
|
+
Template.transaction do
|
|
10
|
+
Template.version_siblings_of(template).current.where.not(id: template.id)
|
|
11
|
+
.update_all(current: false, updated_at: Time.current)
|
|
12
|
+
# A caller's copy of an already-demoted row still reads `current == true` in memory
|
|
13
|
+
# — publishing a newer version demotes it with update_all — so without this the
|
|
14
|
+
# write below looks like a no-op change and never reaches the database.
|
|
15
|
+
template.reload.update!(current: true)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
template
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# Adding a version is never just an insert. `FormulaVersion` refuses two active versions
|
|
5
|
+
# whose effective ranges overlap, so publishing a rule that replaces one already in force
|
|
6
|
+
# has to supersede the incumbent first — and `SupersedeFormulaVersions` refuses outright
|
|
7
|
+
# where that would leave part of the incumbent's range uncovered.
|
|
8
|
+
#
|
|
9
|
+
# Every authoring path goes through here (JSON import, the API), so none of them can grow
|
|
10
|
+
# its own idea of what replacing a rule means or of how version numbers are handed out.
|
|
11
|
+
class PublishFormulaVersion
|
|
12
|
+
def self.call(...)
|
|
13
|
+
new(...).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(formula:, expression:, effective_from:, effective_to: nil,
|
|
17
|
+
status: FormulaVersion::ACTIVE, change_note: nil, variables: [])
|
|
18
|
+
@formula = formula
|
|
19
|
+
@expression = expression.to_s
|
|
20
|
+
@effective_from = CastDate.(effective_from)
|
|
21
|
+
@effective_to = effective_to && CastDate.(effective_to)
|
|
22
|
+
@status = status.to_s
|
|
23
|
+
@change_note = change_note
|
|
24
|
+
@variables = Array(variables).map { |variable| CastVariableAttributes.(variable) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# `requires_new` so this is one unit of work even when the caller already has a
|
|
28
|
+
# transaction open: a rejected variable must not leave behind a version claiming to
|
|
29
|
+
# declare it, and a supersede must not outlive the version it made room for. A caller
|
|
30
|
+
# that rescues and carries on is exactly the case a joined transaction would not cover.
|
|
31
|
+
def call
|
|
32
|
+
Record.transaction(requires_new: true) do
|
|
33
|
+
SupersedeFormulaVersions.(formula:, effective_from:, effective_to:) if status == FormulaVersion::ACTIVE
|
|
34
|
+
|
|
35
|
+
create_version
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :formula, :expression, :effective_from, :effective_to, :status, :change_note, :variables
|
|
42
|
+
|
|
43
|
+
def create_version
|
|
44
|
+
version = formula.versions.create!(version_number: next_version_number, expression:,
|
|
45
|
+
effective_from:, effective_to:, status:, change_note:)
|
|
46
|
+
variables.each { |variable| version.variables.create!(**variable) }
|
|
47
|
+
version
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def next_version_number
|
|
51
|
+
(formula.versions.maximum(:version_number) || 0) + 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# Authoring a template body publishes it: a new row, numbered one past its siblings, with
|
|
5
|
+
# the outgoing version demoted by the model's own before_create hook. Never `update!` on
|
|
6
|
+
# an existing row and never `update_all(current:)` — the one-current-version index refuses
|
|
7
|
+
# the latter, and the former would rewrite a version the history exists to keep.
|
|
8
|
+
# Rollback is `PromoteTemplate`, not deleting the row that went wrong.
|
|
9
|
+
class PublishTemplate
|
|
10
|
+
def self.call(key:, body:, scope: nil, owner: nil, format: Template::HTML)
|
|
11
|
+
template = Template.new(key: key.to_s, scope: (scope || DEFAULT_SCOPE).to_s, owner:,
|
|
12
|
+
body: body.to_s, format: format.to_s, current: true)
|
|
13
|
+
template.version_number = template.next_version_number
|
|
14
|
+
template.save!
|
|
15
|
+
template
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculator
|
|
6
|
+
class ReadImportFile
|
|
7
|
+
def self.call(path)
|
|
8
|
+
raise ImportError, "no import file at #{path}" unless File.file?(path.to_s)
|
|
9
|
+
|
|
10
|
+
parse(File.read(path.to_s), path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.parse(source, path)
|
|
14
|
+
document = JSON.parse(source)
|
|
15
|
+
return document if document.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
raise ImportError, "#{path} must contain a JSON object, got #{document.class}"
|
|
18
|
+
rescue JSON::ParserError => e
|
|
19
|
+
raise ImportError, "#{path} is not valid JSON: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
private_class_method :parse
|
|
22
|
+
end
|
|
23
|
+
end
|