menuconform 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/LICENSE +202 -0
- data/README.md +156 -0
- data/config/importers/ncr_menu.json +12 -0
- data/exe/menuconform +7 -0
- data/lib/menuconform/catalog.rb +28 -0
- data/lib/menuconform/cli.rb +256 -0
- data/lib/menuconform/engine.rb +66 -0
- data/lib/menuconform/finding.rb +26 -0
- data/lib/menuconform/importers/base.rb +64 -0
- data/lib/menuconform/importers/ncr_menu.rb +322 -0
- data/lib/menuconform/importers.rb +40 -0
- data/lib/menuconform/menu.rb +168 -0
- data/lib/menuconform/report.rb +60 -0
- data/lib/menuconform/rules/age_rules.rb +57 -0
- data/lib/menuconform/rules/allergen_rules.rb +36 -0
- data/lib/menuconform/rules/avail_rules.rb +114 -0
- data/lib/menuconform/rules/cart_rules.rb +14 -0
- data/lib/menuconform/rules/conflict_rules.rb +69 -0
- data/lib/menuconform/rules/name_rules.rb +84 -0
- data/lib/menuconform/rules/price_rules.rb +92 -0
- data/lib/menuconform/rules/struct_rules.rb +95 -0
- data/lib/menuconform/scorer.rb +53 -0
- data/lib/menuconform/solver.rb +265 -0
- data/lib/menuconform/ucp_exporter.rb +253 -0
- data/lib/menuconform/version.rb +5 -0
- data/lib/menuconform.rb +23 -0
- data/rules/catalog.json +38 -0
- data/schema/menu_ir.schema.json +413 -0
- metadata +86 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "json_schemer"
|
|
6
|
+
|
|
7
|
+
module Menuconform
|
|
8
|
+
# Runs a Menu IR document through schema validation and all implemented rules.
|
|
9
|
+
# Severities always come from the rule catalog. reference_time is the only
|
|
10
|
+
# time input (AVAIL-003 suspension horizon) — inject it for reproducible runs.
|
|
11
|
+
class Engine
|
|
12
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
13
|
+
DEFAULT_SCHEMA = File.join(ROOT, "schema", "menu_ir.schema.json")
|
|
14
|
+
DEFAULT_CATALOG = File.join(ROOT, "rules", "catalog.json")
|
|
15
|
+
|
|
16
|
+
RULES = [
|
|
17
|
+
Rules::StructRules,
|
|
18
|
+
Rules::ConflictRules,
|
|
19
|
+
Rules::PriceRules,
|
|
20
|
+
Rules::AvailRules,
|
|
21
|
+
Rules::NameRules,
|
|
22
|
+
Rules::AllergenRules,
|
|
23
|
+
Rules::AgeRules,
|
|
24
|
+
Rules::CartRules
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :catalog
|
|
28
|
+
|
|
29
|
+
def initialize(schema_path: DEFAULT_SCHEMA, catalog_path: DEFAULT_CATALOG)
|
|
30
|
+
@schemer = JSONSchemer.schema(JSON.parse(File.read(schema_path, encoding: "UTF-8")))
|
|
31
|
+
@catalog = Catalog.load(catalog_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# doc: parsed IR document (Hash). Returns [Finding].
|
|
35
|
+
def run(doc, reference_time: Time.now.utc)
|
|
36
|
+
schema_errors = @schemer.validate(doc).to_a
|
|
37
|
+
unless schema_errors.empty?
|
|
38
|
+
detail = schema_errors.first(3).map { |e| "#{e['data_pointer']} #{e['error']}" }.join("; ")
|
|
39
|
+
return [with_severity(Finding.new("STRUCT-001", "menu", doc["name"] || "(unnamed)",
|
|
40
|
+
"document fails IR schema validation: #{detail}"))]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
menu = Menu.new(doc, reference_time: reference_time)
|
|
44
|
+
RULES.flat_map { |rule| rule.call(menu) }
|
|
45
|
+
.map { |f| with_severity(f) }
|
|
46
|
+
.sort_by { |f| [f.rule, f.entity_type, f.entity_id.to_s] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Full analysis: findings + score + enriched report.
|
|
50
|
+
def analyze(doc, reference_time: Time.now.utc)
|
|
51
|
+
Report.new(
|
|
52
|
+
doc: doc,
|
|
53
|
+
findings: run(doc, reference_time: reference_time),
|
|
54
|
+
catalog: @catalog,
|
|
55
|
+
reference_time: reference_time
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def with_severity(finding)
|
|
62
|
+
finding.severity = @catalog.severity(finding.rule)
|
|
63
|
+
finding
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
# One conformance finding. Severity is filled in by the engine from the rule
|
|
5
|
+
# catalog so rule implementations can never disagree with the published catalog.
|
|
6
|
+
class Finding
|
|
7
|
+
attr_reader :rule, :entity_type, :entity_id, :message
|
|
8
|
+
attr_accessor :severity
|
|
9
|
+
|
|
10
|
+
def initialize(rule, entity_type, entity_id, message)
|
|
11
|
+
@rule = rule
|
|
12
|
+
@entity_type = entity_type
|
|
13
|
+
@entity_id = entity_id
|
|
14
|
+
@message = message
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
{
|
|
19
|
+
"rule" => rule,
|
|
20
|
+
"severity" => severity,
|
|
21
|
+
"entity" => { "type" => entity_type, "id" => entity_id },
|
|
22
|
+
"message" => message
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Importers
|
|
5
|
+
# An importer turns one source-system export into a Menu IR document.
|
|
6
|
+
# Contract: #call(source, config:) -> Result. Importers NEVER raise on
|
|
7
|
+
# semantically broken menus (that's the rule engine's job) — they raise
|
|
8
|
+
# Importers::SourceError only when the input isn't recognizably the
|
|
9
|
+
# expected source format at all. Vendor field mappings belong in the
|
|
10
|
+
# importer's JSON config (config/importers/<name>.json), not in code.
|
|
11
|
+
class SourceError < StandardError; end
|
|
12
|
+
|
|
13
|
+
Result = Struct.new(:doc, :notes, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
class Base
|
|
16
|
+
def self.format_name
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@notes = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# "12.5" / 12.5 / 1250 -> integer minor units. Sources that already use
|
|
27
|
+
# minor units pass integers through untouched; decimal strings and
|
|
28
|
+
# floats are treated as major units (dollars) and scaled by 100.
|
|
29
|
+
def to_minor_units(value, field: nil)
|
|
30
|
+
case value
|
|
31
|
+
when Integer then value
|
|
32
|
+
when Float then (value * 100).round
|
|
33
|
+
when String
|
|
34
|
+
if value.match?(/\A-?\d+\z/)
|
|
35
|
+
value.to_i
|
|
36
|
+
elsif value.match?(/\A-?\d*\.\d+\z/)
|
|
37
|
+
(value.to_f * 100).round
|
|
38
|
+
else
|
|
39
|
+
note("unparseable money value #{value.inspect}#{" in #{field}" if field}; using 0")
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
when nil then 0
|
|
43
|
+
else
|
|
44
|
+
note("unparseable money value #{value.inspect}#{" in #{field}" if field}; using 0")
|
|
45
|
+
0
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_id(value)
|
|
50
|
+
value.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stash unmapped-but-useful source data under x_ extension keys so
|
|
54
|
+
# nothing is silently dropped.
|
|
55
|
+
def stash(entity, key, value)
|
|
56
|
+
entity["x_#{key}"] = value unless value.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def note(message)
|
|
60
|
+
@notes << message unless @notes.include?(message)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Importers
|
|
5
|
+
# NCR Voyix Menu API importer — v2 menu-details payload (nep-service-version
|
|
6
|
+
# 2A:1 / 2:1 response shape: submenus / menuItems / salesItems / linkGroups /
|
|
7
|
+
# linkedItems / modifierCodes / itemCustomModifiers).
|
|
8
|
+
#
|
|
9
|
+
# Mapping decisions (see the field study in the day 10-11 notes):
|
|
10
|
+
# - salesItems -> IR items; menuItems are a display layer (a menu item
|
|
11
|
+
# fronting several sales items = sizes) — flattened, with the grouping
|
|
12
|
+
# stashed in x_menu_item_id / x_default_variant.
|
|
13
|
+
# - linkGroups -> modifier groups. NCR minQuantity/maxQuantity count UNITS
|
|
14
|
+
# -> min/max_total_units; maxDistinctQuantity -> max_select; NCR has no
|
|
15
|
+
# distinct-minimum, so min_select is always 0. freeQuantity ->
|
|
16
|
+
# included_quantity (allocation order is undocumented at NCR; config
|
|
17
|
+
# decides, default cheapest_first).
|
|
18
|
+
# - Aloha price levels surface as prices[].linkGroupId (group-scoped
|
|
19
|
+
# prices) -> per-link price_override.
|
|
20
|
+
# - Nesting lives on the linkedItem entity (linkGroupIds) -> lowered onto
|
|
21
|
+
# every option link (entity-level to link-level).
|
|
22
|
+
# - itemCustomModifiers carry per-ITEM defaults; the IR default is per
|
|
23
|
+
# link, so a default is set only when every parent of the group agrees.
|
|
24
|
+
# - Lossy constructs (modifier-code price multipliers, step quantities,
|
|
25
|
+
# itemFactor weights, scheduled prices, promotions/quick combos) are
|
|
26
|
+
# stashed under x_ keys and reported as import notes.
|
|
27
|
+
class NcrMenu < Base
|
|
28
|
+
DAY_MAP = {
|
|
29
|
+
"MONDAY" => "mon", "TUESDAY" => "tue", "WEDNESDAY" => "wed",
|
|
30
|
+
"THURSDAY" => "thu", "FRIDAY" => "fri", "SATURDAY" => "sat", "SUNDAY" => "sun"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def self.format_name = "ncr-menu"
|
|
34
|
+
|
|
35
|
+
def call(source, config: {})
|
|
36
|
+
@config = config || {}
|
|
37
|
+
raise SourceError, "expected a JSON object (NCR menu-details payload)" unless source.is_a?(Hash)
|
|
38
|
+
if source.key?("subMenus") || source.key?("quickCombos")
|
|
39
|
+
raise SourceError, "this looks like an NCR Menu API v1 payload; only the v2 shape is supported " \
|
|
40
|
+
"(re-export with nep-service-version 2A:1)"
|
|
41
|
+
end
|
|
42
|
+
unless source["salesItems"].is_a?(Array) && source["linkGroups"].is_a?(Array)
|
|
43
|
+
raise SourceError, "not recognizable as an NCR Menu API v2 menu-details payload " \
|
|
44
|
+
"(missing salesItems/linkGroups collections)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@source = source
|
|
48
|
+
@modifiers_by_id = index_by_id(source["linkedItems"] || [])
|
|
49
|
+
@mod_codes_by_id = index_by_id(source["modifierCodes"] || [])
|
|
50
|
+
@menu_item_info = build_menu_item_info
|
|
51
|
+
@group_parents = build_group_parents
|
|
52
|
+
@icm_defaults = build_icm_defaults
|
|
53
|
+
note("NCR has no distinct-option minimum; min_select is 0 everywhere and unit minimums map to min_total_units")
|
|
54
|
+
|
|
55
|
+
doc = {
|
|
56
|
+
"ir_version" => "0.2",
|
|
57
|
+
"name" => source["displayName"] || "NCR Menu",
|
|
58
|
+
"currency" => currency,
|
|
59
|
+
"timezone" => timezone,
|
|
60
|
+
"availability" => windows(source["availability"]),
|
|
61
|
+
"source" => { "system" => "ncr-menu-v2", "importer" => "menuconform #{VERSION} ncr-menu importer" },
|
|
62
|
+
"categories" => (source["submenus"] || []).map { |sm| category_for(sm) },
|
|
63
|
+
"items" => (source["salesItems"] || []).map { |si| item_for(si) },
|
|
64
|
+
"modifier_groups" => (source["linkGroups"] || []).map { |lg| group_for(lg) },
|
|
65
|
+
"modifiers" => (source["linkedItems"] || []).map { |li| modifier_for(li) }
|
|
66
|
+
}.reject { |_, v| v.nil? }
|
|
67
|
+
|
|
68
|
+
Result.new(doc: doc, notes: @notes)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def timezone
|
|
74
|
+
tz = @config["timezone"]
|
|
75
|
+
note("NCR payloads carry no timezone; defaulting to UTC — set the importer config 'timezone' to the site's IANA zone") if tz.nil?
|
|
76
|
+
tz || "UTC"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def currency
|
|
80
|
+
raw = ((@source["salesItems"] || []) + (@source["linkedItems"] || []))
|
|
81
|
+
.flat_map { |e| e["prices"] || [] }.filter_map { |p| p["currency"] }.uniq
|
|
82
|
+
mapped = raw.filter_map { |c| (@config["currency_map"] || {})[c] }.uniq
|
|
83
|
+
note("multiple currencies in source (#{raw.join(', ')}); using #{mapped.first}") if mapped.size > 1
|
|
84
|
+
unmapped = raw.reject { |c| (@config["currency_map"] || {}).key?(c) }
|
|
85
|
+
note("unmapped currency label(s) #{unmapped.join(', ')}; using #{@config['default_currency'] || 'USD'}") unless unmapped.empty?
|
|
86
|
+
mapped.first || @config["default_currency"] || "USD"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# NCR v2: no availability records = always available (the INVERSE of the
|
|
90
|
+
# IR's empty-array-means-never) — so absence maps to omission, never [].
|
|
91
|
+
def windows(av)
|
|
92
|
+
return nil if av.nil? || av.empty?
|
|
93
|
+
av.filter_map do |a|
|
|
94
|
+
day = DAY_MAP[a["dayOfWeek"]]
|
|
95
|
+
next note("unknown dayOfWeek #{a['dayOfWeek'].inspect}; record dropped") && nil unless day
|
|
96
|
+
{ "days" => [day], "start" => hhmm(a["startTime"], "00:00"), "end" => hhmm(a["endTime"], "24:00") }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def hhmm(value, fallback)
|
|
101
|
+
return fallback unless value.is_a?(String) && value.match?(/\A\d{2}:\d{2}/)
|
|
102
|
+
value[0, 5]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def category_for(sm)
|
|
106
|
+
c = { "id" => to_id(sm["id"]), "name" => sm["displayName"] || sm["posName"] || "" }
|
|
107
|
+
c["description"] = sm["description"] if sm["description"]
|
|
108
|
+
item_ids = idlist(sm["menuItemIds"]).flat_map { |mid| @menu_item_info.dig(mid, :sales_item_ids) || [] }
|
|
109
|
+
c["item_ids"] = item_ids
|
|
110
|
+
w = windows(sm["availability"])
|
|
111
|
+
c["availability"] = w if w
|
|
112
|
+
c
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def item_for(si)
|
|
116
|
+
it = {
|
|
117
|
+
"id" => to_id(si["id"]),
|
|
118
|
+
"name" => si["displayName"] || si["posName"] || "",
|
|
119
|
+
"price" => base_price_minor(si)
|
|
120
|
+
}
|
|
121
|
+
it["active"] = false if si["available"] == false
|
|
122
|
+
groups = idlist(si["linkGroupIds"])
|
|
123
|
+
it["modifier_group_ids"] = groups unless groups.empty?
|
|
124
|
+
decorate_shared!(it, si)
|
|
125
|
+
mi = @menu_item_info.values.find { |info| info[:sales_item_ids].include?(it["id"]) }
|
|
126
|
+
if mi
|
|
127
|
+
stash(it, "menu_item_id", mi[:id])
|
|
128
|
+
stash(it, "default_variant", true) if mi[:default_id] == it["id"]
|
|
129
|
+
it["availability"] = mi[:windows] if mi[:windows]
|
|
130
|
+
end
|
|
131
|
+
it
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def modifier_for(li)
|
|
135
|
+
m = {
|
|
136
|
+
"id" => to_id(li["id"]),
|
|
137
|
+
"name" => li["displayName"] || li["posName"] || "",
|
|
138
|
+
"price" => base_price_minor(li)
|
|
139
|
+
}
|
|
140
|
+
m["active"] = false if li["available"] == false
|
|
141
|
+
decorate_shared!(m, li)
|
|
142
|
+
m
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def group_for(lg)
|
|
146
|
+
r = lg["restriction"] || {}
|
|
147
|
+
g = {
|
|
148
|
+
"id" => to_id(lg["id"]),
|
|
149
|
+
"name" => lg["displayName"] || lg["posName"] || "",
|
|
150
|
+
"min_select" => 0,
|
|
151
|
+
"max_select" => r["maxDistinctQuantity"]
|
|
152
|
+
}
|
|
153
|
+
g["min_total_units"] = r["minQuantity"] if r["minQuantity"]
|
|
154
|
+
g["max_total_units"] = r["maxQuantity"] if r.key?("maxQuantity")
|
|
155
|
+
if (fq = r["freeQuantity"])&.positive?
|
|
156
|
+
g["included_quantity"] = fq
|
|
157
|
+
g["included_counting"] = "units"
|
|
158
|
+
g["included_allocation"] = @config["included_allocation"] || "cheapest_first"
|
|
159
|
+
note("NCR does not document freeQuantity allocation order; assuming #{g['included_allocation']} (importer config)")
|
|
160
|
+
end
|
|
161
|
+
%w[perLinkedItemFreeQuantity perLinkedItemStepQuantity].each do |k|
|
|
162
|
+
next unless r[k]
|
|
163
|
+
stash(g, k.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/\A_/, ""), r[k])
|
|
164
|
+
note("#{k} has no IR construct; stashed as extension data")
|
|
165
|
+
end
|
|
166
|
+
g["options"] = (lg["linkedItemReferences"] || []).map { |ref| option_for(lg, ref, r) }
|
|
167
|
+
g
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def option_for(lg, ref, group_restriction)
|
|
171
|
+
mid = to_id(ref.dig("linkedItemId", "id"))
|
|
172
|
+
lr = ref["restriction"] || {}
|
|
173
|
+
o = { "modifier_id" => mid }
|
|
174
|
+
|
|
175
|
+
max_q = lr["maxQuantity"] || group_restriction["perLinkedItemMaxQuantity"]
|
|
176
|
+
if max_q.nil?
|
|
177
|
+
max_q = @config["per_option_max_fallback"] || 99
|
|
178
|
+
note("per-option maximum is unbounded in NCR; capped at #{max_q} (importer config per_option_max_fallback)")
|
|
179
|
+
end
|
|
180
|
+
o["max_quantity"] = max_q
|
|
181
|
+
min_q = lr["minQuantity"] || group_restriction["perLinkedItemMinQuantity"]
|
|
182
|
+
o["min_quantity"] = min_q if min_q&.positive?
|
|
183
|
+
|
|
184
|
+
po = group_scoped_price(mid, to_id(lg["id"]))
|
|
185
|
+
o["price_override"] = po if po
|
|
186
|
+
|
|
187
|
+
li = @modifiers_by_id[mid]
|
|
188
|
+
children = li ? idlist(li["linkGroupIds"]) : []
|
|
189
|
+
unless children.empty?
|
|
190
|
+
o["child_modifier_group_ids"] = children
|
|
191
|
+
note("NCR nests groups on the modifier entity; lowered onto each option link")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
o["default_quantity"] = 1 if unanimous_default?(to_id(lg["id"]), mid)
|
|
195
|
+
|
|
196
|
+
codes = idlist(ref["modifierCodeIds"]).filter_map { |cid| @mod_codes_by_id[cid] }
|
|
197
|
+
unless codes.empty?
|
|
198
|
+
stash(o, "modifier_codes", codes.map { |c| c["displayName"] || c["modifierCode"] })
|
|
199
|
+
if codes.any? { |c| c["priceMultiplier"] && c["priceMultiplier"] != 1 }
|
|
200
|
+
note("prep-code price multipliers (e.g. Extra = 2x) are not expanded; totals for multiplied prep codes are not modeled")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
stash(o, "item_factor", ref["itemFactor"]) if ref["itemFactor"] && ref["itemFactor"] != 1
|
|
204
|
+
%w[freeQuantity stepQuantity].each do |k|
|
|
205
|
+
next unless lr[k]
|
|
206
|
+
stash(o, "ncr_#{k.gsub(/([A-Z])/) { "_#{$1.downcase}" }}", lr[k])
|
|
207
|
+
note("per-option #{k} has no IR construct; stashed as extension data")
|
|
208
|
+
end
|
|
209
|
+
o
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# --- pricing ------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def active_prices(entity)
|
|
215
|
+
(entity["prices"] || []).select { |p| p["status"].nil? || p["status"] == "ACTIVE" }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def base_price_minor(entity)
|
|
219
|
+
rows = active_prices(entity).select { |p| p["linkGroupId"].nil? }
|
|
220
|
+
base = rows.find { |p| p["basePrice"] } || rows.first
|
|
221
|
+
note("#{entity['id']}: multiple simultaneous base prices; using the first (scheduled prices are not modeled)") if rows.count { |p| p["basePrice"] } > 1
|
|
222
|
+
value = base&.[]("price") || entity["currentPrice"]
|
|
223
|
+
note("#{entity['id']}: no usable price; defaulting to 0") if value.nil?
|
|
224
|
+
to_minor_units(value, field: entity["id"])
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def group_scoped_price(modifier_id, group_id)
|
|
228
|
+
li = @modifiers_by_id[modifier_id]
|
|
229
|
+
return nil unless li
|
|
230
|
+
row = active_prices(li).find { |p| p["linkGroupId"] == group_id }
|
|
231
|
+
row && to_minor_units(row["price"], field: "#{modifier_id}@#{group_id}")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# --- defaults (itemCustomModifiers) ------------------------------------
|
|
235
|
+
|
|
236
|
+
def build_icm_defaults
|
|
237
|
+
map = Hash.new { |h, k| h[k] = Set.new }
|
|
238
|
+
(@source["itemCustomModifiers"] || []).each do |icm|
|
|
239
|
+
next unless icm["include"] || icm["autoAdd"]
|
|
240
|
+
key = [to_id(icm.dig("linkGroupId", "id")), to_id(icm.dig("linkedItemId", "id"))]
|
|
241
|
+
map[key] << to_id(icm.dig("parentItemId", "id"))
|
|
242
|
+
end
|
|
243
|
+
map
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def build_group_parents
|
|
247
|
+
parents = Hash.new { |h, k| h[k] = Set.new }
|
|
248
|
+
((@source["salesItems"] || []) + (@source["linkedItems"] || [])).each do |entity|
|
|
249
|
+
idlist(entity["linkGroupIds"]).each { |gid| parents[gid] << to_id(entity["id"]) }
|
|
250
|
+
end
|
|
251
|
+
parents
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def unanimous_default?(group_id, modifier_id)
|
|
255
|
+
defaulted = @icm_defaults[[group_id, modifier_id]]
|
|
256
|
+
return false if defaulted.empty?
|
|
257
|
+
all_parents = @group_parents[group_id]
|
|
258
|
+
if all_parents.subset?(defaulted)
|
|
259
|
+
true
|
|
260
|
+
else
|
|
261
|
+
note("default for #{modifier_id} in #{group_id} differs per parent item (#{defaulted.size}/#{all_parents.size} parents); " \
|
|
262
|
+
"IR defaults are per link — default dropped")
|
|
263
|
+
false
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# --- shared decoration --------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def decorate_shared!(entity_ir, entity)
|
|
270
|
+
cal = calories(entity["nutritionFactsData"])
|
|
271
|
+
entity_ir["calories"] = cal if cal
|
|
272
|
+
ext = {}
|
|
273
|
+
ext["product_id"] = entity["productId"].to_s if entity["productId"]
|
|
274
|
+
ext["reference_id"] = entity["referenceId"].to_s if entity["referenceId"]
|
|
275
|
+
ext["external_id"] = entity["externalId"].to_s if entity["externalId"]
|
|
276
|
+
entity_ir["external_ids"] = ext unless ext.empty?
|
|
277
|
+
stash(entity_ir, "pos_name", entity["posName"]) if entity["posName"] && entity["posName"] != entity_ir["name"]
|
|
278
|
+
stash(entity_ir, "tags", entity["tags"]) if entity["tags"]&.any?
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def calories(nutrition)
|
|
282
|
+
attr = nutrition&.dig("nutritionAttributes")&.find { |a| a["nutritionType"] == "CALORIES" }
|
|
283
|
+
return nil unless attr
|
|
284
|
+
if (range = attr["rangeValue"])
|
|
285
|
+
out = { "lower" => range["min"].to_i }
|
|
286
|
+
out["upper"] = range["max"].to_i if range["max"]
|
|
287
|
+
out
|
|
288
|
+
elsif attr["value"]
|
|
289
|
+
{ "lower" => attr["value"].round }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# --- structure helpers --------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def build_menu_item_info
|
|
296
|
+
(@source["menuItems"] || []).to_h do |mi|
|
|
297
|
+
sales = (mi["menuItemResourceTypes"] || []).select { |rt| rt["type"] == "SALES_ITEM" }
|
|
298
|
+
.map { |rt| to_id(rt["id"]) }
|
|
299
|
+
skipped = (mi["menuItemResourceTypes"] || []).count { |rt| rt["type"] == "PROMOTION" }
|
|
300
|
+
note("menu item #{mi['id']} fronts #{skipped} promotion(s); promotions/quick combos have no IR construct and are skipped") if skipped.positive?
|
|
301
|
+
[to_id(mi["id"]), {
|
|
302
|
+
id: to_id(mi["id"]),
|
|
303
|
+
sales_item_ids: sales,
|
|
304
|
+
default_id: mi.dig("menuItemDefaultItem", "salesItemId", "id")&.to_s,
|
|
305
|
+
windows: windows(mi["availability"])
|
|
306
|
+
}]
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def index_by_id(collection)
|
|
311
|
+
collection.to_h { |e| [to_id(e["id"]), e] }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Accepts NCR's two reference list shapes: ["id"] and [{"id" => "id"}].
|
|
315
|
+
def idlist(list)
|
|
316
|
+
(list || []).map { |e| e.is_a?(Hash) ? to_id(e["id"]) : to_id(e) }
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
register(NcrMenu)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "importers/base"
|
|
4
|
+
|
|
5
|
+
module Menuconform
|
|
6
|
+
# Registry of source-format importers. Each importer registers itself under
|
|
7
|
+
# a CLI-facing format name (e.g. "ncr-menu"); default mapping config lives
|
|
8
|
+
# in config/importers/<format>.json and can be overridden per run.
|
|
9
|
+
module Importers
|
|
10
|
+
CONFIG_DIR = File.join(File.expand_path("../..", __dir__), "config", "importers")
|
|
11
|
+
|
|
12
|
+
@registry = {}
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def register(klass)
|
|
16
|
+
@registry[klass.format_name] = klass
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def names = @registry.keys.sort
|
|
20
|
+
|
|
21
|
+
def build(format)
|
|
22
|
+
klass = @registry[format]
|
|
23
|
+
raise SourceError, "unknown import format #{format.inspect} (known: #{names.join(', ')})" unless klass
|
|
24
|
+
klass.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def default_config(format)
|
|
28
|
+
path = File.join(CONFIG_DIR, "#{format.tr('-', '_')}.json")
|
|
29
|
+
File.exist?(path) ? JSON.parse(File.read(path, encoding: "UTF-8")) : {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# One-call convenience: source hash -> Result with IR doc.
|
|
33
|
+
def import(format, source, config: nil)
|
|
34
|
+
Importers.build(format).call(source, config: config || default_config(format))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
require_relative "importers/ncr_menu"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Menuconform
|
|
6
|
+
# Read-only wrapper around a schema-valid Menu IR document. Provides the
|
|
7
|
+
# indexes and graph traversals the rules share. Tolerates semantically broken
|
|
8
|
+
# menus (dangling refs, duplicate ids, cycles) by design — reporting those is
|
|
9
|
+
# the rules' job, so every accessor here must stay total.
|
|
10
|
+
class Menu
|
|
11
|
+
attr_reader :doc, :reference_time
|
|
12
|
+
|
|
13
|
+
def initialize(doc, reference_time: Time.now.utc)
|
|
14
|
+
@doc = doc
|
|
15
|
+
@reference_time = reference_time
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def categories = @doc["categories"] || []
|
|
19
|
+
def items = @doc["items"] || []
|
|
20
|
+
def modifier_groups = @doc["modifier_groups"] || []
|
|
21
|
+
def modifiers = @doc["modifiers"] || []
|
|
22
|
+
|
|
23
|
+
# First definition wins on duplicate ids; duplicates are reported by STRUCT-003.
|
|
24
|
+
def items_by_id = @items_by_id ||= index_first(items)
|
|
25
|
+
def groups_by_id = @groups_by_id ||= index_first(modifier_groups)
|
|
26
|
+
def modifiers_by_id = @modifiers_by_id ||= index_first(modifiers)
|
|
27
|
+
def categories_by_id = @categories_by_id ||= index_first(categories)
|
|
28
|
+
|
|
29
|
+
# { "item" => [dup ids], "modifier_group" => [...], ... }
|
|
30
|
+
def duplicate_ids
|
|
31
|
+
@duplicate_ids ||= {
|
|
32
|
+
"category" => dups(categories),
|
|
33
|
+
"item" => dups(items),
|
|
34
|
+
"modifier_group" => dups(modifier_groups),
|
|
35
|
+
"modifier" => dups(modifiers)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Groups attached directly to the item (depth 1), including slot groups.
|
|
40
|
+
def attached_group_ids(item)
|
|
41
|
+
(item["modifier_group_ids"] || []) +
|
|
42
|
+
(item["slots"] || []).flat_map { |s| s["modifier_group_ids"] || [] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Every existing group reachable from the item through option-level nesting.
|
|
46
|
+
def reachable_group_ids(item)
|
|
47
|
+
seen = Set.new
|
|
48
|
+
frontier = attached_group_ids(item)
|
|
49
|
+
until frontier.empty?
|
|
50
|
+
nxt = []
|
|
51
|
+
frontier.each do |gid|
|
|
52
|
+
g = groups_by_id[gid]
|
|
53
|
+
next if g.nil? || seen.include?(gid)
|
|
54
|
+
seen << gid
|
|
55
|
+
(g["options"] || []).each { |o| nxt.concat(o["child_modifier_group_ids"] || []) }
|
|
56
|
+
end
|
|
57
|
+
frontier = nxt
|
|
58
|
+
end
|
|
59
|
+
seen
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# { max_depth: Integer, cyclic: Boolean } — depth counted item->group = 1.
|
|
63
|
+
def depth_info(item)
|
|
64
|
+
cyclic = false
|
|
65
|
+
depth_of = lambda do |gid, path|
|
|
66
|
+
g = groups_by_id[gid]
|
|
67
|
+
return 0 if g.nil?
|
|
68
|
+
if path.include?(gid)
|
|
69
|
+
cyclic = true
|
|
70
|
+
return 0
|
|
71
|
+
end
|
|
72
|
+
child_ids = (g["options"] || []).flat_map { |o| o["child_modifier_group_ids"] || [] }
|
|
73
|
+
1 + child_ids.map { |cid| depth_of.call(cid, path + [gid]) }.max.to_i
|
|
74
|
+
end
|
|
75
|
+
max = attached_group_ids(item).map { |gid| depth_of.call(gid, []) }.max.to_i
|
|
76
|
+
{ max_depth: max, cyclic: cyclic }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Group ids referenced anywhere (items, slots, option children).
|
|
80
|
+
def referenced_group_ids
|
|
81
|
+
@referenced_group_ids ||= begin
|
|
82
|
+
refs = Set.new
|
|
83
|
+
items.each { |it| attached_group_ids(it).each { |gid| refs << gid } }
|
|
84
|
+
modifier_groups.each do |g|
|
|
85
|
+
(g["options"] || []).each { |o| (o["child_modifier_group_ids"] || []).each { |gid| refs << gid } }
|
|
86
|
+
end
|
|
87
|
+
refs
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Modifier ids referenced anywhere (options, conditional-price triggers).
|
|
92
|
+
def referenced_modifier_ids
|
|
93
|
+
@referenced_modifier_ids ||= begin
|
|
94
|
+
refs = Set.new
|
|
95
|
+
modifier_groups.each do |g|
|
|
96
|
+
(g["options"] || []).each do |o|
|
|
97
|
+
refs << o["modifier_id"]
|
|
98
|
+
(o["conditional_prices"] || []).each { |cp| refs << cp["when_modifier_id"] }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
refs
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# modifier id -> [group ids whose options include it]
|
|
106
|
+
def groups_containing_modifier
|
|
107
|
+
@groups_containing_modifier ||= begin
|
|
108
|
+
map = Hash.new { |h, k| h[k] = [] }
|
|
109
|
+
groups_by_id.each_value do |g|
|
|
110
|
+
(g["options"] || []).each { |o| map[o["modifier_id"]] << g["id"] }
|
|
111
|
+
end
|
|
112
|
+
map
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Yields every (group, option-with-conditional-prices, trigger context) pair
|
|
117
|
+
# in item context, deduped per (group, option). Trigger context is one of:
|
|
118
|
+
# { status: :fallback } no trigger group reachable — legal
|
|
119
|
+
# { status: :valid, group: <group> } single reachable max_select:1 group
|
|
120
|
+
# { status: :invalid } multi-select or spanning trigger
|
|
121
|
+
# Options with a dangling when_modifier_id are skipped (STRUCT-002 owns those).
|
|
122
|
+
def each_conditional_context
|
|
123
|
+
seen = Set.new
|
|
124
|
+
items_by_id.each_value do |item|
|
|
125
|
+
reach = reachable_group_ids(item)
|
|
126
|
+
reach.each do |gid|
|
|
127
|
+
g = groups_by_id[gid]
|
|
128
|
+
(g["options"] || []).each do |o|
|
|
129
|
+
cps = o["conditional_prices"] || []
|
|
130
|
+
next if cps.empty?
|
|
131
|
+
key = [gid, o["modifier_id"]]
|
|
132
|
+
next if seen.include?(key)
|
|
133
|
+
when_ids = cps.map { |cp| cp["when_modifier_id"] }
|
|
134
|
+
next if when_ids.any? { |w| !modifiers_by_id.key?(w) }
|
|
135
|
+
trigger_gids = when_ids.flat_map { |w| groups_containing_modifier[w] }
|
|
136
|
+
.select { |tg| reach.include?(tg) }.uniq
|
|
137
|
+
next if trigger_gids.empty? # fallback pricing; nothing to check
|
|
138
|
+
seen << key
|
|
139
|
+
if trigger_gids.size == 1 && groups_by_id[trigger_gids.first]["max_select"] == 1
|
|
140
|
+
yield g, o, { status: :valid, group: groups_by_id[trigger_gids.first] }
|
|
141
|
+
else
|
|
142
|
+
yield g, o, { status: :invalid }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Cheapest possible price for one unit of this option, over base price,
|
|
150
|
+
# link override, and all conditional prices.
|
|
151
|
+
def resolved_min_price(option)
|
|
152
|
+
mod = modifiers_by_id[option["modifier_id"]]
|
|
153
|
+
return 0 if mod.nil?
|
|
154
|
+
base = option.key?("price_override") ? option["price_override"] : mod["price"]
|
|
155
|
+
([base] + (option["conditional_prices"] || []).map { |cp| cp["price"] }).min
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def index_first(collection)
|
|
161
|
+
collection.each_with_object({}) { |e, h| h[e["id"]] ||= e }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def dups(collection)
|
|
165
|
+
collection.map { |e| e["id"] }.tally.select { |_, c| c > 1 }.keys
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|