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.
@@ -0,0 +1,570 @@
1
+ """
2
+ 业务编排层 — SOP 匹配管线和选项展开管线的完整执行流程。
3
+
4
+ 从 main.py 提取,负责:
5
+ - 管线前检查(模板类型检测、Schema 预分析、主数据列推断)
6
+ - 交互式列分类(未识别列的 TTY 交互确认)
7
+ - 调用 workflow / expander 执行管线
8
+ - 管线后报告生成
9
+
10
+ 不负责 CLI 参数解析(留给 main.py)。
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import time
16
+ from typing import Optional
17
+
18
+ # Windows 终端 UTF-8(orchestration 作为独立模块也需要)
19
+ if sys.platform == "win32":
20
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
21
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
22
+
23
+
24
+ class CLIError(Exception):
25
+ """CLI 可恢复错误,run() 捕获后返回 exit_code=1。"""
26
+
27
+
28
+ # ═══════════════════════════════════════════════════════════════════
29
+ # 列分类交互
30
+ # ═══════════════════════════════════════════════════════════════════
31
+
32
+ FIELD_OPTIONS = [
33
+ ("product_name", "商品名"),
34
+ ("size", "规格"),
35
+ ("milk_base", "奶底"),
36
+ ("temperature", "温度/做法"),
37
+ ("sugar", "糖度"),
38
+ ("tea_base", "茶底"),
39
+ ("composite_col","口味做法组合"),
40
+ ("sop", "配料/SOP代码"),
41
+ ("ignore", "忽略此列"),
42
+ ]
43
+
44
+ # 交互 hook(用于测试时注入自定义回调)
45
+ _column_prompt_hook = None
46
+
47
+ # 批量模式标志:CLI 带参数调用时为 True(不进入交互),REPL 模式为 False
48
+ _batch_mode = False
49
+
50
+
51
+ def set_column_prompt_hook(hook) -> None:
52
+ """注入自定义列分类回调(用于自动化测试)。设为 None 恢复默认交互式行为。"""
53
+ global _column_prompt_hook
54
+ _column_prompt_hook = hook
55
+
56
+
57
+ def set_batch_mode(enabled: bool) -> None:
58
+ """设置批量模式标志。True 时所有交互提示自动跳过(CLI 模式)。"""
59
+ global _batch_mode
60
+ _batch_mode = enabled
61
+
62
+
63
+ def _interactive_classify_columns(
64
+ unrecognized_cols: list,
65
+ template_df: "pd.DataFrame",
66
+ schema_result: dict,
67
+ ) -> None:
68
+ """引导用户手动分类未识别列(交互模式)或自动跳过(CLI 模式)。
69
+
70
+ 交互模式(TTY):展示列名 + 样例值,用户选择 canonical 字段映射。
71
+ CLI 模式(非 TTY):打印警告,全部标记为 ignore,不阻塞执行。
72
+ 选择结果写入 column_aliases 长期记忆和当前 schema_result。
73
+ """
74
+ # ── 批量模式(CLI 带参数)── 自动跳过,不阻塞 ──
75
+ if _batch_mode:
76
+ col_list = "、".join(str(c) for c in unrecognized_cols)
77
+ print(
78
+ f"[WARNING] 批量模式,以下 {len(unrecognized_cols)} 个列无法自动识别,"
79
+ f"将跳过: {col_list}"
80
+ )
81
+ print(" 如需手动指定列映射,请使用 REPL 模式: menupilot(无参数启动)")
82
+ for col in unrecognized_cols:
83
+ schema_result["irrelevant_cols"].append(col)
84
+ schema_result["unrecognized_cols"] = []
85
+ return
86
+
87
+ # ── 交互模式(REPL / TTY)──
88
+ import pandas as pd
89
+ from menupilot.data.memory import add_column_alias
90
+
91
+ _skipped_count = 0
92
+
93
+ for col in unrecognized_cols:
94
+ sample_vals = (
95
+ template_df[col].dropna().astype(str).unique()[:3]
96
+ if col in template_df.columns else []
97
+ )
98
+ sample_str = ", ".join(sample_vals) if len(sample_vals) > 0 else "(空)"
99
+
100
+ if _column_prompt_hook is not None:
101
+ field_name = _column_prompt_hook(col, sample_str)
102
+ else:
103
+ print(f"\n[Schema] 发现未能识别的列:「{col}」")
104
+ print(f" 样例值:{sample_str}")
105
+ for i, (field, label) in enumerate(FIELD_OPTIONS, 1):
106
+ print(f" [{i}] {field}({label})")
107
+
108
+ while True:
109
+ try:
110
+ choice = int(input(" 请选择: ").strip()) - 1
111
+ if 0 <= choice < len(FIELD_OPTIONS):
112
+ field_name, _ = FIELD_OPTIONS[choice]
113
+ break
114
+ print(f" [错误] 请输入 1-{len(FIELD_OPTIONS)} 之间的数字")
115
+ except EOFError:
116
+ print(f" [WARNING] 输入流已关闭,剩余列将全部标记为 ignore")
117
+ field_name = "ignore"
118
+ _skipped_count = len(unrecognized_cols) - unrecognized_cols.index(col)
119
+ break
120
+ except ValueError:
121
+ print(f" [错误] 请输入有效数字")
122
+
123
+ add_column_alias(col, field_name)
124
+
125
+ if field_name == "ignore":
126
+ schema_result["irrelevant_cols"].append(col)
127
+ elif field_name == "composite_col":
128
+ if schema_result.get("composite_col") is None:
129
+ schema_result["composite_col"] = col
130
+ else:
131
+ schema_result["field_mapping"][col] = field_name
132
+ elif field_name == "sop":
133
+ if schema_result.get("target_col") is None:
134
+ schema_result["target_col"] = col
135
+ else:
136
+ schema_result["field_mapping"][col] = field_name
137
+ else:
138
+ schema_result["field_mapping"][col] = field_name
139
+
140
+ if _skipped_count > 0:
141
+ for remaining_col in unrecognized_cols[-_skipped_count + 1:]:
142
+ schema_result["irrelevant_cols"].append(remaining_col)
143
+ break
144
+
145
+ schema_result["unrecognized_cols"] = []
146
+
147
+
148
+ def _validate_file(path: str, label: str) -> None:
149
+ """验证输入文件存在,失败时抛 CLIError。"""
150
+ if not os.path.exists(path):
151
+ raise CLIError(f"{label} 文件不存在: {path}")
152
+ if not os.path.isfile(path):
153
+ raise CLIError(f"{label} 不是有效文件: {path}")
154
+
155
+
156
+ # ═══════════════════════════════════════════════════════════════════
157
+ # SOP 匹配管线
158
+ # ═══════════════════════════════════════════════════════════════════
159
+
160
+ def _resolve_sheet_args(argv: list) -> tuple:
161
+ """预扫描 argv,根据 --sheet 在命令行中的位置决定其归属。
162
+
163
+ 规则:
164
+ - -t/--template 之后出现的 --sheet N → 模板表 Sheet
165
+ - -m/--master 之后出现的 --sheet N → 主数据表 Sheet
166
+ - 未跟在任何文件参数之后的 --sheet N → 默认属于模板
167
+ """
168
+ master_sheet = 0
169
+ template_sheet = 0
170
+ last_file_arg = None
171
+
172
+ filtered = []
173
+ i = 0
174
+ while i < len(argv):
175
+ arg = argv[i]
176
+ if arg in ("-m", "--master"):
177
+ last_file_arg = "master"
178
+ filtered.append(arg)
179
+ elif arg in ("-t", "--template"):
180
+ last_file_arg = "template"
181
+ filtered.append(arg)
182
+ elif arg == "--sheet":
183
+ if i + 1 < len(argv):
184
+ try:
185
+ sheet_val = int(argv[i + 1])
186
+ except (ValueError, TypeError):
187
+ sheet_val = 0
188
+ if last_file_arg == "master":
189
+ master_sheet = sheet_val
190
+ else:
191
+ template_sheet = sheet_val
192
+ i += 1
193
+ else:
194
+ filtered.append(arg)
195
+ i += 1
196
+
197
+ return filtered, master_sheet, template_sheet
198
+
199
+
200
+ def run_sop_pipeline(args: Optional[list] = None) -> int:
201
+ """执行 SOP 匹配管线(原 main.run())。
202
+
203
+ Args:
204
+ args: 命令行参数列表,None 时使用 sys.argv[1:]。
205
+
206
+ Returns:
207
+ exit code: 0=成功, 1=失败
208
+ """
209
+ import argparse as _argparse
210
+ from menupilot.agent.workflow import run_pipeline
211
+
212
+ # 构建 parser(与 main.py 的 build_parser 一致)
213
+ parser = _argparse.ArgumentParser(
214
+ prog="menupilot",
215
+ description="POS Template Mapping Agent — 自动将主数据表 SOP 映射到 POS 模板",
216
+ )
217
+ parser.add_argument("-m", "--master", required=True, help="主数据表 Excel 文件路径")
218
+ parser.add_argument("-t", "--template", required=True, help="POS 模板 Excel 文件路径")
219
+ parser.add_argument("-o", "--output", required=True, help="输出 Excel 文件路径")
220
+ parser.add_argument("--target-col", default="配料", help="模板中需要填充 SOP 的目标列名")
221
+ parser.add_argument("-r", "--report", default=None, help="校验报告输出路径")
222
+ parser.add_argument("--langgraph", action="store_true", default=True, help="使用 LangGraph")
223
+ parser.add_argument("--no-langgraph", action="store_false", dest="langgraph")
224
+
225
+ if args is None:
226
+ args = sys.argv[1:]
227
+ filtered_args, master_sheet, template_sheet = _resolve_sheet_args(list(args))
228
+ opts = parser.parse_args(filtered_args)
229
+
230
+ try:
231
+ _validate_file(opts.master, "主数据表")
232
+ _validate_file(opts.template, "模板表")
233
+ except CLIError as e:
234
+ print(f"[ERROR] {e}")
235
+ return 1
236
+
237
+ report_path = opts.report or opts.output.replace(".xlsx", "_report.txt")
238
+
239
+ print("=" * 56)
240
+ print(" POS Template Mapping Agent")
241
+ print("=" * 56)
242
+ print(f" 主数据表: {opts.master} (Sheet {master_sheet})")
243
+ print(f" 模板表: {opts.template} (Sheet {template_sheet})")
244
+ print(f" 目标列: {opts.target_col}")
245
+ print(f" 输出: {opts.output}")
246
+ print(f" 报告: {report_path}")
247
+ print("-" * 56)
248
+
249
+ from menupilot import config as _cfg
250
+ print(f" LLM 模式: {'MOCK' if _cfg.USE_MOCK_LLM else 'REAL'} (模型: {_cfg.DEEPSEEK_MODEL})")
251
+
252
+ # ── 模板类型检测 ──
253
+ from menupilot.excel_io.excel_reader import read_template_raw, read_template
254
+ from menupilot.agent.template_preprocessor import detect_template_type
255
+
256
+ raw_df = read_template_raw(opts.template, sheet_name=template_sheet)
257
+ _template_type = detect_template_type(raw_df)
258
+
259
+ if _template_type == "chowbus":
260
+ print(f"[Template] 检测到 chowbus 模板类型,跳过 Schema 分析")
261
+ opts.target_col = "sop_code"
262
+ else:
263
+ # ── Schema 预分析 + 交互兜底 ──
264
+ from menupilot.agent.schema_analyzer import (
265
+ _template_fingerprint,
266
+ analyze_from_dataframe,
267
+ )
268
+ from menupilot.data.memory import (
269
+ get_template_rule as mem_get_template_rule,
270
+ save_template_rule as mem_save_template_rule,
271
+ )
272
+
273
+ preload_df = read_template(opts.template, sheet_name=template_sheet)
274
+ fingerprint = _template_fingerprint(list(preload_df.columns))
275
+
276
+ cached_schema = mem_get_template_rule(fingerprint)
277
+ if cached_schema is not None:
278
+ print(f"[Schema] 模板指纹缓存命中 {fingerprint[:12]}...(跳过 Schema 分析)")
279
+ else:
280
+ schema_result = analyze_from_dataframe(preload_df)
281
+ unrecognized = schema_result.get("unrecognized_cols", [])
282
+ if unrecognized:
283
+ print(f"[Schema] LLM 未能识别 {len(unrecognized)} 个列: {unrecognized}")
284
+ _interactive_classify_columns(unrecognized, preload_df, schema_result)
285
+ mem_save_template_rule(fingerprint, schema_result)
286
+ print(f"[Schema] 完整结果已写入模板指纹缓存 {fingerprint[:12]}...")
287
+
288
+ # ── 主数据预加载 + 列推断 ──
289
+ from menupilot.excel_io.excel_reader import (
290
+ read_master, MASTER_REQUIRED_COLUMNS,
291
+ MASTER_WILDCARD_COLUMNS, MASTER_OPTIONAL_COLUMNS,
292
+ )
293
+ from menupilot.data.memory import add_column_alias as mem_add_col_alias
294
+ from menupilot.agent.schema_analyzer import infer_master_columns
295
+
296
+ master_df = read_master(opts.master, sheet_name=master_sheet, soft_validation=True)
297
+ missing_master = master_df.attrs.get("_missing_required", [])
298
+
299
+ if missing_master:
300
+ already_covered = (
301
+ set(MASTER_REQUIRED_COLUMNS) | set(MASTER_WILDCARD_COLUMNS) | set(MASTER_OPTIONAL_COLUMNS)
302
+ )
303
+ already_covered.difference_update(missing_master)
304
+ candidate_cols = [c for c in master_df.columns if c not in already_covered]
305
+
306
+ if candidate_cols:
307
+ import pandas as _pd
308
+ sample_data = {}
309
+ for col in candidate_cols:
310
+ vals = (
311
+ master_df[col].dropna().astype(str).unique()[:5].tolist()
312
+ if col in master_df.columns else []
313
+ )
314
+ sample_data[col] = vals
315
+
316
+ _MASTER_CN_TO_CANONICAL = {
317
+ "品名": "product_name", "杯型": "size",
318
+ "做法": "temperature", "糖": "sugar",
319
+ }
320
+ canonical_missing = [
321
+ _MASTER_CN_TO_CANONICAL.get(f, f) for f in missing_master
322
+ ]
323
+ inference = infer_master_columns(candidate_cols, sample_data, canonical_missing)
324
+
325
+ high_conf = {}
326
+ low_conf = {}
327
+ for col, info in inference.items():
328
+ if info.get("confidence") == "high" and info.get("field"):
329
+ high_conf[col] = info
330
+ else:
331
+ low_conf[col] = info
332
+
333
+ if high_conf:
334
+ print(f"[Master] LLM 高置信度识别 {len(high_conf)} 列:")
335
+ for col, info in high_conf.items():
336
+ print(f" 「{col}」→ {info['field']}({info['reason']})")
337
+ mem_add_col_alias(col, info["field"])
338
+
339
+ if low_conf:
340
+ if _batch_mode:
341
+ col_list = "、".join(str(c) for c in low_conf.keys())
342
+ print(f"[WARNING] 以下 {len(low_conf)} 列未能高置信度识别,批量模式下将跳过: {col_list}")
343
+ for col, info in low_conf.items():
344
+ print(f" 「{col}」→ {info.get('reason', '无法判断')}")
345
+ print(" 如需手动指定列映射,请使用 REPL 模式: menupilot(无参数启动)")
346
+ else:
347
+ print(f"[Master] 以下 {len(low_conf)} 列未能高置信度识别,需手动确认:")
348
+ _interactive_classify_columns(
349
+ list(low_conf.keys()), master_df,
350
+ {"field_mapping": {}, "composite_col": None,
351
+ "target_col": None, "irrelevant_cols": [],
352
+ "unrecognized_cols": list(low_conf.keys())},
353
+ )
354
+ else:
355
+ missing_str = "、".join(str(c) for c in missing_master)
356
+ if _batch_mode:
357
+ print(f"[WARNING] 缺少必要字段 {missing_master},且无候选列可推断,将跳过: {missing_str}")
358
+ print(" 如需手动指定列映射,请使用 REPL 模式: menupilot(无参数启动)")
359
+ else:
360
+ print(f"[Master] 缺少必要字段 {missing_master},且无候选列,请手动确认")
361
+ _interactive_classify_columns(
362
+ missing_master, master_df,
363
+ {"field_mapping": {}, "composite_col": None,
364
+ "target_col": None, "irrelevant_cols": [],
365
+ "unrecognized_cols": missing_master},
366
+ )
367
+
368
+ # ── 运行管线 ──
369
+ t0 = time.time()
370
+ state = run_pipeline(
371
+ master_path=opts.master,
372
+ template_path=opts.template,
373
+ output_path=opts.output,
374
+ report_path=report_path,
375
+ target_col=opts.target_col,
376
+ master_sheet=master_sheet,
377
+ template_sheet=template_sheet,
378
+ use_langgraph=opts.langgraph,
379
+ )
380
+ elapsed = time.time() - t0
381
+
382
+ if state.get("error") is not None:
383
+ print(f"\n[FAIL] 管线在 '{state['error_step']}' 步骤失败:")
384
+ print(f" {state['error']}")
385
+ print(f" 耗时: {elapsed:.1f}s")
386
+ return 1
387
+
388
+ api_calls = state["api_call_count"] if state.get("api_call_count") is not None else "?"
389
+ print(f"\n[OK] 映射完成! API 调用: {api_calls} 次 总耗时: {elapsed:.1f}s\n")
390
+
391
+ summary = state.get("console_summary", "") or state["report"]
392
+ if summary:
393
+ try:
394
+ sys.stdout.buffer.write((summary + "\n").encode("utf-8"))
395
+ sys.stdout.buffer.flush()
396
+ except (UnicodeError, AttributeError):
397
+ print(summary)
398
+ else:
399
+ total = len(state["match_results"])
400
+ high = sum(1 for r in state["match_results"] if r.get("confidence") == "HIGH")
401
+ print(f" 总行数: {total}")
402
+ print(f" 高置信度: {high} ({100*high/total:.1f}%)")
403
+
404
+ from menupilot.data.memory import get_new_tokens
405
+ new_tokens = get_new_tokens()
406
+ if new_tokens:
407
+ print(f"\n [记忆] 本次运行新增了 {len(new_tokens)} 个 token 别名:")
408
+ for word, ttype in new_tokens:
409
+ print(f" 「{word}」→ {ttype}")
410
+ print(f" 💡 如有误选,可执行 /memory edit <词语> <新类型> 修正")
411
+
412
+ print(f"\n 输出文件: {opts.output}")
413
+ print(f" 校验报告: {report_path}")
414
+ return 0
415
+
416
+
417
+ def run_sop_pipeline_kwargs(
418
+ master_path: str,
419
+ template_path: str,
420
+ output_path: str,
421
+ target_col: str = "配料",
422
+ report_path: str = "",
423
+ template_sheet: int = 0,
424
+ master_sheet: int = 0,
425
+ column_mapping: Optional[dict] = None,
426
+ ) -> dict:
427
+ """Agent 直接调用入口 — 接受 keyword args,返回结构化结果。
428
+
429
+ 与 run_sop_pipeline 的区别:不走 argparse,直接调用 workflow。
430
+ column_mapping 允许 Agent 传入动态列映射,适配任意列名的模板。
431
+
432
+ Args:
433
+ master_path: 主数据表路径。
434
+ template_path: 模板表路径。
435
+ output_path: 输出路径。
436
+ target_col: 目标填充列名。
437
+ report_path: 报告路径(可选)。
438
+ column_mapping: 列映射 dict,如 {'温度':'做法', '产品名称':'品名'}。
439
+
440
+ Returns:
441
+ {"ok": bool, "total_rows": int, "high_conf": int, "low_conf": int,
442
+ "report": str, "api_calls": int, "elapsed": float, "error": str}
443
+ """
444
+ import time
445
+
446
+ # 注入列映射到 column_aliases 记忆(Agent 分析 schema 后传入)
447
+ if column_mapping:
448
+ from menupilot.data.memory import add_column_alias
449
+ for col_name, canonical in column_mapping.items():
450
+ add_column_alias(str(col_name), str(canonical))
451
+
452
+ from menupilot.agent.workflow import run_pipeline
453
+
454
+ t0 = time.time()
455
+ try:
456
+ state = run_pipeline(
457
+ master_path=master_path,
458
+ template_path=template_path,
459
+ output_path=output_path,
460
+ report_path=report_path or output_path.replace(".xlsx", "_report.txt"),
461
+ target_col=target_col,
462
+ master_sheet=master_sheet,
463
+ template_sheet=template_sheet,
464
+ )
465
+ elapsed = time.time() - t0
466
+
467
+ if state.get("error"):
468
+ return {
469
+ "ok": False,
470
+ "error": state["error"],
471
+ "error_step": state.get("error_step", ""),
472
+ "elapsed": elapsed,
473
+ }
474
+
475
+ results = state.get("match_results", [])
476
+ return {
477
+ "ok": True,
478
+ "total_rows": len(results),
479
+ "high_conf": sum(1 for r in results if r.get("confidence") == "HIGH"),
480
+ "low_conf": sum(1 for r in results if r.get("confidence") == "LOW_CONFIDENCE"),
481
+ "report": state.get("console_summary", ""),
482
+ "api_calls": state.get("api_call_count", 0),
483
+ "elapsed": elapsed,
484
+ "output_path": output_path,
485
+ "report_path": report_path or output_path.replace(".xlsx", "_report.txt"),
486
+ }
487
+ except Exception as e:
488
+ return {"ok": False, "error": str(e), "elapsed": time.time() - t0}
489
+
490
+
491
+ # ═══════════════════════════════════════════════════════════════════
492
+ # 选项展开管线
493
+ # ═══════════════════════════════════════════════════════════════════
494
+
495
+ def run_expand_pipeline(args: Optional[list] = None) -> int:
496
+ """执行选项规格模板展开管线(原 main.run_expand())。
497
+
498
+ Args:
499
+ args: 命令行参数列表,None 时使用 sys.argv[2:](跳过 "expand" 子命令名)。
500
+
501
+ Returns:
502
+ exit code: 0=成功, 1=失败
503
+ """
504
+ import argparse as _argparse
505
+
506
+ parser = _argparse.ArgumentParser(
507
+ prog="menupilot expand",
508
+ description="选项规格模板展开器 — 将主数据表的选项值展开为空白模板行",
509
+ )
510
+ parser.add_argument("-m", "--master", required=True, help="选项规格主数据表 Excel 路径")
511
+ parser.add_argument("-t", "--template", required=True, help="空白选项模板 Excel 路径")
512
+ parser.add_argument("-o", "--output", required=True, help="输出 Excel 文件路径")
513
+ parser.add_argument("--sheet", type=int, default=0, help="Sheet 序号(默认 0)")
514
+ parser.add_argument("--template-sheet", type=int, default=None, help="模板表 Sheet 序号")
515
+ parser.add_argument("--master-sheet", type=int, default=None, help="主数据表 Sheet 序号")
516
+ parser.add_argument("--header-row", type=int, default=2, help="模板表头行号(默认 2)")
517
+
518
+ if args is None:
519
+ args = sys.argv[2:]
520
+ opts = parser.parse_args(args)
521
+
522
+ master_sheet = opts.master_sheet if opts.master_sheet is not None else opts.sheet
523
+ template_sheet = opts.template_sheet if opts.template_sheet is not None else opts.sheet
524
+
525
+ try:
526
+ _validate_file(opts.master, "选项主数据表")
527
+ _validate_file(opts.template, "选项模板表")
528
+ except CLIError as e:
529
+ print(f"[ERROR] {e}")
530
+ return 1
531
+
532
+ from menupilot.excel_io.excel_reader import read_option_master
533
+ from menupilot.excel_io.excel_writer import write_expanded_template
534
+ from menupilot.agent.option_expander import expand_master_to_options, DIMENSIONS
535
+
536
+ print("=" * 56)
537
+ print(" Option Specification Template Expander")
538
+ print("=" * 56)
539
+ print(f" 主数据表: {opts.master} (Sheet {master_sheet})")
540
+ print(f" 模板表: {opts.template} (Sheet {template_sheet})")
541
+ print(f" 输出: {opts.output}")
542
+ print("-" * 56)
543
+
544
+ master_df = read_option_master(opts.master, sheet_name=master_sheet)
545
+ expanded_df = expand_master_to_options(master_df)
546
+
547
+ write_expanded_template(
548
+ opts.template, opts.output, expanded_df,
549
+ header_row=opts.header_row,
550
+ )
551
+
552
+ print(f"\n[OK] 展开完成!")
553
+ print(f" 主数据行数: {len(master_df)}")
554
+ print(f" 生成模板行数: {len(expanded_df)}")
555
+
556
+ if not expanded_df.empty:
557
+ dim_counts = expanded_df["口味做法组名"].value_counts()
558
+ dim_parts = []
559
+ for dim in DIMENSIONS:
560
+ count = int(dim_counts.get(dim, 0))
561
+ if count == 0:
562
+ dim_parts.append(f"{dim}=0(无数据)")
563
+ else:
564
+ dim_parts.append(f"{dim}={count}")
565
+ print(f" 维度分布: {', '.join(dim_parts)}")
566
+ else:
567
+ print(" 维度分布: 无数据")
568
+
569
+ print(f"\n 输出文件: {opts.output}")
570
+ return 0