Class: Menuconform::Menu

Inherits:
Object
  • Object
show all
Defined in:
lib/menuconform/menu.rb

Overview

Read-only wrapper around a schema-valid Menu IR document. Provides the indexes and graph traversals the rules share. Tolerates semantically broken menus (dangling refs, duplicate ids, cycles) by design — reporting those is the rules' job, so every accessor here must stay total.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(doc, reference_time: Time.now.utc) ⇒ Menu

Returns a new instance of Menu.



13
14
15
16
# File 'lib/menuconform/menu.rb', line 13

def initialize(doc, reference_time: Time.now.utc)
  @doc = doc
  @reference_time = reference_time
end

Instance Attribute Details

#docObject (readonly)

Returns the value of attribute doc.



11
12
13
# File 'lib/menuconform/menu.rb', line 11

def doc
  @doc
end

#reference_timeObject (readonly)

Returns the value of attribute reference_time.



11
12
13
# File 'lib/menuconform/menu.rb', line 11

def reference_time
  @reference_time
end

Instance Method Details

#attached_group_ids(item) ⇒ Object

Groups attached directly to the item (depth 1), including slot groups.



40
41
42
43
# File 'lib/menuconform/menu.rb', line 40

def attached_group_ids(item)
  (item["modifier_group_ids"] || []) +
    (item["slots"] || []).flat_map { |s| s["modifier_group_ids"] || [] }
end

#categoriesObject



18
# File 'lib/menuconform/menu.rb', line 18

def categories = @doc["categories"] || []

#categories_by_idObject



27
# File 'lib/menuconform/menu.rb', line 27

def categories_by_id = @categories_by_id ||= index_first(categories)

#depth_info(item) ⇒ Object

{ max_depth: Integer, cyclic: Boolean } — depth counted item->group = 1.



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/menuconform/menu.rb', line 63

def depth_info(item)
  cyclic = false
  depth_of = lambda do |gid, path|
    g = groups_by_id[gid]
    return 0 if g.nil?
    if path.include?(gid)
      cyclic = true
      return 0
    end
    child_ids = (g["options"] || []).flat_map { |o| o["child_modifier_group_ids"] || [] }
    1 + child_ids.map { |cid| depth_of.call(cid, path + [gid]) }.max.to_i
  end
  max = attached_group_ids(item).map { |gid| depth_of.call(gid, []) }.max.to_i
  { max_depth: max, cyclic: cyclic }
end

#duplicate_idsObject

{ "item" => [dup ids], "modifier_group" => [...], ... }



30
31
32
33
34
35
36
37
# File 'lib/menuconform/menu.rb', line 30

def duplicate_ids
  @duplicate_ids ||= {
    "category" => dups(categories),
    "item" => dups(items),
    "modifier_group" => dups(modifier_groups),
    "modifier" => dups(modifiers)
  }
end

#each_conditional_contextObject

Yields every (group, option-with-conditional-prices, trigger context) pair in item context, deduped per (group, option). Trigger context is one of:

{ status: :fallback }              no trigger group reachable — legal
{ status: :valid, group: <group> } single reachable max_select:1 group
{ status: :invalid }               multi-select or spanning trigger

Options with a dangling when_modifier_id are skipped (STRUCT-002 owns those).



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/menuconform/menu.rb', line 122

def each_conditional_context
  seen = Set.new
  items_by_id.each_value do |item|
    reach = reachable_group_ids(item)
    reach.each do |gid|
      g = groups_by_id[gid]
      (g["options"] || []).each do |o|
        cps = o["conditional_prices"] || []
        next if cps.empty?
        key = [gid, o["modifier_id"]]
        next if seen.include?(key)
        when_ids = cps.map { |cp| cp["when_modifier_id"] }
        next if when_ids.any? { |w| !modifiers_by_id.key?(w) }
        trigger_gids = when_ids.flat_map { |w| groups_containing_modifier[w] }
                               .select { |tg| reach.include?(tg) }.uniq
        next if trigger_gids.empty? # fallback pricing; nothing to check
        seen << key
        if trigger_gids.size == 1 && groups_by_id[trigger_gids.first]["max_select"] == 1
          yield g, o, { status: :valid, group: groups_by_id[trigger_gids.first] }
        else
          yield g, o, { status: :invalid }
        end
      end
    end
  end
