MenuPilot 0.1.0__py3-none-any.whl
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.
- menupilot/__init__.py +3 -0
- menupilot/__main__.py +4 -0
- menupilot/agent/__init__.py +0 -0
- menupilot/agent/agent_loop.py +414 -0
- menupilot/agent/matching_engine.py +974 -0
- menupilot/agent/option_expander.py +490 -0
- menupilot/agent/orchestration.py +570 -0
- menupilot/agent/rule_engine.py +509 -0
- menupilot/agent/sandbox.py +216 -0
- menupilot/agent/schema_analyzer.py +1026 -0
- menupilot/agent/template_preprocessor.py +293 -0
- menupilot/agent/token_classifier.py +816 -0
- menupilot/agent/tools.py +365 -0
- menupilot/agent/workflow.py +1072 -0
- menupilot/cli/human_review.py +191 -0
- menupilot/cli/repl.py +821 -0
- menupilot/config.py +113 -0
- menupilot/data/__init__.py +0 -0
- menupilot/data/canonical_schema.py +135 -0
- menupilot/data/mapping_rules.yaml +387 -0
- menupilot/data/memory.py +674 -0
- menupilot/data/token_dict.py +275 -0
- menupilot/excel_io/__init__.py +0 -0
- menupilot/excel_io/excel_reader.py +552 -0
- menupilot/excel_io/excel_writer.py +413 -0
- menupilot/main.py +322 -0
- menupilot/wizard.py +86 -0
- menupilot-0.1.0.dist-info/METADATA +397 -0
- menupilot-0.1.0.dist-info/RECORD +33 -0
- menupilot-0.1.0.dist-info/WHEEL +5 -0
- menupilot-0.1.0.dist-info/entry_points.txt +2 -0
- menupilot-0.1.0.dist-info/licenses/LICENSE +21 -0
- menupilot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Excel 读取模块 — 读取主数据表和模板表,返回 pandas DataFrame。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Tuple
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_df(df: pd.DataFrame) -> pd.DataFrame:
|
|
13
|
+
"""对 DataFrame 中所有字符串列做 Unicode NFC 标准化。
|
|
14
|
+
|
|
15
|
+
防止 Excel 中的特殊 Unicode 编码(如全角/半角混用、NFD 分解字符)
|
|
16
|
+
导致后续匹配失败。对所有 object 类型列执行 unicodedata.normalize('NFC', ...)。
|
|
17
|
+
"""
|
|
18
|
+
for col in df.columns:
|
|
19
|
+
if df[col].dtype == object:
|
|
20
|
+
df[col] = df[col].apply(
|
|
21
|
+
lambda x: unicodedata.normalize("NFC", str(x)) if pd.notna(x) else x
|
|
22
|
+
)
|
|
23
|
+
return df
|
|
24
|
+
|
|
25
|
+
# 主数据表必要字段(缺一不可,SOP 为目标列允许缺失)
|
|
26
|
+
MASTER_REQUIRED_COLUMNS = ["品名", "杯型", "做法", "糖"]
|
|
27
|
+
|
|
28
|
+
# 主数据表可通配字段(缺失时自动注入空列,触发通配逻辑,不报错)
|
|
29
|
+
MASTER_WILDCARD_COLUMNS = ["奶底"]
|
|
30
|
+
|
|
31
|
+
# 主数据表可选字段(存在则保留)
|
|
32
|
+
MASTER_OPTIONAL_COLUMNS = ["全信息", "SOP"]
|
|
33
|
+
|
|
34
|
+
# ── 选项规格主数据格式 ────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
# 选项规格主数据固定列(必须存在)
|
|
37
|
+
OPTION_MASTER_FIXED_COLUMNS = ["主编码", "商品名称"]
|
|
38
|
+
|
|
39
|
+
# 选项规格主数据维度(每个维度对应 3 列:推荐{dim}, 默认{dim}, {dim})
|
|
40
|
+
OPTION_MASTER_DIMENSIONS = ["糖度", "温度", "规格", "奶底", "茶底"]
|
|
41
|
+
|
|
42
|
+
# 列名别名:实际数据中常见的异名列 → 标准列名
|
|
43
|
+
OPTION_MASTER_COLUMN_ALIASES = {
|
|
44
|
+
"产品名称(中文)": "商品名称",
|
|
45
|
+
"产品名称(中文)": "商品名称",
|
|
46
|
+
"产品名称": "商品名称",
|
|
47
|
+
"推荐糖": "推荐糖度",
|
|
48
|
+
"默认糖": "默认糖度",
|
|
49
|
+
"推荐甜度": "推荐糖度",
|
|
50
|
+
"默认甜度": "默认糖度",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# canonical field → 主数据必要字段名 的逆向映射(供列别名匹配使用)
|
|
55
|
+
CANONICAL_TO_MASTER_REQUIRED = {
|
|
56
|
+
"product_name": "品名",
|
|
57
|
+
"size": "杯型",
|
|
58
|
+
"temperature": "做法",
|
|
59
|
+
"sugar": "糖",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _apply_column_aliases(df: pd.DataFrame) -> pd.DataFrame:
|
|
64
|
+
"""根据 column_aliases 记忆自动重命名 DataFrame 的列。
|
|
65
|
+
|
|
66
|
+
流程:对每一列查询 get_column_alias(col_name):
|
|
67
|
+
- 返回 canonical field 如 "temperature"
|
|
68
|
+
- 再查 CANONICAL_TO_MASTER_REQUIRED["temperature"] → "做法"
|
|
69
|
+
- 若 "做法" 尚不在 df.columns 中 → 将列重命名为 "做法"
|
|
70
|
+
|
|
71
|
+
目标:让「温度」这样的异构列名自动对齐到主数据校验所需的「做法」。
|
|
72
|
+
已在 memory 中标记为 "ignore" 的列不会重命名。
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
重命名后的 DataFrame(原位修改 + 返回)。
|
|
76
|
+
"""
|
|
77
|
+
from menupilot.data.memory import get_column_alias
|
|
78
|
+
|
|
79
|
+
for col in list(df.columns):
|
|
80
|
+
alias = get_column_alias(col)
|
|
81
|
+
if alias is None or alias == "ignore":
|
|
82
|
+
continue
|
|
83
|
+
required_name = CANONICAL_TO_MASTER_REQUIRED.get(alias)
|
|
84
|
+
if required_name and required_name not in df.columns:
|
|
85
|
+
df.rename(columns={col: required_name}, inplace=True)
|
|
86
|
+
|
|
87
|
+
return df
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_excel(filepath: str, sheet_name=0) -> pd.DataFrame:
|
|
91
|
+
"""读取 Excel 文件,返回 DataFrame。
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
filepath: Excel 文件路径。
|
|
95
|
+
sheet_name: 工作表名或索引,默认第一个 sheet。
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
pd.DataFrame
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
FileNotFoundError: 文件不存在。
|
|
102
|
+
ValueError: 工作表名无效。
|
|
103
|
+
"""
|
|
104
|
+
path = Path(filepath)
|
|
105
|
+
if not path.exists():
|
|
106
|
+
raise FileNotFoundError(f"文件不存在: {filepath}")
|
|
107
|
+
|
|
108
|
+
with pd.ExcelFile(filepath) as xl:
|
|
109
|
+
if isinstance(sheet_name, str) and sheet_name not in xl.sheet_names:
|
|
110
|
+
raise ValueError(f"工作表 '{sheet_name}' 不存在,可用 sheet: {xl.sheet_names}")
|
|
111
|
+
|
|
112
|
+
df = pd.read_excel(filepath, sheet_name=sheet_name)
|
|
113
|
+
# 去除首尾空白列名(空 DataFrame 的列名可能为整数类型)
|
|
114
|
+
if len(df.columns) > 0:
|
|
115
|
+
df.columns = [str(c).strip() for c in df.columns]
|
|
116
|
+
return _normalize_df(df)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_master(filepath: str, sheet_name=0, soft_validation: bool = False) -> pd.DataFrame:
|
|
120
|
+
"""读取主数据表,校验必要字段。
|
|
121
|
+
|
|
122
|
+
必要字段: 品名, 杯型, 做法, 糖(缺一报错)
|
|
123
|
+
可通配字段: 奶底(缺失时自动注入空列,触发通配逻辑)
|
|
124
|
+
可选字段: 全信息, SOP
|
|
125
|
+
|
|
126
|
+
soft_validation=True 时:
|
|
127
|
+
- 先应用 column_aliases 自动重命名
|
|
128
|
+
- 缺列不抛异常,而是标记在 df.attrs['_missing_required'] 上
|
|
129
|
+
这是为了允许上层(main.py)在送入管线前做 LLM 推断 + 交互兜底。
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
filepath: 主数据表 Excel 路径。
|
|
133
|
+
sheet_name: 工作表名或索引。
|
|
134
|
+
soft_validation: True 时不抛异常,标记缺失列。
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
pd.DataFrame,列名已 strip。
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ValueError: soft_validation=False 且缺少必要字段。
|
|
141
|
+
"""
|
|
142
|
+
df = read_excel(filepath, sheet_name=sheet_name)
|
|
143
|
+
|
|
144
|
+
# ── Step 1: 应用 column_aliases 记忆自动重命名 ──
|
|
145
|
+
_apply_column_aliases(df)
|
|
146
|
+
|
|
147
|
+
# ── Step 2: 检测必要字段 ──
|
|
148
|
+
missing_required = [c for c in MASTER_REQUIRED_COLUMNS if c not in df.columns]
|
|
149
|
+
|
|
150
|
+
if missing_required:
|
|
151
|
+
if soft_validation:
|
|
152
|
+
df.attrs["_missing_required"] = missing_required
|
|
153
|
+
else:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"主数据表缺少必要字段: {missing_required}\n"
|
|
156
|
+
f"当前列名: {list(df.columns)}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# ── 检测可通配字段:缺失时注入空列 ──
|
|
160
|
+
for col in MASTER_WILDCARD_COLUMNS:
|
|
161
|
+
if col not in df.columns:
|
|
162
|
+
print(f"[INFO] 主数据表未检测到「{col}」列,该维度将作为通配符处理")
|
|
163
|
+
df[col] = None
|
|
164
|
+
|
|
165
|
+
return df
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def read_template(filepath: str, sheet_name=0) -> pd.DataFrame:
|
|
169
|
+
"""读取模板表。
|
|
170
|
+
|
|
171
|
+
模板表字段名因来源而异,不做固定校验,由 Schema Analyzer 负责识别。
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
filepath: 模板表 Excel 路径。
|
|
175
|
+
sheet_name: 工作表名或索引。
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
pd.DataFrame,列名已 strip。
|
|
179
|
+
"""
|
|
180
|
+
df = read_excel(filepath, sheet_name=sheet_name)
|
|
181
|
+
|
|
182
|
+
if df.empty:
|
|
183
|
+
raise ValueError(f"模板表为空: {filepath}")
|
|
184
|
+
|
|
185
|
+
return df
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def read_template_raw(filepath: str, sheet_name=0) -> "pd.DataFrame":
|
|
189
|
+
"""以 header=None 读取模板表,保留原始行数据。
|
|
190
|
+
|
|
191
|
+
用于模板类型检测(chowbus vs standard)和散列字段收集。
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
filepath: 模板表 Excel 路径。
|
|
195
|
+
sheet_name: 工作表名或索引。
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
pd.DataFrame,header=None,所有行列保留原始值。
|
|
199
|
+
"""
|
|
200
|
+
import pandas as pd
|
|
201
|
+
from pathlib import Path
|
|
202
|
+
|
|
203
|
+
path = Path(filepath)
|
|
204
|
+
if not path.exists():
|
|
205
|
+
raise FileNotFoundError(f"文件不存在: {filepath}")
|
|
206
|
+
|
|
207
|
+
df = pd.read_excel(filepath, sheet_name=sheet_name, header=None)
|
|
208
|
+
return df
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def read_option_master(filepath: str, sheet_name=0) -> pd.DataFrame:
|
|
212
|
+
"""读取选项规格主数据表,校验必要字段。
|
|
213
|
+
|
|
214
|
+
选项规格主数据格式(固定列):
|
|
215
|
+
固定列: 主编码, 商品名称
|
|
216
|
+
维度列(每维度 3 列): 推荐{dim}, 默认{dim}, {dim}
|
|
217
|
+
其中 dim ∈ {糖度, 温度, 规格, 奶底, 茶底}
|
|
218
|
+
|
|
219
|
+
必要字段(缺一报错): 主编码, 商品名称
|
|
220
|
+
可选字段(缺失时自动注入空列): 推荐{dim}, 默认{dim}, {dim}
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
filepath: 选项规格主数据表 Excel 路径。
|
|
224
|
+
sheet_name: 工作表名或索引。
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
pd.DataFrame,所有预期列均已就位。
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
ValueError: 缺少「主编码」或「商品名称」列。
|
|
231
|
+
"""
|
|
232
|
+
df = read_excel(filepath, sheet_name=sheet_name)
|
|
233
|
+
|
|
234
|
+
# ── Step 0: 应用列名别名(自动重命名异名列)──
|
|
235
|
+
for col in list(df.columns):
|
|
236
|
+
if col in OPTION_MASTER_COLUMN_ALIASES:
|
|
237
|
+
target = OPTION_MASTER_COLUMN_ALIASES[col]
|
|
238
|
+
if target not in df.columns:
|
|
239
|
+
df.rename(columns={col: target}, inplace=True)
|
|
240
|
+
print(f"[INFO] 列名别名: 「{col}」→「{target}」")
|
|
241
|
+
|
|
242
|
+
# 验证固定必要列
|
|
243
|
+
missing_fixed = [c for c in OPTION_MASTER_FIXED_COLUMNS if c not in df.columns]
|
|
244
|
+
if missing_fixed:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"选项规格主数据表缺少必要列: {missing_fixed}\n"
|
|
247
|
+
f"当前列名: {list(df.columns)}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# 软校验:维度列表列缺失时警告,推荐/默认列缺失时自动注入空列
|
|
251
|
+
for dim in OPTION_MASTER_DIMENSIONS:
|
|
252
|
+
if dim not in df.columns:
|
|
253
|
+
print(f"[WARNING] 主数据表缺少「{dim}」列,该维度将被跳过")
|
|
254
|
+
for prefix in ["推荐", "默认"]:
|
|
255
|
+
col = f"{prefix}{dim}"
|
|
256
|
+
if col not in df.columns:
|
|
257
|
+
df[col] = None
|
|
258
|
+
|
|
259
|
+
return df
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── 自测 ──────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
import tempfile
|
|
266
|
+
import os, shutil as _shutil
|
|
267
|
+
|
|
268
|
+
# ── 备份真实 memory.json ──
|
|
269
|
+
_mem_path = os.path.expanduser("~/.menupilot/memory.json")
|
|
270
|
+
_mem_backup = None
|
|
271
|
+
if os.path.exists(_mem_path):
|
|
272
|
+
_mem_backup_path = _mem_path + ".self_test_backup"
|
|
273
|
+
_shutil.copy(_mem_path, _mem_backup_path)
|
|
274
|
+
_mem_backup = _mem_backup_path
|
|
275
|
+
|
|
276
|
+
passed = 0
|
|
277
|
+
failed = 0
|
|
278
|
+
|
|
279
|
+
def check(condition, msg):
|
|
280
|
+
global passed, failed
|
|
281
|
+
if condition:
|
|
282
|
+
passed += 1
|
|
283
|
+
print(f" PASS {msg}")
|
|
284
|
+
else:
|
|
285
|
+
failed += 1
|
|
286
|
+
print(f" FAIL {msg}")
|
|
287
|
+
|
|
288
|
+
print("=== Excel Reader 自测 ===\n")
|
|
289
|
+
|
|
290
|
+
tmpdir = tempfile.mkdtemp()
|
|
291
|
+
|
|
292
|
+
# ── 准备测试文件 ──
|
|
293
|
+
master_path = os.path.join(tmpdir, "master_test.xlsx")
|
|
294
|
+
template_path = os.path.join(tmpdir, "template_test.xlsx")
|
|
295
|
+
empty_path = os.path.join(tmpdir, "empty.xlsx")
|
|
296
|
+
multi_sheet_path = os.path.join(tmpdir, "multi_sheet.xlsx")
|
|
297
|
+
opt_master_path = os.path.join(tmpdir, "opt_master.xlsx")
|
|
298
|
+
opt_no_code_path = os.path.join(tmpdir, "opt_no_code.xlsx")
|
|
299
|
+
opt_minimal_path = os.path.join(tmpdir, "opt_minimal.xlsx")
|
|
300
|
+
opt_no_dim_path = os.path.join(tmpdir, "opt_no_dim.xlsx")
|
|
301
|
+
|
|
302
|
+
pd.DataFrame({
|
|
303
|
+
"品名": ["浅浅清茶", "浅浅清茶"],
|
|
304
|
+
"杯型": ["中杯", "中杯"],
|
|
305
|
+
"奶底": ["牛奶", "牛奶"],
|
|
306
|
+
"做法": ["少冰", "去冰"],
|
|
307
|
+
"糖": ["七分糖", "标准糖"],
|
|
308
|
+
"全信息": ["", ""],
|
|
309
|
+
"SOP": ["T240", "T265"],
|
|
310
|
+
}).to_excel(master_path, index=False)
|
|
311
|
+
|
|
312
|
+
pd.DataFrame({
|
|
313
|
+
"菜品名称": ["五黄高纤慢养瓶", "五黄高纤慢养瓶"],
|
|
314
|
+
"规格": ["五角瓶", "五角瓶"],
|
|
315
|
+
"口味做法组合": ["红茶, 十二分糖, 温热", "红茶, 十二分糖, 正常冰"],
|
|
316
|
+
"配料": ["", ""],
|
|
317
|
+
}).to_excel(template_path, index=False)
|
|
318
|
+
|
|
319
|
+
pd.DataFrame().to_excel(empty_path, index=False)
|
|
320
|
+
|
|
321
|
+
# 多 sheet 文件
|
|
322
|
+
with pd.ExcelWriter(multi_sheet_path) as w:
|
|
323
|
+
pd.DataFrame({"A": [1]}).to_excel(w, sheet_name="Sheet1", index=False)
|
|
324
|
+
pd.DataFrame({"B": [2]}).to_excel(w, sheet_name="Data", index=False)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
# ── 1. read_excel 基础读取 ──
|
|
328
|
+
print("1. read_excel 基础读取")
|
|
329
|
+
df = read_excel(master_path)
|
|
330
|
+
check(len(df) == 2, f"读取 2 行(实际 {len(df)})")
|
|
331
|
+
check("品名" in df.columns, "包含 '品名' 列")
|
|
332
|
+
print()
|
|
333
|
+
|
|
334
|
+
# ── 2. read_master 校验 ──
|
|
335
|
+
print("2. read_master 必要字段校验")
|
|
336
|
+
m = read_master(master_path)
|
|
337
|
+
check("品名" in m.columns and "杯型" in m.columns, "必要字段完整读取")
|
|
338
|
+
check(m.iloc[0]["品名"] == "浅浅清茶", "第一行品名正确")
|
|
339
|
+
|
|
340
|
+
# 缺字段应抛异常
|
|
341
|
+
bad_path = os.path.join(tmpdir, "bad_master.xlsx")
|
|
342
|
+
pd.DataFrame({"品名": ["a"]}).to_excel(bad_path, index=False)
|
|
343
|
+
try:
|
|
344
|
+
read_master(bad_path)
|
|
345
|
+
check(False, "缺少字段应抛 ValueError")
|
|
346
|
+
except ValueError as e:
|
|
347
|
+
check("缺少必要字段" in str(e), f"ValueError 正确抛出: {e}")
|
|
348
|
+
print()
|
|
349
|
+
|
|
350
|
+
# ── 3. read_template ──
|
|
351
|
+
print("3. read_template")
|
|
352
|
+
t = read_template(template_path)
|
|
353
|
+
check(len(t) == 2, f"模板读取 2 行(实际 {len(t)})")
|
|
354
|
+
check("配料" in t.columns, "包含 '配料' 列")
|
|
355
|
+
|
|
356
|
+
# 空模板应抛异常
|
|
357
|
+
try:
|
|
358
|
+
read_template(empty_path)
|
|
359
|
+
check(False, "空模板应抛 ValueError")
|
|
360
|
+
except ValueError:
|
|
361
|
+
check(True, "空模板正确抛出 ValueError")
|
|
362
|
+
print()
|
|
363
|
+
|
|
364
|
+
# ── 4. 文件不存在 ──
|
|
365
|
+
print("4. 文件不存在")
|
|
366
|
+
try:
|
|
367
|
+
read_excel("不存在的文件.xlsx")
|
|
368
|
+
check(False, "文件不存在应抛异常")
|
|
369
|
+
except FileNotFoundError:
|
|
370
|
+
check(True, "FileNotFoundError 正确抛出")
|
|
371
|
+
print()
|
|
372
|
+
|
|
373
|
+
# ── 5. 指定 sheet 名 ──
|
|
374
|
+
print("5. 指定 sheet 名")
|
|
375
|
+
df_sheet = read_excel(multi_sheet_path, sheet_name="Data")
|
|
376
|
+
check("B" in df_sheet.columns, "读取 'Data' sheet 成功")
|
|
377
|
+
|
|
378
|
+
# 无效 sheet 名
|
|
379
|
+
try:
|
|
380
|
+
read_excel(multi_sheet_path, sheet_name="NoSuch")
|
|
381
|
+
check(False, "无效 sheet 名应抛异常")
|
|
382
|
+
except ValueError:
|
|
383
|
+
check(True, "无效 sheet 名正确抛出 ValueError")
|
|
384
|
+
print()
|
|
385
|
+
|
|
386
|
+
# ── 6. 列名 strip ──
|
|
387
|
+
print("6. 列名空白 strip")
|
|
388
|
+
strip_path = os.path.join(tmpdir, "strip.xlsx")
|
|
389
|
+
pd.DataFrame({" 品名 ": ["a"], " 杯型 ": ["b"]}).to_excel(strip_path, index=False)
|
|
390
|
+
df_strip = read_excel(strip_path)
|
|
391
|
+
check("品名" in df_strip.columns and " 品名 " not in df_strip.columns, "列名空白已去除")
|
|
392
|
+
print()
|
|
393
|
+
|
|
394
|
+
# ── 7. 缺可通配字段(奶底)→ 自动注入空列 ──
|
|
395
|
+
print("7. 缺可通配字段(奶底)→ 自动注入空列")
|
|
396
|
+
no_milk_path = os.path.join(tmpdir, "no_milk_master.xlsx")
|
|
397
|
+
pd.DataFrame({
|
|
398
|
+
"品名": ["测试商品"],
|
|
399
|
+
"杯型": ["中杯"],
|
|
400
|
+
"做法": ["少冰"],
|
|
401
|
+
"糖": ["七分糖"],
|
|
402
|
+
}).to_excel(no_milk_path, index=False)
|
|
403
|
+
df_no_milk = read_master(no_milk_path)
|
|
404
|
+
check("奶底" in df_no_milk.columns, "奶底 列被自动注入")
|
|
405
|
+
check(df_no_milk["奶底"].isna().all(), "注入的奶底列全部为 None")
|
|
406
|
+
check(df_no_milk.iloc[0]["品名"] == "测试商品", "其他列正常")
|
|
407
|
+
print()
|
|
408
|
+
|
|
409
|
+
# ── 9. column_aliases 自动重命名 ──
|
|
410
|
+
print("9. column_aliases 自动重命名(温度 → 做法)")
|
|
411
|
+
from menupilot.data.memory import reset_memory, add_column_alias
|
|
412
|
+
reset_memory()
|
|
413
|
+
alias_path = os.path.join(tmpdir, "alias_master.xlsx")
|
|
414
|
+
pd.DataFrame({
|
|
415
|
+
"品名": ["测试商品"],
|
|
416
|
+
"杯型": ["中杯"],
|
|
417
|
+
"温度": ["少冰"], # 异构列名:用户写了「温度」而非「做法」
|
|
418
|
+
"糖": ["七分糖"],
|
|
419
|
+
}).to_excel(alias_path, index=False)
|
|
420
|
+
# 预热记忆:告诉系统「温度」→ canonical temperature → 映射到主数据「做法」
|
|
421
|
+
add_column_alias("温度", "temperature")
|
|
422
|
+
df_alias = read_master(alias_path)
|
|
423
|
+
check("做法" in df_alias.columns, "「温度」被自动重命名为「做法」")
|
|
424
|
+
check("温度" not in df_alias.columns, "原列名「温度」不再存在")
|
|
425
|
+
check(df_alias.iloc[0]["做法"] == "少冰", "重命名后数据保留正确")
|
|
426
|
+
reset_memory()
|
|
427
|
+
print()
|
|
428
|
+
|
|
429
|
+
# ── 10. soft_validation 模式 → 不抛异常,标记缺失列 ──
|
|
430
|
+
print("10. soft_validation 模式 → 标记缺失列,不抛异常")
|
|
431
|
+
reset_memory()
|
|
432
|
+
soft_path = os.path.join(tmpdir, "soft_master.xlsx")
|
|
433
|
+
pd.DataFrame({
|
|
434
|
+
"品名": ["测试"],
|
|
435
|
+
"杯型": ["中杯"],
|
|
436
|
+
"Unnamed: 2": [""],
|
|
437
|
+
"温度": ["少冰"],
|
|
438
|
+
"糖": ["七分糖"],
|
|
439
|
+
"代码": ["T240"],
|
|
440
|
+
}).to_excel(soft_path, index=False)
|
|
441
|
+
df_soft = read_master(soft_path, soft_validation=True)
|
|
442
|
+
check("做法" not in df_soft.columns, "「做法」列确实不存在")
|
|
443
|
+
check(df_soft.attrs.get("_missing_required") == ["做法"],
|
|
444
|
+
f"标记缺失字段: {df_soft.attrs.get('_missing_required')}")
|
|
445
|
+
# 硬校验模式仍抛异常
|
|
446
|
+
try:
|
|
447
|
+
read_master(soft_path, soft_validation=False)
|
|
448
|
+
check(False, "硬校验模式下缺列应抛 ValueError")
|
|
449
|
+
except ValueError as e:
|
|
450
|
+
check("做法" in str(e), f"硬校验仍报错: {e}")
|
|
451
|
+
reset_memory()
|
|
452
|
+
print()
|
|
453
|
+
|
|
454
|
+
# ── 11. 缺必要字段(做法)→ 仍抛异常 ──
|
|
455
|
+
print("11. 缺必要字段(做法)→ 仍抛异常(硬校验)")
|
|
456
|
+
bad_required_path = os.path.join(tmpdir, "bad_required.xlsx")
|
|
457
|
+
pd.DataFrame({
|
|
458
|
+
"品名": ["测试"],
|
|
459
|
+
"杯型": ["中杯"],
|
|
460
|
+
"奶底": ["牛奶"],
|
|
461
|
+
"糖": ["七分糖"],
|
|
462
|
+
}).to_excel(bad_required_path, index=False)
|
|
463
|
+
try:
|
|
464
|
+
read_master(bad_required_path)
|
|
465
|
+
check(False, "缺做法应抛 ValueError")
|
|
466
|
+
except ValueError as e:
|
|
467
|
+
check("做法" in str(e), f"报错信息包含「做法」(实际: {e})")
|
|
468
|
+
print()
|
|
469
|
+
|
|
470
|
+
# ── 12. read_option_master 正常读取 ──
|
|
471
|
+
print("12. read_option_master 正常读取")
|
|
472
|
+
pd.DataFrame({
|
|
473
|
+
"主编码": ["A001", "A002"],
|
|
474
|
+
"商品名称": ["茉莉绿茶", "珍珠奶茶"],
|
|
475
|
+
"推荐糖度": ["七分糖", "全糖"],
|
|
476
|
+
"默认糖度": ["五分糖", "标准糖"],
|
|
477
|
+
"糖度": ["七分糖;五分糖;三分糖", "全糖;标准糖"],
|
|
478
|
+
"推荐温度": ["正常冰", "热"],
|
|
479
|
+
"默认温度": ["少冰", "热"],
|
|
480
|
+
"温度": ["正常冰;少冰;去冰", "热;正常冰"],
|
|
481
|
+
"推荐规格": ["中杯", "大杯"],
|
|
482
|
+
"默认规格": ["中杯", "大杯"],
|
|
483
|
+
"规格": ["中杯;大杯", "大杯;中杯"],
|
|
484
|
+
"推荐奶底": ["牛奶", ""],
|
|
485
|
+
"默认奶底": ["燕麦奶", ""],
|
|
486
|
+
"奶底": ["牛奶;燕麦奶", ""],
|
|
487
|
+
"推荐茶底": ["绿茶", ""],
|
|
488
|
+
"默认茶底": ["绿茶", ""],
|
|
489
|
+
"茶底": ["绿茶;乌龙茶", ""],
|
|
490
|
+
}).to_excel(opt_master_path, index=False)
|
|
491
|
+
om = read_option_master(opt_master_path)
|
|
492
|
+
check(len(om) == 2, f"读取 2 行(实际 {len(om)})")
|
|
493
|
+
check("主编码" in om.columns, "包含「主编码」列")
|
|
494
|
+
check(om.iloc[0]["主编码"] == "A001", "第一行主编码 = A001")
|
|
495
|
+
check(om.iloc[0]["糖度"] == "七分糖;五分糖;三分糖", "糖度列正确")
|
|
496
|
+
print()
|
|
497
|
+
|
|
498
|
+
# ── 13. read_option_master 缺少主编码 → ValueError ──
|
|
499
|
+
print("13. read_option_master 缺少主编码 → ValueError")
|
|
500
|
+
pd.DataFrame({"商品名称": ["测试"]}).to_excel(opt_no_code_path, index=False)
|
|
501
|
+
try:
|
|
502
|
+
read_option_master(opt_no_code_path)
|
|
503
|
+
check(False, "缺少主编码应抛 ValueError")
|
|
504
|
+
except ValueError as e:
|
|
505
|
+
check("主编码" in str(e), f"报错含「主编码」(实际: {e})")
|
|
506
|
+
print()
|
|
507
|
+
|
|
508
|
+
# ── 14. read_option_master 缺少维度列 → 注入空列 ──
|
|
509
|
+
print("14. read_option_master 缺少维度列 → 自动注入空列")
|
|
510
|
+
pd.DataFrame({
|
|
511
|
+
"主编码": ["A001"],
|
|
512
|
+
"商品名称": ["测试"],
|
|
513
|
+
"糖度": ["七分糖"],
|
|
514
|
+
}).to_excel(opt_minimal_path, index=False)
|
|
515
|
+
om_min = read_option_master(opt_minimal_path)
|
|
516
|
+
check("推荐糖度" in om_min.columns, "「推荐糖度」被自动注入")
|
|
517
|
+
check("默认温度" in om_min.columns, "「默认温度」被自动注入")
|
|
518
|
+
check("规格" not in om_min.columns, "「规格」列不存在(维度列表列不注入,仅警告)")
|
|
519
|
+
check(om_min["推荐糖度"].isna().all(), "注入的推荐糖度列为 None")
|
|
520
|
+
print()
|
|
521
|
+
|
|
522
|
+
# ── 15. read_option_master 缺少维度列表列 → 警告 ──
|
|
523
|
+
print("15. read_option_master 缺少维度列表列 → 警告(不报错)")
|
|
524
|
+
pd.DataFrame({
|
|
525
|
+
"主编码": ["A001"],
|
|
526
|
+
"商品名称": ["测试"],
|
|
527
|
+
}).to_excel(opt_no_dim_path, index=False)
|
|
528
|
+
om_no_dim = read_option_master(opt_no_dim_path)
|
|
529
|
+
check(len(om_no_dim) == 1, "仍然正常读取 1 行")
|
|
530
|
+
check("主编码" in om_no_dim.columns, "主编码列正常")
|
|
531
|
+
print()
|
|
532
|
+
|
|
533
|
+
finally:
|
|
534
|
+
for f in [master_path, template_path, empty_path, multi_sheet_path,
|
|
535
|
+
os.path.join(tmpdir, "bad_master.xlsx"),
|
|
536
|
+
os.path.join(tmpdir, "strip.xlsx"),
|
|
537
|
+
os.path.join(tmpdir, "no_milk_master.xlsx"),
|
|
538
|
+
os.path.join(tmpdir, "bad_required.xlsx"),
|
|
539
|
+
os.path.join(tmpdir, "alias_master.xlsx"),
|
|
540
|
+
os.path.join(tmpdir, "soft_master.xlsx"),
|
|
541
|
+
opt_master_path, opt_no_code_path, opt_minimal_path, opt_no_dim_path]:
|
|
542
|
+
if os.path.exists(f):
|
|
543
|
+
os.remove(f)
|
|
544
|
+
os.rmdir(tmpdir)
|
|
545
|
+
|
|
546
|
+
# ── 还原真实 memory.json ──
|
|
547
|
+
if _mem_backup:
|
|
548
|
+
from menupilot.data.memory import reload as _mem_reload
|
|
549
|
+
_shutil.move(_mem_backup, _mem_path)
|
|
550
|
+
_mem_reload()
|
|
551
|
+
|
|
552
|
+
print(f"=== 结果: {passed} passed, {failed} failed ===")
|