Module: Menuconform::Rules::PriceRules

Defined in:
lib/menuconform/rules/price_rules.rb

Overview

PRICE-001..003. PRICE-003 uses a conservative static under-approximation of the minimum reachable total (depth-1 groups only, included_quantity ignored, cheapest conditional price assumed): it can miss negative totals hidden in nested groups, but a total it reports as negative genuinely is reachable- negative under those assumptions. The day 7-8 cart solver supersedes it for exact analysis.

Class Method Summary collapse

Class Method Details

.call(menu) ⇒ Object



14
15
16
# File 'lib/menuconform/rules/price_rules.rb', line 14

def call(menu)
  negative_prices(menu) + incomplete_matrices(menu) + negative_totals(menu)
end

.incomplete_matrices(menu) ⇒ Object



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/menuconform/rules/price_rules.rb', line 25

def incomplete_matrices(menu)
  out = []
  menu.each_conditional_context do |g, o, trigger|
    next unless trigger[:status] == :valid
    covered = (o["conditional_prices"] || []).map { |cp| cp["when_modifier_id"] }
    all = (trigger[:group]["options"] || []).map { |t| t["modifier_id"] }
    missing = all - covered
    next if missing.empty?
    out << Finding.new("PRICE-002", "modifier_group", g["id"],
                       "option #{o['modifier_id']} conditional-price matrix does not cover " \
                       "#{missing.join(', ')} in trigger group #{trigger[:group]['id']} " \
                       "(falls back to base price)")
  end
  out
end

.min_group_contribution(menu, group) ⇒ Object

Cheapest satisfying contribution of one group: forced picks take the cheapest options; beyond the minimum, only negative-priced options are added, within max_select / max_total_units / per-option max_quantity.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/menuconform/rules/price_rules.rb', line 57

def min_group_contribution(menu, group)
  options = (group["options"] || []).select { |o| menu.modifiers_by_id.key?(o["modifier_id"]) }
  min_sel = [group["min_select"] || 0, 0].max
  max_sel = group["max_select"] || options.size
  return 0 if options.empty? || min_sel > max_sel || min_sel > options.size

  budget = group["max_total_units"] || Float::INFINITY
  candidates = options.map do |o|
    unit_price = menu.resolved_min_price(o)
    units = if unit_price.negative?
              o["max_quantity"] || 1
            else
              [o["min_quantity"] || 0, 1].max
            end
    { price: unit_price, units: units, cost: unit_price * units }
  end.sort_by { |c| c[:cost] }

  total = 0
  used_units = 0.0
  selected = 0
  candidates.each do |c|
    forced = selected < min_sel
    break if !forced && selected >= max_sel
    next unless forced || c[:cost].negative?
    units = [c[:units], budget - used_units].min
    units = 1 if forced && units < 1
    next if units < 1
    total += c[:price] * units
    used_units += units
    selected += 1
  end
  total
end

.negative_prices(menu) ⇒ Object



18
19
20
21
22
23
# File 'lib/menuconform/rules/price_rules.rb', line 18

def negative_prices(menu)
  menu.items_by_id.each_value.filter_map do |it|
    next unless it["price"].negative?
    Finding.new("PRICE-001", "item", it["id"], "item price #{it['price']} is negative")
  end
end

.negative_totals(menu) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/menuconform/rules/price_rules.rb', line 41

def negative_totals(menu)
  menu.items_by_id.each_value.filter_map do |it|
    next if it["price"].negative? # PRICE-001 already anchors this item
    min_total = it["price"] + menu.attached_group_ids(it).uniq.sum do |gid|
      g = menu.groups_by_id[gid]
      g ? min_group_contribution(menu, g) : 0
    end
    next unless min_total.negative?
    Finding.new("PRICE-003", "item", it["id"],
                "a valid selection can drive the total to #{min_total}")
  end
end