diff --git a/app/core/db/product_db.py b/app/core/db/product_db.py index 8ae2fb8..95c50ed 100644 --- a/app/core/db/product_db.py +++ b/app/core/db/product_db.py @@ -178,18 +178,30 @@ class ProductDatabase: try: row = conn.execute("SELECT avg_price FROM products WHERE barcode=?", (str(barcode).strip(),)).fetchone() - return row[0] if row and row[0] else None + if not row: + return None + return row[0] if row[0] is not None else 0.0 finally: conn.close() - def get_prices(self, barcodes: List[str]) -> Dict[str, float]: + def get_prices(self, barcodes: List[str], column: str = 'price') -> Dict[str, float]: + """批量查进货价。 + + Args: + barcodes: 条码列表 + column: 价字段,默认 'price'(Excel 导入的进货价,按商家主单位), + 可选 'avg_price'(OCR 学来的历史均价,调试用)。 + """ if not barcodes: return {} + valid = {'price', 'avg_price', 'min_price', 'max_price'} + if column not in valid: + raise ValueError(f"column must be one of {valid}") conn = self._connect() try: placeholders = ','.join('?' * len(barcodes)) rows = conn.execute( - f"SELECT barcode, avg_price FROM products WHERE barcode IN ({placeholders})", + f"SELECT barcode, {column} FROM products WHERE barcode IN ({placeholders})", [str(b).strip() for b in barcodes]).fetchall() return {r[0]: r[1] for r in rows if r[1]} finally: @@ -357,13 +369,23 @@ class ProductDatabase: else: # 高可信度源全字段覆盖;低可信度仅填空 if source in ('template', 'user_confirmed') or new_conf > 50: - conn.execute( - "UPDATE products SET name=?, specification=?, unit=?, price=?, " - "source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, " - "avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?", - (name or old_name, spec or old_spec, unit or old_unit, price, - source, new_conf, new_count, now, now, - new_avg, new_min, new_max, new_pc, barcode)) + # OCR 来源不覆盖 price(price 是商品资料 Excel 导入的进货价,应作为基准不被 OCR 学习的"按箱单价"覆盖) + if source == 'ocr': + conn.execute( + "UPDATE products SET name=?, specification=?, unit=?, " + "source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, " + "avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?", + (name or old_name, spec or old_spec, unit or old_unit, + source, new_conf, new_count, now, now, + new_avg, new_min, new_max, new_pc, barcode)) + else: + conn.execute( + "UPDATE products SET name=?, specification=?, unit=?, price=?, " + "source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, " + "avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?", + (name or old_name, spec or old_spec, unit or old_unit, price, + source, new_conf, new_count, now, now, + new_avg, new_min, new_max, new_pc, barcode)) else: conn.execute( "UPDATE products SET " diff --git a/app/core/excel/processor.py b/app/core/excel/processor.py index 9c9cb05..400143c 100644 --- a/app/core/excel/processor.py +++ b/app/core/excel/processor.py @@ -352,29 +352,8 @@ class ExcelProcessor: product['package_quantity'] = package_quantity logger.info(f"解析已设置的规格: {product['specification']} -> 包装数量={package_quantity}") - # 新增逻辑:根据规格推断单位为"件" - if not product['unit'] and product.get('barcode') and product.get('specification') and product.get('quantity') and product.get('price') is not None: - # 检查规格是否符合容量*数量格式 - volume_pattern = r'(\d+(?:\.\d+)?)\s*(?:ml|[mL]L|l|L|升|毫升)[*×xX](\d+)' - match = re.search(volume_pattern, product['specification']) - - # 判断是否需要推断单位为"件" - if match: - product['unit'] = '件' - logger.info(f"根据规格推断单位: {product['specification']} -> 单位=件") - else: - # 检查简单的数量*数量格式 - simple_pattern = r'(\d+)[*×xX](\d+)' - match = re.search(simple_pattern, product['specification']) - if match: - product['unit'] = '件' - logger.info(f"根据规格推断单位: {product['specification']} -> 单位=件") - - # 应用单位转换规则 - product = self.unit_converter.process_unit_conversion(product) - - # 如果数量为0但单价和金额都存在,计算数量 = 金额/单价 - if (product['quantity'] == 0 or product['quantity'] is None) and product['price'] > 0 and product['amount']: + # 如果数量为0但单价和金额都存在,先算数量(必须在 unit 推断之前,否则 unit 推断条件因 quantity=0 被跳过) + if (product['quantity'] == 0 or product['quantity'] is None) and product['price'] and product['amount']: try: amount = parse_monetary_string(product['amount']) if amount is not None and amount > 0: @@ -383,6 +362,17 @@ class ExcelProcessor: product['quantity'] = quantity except Exception as e: logger.warning(f"通过金额和单价计算数量失败: {e}") + + # 推断单位为"件":仅依赖规格文本(spec 已经是 "1*N"),不再要求 quantity 非零 + if not product['unit'] and product.get('specification'): + volume_pattern = r'(\d+(?:\.\d+)?)\s*(?:ml|[mL]L|l|L|升|毫升)[*×xX](\d+)' + simple_pattern = r'(\d+)[*×xX](\d+)' + if re.search(volume_pattern, product['specification']) or re.search(simple_pattern, product['specification']): + product['unit'] = '件' + logger.info(f"根据规格推断单位: {product['specification']} -> 单位=件") + + # 应用单位转换规则 + product = self.unit_converter.process_unit_conversion(product) # 应用记忆库补全 product = self._apply_memory(product) diff --git a/config.ini b/config.ini index f1a9a50..012302e 100644 --- a/config.ini +++ b/config.ini @@ -34,7 +34,7 @@ purchase_order = 银豹-采购单模板.xls item_data = 商品资料.xlsx [App] -version = 2026.05.05.0239 +version = 2026.07.19.1522 [Gitea] base_url = https://gitea.94kan.cn @@ -45,4 +45,3 @@ token = [WebAuth] username = admin password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC - diff --git a/docs/订单批量识别/ALIGNMENT_订单批量识别.md b/docs/订单批量识别/ALIGNMENT_订单批量识别.md new file mode 100644 index 0000000..cee2a0e --- /dev/null +++ b/docs/订单批量识别/ALIGNMENT_订单批量识别.md @@ -0,0 +1,81 @@ +# ALIGNMENT — 订单批量识别(OCR 元信息提取 + 批量一键处理) + +## 一、目标 + +完善 OCR → result 完整链路,新增 3 个能力: +1. **OCR 元信息识别与持久化**:从图片 OCR 原文识别供应商、单据日期、应付金额,存 SQLite +2. **基于元信息的文件命名**:result 文件 + 原图片按"采购单_YYYYMMDD_供应商_.xls"格式命名 +3. **批量一键处理**:扫描 `data/input/` 内所有图片,批量跑完整流程,每张独立出 result(默认不合并) + +## 二、需求边界(已与用户确认) + +### 必做 +- OCR 后从原始 OCR 文本识别 3 个字段: + - **供应商名称**:从 OCR 文本顶部识别(关键词:供货/供应/酒行/商行/批发等,取匹配行原文) + - **单据日期**:从 OCR 文本识别(关键词:日期/单据日期/年月日;正则提取 YYYY-MM-DD / YYYY/MM/DD / YYYY.MM.DD / YYYYMMDD 等多种格式) + - **总金额**:取"应付金额/实付金额/金额合计"字段;如无则取"总计"行最大金额 +- 三个字段存到新增 SQLite 表 `order_metadata`(与现有 `product_cache.db` 共用文件) +- result 文件名格式:`采购单_{YYYYMMDD}_{供应商名}_{原文件hash}.xls` +- 原图片(input 目录)重命名为:`原名_{YYYYMMDD}_{供应商名}_{hash}.{原扩展名}`(保留扩展名,hash 不变) +- 批量处理:`data/input/` 内全部图片 → OCR → process_excel → result;输出 `data/result/采购单_*.xls` +- 完整流程跑完,日志末尾显示 3 个参数汇总(按文件列出供应商/日期/金额 + 成功/失败状态) +- 三个字段任一识别失败 → 填"未知",文件后缀仍含原 hash 保证唯一性 + +### 不做 +- 不修改现有 fill_template 模板 +- 不改商品资料库 / 条码映射 / 单位转换逻辑 +- 不合并多个订单为单个 result(用户明确"默认全部不合并") +- 不做供应商库匹配(图片原文识别优先) +- 不做 OCR 失败重试机制增强(沿用现有 OCRService.process_image) + +### 已确认决策 +| 决策点 | 选择 | +|---|---| +| 供应商识别来源 | OCR 原文顶部文本(关键词匹配) | +| 总金额字段 | 应付金额/实付/金额合计,缺则取总计行最大金额 | +| 日期格式 | YYYYMMDD 作文件名后缀 | +| 识别失败默认值 | "未知供应商" + 原 hash | +| Result 输出位置 | `data/result/`(不分子目录) | + +## 三、对现有项目的影响 + +### 需要修改的文件 +- `app/core/ocr/table_ocr.py` 或新增 `app/core/ocr/metadata_extractor.py`:识别 3 个字段 +- `app/core/db/product_db.py`:新增 `order_metadata` 表及 CRUD +- `app/services/order_service.py`:在 process_excel 流程里提取元信息 + 落库 + 应用新文件名 +- `app/ui/main_window.py` / `action_handlers.py`:新增"批量处理"按钮 + 进度条 + 完成后日志汇总 +- `app/services/processor_service.py`:新增批量入口 `process_all_inputs` + +### 新增文件 +- `app/core/ocr/metadata_extractor.py`:单据元信息识别器 +- `tests/test_metadata_extractor.py`:单元测试 + +### 不需要修改 +- 模板、银豹格式、单位转换、记忆库核心逻辑 + +## 四、验收标准 + +### 功能验收 +- [ ] OCR 后能识别出供应商/日期/应付金额(真实单子样本) +- [ ] 三个字段存到 `data/product_cache.db` 的 `order_metadata` 表 +- [ ] result 文件名 = `采购单_YYYYMMDD_供应商_.xls` +- [ ] 原图片在 input 中按相同规则重命名(保留扩展名) +- [ ] 一键处理按钮能扫描 `data/input/` 全部图片,串行处理(避免并发占资源) +- [ ] 完整流程跑完,日志显示每张的处理结果 + 3 个参数 + 状态 +- [ ] 全部 191 个现有测试不破坏 + +### 边界验收 +- [ ] 三个字段都识别不到时,使用"未知"+原 hash +- [ ] 部分识别失败,文件名仍包含原 hash 保证唯一性 +- [ ] `data/input/` 为空时,按钮点击给出"无待处理图片"提示 + +## 五、不确定点(已澄清或可后续处理) + +- **OCR 文本顶部区域范围**:取 OCR 返回的前 20 行文本(覆盖常见采购单抬头区域),如有"供应商"明确标注则取该标注 +- **日期格式多样性**:正则同时支持 4 种格式,标准化为 YYYYMMDD +- **并发处理**:本期串行(10 张单子每张 1~2 秒,串行 10~20 秒可接受);并发留待后续 +- **历史 result 重命名**:本期**不**迁移历史 result(避免破坏现有用户数据) + +## 六、后续文档 +- DESIGN_订单批量识别.md(架构设计) +- TASK_订单批量识别.md(任务拆分) \ No newline at end of file diff --git a/docs/订单批量识别/DESIGN_订单批量识别.md b/docs/订单批量识别/DESIGN_订单批量识别.md new file mode 100644 index 0000000..4c7e963 --- /dev/null +++ b/docs/订单批量识别/DESIGN_订单批量识别.md @@ -0,0 +1,240 @@ +# DESIGN — 订单批量识别(OCR 元信息提取 + 批量一键处理) + +## 一、整体架构 + +```mermaid +graph TB + A[扫描 data/input/*.png/jpg] --> B[主流程入口] + B --> C[OCRService.process_image] + C --> D[TableOCR 返回 Excel 数据] + D --> E[metadata_extractor 识别三字段] + E --> F[product_db.save_order_metadata] + F --> G[重命名 input 原图片] + G --> H[OrderService.process_excel] + H --> I[ExcelProcessor.fill_template → 新文件名] + I --> J[汇总日志] +``` + +## 二、模块设计 + +### 1. `app/core/ocr/metadata_extractor.py`(新增) + +**核心类** `OrderMetadataExtractor` + +```python +class OrderMetadataExtractor: + def extract(ocr_text: str, ocr_rows: list[list[str]]) -> OrderMetadata: + """从 OCR 原始文本和解析后的二维数组提取三字段。 + + Returns: + OrderMetadata(supplier, bill_date, total_amount, raw_supplier_text) + """ +``` + +**提取算法**: + +#### Supplier(供应商) +1. 取前 20 行(OCR 顶部) +2. 行内关键词匹配:`供货单`、`供应商`、`供货方`、`批发`、`酒行`、`商行`、`经销`、`专卖店`、`配送单`、`送货单` +3. 命中后取该行清理(去数字、空白、特殊字符)后的文本 +4. 多行匹配时取最长非空行 +5. 兜底:取非空前 3 行中第一个中文 ≥ 4 字的行(启发式) + +#### BillDate(单据日期) +1. 全文正则匹配,按优先级: + - `(\d{4})[-./年](\d{1,2})[-./月](\d{1,2})日?` → 标准化 YYYYMMDD + - `(\d{4})(\d{2})(\d{2})` → YYYYMMDD(紧邻的 8 位数字) +2. 多个匹配时取**第一个** +3. 校验:年 1900~2100,月 1~12,日 1~31 + +#### TotalAmount(应付金额) +1. 关键词优先:`应付金额`、`实付金额`、`金额合计`、`应付合计`、`合计金额`、`总计` +2. 取关键词后最近一个 `数字.数字` 或 `数字` +3. 兜底:取所有金额中最大值(排除单价列) + +### 2. `app/core/db/product_db.py` 扩展 + +**新增表** `order_metadata`: + +```sql +CREATE TABLE IF NOT EXISTS order_metadata ( + file_hash TEXT PRIMARY KEY, -- OCR 输出 xlsx 的 hash,与原图同 hash + supplier TEXT DEFAULT '', -- 供应商 + bill_date TEXT DEFAULT '', -- 单据日期 YYYYMMDD + total_amount REAL DEFAULT 0, -- 应付金额 + raw_supplier_text TEXT DEFAULT '', -- 原始文本,便于回查 + source_image TEXT DEFAULT '', -- 原图路径 + created_at TEXT DEFAULT '', + updated_at TEXT DEFAULT '' +); +``` + +**新增方法**: +- `save_order_metadata(file_hash, supplier, bill_date, total_amount, raw_supplier_text, source_image)` +- `get_order_metadata(file_hash) -> dict | None` +- `list_all_metadata() -> list[dict]`(用于批量处理后的日志汇总) + +### 3. `app/services/order_service.py` 扩展 + +**修改** `process_excel(excel_path)` 流程: + +```python +def process_excel(self, excel_path): + # ... 现有 fill_template 流程 ... + + # 1. 从 output/xxx.xlsx 的文件名提取 hash(现有逻辑已用) + file_hash = Path(excel_path).stem + + # 2. 读 OCR 原始文本(来自 OCRService 缓存或从 xlsx 头部回读) + ocr_text = self._read_ocr_text(file_hash) # 新增 + + # 3. 提取元信息 + meta = self.extractor.extract(ocr_text, ocr_rows) + + # 4. 落库 + self.product_db.save_order_metadata(file_hash, ...) + + # 5. 应用新文件名(result + 原图) + new_result_name = f"采购单_{meta.bill_date or '未知'}_{meta.supplier or '未知供应商'}_{file_hash}.xls" + # 替换 result_path + # 原图重命名:找到 data/input/ 下对应的图(hash 一致) + + return new_result_path, meta +``` + +**OCR 文本获取策略**: +- OCR 写入 `data/output/{hash}.xlsx` 时,同步写 `data/output/{hash}.meta.json`(含 OCR 原始 text) +- 新增 `app/core/ocr/table_ocr.py` 写入 metadata 时 dump 原始 text +- 如 meta.json 不存在(历史文件),回退:从 xlsx 顶部 cell 拼文本 + +### 4. `app/services/processor_service.py` 扩展 + +**新增方法**: + +```python +class ProcessorService: + def process_all_inputs(self) -> dict: + """扫描 data/input/ 下所有图片,串行处理。 + + Returns: + { + 'total': int, + 'success': int, + 'failed': int, + 'results': [ + {'image': '...', 'hash': '...', 'status': 'success', + 'supplier': '...', 'bill_date': '...', 'total_amount': '...', + 'result_file': '...', 'error': None}, + ... + ] + } + """ +``` + +**流程**: +1. 扫描 `data/input/{*.png,*.jpg,*.jpeg,*.bmp,*.pdf}` +2. 对每个图片: + - 调 `OCRService.process_image`(已有) + - 拿到 `data/output/{hash}.xlsx` + - 调 `OrderService.process_excel`(已扩展) + - 收集结果 +3. 整体串行(避免百度 API 限流和数据库锁) +4. 返回汇总 dict + +### 5. `app/ui/action_handlers.py` 扩展 + +**新增方法** `batch_process_all_inputs`: +1. 弹进度对话框(不可关闭,tk.Toplevel + progressbar + 实时日志 Text) +2. 在线程中调 `ProcessorService.process_all_inputs` +3. 完成后: + - 在主窗口日志区显示汇总 + - 弹出 MessageBox 列出失败清单 + - 自动打开 `data/result/` 文件夹(可选) + +**新增按钮**:`"一键处理全部图片"` 放在 GUI 主流程区,与现有"一键处理"按钮并列 + +## 三、关键接口契约 + +### OrderMetadata dataclass + +```python +@dataclass +class OrderMetadata: + supplier: str = '' + bill_date: str = '' # YYYYMMDD + total_amount: float = 0.0 + raw_supplier_text: str = '' + + def is_complete(self) -> bool: + return bool(self.supplier and self.bill_date) +``` + +### 文件命名 + +- **result xls**:`采购单_{YYYYMMDD}_{供应商}_{hash}.xls` + - 供应商名清理:`/`、`\`、空格、`*`、`?`、`:`、`"`、`<`、`>`、`|` 替换为 `_` + - hash 长度 32(与现有 md5 一致) +- **原图片**:`{原stem}_{YYYYMMDD}_{供应商}_{hash}.{原扩展名}` + - 不覆盖已重命名的图片 + +### 数据库迁移 + +`ProductDatabase._connect` 中初始化时检查表存在并创建。新增方法: + +```python +def _init_order_metadata_table(self, conn): + conn.execute(""" + CREATE TABLE IF NOT EXISTS order_metadata (...) + """) +``` + +## 四、依赖与时序 + +### 调用时序 + +``` +main_window 点击 "一键处理全部图片" + ↓ +action_handlers.batch_process_all_inputs (UI 线程) + ↓ +processor_service.process_all_inputs (后台线程) + ├─ for each image: + │ ├─ ocr_service.process_image + │ ├─ ocr_text 写到 data/output/{hash}.meta.json ← 新增 + │ ├─ order_service.process_excel + │ │ ├─ 读 {hash}.meta.json → OrderMetadata + │ │ ├─ product_db.save_order_metadata + │ │ ├─ 重命名 input 原图 + │ │ ├─ fill_template 到 临时名 + │ │ └─ 重命名到 采购单_YYYYMMDD_供应商_hash.xls + │ └─ 收集结果 + └─ 返回汇总 dict + ↓ +action_handlers 显示日志汇总 + MessageBox +``` + +## 五、异常处理 + +| 场景 | 处理 | +|---|---| +| `data/input/` 为空 | UI 提示"无待处理图片",不报错 | +| OCR 失败(网络/凭据) | 单张失败跳过,结果记 `error`,继续下一张 | +| process_excel 失败 | 同上 | +| 元信息识别失败 | supplier/bill_date 填空为"未知",继续 | +| 原图重命名冲突 | 加 `_{N}` 后缀 | +| 数据库写入失败 | log 警告,不阻断流程 | + +## 六、性能预期 + +- 单张图 OCR 1~3 秒 + 处理 0.5 秒 = 1.5~3.5 秒 +- 10 张串行:15~35 秒(UI 显示进度条) +- 数据库写入 < 10ms/张,可忽略 + +## 七、风险与缓解 + +| 风险 | 缓解 | +|---|---| +| 供应商名称过长或包含特殊字符 | 清理规则 + 截断到 50 字符 | +| 日期格式五花八门 | 正则覆盖 4 种主流格式 + 校验范围 | +| OCR 文本被错误识别 | 保留 `raw_supplier_text` 便于回查修正 | +| 批量处理中程序崩溃 | 串行处理 + 已处理的不重做(processed_files.json 已记录) | \ No newline at end of file diff --git a/docs/订单批量识别/TASK_订单批量识别.md b/docs/订单批量识别/TASK_订单批量识别.md new file mode 100644 index 0000000..ec39f54 --- /dev/null +++ b/docs/订单批量识别/TASK_订单批量识别.md @@ -0,0 +1,106 @@ +# TASK — 订单批量识别(OCR 元信息提取 + 批量一键处理) + +## 任务依赖图 + +```mermaid +graph TD + T1[任务1: metadata_extractor] --> T4[任务4: order_service 集成] + T2[任务2: order_metadata 表] --> T4 + T3[任务3: OCR meta.json 落盘] --> T4 + T4 --> T5[任务5: 文件命名 + 重命名原图] + T5 --> T6[任务6: processor_service 批量入口] + T6 --> T7[任务7: UI 一键处理按钮 + 进度] + T1 --> T8[任务8: 单元测试] + T2 --> T8 +``` + +## 原子任务列表 + +### 任务 1:metadata_extractor.py +| 项 | 内容 | +|---|---| +| 输入契约 | ocr_text (str)、ocr_rows (list[list[str]]) | +| 输出契约 | OrderMetadata(supplier, bill_date, total_amount, raw_supplier_text) | +| 实现约束 | 供应商关键词清单 + 启发式;日期正则;金额关键词 | +| 验收标准 | 单元测试覆盖:完整三字段、部分缺失、OCR 噪声、日期格式 4 种 | +| 依赖 | 无 | + +### 任务 2:order_metadata 表 + CRUD +| 项 | 内容 | +|---|---| +| 输入契约 | ProductDatabase 实例 | +| 输出契约 | save_order_metadata / get_order_metadata / list_all_metadata | +| 实现约束 | 建表 IF NOT EXISTS;幂等 save(PK = file_hash) | +| 验收标准 | 测试创建/查询/更新/列出 | +| 依赖 | 无 | + +### 任务 3:OCR meta.json 落盘 +| 项 | 内容 | +|---|---| +| 输入契约 | OCRService.process_image 返回的 (excel_path, raw_text) | +| 输出契约 | data/output/{hash}.meta.json 含 ocr_text | +| 实现约束 | 修改 table_ocr.py 返回原始 text;OCRService 调用方写 meta.json | +| 验收标准 | OCR 一次后 meta.json 存在且含完整 text | +| 依赖 | 无 | + +### 任务 4:order_service.process_excel 集成元信息 +| 项 | 内容 | +|---|---| +| 输入契约 | excel_path | +| 输出契约 | (new_result_path, metadata) | +| 实现约束 | 读 meta.json → extract → save → 应用新 result 文件名 | +| 验收标准 | 跑真实 OCR 文件后 result 文件名符合新规则 | +| 依赖 | T1、T2、T3 | + +### 任务 5:原图重命名 +| 项 | 内容 | +|---|---| +| 输入契约 | (原图路径, metadata, file_hash) | +| 输出契约 | 新图路径(已重命名) | +| 实现约束 | 保留扩展名;清理非法字符;冲突加 _N | +| 验收标准 | 处理后 input 中文件名带日期/供应商/hash | +| 依赖 | T4 | + +### 任务 6:processor_service.process_all_inputs +| 项 | 内容 | +|---|---| +| 输入契约 | 无 | +| 输出契约 | dict(total, success, failed, results) | +| 实现约束 | 扫描 data/input/ 全部图片;串行;现有 processed_files.json 不重做 | +| 验收标准 | input 放 3 张图 → 处理后 results 长度=3;status 准确 | +| 依赖 | T4、T5 | + +### 任务 7:UI 一键处理按钮 + 进度 +| 项 | 内容 | +|---|---| +| 输入契约 | UI 按钮点击事件 | +| 输出契约 | 进度对话框 + 主窗口日志更新 + 完成 MessageBox | +| 实现约束 | 后台线程 + UI 通信用 queue;进度不可关闭 | +| 验收标准 | 点击按钮 → 进度可见 → 完成有汇总 | +| 依赖 | T6 | + +### 任务 8:单元测试 +| 项 | 内容 | +|---|---| +| 输入契约 | 测试用例集 | +| 输出契约 | 191 + 新测试 全部 pass | +| 实现约束 | tests/test_metadata_extractor.py;tests/test_order_metadata.py | +| 验收标准 | `pytest tests/` 全绿 | +| 依赖 | T1、T2 | + +## 工作量评估 + +- T1:30 分钟(算法 + 10 个测试用例) +- T2:15 分钟 +- T3:20 分钟 +- T4:30 分钟 +- T5:20 分钟 +- T6:30 分钟 +- T7:40 分钟(UI 线程) +- T8:30 分钟 +- 测试与集成:30 分钟 + +## 交付物 +- 8 个原子任务全部通过 +- `docs/订单批量识别/FINAL_订单批量识别.md`:项目总结 +- `docs/订单批量识别/TODO_订单批量识别.md`:待办清单 \ No newline at end of file