menuconform 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +10 -2
- data/lib/menuconform/cli.rb +23 -3
- data/lib/menuconform/nested_options_exporter.rb +297 -0
- data/lib/menuconform/version.rb +1 -1
- data/lib/menuconform.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7ec9be4b3e1a835ffc1fe2c723c22208ac41ab9826560c3cbc32238dc10bd8e
|
|
4
|
+
data.tar.gz: fddcc3464cd86ace1764f20f4ffdb8f50e9d0bf879f86147501691846ba9462c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b7c7a7e881f869019dd09e4a4e11c9db19c41ffa2f293c74dd585050721391d0cc64eb8acfb6361beb8b0b8811c5bf25f442a368a1c6a36f52a42eba0f6b7e4
|
|
7
|
+
data.tar.gz: 4eeb1651ec3de2eae25f59567eb083c19509f96e55f6b3307fff6edf6132c53770c9f3352c60eee245dc0d2004aece3c42eca5b92914c7b7e5e9d4974cffc620
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-09-13
|
|
4
|
+
|
|
5
|
+
- Experimental export target for UCP proposal [#821 (Nested Options)](https://github.com/Universal-Commerce-Protocol/ucp/issues/821):
|
|
6
|
+
`menuconform export --target nested-options-821`. Every modifier group lowers
|
|
7
|
+
to a product option whose values carry `product_id` references; measured
|
|
8
|
+
corpus results in [docs/export-lossiness.md](docs/export-lossiness.md).
|
|
9
|
+
- Experimental export target for UCP proposal [#824 (Selection Rules)](https://github.com/Universal-Commerce-Protocol/ucp/issues/824),
|
|
10
|
+
composing with #821: `--target selection-rules-824` emits two-axis
|
|
11
|
+
`selection_rule` on options and `quantity_rule` on option values, at depth
|
|
12
|
+
cap 6. The selection-constraints loss class disappears across the corpus.
|
|
13
|
+
- Allergen vocabulary extended to the union of US big-9 and EU-14.
|
|
14
|
+
- CI (GitHub Actions, Ruby 3.1 and 3.4) and issue templates.
|
|
15
|
+
|
|
16
|
+
## 0.1.0 — 2026-08-31
|
|
17
|
+
|
|
18
|
+
- Initial release: Menu IR v0.2 (JSON Schema 2020-12), 33-rule engine across
|
|
19
|
+
9 families including the exact cart solver (CART-001..004), published 0-100
|
|
20
|
+
scoring formula, JSON report with fix hints, NCR Voyix Menu API importer,
|
|
21
|
+
UCP catalog exporter with EXPORT- lossiness findings, 12-fixture corpus
|
|
22
|
+
with pinned expected findings, CLI (`check` / `import` / `export` / `rules`).
|
data/README.md
CHANGED
|
@@ -72,7 +72,12 @@ schema-invalid document at 59. Deterministic: the only time input is an injectab
|
|
|
72
72
|
|
|
73
73
|
## Install & use
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
```
|
|
76
|
+
gem install menuconform
|
|
77
|
+
menuconform check menu.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or from source (Ruby ≥ 3.1):
|
|
76
81
|
|
|
77
82
|
```
|
|
78
83
|
git clone https://github.com/amitkssolanki/menuconform && cd menuconform
|
|
@@ -125,7 +130,10 @@ shapes (`product`/`variant`/`option`/`price`) and reports what the export loses
|
|
|
125
130
|
everything else — multi-select modifiers, nested groups, conditional prices,
|
|
126
131
|
half/half, allergens, age restrictions, schedules — is dropped, degraded, or makes
|
|
127
132
|
the item unrepresentable (`EXPORT-001`, exit 1). The full lossy-construct analysis
|
|
128
|
-
lives in [docs/export-lossiness.md](docs/export-lossiness.md)
|
|
133
|
+
lives in [docs/export-lossiness.md](docs/export-lossiness.md), including a measured
|
|
134
|
+
evaluation of proposal
|
|
135
|
+
[ucp#821](https://github.com/Universal-Commerce-Protocol/ucp/issues/821) via the
|
|
136
|
+
experimental `--target nested-options-821`. Export findings
|
|
129
137
|
deliberately do **not** affect the `check` score: `check` judges your menu data,
|
|
130
138
|
`export` judges the current draft's fit for food.
|
|
131
139
|
|
data/lib/menuconform/cli.rb
CHANGED
|
@@ -147,11 +147,19 @@ module Menuconform
|
|
|
147
147
|
|
|
148
148
|
# Artifact goes to --out (or stdout); EXPORT- findings go to stderr.
|
|
149
149
|
# Exit 1 when any EXPORT-001 (item cannot exist under the pinned draft).
|
|
150
|
+
EXPORT_TARGETS = {
|
|
151
|
+
"catalog" => :catalog,
|
|
152
|
+
"catalog-2026-08-25" => :catalog,
|
|
153
|
+
"nested-options-821" => :nested_options,
|
|
154
|
+
"selection-rules-824" => :selection_rules
|
|
155
|
+
}.freeze
|
|
156
|
+
|
|
150
157
|
def export(args)
|
|
151
|
-
options = { from: nil, config: nil, out: nil, reference_time: Time.now.utc }
|
|
158
|
+
options = { from: nil, config: nil, out: nil, reference_time: Time.now.utc, target: "catalog" }
|
|
152
159
|
parser = OptionParser.new do |o|
|
|
153
160
|
o.on("--from FORMAT") { |f| options[:from] = f }
|
|
154
161
|
o.on("--config PATH") { |p| options[:config] = p }
|
|
162
|
+
o.on("--target TARGET") { |t| options[:target] = t }
|
|
155
163
|
o.on("-o PATH", "--out PATH") { |p| options[:out] = p }
|
|
156
164
|
o.on("--reference-time TIME") do |t|
|
|
157
165
|
options[:reference_time] = Time.iso8601(t)
|
|
@@ -183,6 +191,9 @@ module Menuconform
|
|
|
183
191
|
doc = result.doc
|
|
184
192
|
end
|
|
185
193
|
|
|
194
|
+
target = EXPORT_TARGETS[options[:target]]
|
|
195
|
+
return usage_error("unknown export target #{options[:target].inspect} (known: #{EXPORT_TARGETS.keys.join(', ')})") unless target
|
|
196
|
+
|
|
186
197
|
engine = Engine.new
|
|
187
198
|
schema_gate = engine.run(doc, reference_time: options[:reference_time])
|
|
188
199
|
if schema_gate.any? { |f| f.rule == "STRUCT-001" }
|
|
@@ -190,7 +201,11 @@ module Menuconform
|
|
|
190
201
|
return 1
|
|
191
202
|
end
|
|
192
203
|
|
|
193
|
-
exporter =
|
|
204
|
+
exporter = case target
|
|
205
|
+
when :nested_options then NestedOptionsExporter.new(catalog: engine.catalog)
|
|
206
|
+
when :selection_rules then NestedOptionsExporter.new(catalog: engine.catalog, selection_rules: true)
|
|
207
|
+
else UcpExporter.new(catalog: engine.catalog)
|
|
208
|
+
end
|
|
194
209
|
result = exporter.call(doc, reference_time: options[:reference_time])
|
|
195
210
|
|
|
196
211
|
result[:findings].each do |f|
|
|
@@ -199,7 +214,12 @@ module Menuconform
|
|
|
199
214
|
errors = result[:findings].count { |f| f.severity == "error" }
|
|
200
215
|
warns = result[:findings].count { |f| f.severity == "warn" }
|
|
201
216
|
exported = result[:artifact]["catalog"]["products"].size
|
|
202
|
-
|
|
217
|
+
label = case target
|
|
218
|
+
when :nested_options then "UCP #{UcpExporter::UCP_SPEC_PIN} + proposal #821 (experimental)"
|
|
219
|
+
when :selection_rules then "UCP #{UcpExporter::UCP_SPEC_PIN} + proposals #821+#824 (experimental)"
|
|
220
|
+
else "UCP #{UcpExporter::UCP_SPEC_PIN}"
|
|
221
|
+
end
|
|
222
|
+
@stderr.puts "exported #{exported} product(s) against #{label}; " \
|
|
203
223
|
"#{errors} unrepresentable item(s), #{warns} lossy construct(s)"
|
|
204
224
|
|
|
205
225
|
json = JSON.pretty_generate(result[:artifact])
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Menuconform
|
|
6
|
+
# EXPERIMENTAL export target for UCP proposal #821 ("Nested Options",
|
|
7
|
+
# Universal-Commerce-Protocol/ucp#821): option values may carry a product_id
|
|
8
|
+
# referencing another product; nesting is capped at 5 products deep; pricing
|
|
9
|
+
# is recursive addition; no selection constraints (min/max/defaults) and no
|
|
10
|
+
# non-additive pricing — both are the proposal's stated non-goals.
|
|
11
|
+
#
|
|
12
|
+
# Mapping:
|
|
13
|
+
# - Every IR modifier group becomes a product option; every option link
|
|
14
|
+
# becomes an option value referencing a modifier-product. Nested groups
|
|
15
|
+
# recurse. Items with any priced required group are now representable —
|
|
16
|
+
# the class that is EXPORT-001 under the shipped catalog model.
|
|
17
|
+
# - A modifier-product is shared across links only when the link-resolved
|
|
18
|
+
# price and child groups match; a link price_override otherwise forces a
|
|
19
|
+
# duplicated product (#821 has no per-reference override — measured and
|
|
20
|
+
# reported as a menu-level finding).
|
|
21
|
+
# - Conditional prices export at base/override price (estimate): exact
|
|
22
|
+
# expression would require duplicating the whole subtree per trigger
|
|
23
|
+
# selection, the same explosion the proposal exists to avoid.
|
|
24
|
+
# - Depth: IR group-depth d costs d+1 products (item + one per level), so
|
|
25
|
+
# the 5-product cap holds IR depth <= 4; deeper items are EXPORT-001.
|
|
26
|
+
# - Selection constraints, included quantities, and non-full_price slot
|
|
27
|
+
# pricing are EXPORT-002 (proposal non-goals). Allergens / min_age /
|
|
28
|
+
# dietary / schedules are unchanged by #821 and carry the same findings
|
|
29
|
+
# as the shipped-catalog exporter.
|
|
30
|
+
class NestedOptionsExporter
|
|
31
|
+
PROPOSAL = "Universal-Commerce-Protocol/ucp#821 (nested options, experimental)"
|
|
32
|
+
PROPOSAL_WITH_RULES = "Universal-Commerce-Protocol/ucp#821 + ucp#824 (nested options + selection rules, experimental)"
|
|
33
|
+
PRODUCT_DEPTH_CAP = 5
|
|
34
|
+
# ucp#824 composes with #821; depth 6 per the #821 thread agreement.
|
|
35
|
+
DEPTH_CAP_WITH_RULES = 6
|
|
36
|
+
|
|
37
|
+
# selection_rules: true adds ucp#824's selection_rule (on product options)
|
|
38
|
+
# and quantity_rule (on option values) — the IR's two-axis constraints,
|
|
39
|
+
# per-link quantity bounds, and defaults all become expressible, so the
|
|
40
|
+
# constraints loss class disappears from the findings.
|
|
41
|
+
def initialize(catalog:, selection_rules: false)
|
|
42
|
+
@catalog = catalog
|
|
43
|
+
@selection_rules = selection_rules
|
|
44
|
+
@depth_cap = selection_rules ? DEPTH_CAP_WITH_RULES : PRODUCT_DEPTH_CAP
|
|
45
|
+
@proposal = selection_rules ? PROPOSAL_WITH_RULES : PROPOSAL
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns { artifact: Hash, findings: [Finding] } (severities stamped).
|
|
49
|
+
def call(doc, reference_time: Time.now.utc)
|
|
50
|
+
menu = Menu.new(doc, reference_time: reference_time)
|
|
51
|
+
findings = []
|
|
52
|
+
products = []
|
|
53
|
+
@modifier_products = {}
|
|
54
|
+
@mod_dup_counts = Hash.new(0)
|
|
55
|
+
|
|
56
|
+
findings << finding("EXPORT-002", "menu", doc["name"] || "(unnamed)",
|
|
57
|
+
"menu-level service windows have no UCP catalog construct (unchanged by #821)") if present?(doc["availability"])
|
|
58
|
+
findings << finding("EXPORT-002", "menu", doc["name"] || "(unnamed)",
|
|
59
|
+
"special_hours have no UCP catalog construct (unchanged by #821)") if present?(doc["special_hours"])
|
|
60
|
+
menu.categories_by_id.each_value do |c|
|
|
61
|
+
next unless present?(c["availability"])
|
|
62
|
+
findings << finding("EXPORT-002", "category", c["id"],
|
|
63
|
+
"category availability windows have no UCP catalog construct (unchanged by #821)")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
menu.items_by_id.each_value do |item|
|
|
67
|
+
next if menu.depth_info(item)[:cyclic]
|
|
68
|
+
product, item_findings = export_item(menu, item)
|
|
69
|
+
findings.concat(item_findings)
|
|
70
|
+
products << product if product
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
duplicated = @mod_dup_counts.select { |_, n| n > 1 }
|
|
74
|
+
unless duplicated.empty?
|
|
75
|
+
extra = duplicated.values.sum - duplicated.size
|
|
76
|
+
findings << finding("EXPORT-002", "menu", doc["name"] || "(unnamed)",
|
|
77
|
+
"#{duplicated.size} modifier(s) carry context-dependent prices and had to be duplicated into " \
|
|
78
|
+
"#{extra} extra product(s) — #821 option values have no per-reference price override " \
|
|
79
|
+
"(affected: #{duplicated.keys.sort.join(', ')})")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
artifact = {
|
|
83
|
+
"ucp_spec_version" => UcpExporter::UCP_SPEC_PIN,
|
|
84
|
+
"experimental_proposal" => @proposal,
|
|
85
|
+
"generated_by" => "menuconform #{VERSION}",
|
|
86
|
+
"catalog" => { "products" => products + @modifier_products.values.sort_by { |p| p["id"] } }
|
|
87
|
+
}
|
|
88
|
+
{ artifact: artifact, findings: findings.each { |f| f.severity = @catalog.severity(f.rule) } }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def export_item(menu, item)
|
|
94
|
+
findings = []
|
|
95
|
+
depth = menu.depth_info(item)[:max_depth]
|
|
96
|
+
if depth + 1 > @depth_cap
|
|
97
|
+
findings << finding("EXPORT-001", "item", item["id"],
|
|
98
|
+
"nesting needs #{depth + 1} products (item + #{depth} modifier levels); " \
|
|
99
|
+
"the cap is #{@depth_cap} — this item cannot be represented")
|
|
100
|
+
return [nil, findings]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
findings.concat(lossiness_findings(menu, item))
|
|
104
|
+
[build_product(menu, item), findings]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_product(menu, item)
|
|
108
|
+
currency = menu.doc["currency"]
|
|
109
|
+
options = (item["modifier_group_ids"] || []).uniq.filter_map { |gid| group_option(menu, gid, [], nil) }
|
|
110
|
+
(item["slots"] || []).each do |slot|
|
|
111
|
+
(slot["modifier_group_ids"] || []).uniq.each do |gid|
|
|
112
|
+
opt = group_option(menu, gid, [], slot["name"])
|
|
113
|
+
options << opt if opt
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
base = {
|
|
118
|
+
"id" => item["id"],
|
|
119
|
+
"title" => item["name"],
|
|
120
|
+
"description" => { "plain" => item["description"] || item["name"] || "" },
|
|
121
|
+
"price_range" => {
|
|
122
|
+
"min" => { "amount" => item["price"], "currency" => currency },
|
|
123
|
+
"max" => { "amount" => item["price"], "currency" => currency }
|
|
124
|
+
},
|
|
125
|
+
"variants" => [base_variant(menu, item, currency)]
|
|
126
|
+
}
|
|
127
|
+
base["options"] = options unless options.empty?
|
|
128
|
+
base["tags"] = item["dietary"] if item["dietary"]&.any?
|
|
129
|
+
base
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def base_variant(menu, item, currency)
|
|
133
|
+
v = {
|
|
134
|
+
"id" => "#{item['id']}~base",
|
|
135
|
+
"title" => item["name"],
|
|
136
|
+
"description" => { "plain" => item["description"] || item["name"] || "" },
|
|
137
|
+
"price" => { "amount" => item["price"], "currency" => currency }
|
|
138
|
+
}
|
|
139
|
+
v["availability"] = { "available" => false, "status" => "out_of_stock" } unless orderable?(menu, item)
|
|
140
|
+
v
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def group_option(menu, gid, stack, slot_name)
|
|
144
|
+
g = menu.groups_by_id[gid]
|
|
145
|
+
return nil if g.nil? || stack.include?(gid)
|
|
146
|
+
values = (g["options"] || []).filter_map do |link|
|
|
147
|
+
next unless menu.modifiers_by_id.key?(link["modifier_id"])
|
|
148
|
+
pid = modifier_product_id(menu, link, stack + [gid])
|
|
149
|
+
value = { "id" => "#{gid}:#{link['modifier_id']}", "label" => menu.modifiers_by_id[link["modifier_id"]]["name"],
|
|
150
|
+
"product_id" => pid }
|
|
151
|
+
if @selection_rules && (qr = quantity_rule(link))
|
|
152
|
+
value["quantity_rule"] = qr
|
|
153
|
+
end
|
|
154
|
+
value
|
|
155
|
+
end
|
|
156
|
+
return nil if values.empty?
|
|
157
|
+
option = { "name" => slot_name ? "#{slot_name}: #{g['name']}" : g["name"], "values" => values }
|
|
158
|
+
if @selection_rules && (sr = selection_rule(g))
|
|
159
|
+
option["selection_rule"] = sr
|
|
160
|
+
end
|
|
161
|
+
option
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# ucp#824 selection_rule from the IR group's two constraint axes.
|
|
165
|
+
def selection_rule(g)
|
|
166
|
+
rule = {}
|
|
167
|
+
rule["min_distinct"] = g["min_select"] if (g["min_select"] || 0).positive?
|
|
168
|
+
rule["max_distinct"] = g["max_select"] unless g["max_select"].nil?
|
|
169
|
+
rule["min_units"] = g["min_total_units"] if (g["min_total_units"] || 0).positive?
|
|
170
|
+
rule["max_units"] = g["max_total_units"] unless g["max_total_units"].nil?
|
|
171
|
+
rule.empty? ? nil : rule
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ucp#824 quantity_rule from the IR option link.
|
|
175
|
+
def quantity_rule(link)
|
|
176
|
+
rule = {}
|
|
177
|
+
rule["min"] = link["min_quantity"] if (link["min_quantity"] || 0) > 1
|
|
178
|
+
rule["max"] = link["max_quantity"] if (link["max_quantity"] || 1) != 1
|
|
179
|
+
rule["default"] = link["default_quantity"] if (link["default_quantity"] || 0).positive?
|
|
180
|
+
rule.empty? ? nil : rule
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Shared when link-resolved price and child groups agree; duplicated
|
|
184
|
+
# otherwise (measured for the per-reference-override argument).
|
|
185
|
+
def modifier_product_id(menu, link, stack)
|
|
186
|
+
mod = menu.modifiers_by_id[link["modifier_id"]]
|
|
187
|
+
price = link.key?("price_override") ? link["price_override"] : mod["price"]
|
|
188
|
+
children = (link["child_modifier_group_ids"] || []).uniq
|
|
189
|
+
signature = [mod["id"], price, children]
|
|
190
|
+
return @modifier_products_by_sig[signature] if @modifier_products_by_sig&.key?(signature)
|
|
191
|
+
|
|
192
|
+
@modifier_products_by_sig ||= {}
|
|
193
|
+
@mod_dup_counts[mod["id"]] += 1
|
|
194
|
+
pid = @mod_dup_counts[mod["id"]] == 1 ? "mod:#{mod['id']}" : "mod:#{mod['id']}~#{@mod_dup_counts[mod['id']]}"
|
|
195
|
+
@modifier_products_by_sig[signature] = pid
|
|
196
|
+
|
|
197
|
+
currency = menu.doc["currency"]
|
|
198
|
+
product = {
|
|
199
|
+
"id" => pid,
|
|
200
|
+
"title" => mod["name"],
|
|
201
|
+
"description" => { "plain" => mod["description"] || mod["name"] || "" },
|
|
202
|
+
"price_range" => {
|
|
203
|
+
"min" => { "amount" => price, "currency" => currency },
|
|
204
|
+
"max" => { "amount" => price, "currency" => currency }
|
|
205
|
+
},
|
|
206
|
+
"variants" => [{
|
|
207
|
+
"id" => "#{pid}~base",
|
|
208
|
+
"title" => mod["name"],
|
|
209
|
+
"description" => { "plain" => mod["description"] || mod["name"] || "" },
|
|
210
|
+
"price" => { "amount" => price, "currency" => currency }
|
|
211
|
+
}]
|
|
212
|
+
}
|
|
213
|
+
child_options = children.filter_map { |cgid| group_option(menu, cgid, stack, nil) }
|
|
214
|
+
product["options"] = child_options unless child_options.empty?
|
|
215
|
+
product["tags"] = mod["dietary"] if mod["dietary"]&.any?
|
|
216
|
+
@modifier_products[pid] = product
|
|
217
|
+
pid
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def lossiness_findings(menu, item)
|
|
221
|
+
out = []
|
|
222
|
+
id = item["id"]
|
|
223
|
+
reach = menu.reachable_group_ids(item).sort.filter_map { |gid| menu.groups_by_id[gid] }
|
|
224
|
+
|
|
225
|
+
unless @selection_rules
|
|
226
|
+
constrained = reach.select { |g| constrained?(g) }.map { |g| g["id"] }
|
|
227
|
+
unless constrained.empty?
|
|
228
|
+
out << finding("EXPORT-002", "item", id,
|
|
229
|
+
"selection constraints on #{constrained.join(', ')} dropped (#821 non-goal) — " \
|
|
230
|
+
"an agent can traverse this item but cannot validate a cart client-side")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
conditional = reach.flat_map { |g| (g["options"] || []).select { |o| (o["conditional_prices"] || []).any? }.map { |o| o["modifier_id"] } }.uniq
|
|
235
|
+
unless conditional.empty?
|
|
236
|
+
out << finding("EXPORT-002", "item", id,
|
|
237
|
+
"conditional prices on #{conditional.join(', ')} exported at base price (estimate) — exact " \
|
|
238
|
+
"expression under #821 requires duplicating the subtree per trigger selection")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
included = reach.select { |g| (g["included_quantity"] || 0).positive? }.map { |g| g["id"] }
|
|
242
|
+
unless included.empty?
|
|
243
|
+
out << finding("EXPORT-002", "item", id,
|
|
244
|
+
"included/free quantities on #{included.join(', ')} dropped — #821 pricing is strictly additive")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
if item["slots"]&.any? && item["slot_pricing"] != "full_price"
|
|
248
|
+
out << finding("EXPORT-002", "item", id,
|
|
249
|
+
"half/half structure exports, but #{item['slot_pricing']} slot pricing is not additive — " \
|
|
250
|
+
"totals are estimates until cart-create")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if item["allergens"]
|
|
254
|
+
out << finding("EXPORT-002", "item", id, "allergen declarations still have no catalog field (unchanged by #821)")
|
|
255
|
+
end
|
|
256
|
+
if item["min_age"]
|
|
257
|
+
out << finding("EXPORT-002", "item", id, "min_age #{item['min_age']} still has no catalog field (unchanged by #821)")
|
|
258
|
+
end
|
|
259
|
+
if item["dietary"]&.any?
|
|
260
|
+
out << finding("EXPORT-002", "item", id, "dietary claims still degrade to free-text tags (unchanged by #821)")
|
|
261
|
+
end
|
|
262
|
+
if present?(item["availability"])
|
|
263
|
+
out << finding("EXPORT-002", "item", id, "item availability windows still have no catalog construct (unchanged by #821)")
|
|
264
|
+
end
|
|
265
|
+
if item["suspended_until"]
|
|
266
|
+
out << finding("EXPORT-002", "item", id, "timed suspension still maps to a bare out_of_stock flag (unchanged by #821)")
|
|
267
|
+
end
|
|
268
|
+
out
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def constrained?(g)
|
|
272
|
+
(g["min_select"] || 0).positive? || !g["max_select"].nil? ||
|
|
273
|
+
g.key?("min_total_units") || !g["max_total_units"].nil? ||
|
|
274
|
+
(g["options"] || []).any? { |o| (o["default_quantity"] || 0).positive? || (o["max_quantity"] || 1) != 1 || (o["min_quantity"] || 0).positive? }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def orderable?(menu, item)
|
|
278
|
+
return false if item["active"] == false
|
|
279
|
+
raw = item["suspended_until"]
|
|
280
|
+
return true unless raw
|
|
281
|
+
t = begin
|
|
282
|
+
Time.iso8601(raw)
|
|
283
|
+
rescue ArgumentError
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
t.nil? || t <= menu.reference_time
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def present?(value)
|
|
290
|
+
!value.nil? && !value.empty?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def finding(rule, type, id, message)
|
|
294
|
+
Finding.new(rule, type, id, message)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
data/lib/menuconform/version.rb
CHANGED
data/lib/menuconform.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "menuconform/solver"
|
|
|
16
16
|
require_relative "menuconform/rules/cart_rules"
|
|
17
17
|
require_relative "menuconform/scorer"
|
|
18
18
|
require_relative "menuconform/ucp_exporter"
|
|
19
|
+
require_relative "menuconform/nested_options_exporter"
|
|
19
20
|
require_relative "menuconform/report"
|
|
20
21
|
require_relative "menuconform/engine"
|
|
21
22
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: menuconform
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Amit Solanki
|
|
@@ -33,6 +33,7 @@ executables:
|
|
|
33
33
|
extensions: []
|
|
34
34
|
extra_rdoc_files: []
|
|
35
35
|
files:
|
|
36
|
+
- CHANGELOG.md
|
|
36
37
|
- LICENSE
|
|
37
38
|
- README.md
|
|
38
39
|
- config/importers/ncr_menu.json
|
|
@@ -46,6 +47,7 @@ files:
|
|
|
46
47
|
- lib/menuconform/importers/base.rb
|
|
47
48
|
- lib/menuconform/importers/ncr_menu.rb
|
|
48
49
|
- lib/menuconform/menu.rb
|
|
50
|
+
- lib/menuconform/nested_options_exporter.rb
|
|
49
51
|
- lib/menuconform/report.rb
|
|
50
52
|
- lib/menuconform/rules/age_rules.rb
|
|
51
53
|
- lib/menuconform/rules/allergen_rules.rb
|