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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Menuconform
|
|
6
|
+
# Machine-readable conformance report: score, breakdown, and findings
|
|
7
|
+
# enriched with catalog titles and fix hints. Contains no wall-clock
|
|
8
|
+
# timestamp of its own — reference_time is the only time input, and it is
|
|
9
|
+
# stated so the report is reproducible.
|
|
10
|
+
class Report
|
|
11
|
+
attr_reader :doc, :findings, :catalog, :reference_time
|
|
12
|
+
|
|
13
|
+
def initialize(doc:, findings:, catalog:, reference_time:)
|
|
14
|
+
@doc = doc
|
|
15
|
+
@findings = findings
|
|
16
|
+
@catalog = catalog
|
|
17
|
+
@reference_time = reference_time
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def score_breakdown = @score_breakdown ||= Scorer.score(findings)
|
|
21
|
+
def score = score_breakdown["score"]
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
"menuconform_version" => VERSION,
|
|
26
|
+
"rules_version" => catalog.rules_version,
|
|
27
|
+
"reference_time" => reference_time.utc.iso8601,
|
|
28
|
+
"menu" => {
|
|
29
|
+
"name" => doc["name"],
|
|
30
|
+
"ir_version" => doc["ir_version"],
|
|
31
|
+
"currency" => doc["currency"],
|
|
32
|
+
"timezone" => doc["timezone"]
|
|
33
|
+
},
|
|
34
|
+
"score" => score,
|
|
35
|
+
"score_breakdown" => score_breakdown.except("score"),
|
|
36
|
+
"severity_counts" => severity_counts,
|
|
37
|
+
"findings" => findings.map { |f| enrich(f) }
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_json(*args)
|
|
42
|
+
JSON.pretty_generate(to_h, *args)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def severity_counts
|
|
48
|
+
base = { "error" => 0, "warn" => 0, "info" => 0 }
|
|
49
|
+
findings.each_with_object(base) { |f, h| h[f.severity] += 1 }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def enrich(finding)
|
|
53
|
+
entry = catalog.entry(finding.rule)
|
|
54
|
+
finding.to_h.merge(
|
|
55
|
+
"title" => entry["title"],
|
|
56
|
+
"fix_hint" => entry["fix_hint"]
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Rules
|
|
5
|
+
# AGE-001..002. AGE-001 is a name heuristic by necessity: menus don't label
|
|
6
|
+
# alcohol, which is exactly the problem. The exclusion list guards the known
|
|
7
|
+
# soda/dessert traps (root beer, ginger ale, rum raisin); keep both lists
|
|
8
|
+
# tight — a false 'unlabeled alcohol' error costs the report credibility.
|
|
9
|
+
module AgeRules
|
|
10
|
+
ALCOHOL = /\b(margarita|mojito|daiquiri|sangria|mimosa|bellini|negroni|martini|cocktail|
|
|
11
|
+
beer|lager|ale|ipa|stout|porter|pilsner|hefeweizen|
|
|
12
|
+
wine|chardonnay|merlot|cabernet|riesling|rose\s+all\s+day|prosecco|champagne|
|
|
13
|
+
whiskey|whisky|bourbon|scotch|vodka|tequila|mezcal|rum|gin|brandy|cognac|
|
|
14
|
+
liqueur|sake|soju|cider|hard\s+seltzer)\b/xi
|
|
15
|
+
EXCLUSIONS = /root\s+beer|ginger\s+ale|ginger\s+beer|birch\s+beer|butter\s*beer|
|
|
16
|
+
rum\s+(raisin|cake)|beer\s*battered|beer\s*cheese|
|
|
17
|
+
non.?alcoholic|alcohol.?free|virgin|0\.0/xi
|
|
18
|
+
KIDS_CATEGORY = /\bkids?\b|\bchild(ren)?\b|\bjunior\b|\blittle\b/i
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def call(menu)
|
|
23
|
+
unlabeled_alcohol(menu) + kids_category_conflicts(menu)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def alcohol_indicative?(name)
|
|
27
|
+
n = name || ""
|
|
28
|
+
n.match?(ALCOHOL) && !n.match?(EXCLUSIONS)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def unlabeled_alcohol(menu)
|
|
32
|
+
check = lambda do |entity, type|
|
|
33
|
+
return nil if entity["min_age"] || !alcohol_indicative?(entity["name"])
|
|
34
|
+
Finding.new("AGE-001", type, entity["id"],
|
|
35
|
+
"name '#{entity['name']}' indicates alcohol but no min_age is set")
|
|
36
|
+
end
|
|
37
|
+
menu.items_by_id.each_value.filter_map { |it| check.call(it, "item") } +
|
|
38
|
+
menu.modifiers_by_id.each_value.filter_map { |m| check.call(m, "modifier") }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def kids_category_conflicts(menu)
|
|
42
|
+
out = {}
|
|
43
|
+
menu.categories_by_id.each_value do |c|
|
|
44
|
+
next unless (c["name"] || "").match?(KIDS_CATEGORY)
|
|
45
|
+
(c["item_ids"] || []).each do |iid|
|
|
46
|
+
it = menu.items_by_id[iid]
|
|
47
|
+
next unless it
|
|
48
|
+
next unless it["min_age"] || alcohol_indicative?(it["name"])
|
|
49
|
+
out[iid] ||= Finding.new("AGE-002", "item", iid,
|
|
50
|
+
"age-restricted or alcohol-indicative item listed in kids-oriented category #{c['id']}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
out.values
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Rules
|
|
5
|
+
# ALLERGEN-001..002. An explicit empty contains list is a declaration
|
|
6
|
+
# ("we state this has none of the big-9") and satisfies ALLERGEN-001;
|
|
7
|
+
# only a wholly absent allergens object is an enrichment gap.
|
|
8
|
+
module AllergenRules
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(menu)
|
|
12
|
+
missing(menu) + contradictions(menu)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def missing(menu)
|
|
16
|
+
menu.items_by_id.each_value.filter_map do |it|
|
|
17
|
+
next if it.key?("allergens")
|
|
18
|
+
Finding.new("ALLERGEN-001", "item", it["id"], "no allergen information declared")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def contradictions(menu)
|
|
23
|
+
check = lambda do |entity, type|
|
|
24
|
+
a = entity["allergens"]
|
|
25
|
+
return nil unless a
|
|
26
|
+
both = (a["contains"] || []) & (a["may_contain"] || [])
|
|
27
|
+
return nil if both.empty?
|
|
28
|
+
Finding.new("ALLERGEN-002", type, entity["id"],
|
|
29
|
+
"#{both.join(', ')} declared in both contains and may_contain")
|
|
30
|
+
end
|
|
31
|
+
menu.items_by_id.each_value.filter_map { |it| check.call(it, "item") } +
|
|
32
|
+
menu.modifiers_by_id.each_value.filter_map { |m| check.call(m, "modifier") }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Menuconform
|
|
6
|
+
module Rules
|
|
7
|
+
# AVAIL-001..003. Weekly-window math: windows expand to minute intervals over
|
|
8
|
+
# a 10080-minute week; end < start wraps past midnight; '24:00' is end-of-day.
|
|
9
|
+
# Date bounds (start_date/end_date) are ignored for overlap checks in this
|
|
10
|
+
# release — AVAIL-001 reasons about the recurring weekly pattern only.
|
|
11
|
+
module AvailRules
|
|
12
|
+
DAYS = %w[mon tue wed thu fri sat sun].freeze
|
|
13
|
+
WEEK_MINUTES = 7 * 1440
|
|
14
|
+
SUSPENSION_HORIZON_SECONDS = 365 * 24 * 3600
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(menu)
|
|
19
|
+
contradictions(menu) + declared_never(menu) + indefinite_suspensions(menu)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def contradictions(menu)
|
|
23
|
+
out = []
|
|
24
|
+
menu_windows = menu.doc["availability"]
|
|
25
|
+
|
|
26
|
+
menu.categories_by_id.each_value do |c|
|
|
27
|
+
next unless present?(c["availability"]) && present?(menu_windows)
|
|
28
|
+
next if overlap?(c["availability"], menu_windows)
|
|
29
|
+
out << Finding.new("AVAIL-001", "category", c["id"],
|
|
30
|
+
"category windows never overlap the menu's windows")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
menu.items_by_id.each_value do |it|
|
|
34
|
+
next unless present?(it["availability"])
|
|
35
|
+
contexts = menu.categories.select { |c| (c["item_ids"] || []).include?(it["id"]) }
|
|
36
|
+
.map { |c| present?(c["availability"]) ? c["availability"] : menu_windows }
|
|
37
|
+
contexts = [menu_windows] if contexts.empty?
|
|
38
|
+
contexts = contexts.select { |w| present?(w) }
|
|
39
|
+
next if contexts.empty? # nothing to contradict
|
|
40
|
+
next if contexts.any? { |w| overlap?(it["availability"], w) }
|
|
41
|
+
out << Finding.new("AVAIL-001", "item", it["id"],
|
|
42
|
+
"item windows never overlap its category/menu windows — never orderable")
|
|
43
|
+
end
|
|
44
|
+
out
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def declared_never(menu)
|
|
48
|
+
out = []
|
|
49
|
+
menu.categories_by_id.each_value do |c|
|
|
50
|
+
out << Finding.new("AVAIL-002", "category", c["id"], "availability is an explicit empty list") if c["availability"] == []
|
|
51
|
+
end
|
|
52
|
+
menu.items_by_id.each_value do |it|
|
|
53
|
+
out << Finding.new("AVAIL-002", "item", it["id"], "availability is an explicit empty list") if it["availability"] == []
|
|
54
|
+
end
|
|
55
|
+
out
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def indefinite_suspensions(menu)
|
|
59
|
+
horizon = menu.reference_time + SUSPENSION_HORIZON_SECONDS
|
|
60
|
+
check = lambda do |entity, type|
|
|
61
|
+
raw = entity["suspended_until"]
|
|
62
|
+
return nil unless raw
|
|
63
|
+
t = begin
|
|
64
|
+
Time.iso8601(raw)
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
return nil unless t && t > horizon
|
|
69
|
+
Finding.new("AVAIL-003", type, entity["id"],
|
|
70
|
+
"suspended_until #{raw} is more than a year past the reference time — retirement via 86")
|
|
71
|
+
end
|
|
72
|
+
menu.items_by_id.each_value.filter_map { |it| check.call(it, "item") } +
|
|
73
|
+
menu.modifiers_by_id.each_value.filter_map { |m| check.call(m, "modifier") }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def present?(windows)
|
|
77
|
+
!windows.nil? && !windows.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def overlap?(windows_a, windows_b)
|
|
81
|
+
ints_a = intervals(windows_a)
|
|
82
|
+
ints_b = intervals(windows_b)
|
|
83
|
+
ints_a.any? { |a| ints_b.any? { |b| a[0] < b[1] && b[0] < a[1] } }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def intervals(windows)
|
|
87
|
+
windows.flat_map do |w|
|
|
88
|
+
days = w["days"] || DAYS
|
|
89
|
+
s = minute_of_day(w["start"])
|
|
90
|
+
e = minute_of_day(w["end"])
|
|
91
|
+
days.flat_map do |d|
|
|
92
|
+
di = DAYS.index(d)
|
|
93
|
+
next [] if di.nil? || s.nil? || e.nil?
|
|
94
|
+
base = di * 1440
|
|
95
|
+
if e > s
|
|
96
|
+
[[base + s, base + e]]
|
|
97
|
+
elsif e < s # wraps past midnight into the next day
|
|
98
|
+
nxt = ((di + 1) % 7) * 1440
|
|
99
|
+
[[base + s, base + 1440], [nxt, nxt + e]].reject { |i| i[0] == i[1] }
|
|
100
|
+
else
|
|
101
|
+
[] # zero-length window
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def minute_of_day(hhmm)
|
|
108
|
+
return nil unless hhmm.is_a?(String) && hhmm.match?(/\A\d{2}:\d{2}\z/)
|
|
109
|
+
h, m = hhmm.split(":").map(&:to_i)
|
|
110
|
+
(h * 60) + m
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Rules
|
|
5
|
+
# CART-001..004, produced by the cart solver (see Menuconform::Solver).
|
|
6
|
+
module CartRules
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(menu)
|
|
10
|
+
Solver.new(menu).findings
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Rules
|
|
5
|
+
# CONFLICT-001..005: statically unsatisfiable or ambiguous constraints.
|
|
6
|
+
module ConflictRules
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(menu)
|
|
10
|
+
out = []
|
|
11
|
+
menu.groups_by_id.each_value do |g|
|
|
12
|
+
out.concat(group_findings(g))
|
|
13
|
+
end
|
|
14
|
+
menu.each_conditional_context do |g, o, trigger|
|
|
15
|
+
next unless trigger[:status] == :invalid
|
|
16
|
+
out << Finding.new("CONFLICT-005", "modifier_group", g["id"],
|
|
17
|
+
"option #{o['modifier_id']} has conditional prices whose trigger " \
|
|
18
|
+
"is not a single max_select:1 group attached to the same item")
|
|
19
|
+
end
|
|
20
|
+
out
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def group_findings(g)
|
|
24
|
+
out = []
|
|
25
|
+
min = g["min_select"] || 0
|
|
26
|
+
max = g["max_select"]
|
|
27
|
+
min_units = g["min_total_units"]
|
|
28
|
+
max_units = g["max_total_units"]
|
|
29
|
+
options = g["options"] || []
|
|
30
|
+
|
|
31
|
+
if (!max.nil? && min > max) || (min_units && !max_units.nil? && min_units > max_units)
|
|
32
|
+
out << Finding.new("CONFLICT-001", "modifier_group", g["id"],
|
|
33
|
+
"minimum exceeds maximum (min_select #{min} / max_select #{max.inspect}, " \
|
|
34
|
+
"min_total_units #{min_units.inspect} / max_total_units #{max_units.inspect})")
|
|
35
|
+
end
|
|
36
|
+
if min.positive? && options.empty?
|
|
37
|
+
out << Finding.new("CONFLICT-002", "modifier_group", g["id"],
|
|
38
|
+
"requires #{min} selection(s) but offers no options")
|
|
39
|
+
end
|
|
40
|
+
out.concat(default_findings(g, options, max, max_units))
|
|
41
|
+
out.concat(included_findings(g, max, max_units))
|
|
42
|
+
out
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def default_findings(g, options, max, max_units)
|
|
46
|
+
defaults = options.select { |o| (o["default_quantity"] || 0).positive? }
|
|
47
|
+
default_units = defaults.sum { |o| o["default_quantity"] }
|
|
48
|
+
problems = []
|
|
49
|
+
problems << "#{defaults.size} default option(s) exceed max_select #{max}" if !max.nil? && defaults.size > max
|
|
50
|
+
problems << "#{default_units} default unit(s) exceed max_total_units #{max_units}" if !max_units.nil? && default_units > max_units
|
|
51
|
+
defaults.each do |o|
|
|
52
|
+
mq = o["max_quantity"] || 1
|
|
53
|
+
problems << "option #{o['modifier_id']} defaults to #{o['default_quantity']} but max_quantity is #{mq}" if o["default_quantity"] > mq
|
|
54
|
+
end
|
|
55
|
+
return [] if problems.empty?
|
|
56
|
+
[Finding.new("CONFLICT-003", "modifier_group", g["id"], problems.join("; "))]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def included_findings(g, max, max_units)
|
|
60
|
+
inc = g["included_quantity"] || 0
|
|
61
|
+
return [] unless inc.positive?
|
|
62
|
+
cap = g["included_counting"] == "options" ? max : max_units
|
|
63
|
+
return [] if cap.nil? || inc <= cap
|
|
64
|
+
[Finding.new("CONFLICT-004", "modifier_group", g["id"],
|
|
65
|
+
"included_quantity #{inc} exceeds the group maximum (#{cap})")]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Rules
|
|
5
|
+
# NAME-001..003: naming quality of customer-facing entities.
|
|
6
|
+
module NameRules
|
|
7
|
+
JUNK_TOKENS = /\b(86|dnu|do not use|do not sell|test|void|zzz+)\b/i
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(menu)
|
|
12
|
+
empty_names(menu) + duplicate_names(menu) + junk_names(menu)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each_named_entity(menu)
|
|
16
|
+
menu.categories_by_id.each_value { |e| yield e, "category" }
|
|
17
|
+
menu.items_by_id.each_value { |e| yield e, "item" }
|
|
18
|
+
menu.groups_by_id.each_value { |e| yield e, "modifier_group" }
|
|
19
|
+
menu.modifiers_by_id.each_value { |e| yield e, "modifier" }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def empty_names(menu)
|
|
23
|
+
out = []
|
|
24
|
+
each_named_entity(menu) do |e, type|
|
|
25
|
+
next unless (e["name"] || "").strip.empty?
|
|
26
|
+
out << Finding.new("NAME-001", type, e["id"], "name is empty or whitespace-only")
|
|
27
|
+
end
|
|
28
|
+
out
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Scopes: item names within one category; modifier names within one group.
|
|
32
|
+
# Every occurrence after the first (in listing order) is flagged.
|
|
33
|
+
def duplicate_names(menu)
|
|
34
|
+
out = {}
|
|
35
|
+
menu.categories_by_id.each_value do |c|
|
|
36
|
+
seen = {}
|
|
37
|
+
(c["item_ids"] || []).each do |iid|
|
|
38
|
+
it = menu.items_by_id[iid]
|
|
39
|
+
next unless it
|
|
40
|
+
key = normalize(it["name"])
|
|
41
|
+
next if key.empty?
|
|
42
|
+
if seen.key?(key) && seen[key] != iid
|
|
43
|
+
out[["item", iid]] ||= Finding.new("NAME-002", "item", iid,
|
|
44
|
+
"name duplicates '#{seen[key]}' in category #{c['id']} (case-insensitive)")
|
|
45
|
+
else
|
|
46
|
+
seen[key] ||= iid
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
menu.groups_by_id.each_value do |g|
|
|
51
|
+
seen = {}
|
|
52
|
+
(g["options"] || []).each do |o|
|
|
53
|
+
m = menu.modifiers_by_id[o["modifier_id"]]
|
|
54
|
+
next unless m
|
|
55
|
+
key = normalize(m["name"])
|
|
56
|
+
next if key.empty?
|
|
57
|
+
if seen.key?(key) && seen[key] != m["id"]
|
|
58
|
+
out[["modifier", m["id"]]] ||= Finding.new("NAME-002", "modifier", m["id"],
|
|
59
|
+
"name duplicates '#{seen[key]}' in group #{g['id']} (case-insensitive)")
|
|
60
|
+
else
|
|
61
|
+
seen[key] ||= m["id"]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
out.values
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def junk_names(menu)
|
|
69
|
+
out = []
|
|
70
|
+
each_named_entity(menu) do |e, type|
|
|
71
|
+
name = e["name"] || ""
|
|
72
|
+
next if name.strip.empty? || !name.match?(JUNK_TOKENS)
|
|
73
|
+
out << Finding.new("NAME-003", type, e["id"],
|
|
74
|
+
"customer-facing name '#{name}' carries back-of-house tokens")
|
|
75
|
+
end
|
|
76
|
+
out
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize(name)
|
|
80
|
+
(name || "").strip.downcase
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Rules
|
|
5
|
+
# PRICE-001..003. PRICE-003 uses a conservative static under-approximation of
|
|
6
|
+
# the minimum reachable total (depth-1 groups only, included_quantity ignored,
|
|
7
|
+
# cheapest conditional price assumed): it can miss negative totals hidden in
|
|
8
|
+
# nested groups, but a total it reports as negative genuinely is reachable-
|
|
9
|
+
# negative under those assumptions. The day 7-8 cart solver supersedes it for
|
|
10
|
+
# exact analysis.
|
|
11
|
+
module PriceRules
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(menu)
|
|
15
|
+
negative_prices(menu) + incomplete_matrices(menu) + negative_totals(menu)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def negative_prices(menu)
|
|
19
|
+
menu.items_by_id.each_value.filter_map do |it|
|
|
20
|
+
next unless it["price"].negative?
|
|
21
|
+
Finding.new("PRICE-001", "item", it["id"], "item price #{it['price']} is negative")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def incomplete_matrices(menu)
|
|
26
|
+
out = []
|
|
27
|
+
menu.each_conditional_context do |g, o, trigger|
|
|
28
|
+
next unless trigger[:status] == :valid
|
|
29
|
+
covered = (o["conditional_prices"] || []).map { |cp| cp["when_modifier_id"] }
|
|
30
|
+
all = (trigger[:group]["options"] || []).map { |t| t["modifier_id"] }
|
|
31
|
+
missing = all - covered
|
|
32
|
+
next if missing.empty?
|
|
33
|
+
out << Finding.new("PRICE-002", "modifier_group", g["id"],
|
|
34
|
+
"option #{o['modifier_id']} conditional-price matrix does not cover " \
|
|
35
|
+
"#{missing.join(', ')} in trigger group #{trigger[:group]['id']} " \
|
|
36
|
+
"(falls back to base price)")
|
|
37
|
+
end
|
|
38
|
+
out
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def negative_totals(menu)
|
|
42
|
+
menu.items_by_id.each_value.filter_map do |it|
|
|
43
|
+
next if it["price"].negative? # PRICE-001 already anchors this item
|
|
44
|
+
min_total = it["price"] + menu.attached_group_ids(it).uniq.sum do |gid|
|
|
45
|
+
g = menu.groups_by_id[gid]
|
|
46
|
+
g ? min_group_contribution(menu, g) : 0
|
|
47
|
+
end
|
|
48
|
+
next unless min_total.negative?
|
|
49
|
+
Finding.new("PRICE-003", "item", it["id"],
|
|
50
|
+
"a valid selection can drive the total to #{min_total}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Cheapest satisfying contribution of one group: forced picks take the
|
|
55
|
+
# cheapest options; beyond the minimum, only negative-priced options are
|
|
56
|
+
# added, within max_select / max_total_units / per-option max_quantity.
|
|
57
|
+
def min_group_contribution(menu, group)
|
|
58
|
+
options = (group["options"] || []).select { |o| menu.modifiers_by_id.key?(o["modifier_id"]) }
|
|
59
|
+
min_sel = [group["min_select"] || 0, 0].max
|
|
60
|
+
max_sel = group["max_select"] || options.size
|
|
61
|
+
return 0 if options.empty? || min_sel > max_sel || min_sel > options.size
|
|
62
|
+
|
|
63
|
+
budget = group["max_total_units"] || Float::INFINITY
|
|
64
|
+
candidates = options.map do |o|
|
|
65
|
+
unit_price = menu.resolved_min_price(o)
|
|
66
|
+
units = if unit_price.negative?
|
|
67
|
+
o["max_quantity"] || 1
|
|
68
|
+
else
|
|
69
|
+
[o["min_quantity"] || 0, 1].max
|
|
70
|
+
end
|
|
71
|
+
{ price: unit_price, units: units, cost: unit_price * units }
|
|
72
|
+
end.sort_by { |c| c[:cost] }
|
|
73
|
+
|
|
74
|
+
total = 0
|
|
75
|
+
used_units = 0.0
|
|
76
|
+
selected = 0
|
|
77
|
+
candidates.each do |c|
|
|
78
|
+
forced = selected < min_sel
|
|
79
|
+
break if !forced && selected >= max_sel
|
|
80
|
+
next unless forced || c[:cost].negative?
|
|
81
|
+
units = [c[:units], budget - used_units].min
|
|
82
|
+
units = 1 if forced && units < 1
|
|
83
|
+
next if units < 1
|
|
84
|
+
total += c[:price] * units
|
|
85
|
+
used_units += units
|
|
86
|
+
selected += 1
|
|
87
|
+
end
|
|
88
|
+
total
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
module Rules
|
|
5
|
+
# STRUCT-002..008. STRUCT-001 (schema validity) runs in the engine before
|
|
6
|
+
# any rule sees the document.
|
|
7
|
+
module StructRules
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(menu)
|
|
11
|
+
dangling_refs(menu) + duplicate_ids(menu) + orphans(menu) +
|
|
12
|
+
nesting_depth(menu) + uncategorized_items(menu) + empty_categories(menu)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dangling_refs(menu)
|
|
16
|
+
out = {}
|
|
17
|
+
add = lambda do |type, id, missing, what|
|
|
18
|
+
out[[type, id, missing]] ||=
|
|
19
|
+
Finding.new("STRUCT-002", type, id, "references #{what} #{missing}, which is not defined")
|
|
20
|
+
end
|
|
21
|
+
menu.categories.each do |c|
|
|
22
|
+
(c["item_ids"] || []).each do |iid|
|
|
23
|
+
add.call("category", c["id"], iid, "item") unless menu.items_by_id.key?(iid)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
menu.items.each do |it|
|
|
27
|
+
menu.attached_group_ids(it).each do |gid|
|
|
28
|
+
add.call("item", it["id"], gid, "modifier group") unless menu.groups_by_id.key?(gid)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
menu.modifier_groups.each do |g|
|
|
32
|
+
(g["options"] || []).each do |o|
|
|
33
|
+
add.call("modifier_group", g["id"], o["modifier_id"], "modifier") unless menu.modifiers_by_id.key?(o["modifier_id"])
|
|
34
|
+
(o["child_modifier_group_ids"] || []).each do |cgid|
|
|
35
|
+
add.call("modifier_group", g["id"], cgid, "child modifier group") unless menu.groups_by_id.key?(cgid)
|
|
36
|
+
end
|
|
37
|
+
(o["conditional_prices"] || []).each do |cp|
|
|
38
|
+
wid = cp["when_modifier_id"]
|
|
39
|
+
add.call("modifier_group", g["id"], wid, "conditional-price trigger modifier") unless menu.modifiers_by_id.key?(wid)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
out.values
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def duplicate_ids(menu)
|
|
47
|
+
menu.duplicate_ids.flat_map do |type, ids|
|
|
48
|
+
ids.map { |id| Finding.new("STRUCT-003", type, id, "more than one #{type} entry shares this id") }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Orphan groups and modifiers. Items get STRUCT-007 instead.
|
|
53
|
+
def orphans(menu)
|
|
54
|
+
out = []
|
|
55
|
+
menu.groups_by_id.each_value do |g|
|
|
56
|
+
next if menu.referenced_group_ids.include?(g["id"])
|
|
57
|
+
out << Finding.new("STRUCT-004", "modifier_group", g["id"], "defined but referenced by nothing")
|
|
58
|
+
end
|
|
59
|
+
menu.modifiers_by_id.each_value do |m|
|
|
60
|
+
next if menu.referenced_modifier_ids.include?(m["id"])
|
|
61
|
+
out << Finding.new("STRUCT-004", "modifier", m["id"], "defined but referenced by nothing")
|
|
62
|
+
end
|
|
63
|
+
out
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def nesting_depth(menu)
|
|
67
|
+
menu.items_by_id.each_value.filter_map do |it|
|
|
68
|
+
info = menu.depth_info(it)
|
|
69
|
+
if info[:cyclic]
|
|
70
|
+
Finding.new("STRUCT-005", "item", it["id"], "modifier group nesting contains a cycle")
|
|
71
|
+
elsif info[:max_depth] > 6
|
|
72
|
+
Finding.new("STRUCT-005", "item", it["id"], "modifier group nesting depth #{info[:max_depth]} exceeds 6")
|
|
73
|
+
elsif info[:max_depth] > 4
|
|
74
|
+
Finding.new("STRUCT-006", "item", it["id"], "modifier group nesting depth #{info[:max_depth]} exceeds 4")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def uncategorized_items(menu)
|
|
80
|
+
listed = menu.categories.flat_map { |c| c["item_ids"] || [] }.to_set
|
|
81
|
+
menu.items_by_id.each_value.filter_map do |it|
|
|
82
|
+
next if listed.include?(it["id"])
|
|
83
|
+
Finding.new("STRUCT-007", "item", it["id"], "not listed in any category")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def empty_categories(menu)
|
|
88
|
+
menu.categories_by_id.each_value.filter_map do |c|
|
|
89
|
+
next unless (c["item_ids"] || []).empty?
|
|
90
|
+
Finding.new("STRUCT-008", "category", c["id"], "category lists no items")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Menuconform
|
|
4
|
+
# The published SPEC §7 scoring formula. Pure function of the findings and
|
|
5
|
+
# the weights below — same findings, same rules version, same score.
|
|
6
|
+
module Scorer
|
|
7
|
+
WEIGHTS = { "error" => 5.0, "warn" => 2.0, "info" => 0.5 }.freeze
|
|
8
|
+
PER_RULE_CAP = 15.0
|
|
9
|
+
CART_CAP_RULES = %w[CART-001 CART-002 CART-003].freeze
|
|
10
|
+
CART_CAP = 49
|
|
11
|
+
SCHEMA_CAP_RULE = "STRUCT-001"
|
|
12
|
+
SCHEMA_CAP = 59
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# findings: [Finding] with severities set. Returns a breakdown hash.
|
|
17
|
+
def score(findings)
|
|
18
|
+
deductions = findings.group_by(&:rule).sort.map do |rule, group|
|
|
19
|
+
severity = group.first.severity
|
|
20
|
+
raw_points = group.size * WEIGHTS.fetch(severity)
|
|
21
|
+
points = [raw_points, PER_RULE_CAP].min
|
|
22
|
+
{
|
|
23
|
+
"rule" => rule,
|
|
24
|
+
"severity" => severity,
|
|
25
|
+
"count" => group.size,
|
|
26
|
+
"points" => points,
|
|
27
|
+
"capped" => raw_points > PER_RULE_CAP
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
raw = 100.0 - deductions.sum { |d| d["points"] }
|
|
32
|
+
capped = [raw, 0.0].max
|
|
33
|
+
|
|
34
|
+
hard_caps = []
|
|
35
|
+
if findings.any? { |f| CART_CAP_RULES.include?(f.rule) } && capped > CART_CAP
|
|
36
|
+
hard_caps << { "reason" => "cart-blocking findings present (CART-001/002/003)", "cap" => CART_CAP }
|
|
37
|
+
capped = CART_CAP
|
|
38
|
+
end
|
|
39
|
+
if findings.any? { |f| f.rule == SCHEMA_CAP_RULE } && capped > SCHEMA_CAP
|
|
40
|
+
hard_caps << { "reason" => "document fails schema validation (STRUCT-001)", "cap" => SCHEMA_CAP }
|
|
41
|
+
capped = SCHEMA_CAP
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
"score" => capped.round, # Ruby rounds half away from zero: half-up for our range
|
|
46
|
+
"base" => 100,
|
|
47
|
+
"deductions" => deductions,
|
|
48
|
+
"raw_score" => raw,
|
|
49
|
+
"hard_caps" => hard_caps
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|