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.
@@ -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