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,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Excel 写入模块 — 将匹配结果写入模板 Excel,保留原始格式,生成校验报告。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import openpyxl
|
|
10
|
+
|
|
11
|
+
# 在输出文件中新增的置信度列名
|
|
12
|
+
CONFIDENCE_COLUMN = "匹配置信度"
|
|
13
|
+
|
|
14
|
+
# 置信度值
|
|
15
|
+
HIGH = "HIGH"
|
|
16
|
+
LOW_CONFIDENCE = "LOW_CONFIDENCE"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_result(
|
|
20
|
+
template_path: str,
|
|
21
|
+
output_path: str,
|
|
22
|
+
result_df: pd.DataFrame,
|
|
23
|
+
target_col: str = "配料",
|
|
24
|
+
confidence_col: str = CONFIDENCE_COLUMN,
|
|
25
|
+
header_row: int = 1,
|
|
26
|
+
data_start_row: Optional[int] = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""将匹配结果写入 Excel,保留模板原始格式。
|
|
29
|
+
|
|
30
|
+
以 openpyxl 打开模板文件,只修改目标列和置信度列,
|
|
31
|
+
其他单元格的样式、公式、数据验证均保持不变。
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
template_path: 原始模板 Excel 路径。
|
|
35
|
+
output_path: 输出文件路径。
|
|
36
|
+
result_df: 包含填充结果的 DataFrame,须至少包含 target_col 和 confidence_col。
|
|
37
|
+
target_col: 需要填充的目标列名,默认 "配料"。
|
|
38
|
+
confidence_col: 置信度列名,默认 "匹配置信度"。
|
|
39
|
+
header_row: 用于搜索目标列/置信度列的表头行号(1=第一行)。
|
|
40
|
+
data_start_row: 数据写入起始行号,默认 = header_row + 1。
|
|
41
|
+
chowbus 模板应传 header_row=1, data_start_row=3(跳过两行表头)。
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
output_path
|
|
45
|
+
"""
|
|
46
|
+
if data_start_row is None:
|
|
47
|
+
data_start_row = header_row + 1
|
|
48
|
+
|
|
49
|
+
wb = openpyxl.load_workbook(template_path)
|
|
50
|
+
ws = wb.active
|
|
51
|
+
|
|
52
|
+
# 定位目标列和置信度列的列号(始终在 header_row 搜索)
|
|
53
|
+
target_col_idx = None
|
|
54
|
+
confidence_col_idx = None
|
|
55
|
+
max_col = ws.max_column
|
|
56
|
+
|
|
57
|
+
for col in range(1, max_col + 1):
|
|
58
|
+
val = ws.cell(row=header_row, column=col).value
|
|
59
|
+
if val and str(val).strip() == target_col:
|
|
60
|
+
target_col_idx = col
|
|
61
|
+
if val and str(val).strip() == confidence_col:
|
|
62
|
+
confidence_col_idx = col
|
|
63
|
+
|
|
64
|
+
if target_col_idx is None:
|
|
65
|
+
# 目标列不存在,追加到末尾
|
|
66
|
+
target_col_idx = max_col + 1
|
|
67
|
+
ws.cell(row=1, column=target_col_idx, value=target_col)
|
|
68
|
+
|
|
69
|
+
if confidence_col_idx is None:
|
|
70
|
+
# 置信度列不存在,追加到目标列后面
|
|
71
|
+
confidence_col_idx = target_col_idx + 1
|
|
72
|
+
ws.cell(row=1, column=confidence_col_idx, value=confidence_col)
|
|
73
|
+
|
|
74
|
+
# 写入数据
|
|
75
|
+
for i, (_, row) in enumerate(result_df.iterrows()):
|
|
76
|
+
excel_row = data_start_row + i
|
|
77
|
+
if target_col in result_df.columns:
|
|
78
|
+
ws.cell(row=excel_row, column=target_col_idx, value=row.get(target_col, ""))
|
|
79
|
+
if CONFIDENCE_COLUMN in result_df.columns:
|
|
80
|
+
ws.cell(row=excel_row, column=confidence_col_idx, value=row.get(CONFIDENCE_COLUMN, ""))
|
|
81
|
+
|
|
82
|
+
wb.save(output_path)
|
|
83
|
+
return output_path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_report(report_path: str, low_confidence_rows: List[dict]) -> str:
|
|
87
|
+
"""生成校验报告,汇总所有低置信度行及失败原因。
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
report_path: 报告输出路径。
|
|
91
|
+
low_confidence_rows: 低置信度行列表,每项为 dict,包含 row_index、reason 等。
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
report_path
|
|
95
|
+
"""
|
|
96
|
+
lines = [
|
|
97
|
+
"=" * 60,
|
|
98
|
+
"POS Template Mapping — 校验报告",
|
|
99
|
+
"=" * 60,
|
|
100
|
+
"",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
if not low_confidence_rows:
|
|
104
|
+
lines.append("全部行匹配置信度为 HIGH,无需关注。")
|
|
105
|
+
else:
|
|
106
|
+
lines.append(f"低置信度行数: {len(low_confidence_rows)}")
|
|
107
|
+
lines.append("")
|
|
108
|
+
lines.append("-" * 60)
|
|
109
|
+
for item in low_confidence_rows:
|
|
110
|
+
row_idx = item.get("row_index", "?")
|
|
111
|
+
reason = item.get("reason", "未知原因")
|
|
112
|
+
product = item.get("product_name", "?")
|
|
113
|
+
lines.append(f" 行 {row_idx}: {product}")
|
|
114
|
+
lines.append(f" 原因: {reason}")
|
|
115
|
+
lines.append("")
|
|
116
|
+
lines.append("-" * 60)
|
|
117
|
+
|
|
118
|
+
lines.append("")
|
|
119
|
+
lines.append(f"报告生成完毕。")
|
|
120
|
+
|
|
121
|
+
Path(report_path).write_text("\n".join(lines), encoding="utf-8")
|
|
122
|
+
return report_path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── 选项规格模板写入 ──────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
# 选项规格模板的 8 个固定列名
|
|
128
|
+
OPTION_TEMPLATE_COLUMNS = [
|
|
129
|
+
"商品编码", "商品名称", "口味做法组名", "选项名称",
|
|
130
|
+
"最少必选", "最多可选", "推荐项", "默认项",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def write_expanded_template(
|
|
135
|
+
template_path: str,
|
|
136
|
+
output_path: str,
|
|
137
|
+
expanded_df: pd.DataFrame,
|
|
138
|
+
header_row: int = 1,
|
|
139
|
+
) -> str:
|
|
140
|
+
"""将展开后的选项规格数据写入模板 Excel,保留原始格式。
|
|
141
|
+
|
|
142
|
+
以 openpyxl 打开模板,定位 8 个固定列头,清除表头以下旧数据后写入。
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
template_path: 空白选项模板 Excel 路径(含表头,无数据行)。
|
|
146
|
+
output_path: 输出文件路径。
|
|
147
|
+
expanded_df: expand_master_to_options() 返回的 DataFrame。
|
|
148
|
+
header_row: 列头所在行号(1=第一行)。
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
output_path
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
FileNotFoundError: 模板文件不存在。
|
|
155
|
+
"""
|
|
156
|
+
path = Path(template_path)
|
|
157
|
+
if not path.exists():
|
|
158
|
+
raise FileNotFoundError(
|
|
159
|
+
f"模板文件不存在: {template_path}\n"
|
|
160
|
+
f"请先用 Excel 创建一个包含以下表头的空白模板文件:\n"
|
|
161
|
+
f" {', '.join(OPTION_TEMPLATE_COLUMNS)}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
wb = openpyxl.load_workbook(template_path)
|
|
165
|
+
ws = wb.active
|
|
166
|
+
|
|
167
|
+
# ── Step 1: 定位 8 个固定列头 ──
|
|
168
|
+
max_col = ws.max_column
|
|
169
|
+
col_map = {} # column_name → 1-based column index
|
|
170
|
+
|
|
171
|
+
for col in range(1, max_col + 1):
|
|
172
|
+
val = ws.cell(row=header_row, column=col).value
|
|
173
|
+
if val:
|
|
174
|
+
cleaned = str(val).strip()
|
|
175
|
+
# 去除 * 后缀(真实模板常见:商品编码* → 商品编码)
|
|
176
|
+
if cleaned.endswith("*"):
|
|
177
|
+
cleaned_no_star = cleaned[:-1]
|
|
178
|
+
else:
|
|
179
|
+
cleaned_no_star = cleaned
|
|
180
|
+
if cleaned in OPTION_TEMPLATE_COLUMNS:
|
|
181
|
+
col_map[cleaned] = col
|
|
182
|
+
elif cleaned_no_star in OPTION_TEMPLATE_COLUMNS:
|
|
183
|
+
col_map[cleaned_no_star] = col
|
|
184
|
+
|
|
185
|
+
# 缺失的列头追加到末尾
|
|
186
|
+
next_col = max_col + 1
|
|
187
|
+
for col_name in OPTION_TEMPLATE_COLUMNS:
|
|
188
|
+
if col_name not in col_map:
|
|
189
|
+
ws.cell(row=header_row, column=next_col, value=col_name)
|
|
190
|
+
col_map[col_name] = next_col
|
|
191
|
+
next_col += 1
|
|
192
|
+
|
|
193
|
+
data_start_row = header_row + 1
|
|
194
|
+
|
|
195
|
+
# ── Step 2: 清除表头以下的旧数据 ──
|
|
196
|
+
for row in range(data_start_row, ws.max_row + 1):
|
|
197
|
+
for col_name, col_idx in col_map.items():
|
|
198
|
+
ws.cell(row=row, column=col_idx, value=None)
|
|
199
|
+
|
|
200
|
+
# ── Step 3: 写入展开数据 ──
|
|
201
|
+
for i, (_, drow) in enumerate(expanded_df.iterrows()):
|
|
202
|
+
excel_row = data_start_row + i
|
|
203
|
+
for col_name, col_idx in col_map.items():
|
|
204
|
+
if col_name in expanded_df.columns:
|
|
205
|
+
val = drow.get(col_name, "")
|
|
206
|
+
# 最少必选/最多可选 保持整数
|
|
207
|
+
if col_name in ("最少必选", "最多可选"):
|
|
208
|
+
try:
|
|
209
|
+
val = int(val)
|
|
210
|
+
except (ValueError, TypeError):
|
|
211
|
+
val = 1
|
|
212
|
+
ws.cell(row=excel_row, column=col_idx, value=val)
|
|
213
|
+
|
|
214
|
+
wb.save(output_path)
|
|
215
|
+
return output_path
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── 自测 ──────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
import tempfile
|
|
222
|
+
import os
|
|
223
|
+
|
|
224
|
+
passed = 0
|
|
225
|
+
failed = 0
|
|
226
|
+
|
|
227
|
+
def check(condition, msg):
|
|
228
|
+
global passed, failed
|
|
229
|
+
if condition:
|
|
230
|
+
passed += 1
|
|
231
|
+
print(f" PASS {msg}")
|
|
232
|
+
else:
|
|
233
|
+
failed += 1
|
|
234
|
+
print(f" FAIL {msg}")
|
|
235
|
+
|
|
236
|
+
print("=== Excel Writer 自测 ===\n")
|
|
237
|
+
|
|
238
|
+
tmpdir = tempfile.mkdtemp()
|
|
239
|
+
|
|
240
|
+
# ── 准备模板文件和结果数据 ──
|
|
241
|
+
template_path = os.path.join(tmpdir, "template.xlsx")
|
|
242
|
+
output_path = os.path.join(tmpdir, "output.xlsx")
|
|
243
|
+
report_path = os.path.join(tmpdir, "report.txt")
|
|
244
|
+
|
|
245
|
+
# 使用 openpyxl 直接创建带样式的模板(验证样式保留)
|
|
246
|
+
wb = openpyxl.Workbook()
|
|
247
|
+
ws = wb.active
|
|
248
|
+
ws.title = "Template"
|
|
249
|
+
|
|
250
|
+
headers = ["菜品名称", "规格", "口味做法组合", "配料"]
|
|
251
|
+
data = [
|
|
252
|
+
["五黄高纤慢养瓶", "五角瓶", "红茶, 十二分糖, 温热", ""],
|
|
253
|
+
["五黄高纤慢养瓶", "五角瓶", "红茶, 十二分糖, 正常冰", ""],
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
for c, h in enumerate(headers, 1):
|
|
257
|
+
cell = ws.cell(row=1, column=c, value=h)
|
|
258
|
+
cell.font = openpyxl.styles.Font(bold=True, color="FFFFFF")
|
|
259
|
+
cell.fill = openpyxl.styles.PatternFill("solid", fgColor="4472C4")
|
|
260
|
+
|
|
261
|
+
for r, row_data in enumerate(data, 2):
|
|
262
|
+
for c, val in enumerate(row_data, 1):
|
|
263
|
+
ws.cell(row=r, column=c, value=val)
|
|
264
|
+
|
|
265
|
+
ws.column_dimensions["A"].width = 20
|
|
266
|
+
wb.save(template_path)
|
|
267
|
+
|
|
268
|
+
# 构造结果 DataFrame
|
|
269
|
+
result_df = pd.DataFrame({
|
|
270
|
+
"配料": ["T240、B30/80、S4", "T265、B30/105、S5"],
|
|
271
|
+
"匹配置信度": [HIGH, LOW_CONFIDENCE],
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
# 创建选项规格模板
|
|
275
|
+
opt_template_path = os.path.join(tmpdir, "opt_template.xlsx")
|
|
276
|
+
opt_output_path = os.path.join(tmpdir, "opt_output.xlsx")
|
|
277
|
+
wb_opt = openpyxl.Workbook()
|
|
278
|
+
ws_opt = wb_opt.active
|
|
279
|
+
for c, h in enumerate(OPTION_TEMPLATE_COLUMNS, 1):
|
|
280
|
+
cell = ws_opt.cell(row=1, column=c, value=h)
|
|
281
|
+
cell.font = openpyxl.styles.Font(bold=True)
|
|
282
|
+
# 预留一列不相关的列,验证格式保留
|
|
283
|
+
ws_opt.cell(row=1, column=9, value="其他列")
|
|
284
|
+
ws_opt.column_dimensions["A"].width = 15
|
|
285
|
+
wb_opt.save(opt_template_path)
|
|
286
|
+
|
|
287
|
+
# 构造展开结果 DataFrame
|
|
288
|
+
expanded_df = pd.DataFrame({
|
|
289
|
+
"商品编码": ["A001"] * 6,
|
|
290
|
+
"商品名称": ["茉莉绿茶"] * 6,
|
|
291
|
+
"口味做法组名": ["糖度", "糖度", "糖度", "温度", "温度", "温度"],
|
|
292
|
+
"选项名称": ["七分糖", "五分糖", "三分糖", "正常冰", "少冰", "去冰"],
|
|
293
|
+
"最少必选": [1] * 6,
|
|
294
|
+
"最多可选": [1] * 6,
|
|
295
|
+
"推荐项": ["是", "否", "否", "是", "否", "否"],
|
|
296
|
+
"默认项": ["否", "是", "否", "否", "是", "否"],
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
# ── 1. 写入结果并保留格式 ──
|
|
301
|
+
print("1. 写入结果并保留格式")
|
|
302
|
+
out = write_result(template_path, output_path, result_df)
|
|
303
|
+
|
|
304
|
+
# 验证输出文件存在
|
|
305
|
+
check(os.path.exists(out), "输出文件已生成")
|
|
306
|
+
|
|
307
|
+
# 读取验证内容
|
|
308
|
+
wb_out = openpyxl.load_workbook(output_path)
|
|
309
|
+
ws_out = wb_out.active
|
|
310
|
+
|
|
311
|
+
check(ws_out.cell(row=1, column=1).value == "菜品名称", "表头保留 '菜品名称'")
|
|
312
|
+
check(ws_out.cell(row=1, column=4).value == "配料", "表头保留 '配料'")
|
|
313
|
+
|
|
314
|
+
# 验证填充数据
|
|
315
|
+
check(ws_out.cell(row=2, column=4).value == "T240、B30/80、S4", "第1行配料已填充")
|
|
316
|
+
check(ws_out.cell(row=3, column=4).value == "T265、B30/105、S5", "第2行配料已填充")
|
|
317
|
+
|
|
318
|
+
# 验证置信度列
|
|
319
|
+
check(ws_out.cell(row=1, column=5).value == "匹配置信度", "置信度列表头已添加")
|
|
320
|
+
check(ws_out.cell(row=2, column=5).value == "HIGH", "第1行 HIGH")
|
|
321
|
+
check(ws_out.cell(row=3, column=5).value == "LOW_CONFIDENCE", "第2行 LOW_CONFIDENCE")
|
|
322
|
+
|
|
323
|
+
# 验证样式保留(表头加粗 + 蓝色背景)
|
|
324
|
+
cell_a1 = ws_out.cell(row=1, column=1)
|
|
325
|
+
check(cell_a1.font.bold is True, "表头加粗保留")
|
|
326
|
+
check(cell_a1.fill.fgColor.rgb in ("004472C4", "FF4472C4"), "表头蓝色背景保留")
|
|
327
|
+
|
|
328
|
+
# 验证列宽保留
|
|
329
|
+
check(ws_out.column_dimensions["A"].width == 20, "列宽保留")
|
|
330
|
+
print()
|
|
331
|
+
|
|
332
|
+
# ── 2. 校验报告 ──
|
|
333
|
+
print("2. 校验报告")
|
|
334
|
+
low_rows = [
|
|
335
|
+
{"row_index": 2, "product_name": "五黄高纤慢养瓶", "reason": "商品名匹配低于阈值"},
|
|
336
|
+
]
|
|
337
|
+
rp = write_report(report_path, low_rows)
|
|
338
|
+
check(os.path.exists(rp), "报告文件已生成")
|
|
339
|
+
|
|
340
|
+
report_text = Path(rp).read_text(encoding="utf-8")
|
|
341
|
+
check("低置信度行数: 1" in report_text, "报告含低置信度计数")
|
|
342
|
+
check("商品名匹配低于阈值" in report_text, "报告含失败原因")
|
|
343
|
+
print()
|
|
344
|
+
|
|
345
|
+
# ── 3. 空报告(全部 HIGH) ──
|
|
346
|
+
print("3. 空报告(全部 HIGH)")
|
|
347
|
+
empty_report = os.path.join(tmpdir, "empty_report.txt")
|
|
348
|
+
write_report(empty_report, [])
|
|
349
|
+
empty_text = Path(empty_report).read_text(encoding="utf-8")
|
|
350
|
+
check("全部行匹配置信度为 HIGH" in empty_text, "空报告提示无需关注")
|
|
351
|
+
print()
|
|
352
|
+
|
|
353
|
+
# ── 4. 目标列已存在时的覆盖 ──
|
|
354
|
+
print("4. 目标列覆盖写入")
|
|
355
|
+
out2 = write_result(template_path, output_path, result_df)
|
|
356
|
+
wb2 = openpyxl.load_workbook(out2)
|
|
357
|
+
ws2 = wb2.active
|
|
358
|
+
check(ws2.cell(row=2, column=4).value == "T240、B30/80、S4", "覆盖后数据正确")
|
|
359
|
+
print()
|
|
360
|
+
|
|
361
|
+
# ── 5. write_expanded_template 写入空白模板 ──
|
|
362
|
+
print("5. write_expanded_template 写入选项规格模板")
|
|
363
|
+
opt_out = write_expanded_template(opt_template_path, opt_output_path, expanded_df)
|
|
364
|
+
check(os.path.exists(opt_out), "选项输出文件已生成")
|
|
365
|
+
|
|
366
|
+
wb_opt_out = openpyxl.load_workbook(opt_output_path)
|
|
367
|
+
ws_opt_out = wb_opt_out.active
|
|
368
|
+
|
|
369
|
+
# 验证表头
|
|
370
|
+
check(ws_opt_out.cell(row=1, column=1).value == "商品编码", "表头「商品编码」")
|
|
371
|
+
check(ws_opt_out.cell(row=1, column=3).value == "口味做法组名", "表头「口味做法组名」")
|
|
372
|
+
|
|
373
|
+
# 验证数据行
|
|
374
|
+
check(ws_opt_out.cell(row=2, column=1).value == "A001", "第1行 商品编码")
|
|
375
|
+
check(ws_opt_out.cell(row=2, column=3).value == "糖度", "第1行 口味做法组名=糖度")
|
|
376
|
+
check(ws_opt_out.cell(row=2, column=4).value == "七分糖", "第1行 选项名称=七分糖")
|
|
377
|
+
check(ws_opt_out.cell(row=2, column=5).value == 1, "第1行 最少必选=1(整数)")
|
|
378
|
+
check(ws_opt_out.cell(row=2, column=7).value == "是", "第1行 推荐项=是")
|
|
379
|
+
check(ws_opt_out.cell(row=5, column=4).value == "正常冰", "第5行 选项名称=正常冰(温度维度)")
|
|
380
|
+
|
|
381
|
+
# 验证行数
|
|
382
|
+
data_row_count = sum(1 for row in range(2, ws_opt_out.max_row + 2)
|
|
383
|
+
if ws_opt_out.cell(row=row, column=1).value is not None)
|
|
384
|
+
check(data_row_count == 6, f"共 6 行数据(实际 {data_row_count})")
|
|
385
|
+
print()
|
|
386
|
+
|
|
387
|
+
# ── 6. write_expanded_template 保留模板格式 ──
|
|
388
|
+
print("6. write_expanded_template 保留模板格式")
|
|
389
|
+
check(ws_opt_out.column_dimensions["A"].width == 15, "列宽保留")
|
|
390
|
+
cell_h = ws_opt_out.cell(row=1, column=1)
|
|
391
|
+
check(cell_h.font.bold is True, "表头加粗保留")
|
|
392
|
+
# 不相关列未被修改
|
|
393
|
+
check(ws_opt_out.cell(row=1, column=9).value == "其他列", "其他列未受影响")
|
|
394
|
+
print()
|
|
395
|
+
|
|
396
|
+
# ── 7. 模板文件不存在 → FileNotFoundError ──
|
|
397
|
+
print("7. write_expanded_template 模板不存在 → FileNotFoundError")
|
|
398
|
+
try:
|
|
399
|
+
write_expanded_template("不存在的模板.xlsx", opt_output_path, expanded_df)
|
|
400
|
+
check(False, "应抛出 FileNotFoundError")
|
|
401
|
+
except FileNotFoundError as e:
|
|
402
|
+
check("模板文件不存在" in str(e), f"报错含「模板文件不存在」")
|
|
403
|
+
print()
|
|
404
|
+
|
|
405
|
+
finally:
|
|
406
|
+
for f in [template_path, output_path, report_path,
|
|
407
|
+
os.path.join(tmpdir, "empty_report.txt"),
|
|
408
|
+
opt_template_path, opt_output_path]:
|
|
409
|
+
if os.path.exists(f):
|
|
410
|
+
os.remove(f)
|
|
411
|
+
os.rmdir(tmpdir)
|
|
412
|
+
|
|
413
|
+
print(f"=== 结果: {passed} passed, {failed} failed ===")
|