end

#groups_by_idObject



25
# File 'lib/menuconform/menu.rb', line 25

def groups_by_id = @groups_by_id ||= index_first(modifier_groups)

#groups_containing_modifierObject

modifier id -> [group ids whose options include it]



106
107
108
109
110
111
112
113
114
# File 'lib/menuconform/menu.rb', line 106

def groups_containing_modifier
  @groups_containing_modifier ||= begin
    map = Hash.new { |h, k| h[k] = [] }
    groups_by_id.each_value do |g|
      (g["options"] || []).each { |o| map[o["modifier_id"]] << g["id"] }
    end
    map
  end
end

#itemsObject



19
# File 'lib/menuconform/menu.rb', line 19

def items = @doc["items"] || []

#items_by_idObject

First definition wins on duplicate ids; duplicates are reported by STRUCT-003.



24
# File 'lib/menuconform/menu.rb', line 24

def items_by_id = @items_by_id ||= index_first(items)

#modifier_groupsObject



20
# File 'lib/menuconform/menu.rb', line 20

def modifier_groups = @doc["modifier_groups"] || []

#modifiersObject



21
# File 'lib/menuconform/menu.rb', line 21

def modifiers = @doc["modifiers"] || []

#modifiers_by_idObject



26
# File 'lib/menuconform/menu.rb', line 26

def modifiers_by_id = @modifiers_by_id ||= index_first(modifiers)

#reachable_group_ids(item) ⇒ Object

Every existing group reachable from the item through option-level nesting.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/menuconform/menu.rb', line 46

def reachable_group_ids(item)
  seen = Set.new
  frontier = attached_group_ids(item)
  until frontier.empty?
    nxt = []
    frontier.each do |gid|
      g = groups_by_id[gid]
      next if g.nil? || seen.include?(gid)
      seen << gid
      (g["options"] || []).each { |o| nxt.concat(o["child_modifier_group_ids"] || []) }
    end
    frontier = nxt
  end
  seen
end

#referenced_group_idsObject

Group ids referenced anywhere (items, slots, option children).



80
81
82
83
84
85
86
87
88
89
# File 'lib/menuconform/menu.rb', line 80

def referenced_group_ids
  @referenced_group_ids ||= begin
    refs = Set.new
    items.each { |it| attached_group_ids(it).each { |gid| refs << gid } }
    modifier_groups.each do |g|
      (g["options"] || []).each { |o| (o["child_modifier_group_ids"] || []).each { |gid| refs << gid } }
    end
    refs
  end
end

#referenced_modifier_idsObject

Modifier ids referenced anywhere (options, conditional-price triggers).



92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/menuconform/menu.rb', line 92

def referenced_modifier_ids
  @referenced_modifier_ids ||= begin
    refs = Set.new
    modifier_groups.each do |g|
      (g["options"] || []).each do |o|
        refs << o["modifier_id"]
        (o["conditional_prices"] || []).each { |cp| refs << cp["when_modifier_id"] }
      end
    end
    refs
  end
end

#resolved_min_price(option) ⇒ Object

Cheapest possible price for one unit of this option, over base price, link override, and all conditional prices.



151
152
153
154
155
156
# File 'lib/menuconform/menu.rb', line 151

def resolved_min_price(option)
  mod = modifiers_by_id[option["modifier_id"]]
  return 0 if mod.nil?
  base = option.key?("price_override") ? option["price_override"] : mod["price"]
  ([base] + (option["conditional_prices"] || []).map { |cp| cp["price"] }).min
end