Compare commits
47
Commits
main
..
f58ec994bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f58ec994bc | ||
|
|
7e23d68e9b | ||
|
|
fefcfe4595 | ||
|
|
10ebe9240b | ||
|
|
96cdb0f62e | ||
|
|
76859fd774 | ||
|
|
c06e3e55f9 | ||
|
|
32d41244e5 | ||
|
|
ba8520a351 | ||
|
|
26835e265a | ||
|
|
708402c7fb | ||
|
|
b7bce93995 | ||
|
|
bfccdd3a37 | ||
|
|
3e2f46d26d | ||
|
|
83405a9b8e | ||
|
|
76f7adddd5 | ||
|
|
cd1adc5647 | ||
|
|
fb12e63c4c | ||
|
|
73d17836d7 | ||
|
|
9f97ac3f21 | ||
|
|
3414df5317 | ||
|
|
556f8d8020 | ||
|
|
53e907411d | ||
|
|
c9afe413f5 | ||
|
|
5cf3eeed0f | ||
|
|
ae8d479acd | ||
|
|
b3c175836a | ||
|
|
c0fceea9dc | ||
|
|
772902c919 | ||
|
|
c3a0e29b19 | ||
|
|
9b2007a995 | ||
|
|
4a8169ff63 | ||
|
|
201aac35e6 | ||
|
|
f5eda6cbd8 | ||
|
|
5c0b709528 | ||
|
|
7b7d491663 | ||
|
|
390eeb67af | ||
|
|
4c8def4b04 | ||
|
|
2f088c87ca | ||
|
|
b9739b5267 | ||
|
|
0b40caaf91 | ||
|
|
693c17283b | ||
|
|
71ca90ba6e | ||
|
|
14eeb7b39a | ||
|
|
b3cecda175 | ||
|
|
131fff6a7d | ||
|
|
0035cd1893 |
@@ -1,20 +0,0 @@
|
|||||||
.venv/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
.claude/
|
|
||||||
.playwright-mcp/
|
|
||||||
.trae/
|
|
||||||
node_modules/
|
|
||||||
web/frontend/node_modules/
|
|
||||||
data/
|
|
||||||
docs/
|
|
||||||
tests/
|
|
||||||
*.md
|
|
||||||
!README.md
|
|
||||||
release/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
*.spec
|
|
||||||
*.exe
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 百度 OCR API 配置
|
|
||||||
BAIDU_API_KEY=your_api_key_here
|
|
||||||
BAIDU_SECRET_KEY=your_secret_key_here
|
|
||||||
+3
-22
@@ -1,6 +1,3 @@
|
|||||||
# Environment
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -18,25 +15,9 @@ release/
|
|||||||
logs/
|
logs/
|
||||||
data/temp/
|
data/temp/
|
||||||
|
|
||||||
# Runtime data (all runtime outputs, caches, databases)
|
# Runtime outputs
|
||||||
data/
|
data/output/
|
||||||
|
data/result/
|
||||||
# Claude Code / IDE
|
|
||||||
.claude/
|
|
||||||
.playwright-mcp/
|
|
||||||
.trae/
|
|
||||||
|
|
||||||
# Old project
|
|
||||||
wework_xiaoai_bot/
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Frontend build output
|
|
||||||
web/backend/static/
|
|
||||||
|
|
||||||
# Screenshots (from testing)
|
|
||||||
*.png
|
|
||||||
|
|
||||||
# OS/IDE
|
# OS/IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## 问题与目标
|
||||||
|
- 弹窗尺寸偏小,不便操作
|
||||||
|
- 弹窗及文件选择后没有在最上层,易被其他窗口遮挡
|
||||||
|
- 字段英文名不直观,需要显示中文对应(银豹模板列)
|
||||||
|
|
||||||
|
## 改进方案
|
||||||
|
### 1. 弹窗尺寸与置顶行为
|
||||||
|
- 将列映射向导窗口尺寸调整为 `780x660`,保持自适应(子控件 `fill=tk.BOTH, expand=True`)
|
||||||
|
- 打开向导时:`dlg.lift()`、`dlg.attributes('-topmost', True)`,`after_idle` 取消置顶但保持焦点;`dlg.transient(root)`、`dlg.grab_set()` 防止被遮挡
|
||||||
|
- 选择文件后的回调中再次 `dlg.lift()` 和短暂置顶,确保返回后窗口在最上层
|
||||||
|
- 同步为模板管理窗口应用同样策略
|
||||||
|
|
||||||
|
### 2. 字段标签中文提示
|
||||||
|
- 列映射向导的标准字段改为按 `ColumnMapper.STANDARD_COLUMNS` 动态生成(若可用),并为每个字段追加中文说明,例如:
|
||||||
|
- `barcode(条码)`
|
||||||
|
- `name(商品名称)`
|
||||||
|
- `specification(规格)`
|
||||||
|
- `quantity(数量)`
|
||||||
|
- `unit(单位)`
|
||||||
|
- `unit_price(采购单价)`
|
||||||
|
- `total_price(金额/小计)`
|
||||||
|
- `category(类别)`
|
||||||
|
- `brand(品牌)`
|
||||||
|
- `supplier(供应商)`
|
||||||
|
- 若 `ColumnMapper` 不可用,则用内置 `friendly_labels` 字典生成上述标签
|
||||||
|
|
||||||
|
### 3. 布局优化与可用性
|
||||||
|
- 保持“文件路径 + 浏览 + 预览前30行 + 加载列”四项同一行,按钮设 `padx=6`,保证易点
|
||||||
|
- 映射区使用 `ttk.Combobox(state='readonly')`,宽度适配,并确保行高足够(留白)
|
||||||
|
- 预览区保留顶部表格(前30行),支持点击行自动填充表头行号;加载列时按指定行读取
|
||||||
|
|
||||||
|
### 4. 代码改动位置
|
||||||
|
- `启动器.py`:
|
||||||
|
- `open_column_mapping_wizard_alt`:调整几何尺寸与置顶;在“浏览”回调与预览/加载流程中补充 `lift/topmost`;扩展标准字段与中文标签
|
||||||
|
- `open_template_manager`:统一尺寸与置顶行为
|
||||||
|
- 仅UI与行为改动,不影响处理逻辑;保存仍写入 `suppliers_config.json` 的 `header_row` 与 `column_mapping`
|
||||||
|
|
||||||
|
### 5. 验证
|
||||||
|
- 打开列映射向导:窗口足够大,居中且在最上层;选择文件后窗口仍在最上层
|
||||||
|
- 字段标签显示为英+中:如 `name(商品名称)`
|
||||||
|
- 预览前30行与加载列同一行;点击预览行自动写入表头行号;保存后提示成功并写入配置
|
||||||
|
|
||||||
|
请确认以上方案,确认后我将立即实施并验证。
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## 目标
|
||||||
|
- 在系统设置中提供“供应商管理”GUI,支持新增/编辑/删除供应商,无需手改代码或JSON。
|
||||||
|
- 一站式配置:基本信息、表头与列映射、规则与词典、模板管理,保存后即时生效。
|
||||||
|
|
||||||
|
## 界面设计
|
||||||
|
- 入口:右侧“系统设置”新增按钮“供应商管理”。
|
||||||
|
- 布局:
|
||||||
|
- 左栏:供应商列表(名称),支持搜索/新建/复制/删除。
|
||||||
|
- 右侧Tab:
|
||||||
|
1) 基本信息:`name`、`description`、`filename_patterns`、`content_indicators`、`header_row`
|
||||||
|
2) 列映射与表头:嵌入现有“列映射向导”核心(预览前30行、表头选行、加载列、智能映射、导入/导出)
|
||||||
|
3) 规则与词典:词典编辑(忽略词、单位同义词、包装倍数、名称正则、默认单位/包装);规则预设与规则预览(原始→规范化)
|
||||||
|
4) 模板管理:`output_templates` 列表、当前选择与批量校验
|
||||||
|
- 操作按钮:保存(写入`suppliers_config.json`)、重载处理器、导入/导出供应商配置(单个或全部)。
|
||||||
|
|
||||||
|
## 数据流与验证
|
||||||
|
- 加载/保存:统一读写`config/suppliers_config.json`;保存后调用`ProcessorService.reload_processors()`。
|
||||||
|
- 校验:复用`ProcessorService._validate_suppliers_config`;保存前进行schema校验,错误弹窗聚合列表。
|
||||||
|
- 预设:提供“基础拆分与推断”规则预设;可导入/导出自定义规则。
|
||||||
|
|
||||||
|
## 增强逻辑(自动化建议)
|
||||||
|
- 新建供应商时,可选择样例Excel:
|
||||||
|
- 自动检测表头与初始列映射;基于列名关键词给出映射建议。
|
||||||
|
- 词典预填:常见单位同义词、默认单位“瓶”、常见包装倍数(件/箱/提/盒)。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
1) 创建`open_supplier_manager`弹窗(系统设置入口),左列表+右侧Tab结构。
|
||||||
|
2) 基本信息Tab:表单与校验;保存更新到JSON。
|
||||||
|
3) 列映射与表头Tab:复用现有向导组件(预览/加载/智能映射/导入/导出)。
|
||||||
|
4) 规则与词典Tab:编辑词典与规则预览,保存写入`dictionary`与`rules`。
|
||||||
|
5) 模板管理Tab:维护`output_templates`与`current_template_index`,批量校验与报告显示。
|
||||||
|
6) 保存与重载:统一写入后调用处理器重载并日志提示。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- GUI走查:新增/复制/删除供应商配置;规则预览正确;模板校验能识别缺失列。
|
||||||
|
- 处理生效:保存后立刻按新规则规范化并填充模板输出。
|
||||||
|
|
||||||
|
## 后续扩展
|
||||||
|
- 多供应商批量校验与报告导出;词典共享/继承;规则预设库扩展。
|
||||||
|
|
||||||
|
如果确认,我将开始实现该GUI及其数据流,并将现有向导整合到供应商管理中以形成一站式配置体验。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
## 痛点复盘
|
||||||
|
- 不同供应商存在强差异:列名不统一、单位混杂在数量、规格隐藏在名称、供应商缺失等
|
||||||
|
- 现有向导只解决列映射层面,规则与词典编辑、执行顺序与生效范围不清晰
|
||||||
|
- 缺少可视化“从原始→规范化→模板填充”的贯通验证;流程易跑不通
|
||||||
|
|
||||||
|
## 总体方案
|
||||||
|
- 建立“供应商规则系统”:可配置、可视化、可预览,贯穿 映射→清洗→规则→模板 填充全链路
|
||||||
|
- 提供“规则库 + 词典 + 执行管道”三层抽象,支持每家供应商自定义规则组合与执行顺序
|
||||||
|
- 完善 GUI:供应商管理中的四大Tab一站式配置,规则编辑器内置预设与预览,落地即验证
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
- `suppliers_config.json` 每个供应商对象结构:
|
||||||
|
- `name`、`description`
|
||||||
|
- `filename_patterns`、`content_indicators`
|
||||||
|
- `header_row`
|
||||||
|
- `column_mapping`(源列→标准列)
|
||||||
|
- `rules`: 有序规则数组(见下)
|
||||||
|
- `dictionary`: 解析词典(`ignore_words`、`unit_synonyms`、`pack_multipliers`、`name_patterns`、`default_unit`、`default_package_quantity`)
|
||||||
|
- `output_templates`: 模板列表;`current_template_index`: 当前模板索引
|
||||||
|
|
||||||
|
## 规则库(首批)
|
||||||
|
- `split_quantity_unit(source)`: 拆分数量中的单位(箱/件/提/盒/瓶),无单位用默认单位
|
||||||
|
- `extract_spec_from_name(source)`: 从名称抽取规格/包装(容量×数量/简单双乘),应用忽略词与名称正则
|
||||||
|
- `normalize_unit(target,map)`: 单位归一(同义词→统一单位),件/箱/提/盒按包装倍数转换数量为“瓶”
|
||||||
|
- `compute_quantity_from_total()`: 数量缺失时用金额/单价回推数量
|
||||||
|
- `fill_missing(fills)`: 缺失填充,例如单位默认“瓶”
|
||||||
|
- `mark_gift()`: 金额/单价为0或名称含“赠品/O/o/空”标记赠品
|
||||||
|
- 后续扩展:`classify_category(name)`, `extract_brand(name)`, `strip_noise(name)` 等
|
||||||
|
|
||||||
|
## 执行管道
|
||||||
|
- 处理器执行顺序:映射→清洗→规则(有序)→模板填充
|
||||||
|
- 每条规则可访问 `dictionary`,执行结果在 DataFrame 上可追踪(供预览)
|
||||||
|
- 提供“预览栈”:展示原始→每步规则输出(多步Diff),定位问题
|
||||||
|
|
||||||
|
## GUI优化
|
||||||
|
- 供应商管理:
|
||||||
|
- 左侧供应商列表(搜索/新建/复制/删除/导入/导出)
|
||||||
|
- 右侧四大Tab:
|
||||||
|
- 基本信息:必填校验与保存
|
||||||
|
- 列映射与表头:现有向导增强(滚动条、表头选行、智能映射、导入/导出)
|
||||||
|
- 规则与词典:
|
||||||
|
- 规则编辑器(顺序可调整:上/下移动、插入/删除规则)
|
||||||
|
- 词典编辑(忽略词、单位同义词、包装倍数、名称正则、默认值)
|
||||||
|
- 规则预设(常用组合)与“应用规则预览”(展示原始→规范化两列;可切换查看逐步Diff)
|
||||||
|
- 模板管理:模板列表/当前选择与批量校验,显示缺失/多余列报告
|
||||||
|
- 性能与体验:所有弹窗置顶回焦;滚动与水平滚动;列宽拖拽与一键导出预览为CSV
|
||||||
|
|
||||||
|
## 使用路径(推荐)
|
||||||
|
1. 新建供应商→选样例Excel→自动表头与初始映射建议
|
||||||
|
2. 规则预设:选择“基础拆分与推断”→应用规则预览→查看原始/规范化对比
|
||||||
|
3. 细化词典:补充忽略词、单位同义词、包装倍数、名称正则→再次预览
|
||||||
|
4. 保存并重载→跑一份真实文件→最近文件中打开结果核验→如有差异回到规则编辑器微调
|
||||||
|
|
||||||
|
## 验证与可视化
|
||||||
|
- 单元测试:表头识别/数量拆分/名称规格解析/单位归一/数量回推/赠品标记
|
||||||
|
- 烟雾测试:5–10类典型供货商样本端到端验证(含极端情况:无单位、名称含噪声、数量混合单位)
|
||||||
|
- 日志:每步规则执行计数与示例行输出(前/后5行),便于定位问题
|
||||||
|
|
||||||
|
## 交付物
|
||||||
|
- 规则引擎模块与规范接口;处理器接入
|
||||||
|
- 供应商管理GUI(规则编辑器、词典编辑器、预设与预览)
|
||||||
|
- 扩展配置示例与测试数据;打包脚本校验资源
|
||||||
|
|
||||||
|
## 里程碑
|
||||||
|
- Day 1:规则库与引擎扩展、处理器接入、预览栈接口
|
||||||
|
- Day 2:GUI规则编辑器(顺序调整/增删)、词典编辑器、规则预设与预览
|
||||||
|
- Day 3:模板批量校验、单元与烟雾测试、日志强化
|
||||||
|
- Day 4:回归修正与打包交付
|
||||||
|
|
||||||
|
确认后我将开始实现上述内容,确保不同供应商可独立配置精细规则并“所见即所得”验证,流程稳定可跑通。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## 目标
|
||||||
|
- 为具体供应商定制更细的解析规则(词典、包装倍数、忽略词、同义单位),并在列映射向导提供可视化编辑入口与预览。
|
||||||
|
|
||||||
|
## 配置扩展
|
||||||
|
- 扩展 `suppliers_config.json` 每个供应商对象新增:
|
||||||
|
- `dictionary`: 解析词典
|
||||||
|
- `ignore_words`: ["白膜","彩膜","赠品"](在名称解析时剔除)
|
||||||
|
- `unit_synonyms`: {"箱":"件","提":"件","盒":"件","瓶":"瓶"}
|
||||||
|
- `pack_multipliers`: {"件": 24, "箱": 24, "提": 12, "盒": 10}(缺规格时用于单位归一)
|
||||||
|
- `name_patterns`: [正则表达式](从名称抽取规格/容量×数量,如 `([\d\.]+)(ml|l|升|毫升)[*×xX](\d+)`)
|
||||||
|
- `default_unit`: "瓶"
|
||||||
|
- `default_package_quantity`: 1
|
||||||
|
- `rules`: 规则数组(与现有一致),规则在执行时可访问 `dictionary`
|
||||||
|
- `output_templates`: 模板列表;`current_template_index`: 当前选择索引
|
||||||
|
|
||||||
|
## 规则引擎增强
|
||||||
|
- 在 `app/core/handlers/rule_engine.py`:
|
||||||
|
- `apply_rules(df, rules, dictionary=None)` 接口增加 `dictionary` 参数
|
||||||
|
- `extract_spec_from_name`:先剔除 `ignore_words`,匹配 `name_patterns`,无匹配时按 `pack_multipliers` 推断包装数量
|
||||||
|
- `normalize_unit`:使用 `unit_synonyms` 统一单位;如单位为“件/箱/提/盒”且有 `package_quantity` 或 `pack_multipliers`,数量×包装并单位归一为“瓶”
|
||||||
|
- `split_quantity_unit`:解析数量中的单位,同义词归一;无单位时用 `default_unit`
|
||||||
|
- 其余规则(回推数量、填充、赠品标记)保持不变
|
||||||
|
|
||||||
|
## 供应商处理器接入
|
||||||
|
- `GenericSupplierProcessor`:
|
||||||
|
- 从 `supplier_config['dictionary']` 取词典并传入 `apply_rules`,保证每家供应商按自身词典执行
|
||||||
|
- 若未配置词典,使用默认空词典
|
||||||
|
|
||||||
|
## 向导UI扩展(右侧系统设置→列映射向导)
|
||||||
|
- 增加“供应商规则”区域:
|
||||||
|
- 可编辑列表:
|
||||||
|
- 忽略词(多行输入或表格)
|
||||||
|
- 单位同义词(键值对表格:原单位→统一单位)
|
||||||
|
- 包装倍数(单位→包装数量)
|
||||||
|
- 名称正则(多行,每行一个表达式)
|
||||||
|
- 默认单位、默认包装数量(输入框)
|
||||||
|
- 操作:新增/删除、导入(JSON)/导出(JSON)、保存
|
||||||
|
- 规则预览:
|
||||||
|
- 选择预设(基础拆分与推断或自定义),点击“应用规则预览”,显示“原始/规范化”两列树表对比
|
||||||
|
- 保存行为:将 `dictionary` 与 `rules` 写入对应供应商的 `suppliers_config.json` 并重载处理器
|
||||||
|
|
||||||
|
## 验证与测试
|
||||||
|
- 单元测试:
|
||||||
|
- 名称解析(容量×数量、简单乘法、忽略词影响、同义词归一)
|
||||||
|
- 数量拆分与单位归一(“4瓶/1箱/3件/2提/2盒”)
|
||||||
|
- 包装倍数应用与数量回推
|
||||||
|
- 烟雾测试:构建 5–10 类供货商样本,验证端到端转换与模板填充可用
|
||||||
|
|
||||||
|
## 交付
|
||||||
|
- 完成词典与规则编辑入口、配置扩展与引擎接入,提交验证报告与示例配置;保留导入/导出便于你迭代调整。
|
||||||
|
|
||||||
|
确认后我将按此方案实现:扩展配置→增强规则引擎→处理器接入→向导UI与预览→测试。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## 原因
|
||||||
|
|
||||||
|
* 按钮调用了 `safe_open_validation_panel`,其内部检查 `open_validation_panel` 是否存在;当前代码中未定义该函数,导致始终提示“程序未加载,请重启”。
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
* 在 `启动器.py` 中新增顶层函数 `open_validation_panel(log_widget)`,与其它 `open_*` 工具函数并列,确保加载顺序稳定。
|
||||||
|
|
||||||
|
* 保留按钮绑定到 `safe_open_validation_panel`,其将直接调用新定义的 `open_validation_panel`。
|
||||||
|
|
||||||
|
## 实施
|
||||||
|
|
||||||
|
* 添加 `open_validation_panel`:
|
||||||
|
|
||||||
|
* 入口参数:`log_widget`
|
||||||
|
|
||||||
|
* 实现与之前描述一致:供应商选择、原始文件/期望结果选择、运行验证生成差异、生成建议并“应用建议”写回配置。
|
||||||
|
|
||||||
|
* 放置位置:`show_supported_processors` 与 `safe_open_validation_panel` 相邻区域,确保可见与可用。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
* 启动程序→系统设置→点击“验证匹配”,应正常打开面板无提示。
|
||||||
|
|
||||||
|
* 选择“农夫山泉”与提供的文件后运行验证,查看差异与建议。
|
||||||
|
|
||||||
|
## 预期
|
||||||
|
|
||||||
|
* 面板不再提示重启;功能可用。
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## 目标
|
||||||
|
- 用你提供的原始文件与期望结果跑通“验证闭环”,自动对比差异并生成修正建议。
|
||||||
|
- 将“规则设置”改造成通俗易懂的“向导 + 快速模板 + 自动识别”,让普通用户也能完成操作。
|
||||||
|
|
||||||
|
## 验证闭环(立即可用)
|
||||||
|
- 新增“验证匹配”面板:
|
||||||
|
- 选择原始Excel:`data/output/微信图片_20251115212128_148_108.xlsx`
|
||||||
|
- 选择期望结果:`data/result/采购单_微信图片_20251115212128_148_108.xls`
|
||||||
|
- 一键运行当前供应商流程 → 自动生成临时结果 → 与期望结果进行单元格级对比
|
||||||
|
- 输出差异报告:
|
||||||
|
- 列差异(列缺失/多余/名称不一致)
|
||||||
|
- 行差异(按条码或名称对齐,数量/单位/单价/金额差异)
|
||||||
|
- 规则差异归因(例如:数量未拆分、单位未归一)
|
||||||
|
- 按“应用建议”自动调整当前供应商的规则/词典(可撤销)
|
||||||
|
|
||||||
|
## 简化操作设计
|
||||||
|
- 两种模式:
|
||||||
|
- 简单模式(默认):
|
||||||
|
- 步骤:选择文件 → 选择供应商 → 选择快速模板 → 预览 → 生成
|
||||||
|
- 字段中文说明(name(商品名称)、quantity(数量)等)与自动建议
|
||||||
|
- 高级模式:
|
||||||
|
- 可编辑规则顺序与参数、词典、正则;具备步骤预览与Diff
|
||||||
|
- 快速模板:
|
||||||
|
- “无数量/单位列”模板:自动配置拆分数量单位→名称提取规格→单位归一→缺省填充→标记赠品
|
||||||
|
- “纯金额/单价反推数量”模板:直接回推数量
|
||||||
|
- “条码驱动匹配”模板:条码为主键对齐
|
||||||
|
|
||||||
|
## 自动识别与建议
|
||||||
|
- 列名识别:从表头关键词自动映射常用字段
|
||||||
|
- 单位与数量:从“订单数量”拆分,单位同义词与包装倍数自动套用
|
||||||
|
- 名称提取规格:词典与正则库(容量×数量、双乘、“550水24白膜”)自动解析;失败行计数提示
|
||||||
|
- 自动修正建议:
|
||||||
|
- 若数量为空:建议添加“split_quantity_unit(source=订单数量)”
|
||||||
|
- 若单位为“箱/件/提/盒”:建议添加“normalize_unit + pack_multipliers”
|
||||||
|
- 若金额与单价存在且数量为空:建议添加“compute_quantity_from_total”
|
||||||
|
|
||||||
|
## 差异对比与一键修复
|
||||||
|
- 差异可视化:原始→规范化→模板填充三栏对比;支持仅显示变化列、导出CSV
|
||||||
|
- 一键修复:将建议以“卡片”列表展示,点击应用后立即刷新预览;支持撤销上一步
|
||||||
|
|
||||||
|
## 交互流程
|
||||||
|
1) 进入“验证匹配” → 选择原始与期望 → 运行 → 查看差异
|
||||||
|
2) 点击“应用建议”直观修复规则/词典 → 差异减少直至匹配
|
||||||
|
3) 切回“简单模式”一键生成采购单
|
||||||
|
|
||||||
|
## 技术实现要点
|
||||||
|
- 供应商配置:继续使用 `suppliers_config.json`,但在GUI中隐藏JSON细节,改为中文表单与下拉
|
||||||
|
- 规则执行:保留引擎,新增“建议生成器”根据差异报告生成规则/词典变更
|
||||||
|
- 对比对齐:优先按条码对齐;无条码时按名称近似匹配(去噪后模糊比对)
|
||||||
|
|
||||||
|
## 里程碑
|
||||||
|
- Day 1:验证面板与差异对比;建议生成器(数量/单位/规格/金额)
|
||||||
|
- Day 2:简单/高级模式切换;快速模板与自动识别
|
||||||
|
- Day 3:一键修复与撤销;导出差异报告
|
||||||
|
- Day 4:回归测试与打包;提供“农夫山泉”预设并用你的样例验证匹配
|
||||||
|
|
||||||
|
确认后我将实现“验证匹配”面板并接入建议修复,先用你提供的农夫山泉样例跑通闭环,再推广到其它供应商。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## 目标
|
||||||
|
- 按照优化实施计划,从第3周任务7/8开始全面落地,并同时推进第4周任务9/10,确保可视化、配置驱动和交付质量提升。
|
||||||
|
|
||||||
|
## 任务7:列映射向导完善
|
||||||
|
- 字段扩展与中文提示:标准字段统一以英+中展示,支持更多列(条码、名称、规格、数量、单位、单价、金额/小计、分类、品牌、供应商)。
|
||||||
|
- 自动建议增强:结合样本数据统计(字符串比例、特殊关键词、数值特征)给出更可靠的映射建议。
|
||||||
|
- 表头定位增强:支持预览选择,并保存 `header_row`;向导内清晰提示当前使用的表头行。
|
||||||
|
- 配置管理:支持导入/导出映射方案(JSON),可一键应用到多供应商。
|
||||||
|
- 热重载与快捷入口:保存后自动重载供应商处理器;在系统设置区和快捷键中提供入口。
|
||||||
|
|
||||||
|
## 任务8:模板管理与校验完善
|
||||||
|
- 模板组管理:支持为每个供应商选择/保存多个模板(默认、备用),UI下拉选择当前模板。
|
||||||
|
- 差异检测:读取模板首行表头与系统标准列比较,列表显示缺失/多余字段与修复建议。
|
||||||
|
- 批量校验:可一次性校验选定供应商的所有模板组并生成报告。
|
||||||
|
- 示例生成:按标准列生成示例模板,便于对齐格式。
|
||||||
|
|
||||||
|
## 任务9:单元测试与烟雾测试
|
||||||
|
- 单元测试(pytest):
|
||||||
|
- `_find_header_row` 不同格式表头识别(第一行/中间行/合并行/空白行混杂)。
|
||||||
|
- `GenericSupplierProcessor` 列映射、清洗规则(remove_rows/fill_na/convert_type)与公式计算。
|
||||||
|
- 模板填充的关键路径(必要列检测、数量/赠送量/单价写入)。
|
||||||
|
- 烟雾测试:准备小样本(图片→OCR→Excel→采购单),验证端到端可用,记录日志与输出。
|
||||||
|
|
||||||
|
## 任务10:打包与版本信息
|
||||||
|
- 版本信息:主窗口标题与“关于”对话框显示版本号、构建时间、更新日志入口。
|
||||||
|
- 打包校验:构建后检查模板与配置文件存在性;打包时拷贝资源并生成校验报告。
|
||||||
|
- 更新脚本:完善 `build_exe.py` 支持版本号注入与资源校验失败时中止。
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
- GUI:系统设置页整合入口;对话框使用 780×660/540 尺寸,`topmost`/`lift`/`focus_force` 保证焦点。
|
||||||
|
- 配置:`suppliers_config.json` 增加 `header_row`、`column_mapping`、`output_templates`(数组)与当前选择索引。
|
||||||
|
- 处理器:优先使用保存的表头行;模板管理选择的模板在处理时应用。
|
||||||
|
- 日志与提示:保存/校验/重载操作统一写入日志面板并气泡提示。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- 交互走查:向导流程顺畅、字段标签明确、置顶行为正常。
|
||||||
|
- 单元测试通过率达标(≥90%覆盖关键模块);烟雾测试输出正确文件且无异常。
|
||||||
|
- 打包后 EXE 启动显示版本信息;资源齐备,最近文件可双击打开。
|
||||||
|
|
||||||
|
## 变更范围
|
||||||
|
- `启动器.py`(设置页UI、向导与模板管理弹窗)
|
||||||
|
- `app/core/processors/supplier_processors/generic_supplier_processor.py`(读取与映射逻辑)
|
||||||
|
- `app/core/excel/processor.py`(表头行识别与模板填充校验)
|
||||||
|
- `config/suppliers_config.json`(结构扩展)
|
||||||
|
- `tests/`(新增pytest用例与烟雾测试脚本)
|
||||||
|
- `build_exe.py`(版本注入与校验)
|
||||||
|
|
||||||
|
## 时间安排
|
||||||
|
- Day 1:任务7 UI/配置完成,处理器联动与验证
|
||||||
|
- Day 2:任务8 模板管理增强与批量校验
|
||||||
|
- Day 3:任务9 单元测试与烟雾测试
|
||||||
|
- Day 4:任务10 打包与版本信息,回归测试与文档补充
|
||||||
|
|
||||||
|
确认后我将按以上计划开始实施并逐项提交验证结果。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## 概览
|
||||||
|
- 目标:让改动更可见并提升识别/处理准确性与可操作性
|
||||||
|
- 范围:GUI入口与提示、表头识别与手动指定、供应商配置联动、交互一致性
|
||||||
|
|
||||||
|
## 可见入口改造
|
||||||
|
- 在“Excel处理→特殊处理”区新增两个明显按钮:
|
||||||
|
- “列映射向导”:加载 Excel 源列 → 映射到标准列 → 保存至 `config/suppliers_config.json`
|
||||||
|
- “模板管理”:选择模板、校验表头列是否包含必需项 → 保存路径至供应商配置
|
||||||
|
- 在“快捷操作”区补充一个“显示处理器类型”入口,便于确认支持的文件类型
|
||||||
|
- 在保存成功后增加气泡提示与日志条目(log 面板出现“列映射已保存”“模板路径已保存”)
|
||||||
|
|
||||||
|
## 手动表头行支持
|
||||||
|
- 在“列映射向导”弹窗增加“表头行号(从1开始)”输入框:
|
||||||
|
- 加载列时按照指定行重读 Excel 并展示源列
|
||||||
|
- 保存时将 `header_row`(零基索引)写入 `suppliers_config.json`
|
||||||
|
- 通用供应商处理器读取时优先使用 `supplier_config['header_row']`;未设置时执行自动表头检测(关键词+非空比例+字符串比例)
|
||||||
|
|
||||||
|
## 自动表头检测增强
|
||||||
|
- Excel 单文件处理与通用供应商处理器:
|
||||||
|
- 扫描范围扩大到前 30 行
|
||||||
|
- 扩充关键词:加入金额类(“金额/小计/总计/合计/合计金额”)
|
||||||
|
- 综合评分:关键词命中+非空比例+字符串比例,阈值达到即判为表头;否则选第一个有效行
|
||||||
|
|
||||||
|
## 交互与一致性
|
||||||
|
- “单个识别”按钮直接弹出图片选择(只允许 `jpg/jpeg/png/bmp`),取消即终止
|
||||||
|
- “单个处理”按钮弹出 Excel 选择(只允许 `xlsx/xls`),取消即终止,不再默认处理最新 Excel
|
||||||
|
- 处理完成后不再自动打开 `result/output` 目录,改为通过“最近文件”双击打开
|
||||||
|
- 最近文件仅记录图片/Excel,自动过滤无效并按修改时间降序展示,空历史时从 `data/output`、`data/result` 回填合规文件
|
||||||
|
|
||||||
|
## 供应商配置联动
|
||||||
|
- `suppliers_config.json`:
|
||||||
|
- `column_mapping`:保存源列→标准列映射
|
||||||
|
- `header_row`:保存手动指定的表头行(零基)
|
||||||
|
- `output_template`:保存模板路径(相对路径)
|
||||||
|
- 处理器加载时校验并日志提示不合法项,保留已通过的配置
|
||||||
|
|
||||||
|
## 验证与可视化
|
||||||
|
- 操作日志:保存映射/模板时写入成功日志
|
||||||
|
- 处理器信息:通过“显示处理器类型”对话框列出支持的扩展名和描述
|
||||||
|
- 手动表头验证:在向导中“加载列”后,源列下拉应出现正确列名;处理后“最近文件”出现输出采购单
|
||||||
|
|
||||||
|
## 预计改动位置
|
||||||
|
- GUI按钮与向导弹窗:`启动器.py`(特殊处理区与工具函数)
|
||||||
|
- 自动表头检测与手动表头使用:
|
||||||
|
- Excel处理:`app/core/excel/processor.py` 的 `_find_header_row`、`process_specific_file`
|
||||||
|
- 通用供应商处理:`app/core/processors/supplier_processors/generic_supplier_processor.py` 的 `_read_supplier_data`、`_find_header_row`
|
||||||
|
- 供应商配置:`config/suppliers_config.json`
|
||||||
|
|
||||||
|
## 交付
|
||||||
|
- 提交代码改动后,更新打包 EXE,便携包包含上述 UI 与功能;提供操作说明与可见验证点。
|
||||||
|
|
||||||
|
请确认以上计划,确认后我将按此逐项实现并验证。
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
## 你现在就能这样做
|
||||||
|
- 目标:在没有“规格/数量/单位”列、只有“订单数量”列(如“4瓶/1箱/5箱/3件”)时,拆分出数量与单位,并按名称提取规格,最终规范化为银豹模板字段。
|
||||||
|
|
||||||
|
### A. 最小映射(只为模板填充)
|
||||||
|
- 在“供应商管理 → 列映射与表头”页,只需映射这些常用列:
|
||||||
|
- name ← 商品名称
|
||||||
|
- barcode ← 条码(如有)
|
||||||
|
- unit_price ← 单价(如有)
|
||||||
|
- total_price ← 金额/小计(如有)
|
||||||
|
- 不需要映射“数量/单位/规格”,后续用规则直接从原始列生成。
|
||||||
|
|
||||||
|
### B. 规则与词典设置(关键)
|
||||||
|
- 在“供应商管理 → 规则与词典”页:
|
||||||
|
1) 规则列表按顺序添加:
|
||||||
|
- split_quantity_unit,参数:source=订单数量
|
||||||
|
- extract_spec_from_name,参数:source=商品名称
|
||||||
|
- normalize_unit,参数:target=unit,map={"箱":"件","提":"件","盒":"件"}
|
||||||
|
- fill_missing,参数:fills={"unit":"瓶"}
|
||||||
|
- mark_gift(可选)
|
||||||
|
- compute_quantity_from_total(可选,当只有金额/单价时)
|
||||||
|
2) 词典设置:
|
||||||
|
- unit_synonyms:{"箱":"件","提":"件","盒":"件","瓶":"瓶"}
|
||||||
|
- pack_multipliers:{"件":24,"箱":24,"提":12,"盒":10}(根据你的供应商习惯调整)
|
||||||
|
- default_unit:瓶
|
||||||
|
- default_package_quantity:1
|
||||||
|
- ignore_words:如(白膜、彩膜、赠品)
|
||||||
|
- name_patterns(每行一个正则):
|
||||||
|
- (\d+(?:\.\d+)?)(ml|l|升|毫升)[*×xX](\d+)
|
||||||
|
- (\d+)[*×xX](\d+)瓶
|
||||||
|
- 需要时加入供应商特有格式,如“550水24白膜”可匹配“(\d{2,3}).*?(\d{1,3})”并将第二组当作包装数
|
||||||
|
|
||||||
|
### C. 预览与验证
|
||||||
|
- 在“规则与词典”页选择Excel,点击“生成步骤预览”:
|
||||||
|
- 看“原始 → 规范化”两侧表,对比是否出现 quantity(数值)、unit(单位)、specification、package_quantity
|
||||||
|
- 在“预览步骤”中逐步查看每一条规则的结果;必要时导出CSV核对
|
||||||
|
- 保存后会自动重载处理器;用真实文件跑一次,结果会出现在“最近文件”,双击打开核验。
|
||||||
|
|
||||||
|
### D. 解释关键点
|
||||||
|
- 映射是为了模板填充字段的命名统一;对于“订单数量”这种来源列,规则会直接读取 source=订单数量,不要求你把它映射成标准列。
|
||||||
|
- 规则执行后,会产生标准字段:quantity、unit、specification、package_quantity,可被模板填充。
|
||||||
|
- normalize_unit会用词典的pack_multipliers或name解析出的package_quantity,自动把“件/箱/提/盒”的数量换算成“瓶”,并将unit统一为“瓶”。
|
||||||
|
|
||||||
|
## 我将继续优化的点(让流程更清晰)
|
||||||
|
1) 规则表单中的“来源列”改为可选下拉(直接从Excel列名取值),无需手填。
|
||||||
|
2) 步骤预览支持“仅显示变化的列(Diff模式)”与“错误计数”(未匹配的行统计)。
|
||||||
|
3) 提供“无数量/单位列”的快速模版:自动添加上述规则与词典默认值,一键套用。
|
||||||
|
4) 规则帮助提示:每条规则说明输入/输出字段与常见示例。
|
||||||
|
5) 供应商级的“测试运行”:选文件→一键规范化→预览→导出结果,独立于正式处理。
|
||||||
|
|
||||||
|
如果你同意,我将按上述方案完善GUI交互(来源列下拉、快速模板、Diff预览、错误计数与一键测试),并把默认规则与词典预设直接提供出来,确保“只有订单数量列”的场景开箱可用。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## 目标
|
||||||
|
- 通过规则引擎把不同供货商的OCR表格规范为统一字段,并自动拆分“数量+单位”、从名称推断规格、标记赠品,最终稳定填充银豹模板。
|
||||||
|
|
||||||
|
## 配置与规则
|
||||||
|
- 扩展 `suppliers_config.json`:
|
||||||
|
- `rules`: 规则数组(顺序执行)
|
||||||
|
- `output_templates`: 模板列表;`current_template_index`: 当前模板索引
|
||||||
|
- 规则类型:
|
||||||
|
- `split_quantity_unit`(拆分“4瓶/1箱/3件/2提/2盒”)
|
||||||
|
- `extract_spec_from_name`(解析“1.8L×8瓶”“550水24白膜”等)
|
||||||
|
- `normalize_unit`(单位归一“箱/件/提/盒/瓶”→统一“瓶”,按包装转换数量)
|
||||||
|
- `compute_quantity_from_total`(数量缺失时用金额/单价反推)
|
||||||
|
- `infer_supplier`(文件名/内容推断供应商)
|
||||||
|
- `fill_missing`(填默认值)
|
||||||
|
- `mark_gift`(金额/单价=0或“赠品/O/o/空”)
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
- 新建 `app/core/handlers/rule_engine.py`:输入DataFrame与规则列表,返回规范化DataFrame
|
||||||
|
- 在 `GenericSupplierProcessor` 中:列映射→清洗→规则引擎→输出
|
||||||
|
- 列映射向导增强:
|
||||||
|
- 增加“规则预设”选择与“应用规则预览”按钮,显示原始/规范化后的对比
|
||||||
|
- 支持导入/导出(映射+规则)JSON
|
||||||
|
- 模板管理增强:模板组选择、批量校验、示例模板生成
|
||||||
|
|
||||||
|
## 解析与正则
|
||||||
|
- 名称解析:
|
||||||
|
- 容量×数量:`(\d+(?:\.\d+)?)(ml|l|升|毫升)[*×xX](\d+)`
|
||||||
|
- 简单数量×数量:`(\d+)[*×xX](\d+)`
|
||||||
|
- 词典忽略词:如“白膜”等
|
||||||
|
- 数量拆分:`(?P<num>\d+(?:\.\d+)?)(?P<unit>箱|件|提|盒|瓶)`
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- 单元测试覆盖规则与表头识别;烟雾测试涵盖“无单位/规格/数量混杂”的样本
|
||||||
|
- GUI预览确认规则效果;保存后热重载处理器
|
||||||
|
|
||||||
|
## 里程碑
|
||||||
|
- Day 1:规则引擎与处理器接入
|
||||||
|
- Day 2:向导规则预览与导入/导出;模板组管理
|
||||||
|
- Day 3:测试与样本库
|
||||||
|
- Day 4:打包与交付验证
|
||||||
|
|
||||||
|
请确认,我将立即开始实现并逐步提交验证结果。
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# CLAUDE.md - 益选 OCR 订单处理系统
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
益选 OCR 订单处理系统 (orc-order-v2) 是一个面向零售与分销场景的采购单处理工具。
|
|
||||||
|
|
||||||
**核心流程**: 图片 OCR → Excel 规范化 → 模板填充 → 合并导出
|
|
||||||
|
|
||||||
**目标系统**: 银豹 (PosPal) POS 系统
|
|
||||||
|
|
||||||
**技术栈**: Python 3.9+, Tkinter, Pandas, Baidu OCR API, xlrd/xlwt/openpyxl
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
orc-order-v2/
|
|
||||||
├── 启动器.py # 入口桩 (~13行, 仅导入 main)
|
|
||||||
├── headless_api.py # CLI 自动化接口 (OpenClaw 对接)
|
|
||||||
├── build_exe.py # PyInstaller 打包脚本
|
|
||||||
├── config.ini # 全局配置 (API密钥、路径)
|
|
||||||
├── config/
|
|
||||||
│ ├── config.ini # 配置副本
|
|
||||||
│ ├── barcode_mappings.json # 条码映射规则
|
|
||||||
│ └── suppliers_config.json # 供应商配置 (列映射/清洗规则/计算规则)
|
|
||||||
├── app/
|
|
||||||
│ ├── config/
|
|
||||||
│ │ ├── settings.py # ConfigManager 单例
|
|
||||||
│ │ └── defaults.py # 默认配置
|
|
||||||
│ ├── core/
|
|
||||||
│ │ ├── excel/
|
|
||||||
│ │ │ ├── processor.py # ExcelProcessor - 标准化转换核心
|
|
||||||
│ │ │ ├── converter.py # UnitConverter - 单位转换与规格推断
|
|
||||||
│ │ │ ├── merger.py # PurchaseOrderMerger - 采购单合并
|
|
||||||
│ │ │ ├── validators.py # ProductValidator
|
|
||||||
│ │ │ └── handlers/ # 条码映射、单位转换处理器
|
|
||||||
│ │ ├── handlers/
|
|
||||||
│ │ │ ├── rule_engine.py # 通用规则引擎 (split/extract/normalize/mark)
|
|
||||||
│ │ │ ├── column_mapper.py # 列映射器
|
|
||||||
│ │ │ ├── data_cleaner.py # 数据清洗器
|
|
||||||
│ │ │ └── calculator.py # 计算器
|
|
||||||
│ │ ├── ocr/
|
|
||||||
│ │ │ ├── table_ocr.py # OCRProcessor
|
|
||||||
│ │ │ └── baidu_ocr.py # BaiduOCRClient
|
|
||||||
│ │ ├── processors/
|
|
||||||
│ │ │ ├── base.py # BaseProcessor 抽象基类
|
|
||||||
│ │ │ ├── tobacco_processor.py
|
|
||||||
│ │ │ ├── ocr_processor.py
|
|
||||||
│ │ │ └── supplier_processors/
|
|
||||||
│ │ │ └── generic_supplier_processor.py
|
|
||||||
│ │ └── utils/
|
|
||||||
│ │ ├── file_utils.py # 文件操作工具
|
|
||||||
│ │ ├── log_utils.py # 日志工具
|
|
||||||
│ │ ├── string_utils.py # 字符串工具
|
|
||||||
│ │ └── dialog_utils.py # Tkinter 对话框工具
|
|
||||||
│ ├── services/
|
|
||||||
│ │ ├── order_service.py # 订单服务 (智能路由分发)
|
|
||||||
│ │ ├── ocr_service.py # OCR 服务
|
|
||||||
│ │ ├── processor_service.py # 处理器调度服务
|
|
||||||
│ │ ├── tobacco_service.py # 烟草公司专用服务
|
|
||||||
│ │ └── special_suppliers_service.py # 特殊供应商服务 (蓉城/杨碧月)
|
|
||||||
│ └── ui/ # GUI 模块 (从启动器.py拆分)
|
|
||||||
│ ├── error_utils.py # L0 错误对话框
|
|
||||||
│ ├── theme.py # L0 主题管理 (THEMES, create_modern_button)
|
|
||||||
│ ├── logging_ui.py # L0 日志队列与GUI日志处理器
|
|
||||||
│ ├── ui_widgets.py # L0 StatusBar, ProgressReporter, center_window
|
|
||||||
│ ├── user_settings.py # L1 用户设置与最近文件管理
|
|
||||||
│ ├── result_previews.py # L1 处理结果预览对话框
|
|
||||||
│ ├── command_runner.py # L1 命令执行器 (subprocess + 日志重定向)
|
|
||||||
│ ├── file_operations.py # L2 文件选择/清理/目录操作
|
|
||||||
│ ├── action_handlers.py # L2 业务操作 (OCR/Excel/合并/拖拽)
|
|
||||||
│ ├── barcode_editor.py # L2 条码映射编辑
|
|
||||||
│ ├── config_dialog.py # L3 系统设置对话框
|
|
||||||
│ ├── shortcuts.py # L3 键盘快捷键绑定
|
|
||||||
│ └── main_window.py # L4 main() 主窗口构建
|
|
||||||
├── templates/
|
|
||||||
│ ├── 银豹-采购单模板.xls # 输出模板
|
|
||||||
│ └── 商品资料.xlsx # 单价校验参考数据
|
|
||||||
├── data/
|
|
||||||
│ ├── input/ # 输入文件
|
|
||||||
│ ├── output/ # OCR 输出
|
|
||||||
│ ├── result/ # 最终采购单
|
|
||||||
│ └── user_settings.json # 用户设置
|
|
||||||
└── docs/
|
|
||||||
└── SYSTEM_ARCHITECTURE.md # 系统架构文档
|
|
||||||
```
|
|
||||||
|
|
||||||
## 命令与运行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# GUI 模式
|
|
||||||
python 启动器.py
|
|
||||||
|
|
||||||
# CLI 模式 (OpenClaw 对接)
|
|
||||||
python headless_api.py [input] [--excel|--tobacco|--rongcheng] [--barcode X --target Y]
|
|
||||||
|
|
||||||
# 打包 EXE
|
|
||||||
python build_exe.py
|
|
||||||
|
|
||||||
# 条码映射更新
|
|
||||||
python headless_api.py --update-mapping --barcode 6920584471055 --target 6920584471017
|
|
||||||
```
|
|
||||||
|
|
||||||
## 供应商智能识别逻辑
|
|
||||||
|
|
||||||
系统通过扫描 Excel 前 50 行内容特征自动路由:
|
|
||||||
|
|
||||||
| 供应商 | 识别特征 | 预处理逻辑 |
|
|
||||||
|--------|----------|-----------|
|
|
||||||
| 烟草公司 | "专卖证号" 或 "510109104938" | B/E/G/H 列映射, 数量*10, 单价/10 |
|
|
||||||
| 蓉城易购 | "RCDH" | E/N/Q/S 列映射, 多条码分裂均分数量 |
|
|
||||||
| 杨碧月 | "经手人" + "杨碧月" | 列对齐, 单位转换 (件→瓶) |
|
|
||||||
| 通用供应商 | suppliers_config.json 配置 | 列映射 + 规则引擎 |
|
|
||||||
|
|
||||||
## 配置系统
|
|
||||||
|
|
||||||
- **ConfigManager** (`app/config/settings.py`): 单例模式, 基于 configparser 读取 `config.ini`
|
|
||||||
- **供应商配置** (`config/suppliers_config.json`): JSON 格式, 定义列映射/清洗规则/计算规则
|
|
||||||
- **条码映射** (`config/barcode_mappings.json`): 运行时可更新的条码转换规则
|
|
||||||
|
|
||||||
## 关键约定
|
|
||||||
|
|
||||||
### 输出格式
|
|
||||||
- 银豹采购单模板: 4 列 — 条码(B), 采购量(C), 赠送量(D), 采购单价(E)
|
|
||||||
- 单价保留 4 位小数, 使用 xlwt.XFStyle
|
|
||||||
- 采购单文件名: `采购单_{原文件名}.xls`
|
|
||||||
|
|
||||||
### 单位转换规则
|
|
||||||
- "件"/"箱"/"提"/"盒" → 数量*包装数量, 单价/包装数量, 单位→"瓶"
|
|
||||||
- 赠品: 价格为 0 或金额为 0 的行标记为赠品
|
|
||||||
- 条码映射优先于单位转换
|
|
||||||
|
|
||||||
### 规格推断
|
|
||||||
- 从商品名称推断: "24入纸箱" → 1*24, "450g*15" → 1*15
|
|
||||||
- 支持三级规格: 1*5*12
|
|
||||||
- OCR 修正: "IL" → "1L", "6oo" → "600"
|
|
||||||
|
|
||||||
## 已知技术债务
|
|
||||||
|
|
||||||
1. ~~**启动器.py 过大**~~ (已拆分为 13 个 `app/ui/` 模块, 入口桩仅 13 行)
|
|
||||||
2. **代码重复**: 表头识别、列映射、金额解析在多处重复实现
|
|
||||||
3. **配置不统一**: config.ini + suppliers_config.json + 硬编码路径混用
|
|
||||||
4. **无测试**: 测试目录为空, 无自动化测试
|
|
||||||
5. **旧格式依赖**: xlrd/xlwt 仅支持 .xls, 不支持 .xlsx 写入
|
|
||||||
6. **API 密钥明文**: config.ini 中百度 OCR API 密钥未加密
|
|
||||||
7. **路径硬编码**: config.ini 中 `template_folder = E:\2025Code\python\orc-order-v2\templates`
|
|
||||||
8. **日志不统一**: 混用 `get_logger()` 和 `logging.getLogger()`
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies (tkinter needed by app/core processing)
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
gcc \
|
|
||||||
python3-tk \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
COPY web/backend/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy project files
|
|
||||||
COPY app/ ./app/
|
|
||||||
COPY config/ ./config/
|
|
||||||
COPY config.ini ./
|
|
||||||
COPY templates/ ./templates/
|
|
||||||
COPY web/ ./web/
|
|
||||||
|
|
||||||
# Create data directories
|
|
||||||
RUN mkdir -p data/input data/output data/result data/temp
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 18889
|
|
||||||
|
|
||||||
# Run
|
|
||||||
CMD ["python", "-m", "uvicorn", "web.backend.main:app", "--host", "0.0.0.0", "--port", "18889"]
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:20-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY web/frontend/package.json web/frontend/package-lock.json* ./
|
|
||||||
RUN npm install
|
|
||||||
COPY web/frontend/ .
|
|
||||||
# Override outDir for Docker build (vite config uses ../backend/static for local dev)
|
|
||||||
RUN npx vite build --outDir ./dist
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 18888
|
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# OCR 订单处理系统 - v2.2 更新报告
|
||||||
|
|
||||||
|
## 1. 业务逻辑与 UI 变更 (v2.2 Updates)
|
||||||
|
|
||||||
|
### 1.1 UI 极简优化
|
||||||
|
- **移除专用按钮**:从主界面彻底移除了“蓉城易购”和“烟草处理”两个特定按钮。
|
||||||
|
- **统一入口**:所有供应商 Excel 订单现在均通过“处理 Excel 文件”或直接拖拽至主界面进行处理。系统会自动识别并路由。
|
||||||
|
- **快捷键更新**:移除了 `Ctrl+T` (烟草处理) 快捷键,简化了键盘操作逻辑。
|
||||||
|
|
||||||
|
### 1.2 杨碧月预处理修复
|
||||||
|
- **列名校准**:修正了预处理输出列名,确保与银豹处理器期望的中文列名(商品条码、数量、单价等)完全一致。
|
||||||
|
- **干扰过滤**:在提取列时,自动排除了 `结算单位`、`基本单位数量` 等名称相似的非业务列,提高了匹配精度。
|
||||||
|
|
||||||
|
### 1.3 Headless API 智能增强
|
||||||
|
- **后缀感知**:`headless_api.py` 能够根据文件后缀自动区分图片与 Excel,不再需要显式指定 `--excel` 或 `--tobacco`。
|
||||||
|
- **零配置接入**:OpenClaw 仅需运行 `python headless_api.py [文件路径]` 即可完成全流程。
|
||||||
|
|
||||||
|
## 2. 供应商清洗规则 (保持最新)
|
||||||
|
| 供应商 | 条码列 | 数量逻辑 | 单价逻辑 | 金额逻辑 |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **蓉城易购** | E列 (Index 4) | N列 (Index 13),不换算 | Q列 (Index 16) | S列 (Index 18) |
|
||||||
|
| **烟草公司** | B列 (Index 1) | G列 (Index 6) **x 10** | E列 (Index 4) **/ 10** | H列 (Index 7) |
|
||||||
|
|
||||||
|
## 3. 代码与环境清理
|
||||||
|
- **移除无用文件**:清理了 `run.py` (冗余)、`clean.py` (旧脚本) 以及 `doc/` (旧文档)。
|
||||||
|
- **模块重构**:删除了 `app/cli/` 模块,所有命令行逻辑已合并至根目录的 `headless_api.py`。
|
||||||
|
|
||||||
|
---
|
||||||
|
*报告生成日期:2026-03-31*
|
||||||
|
*负责人:Trae Code Assistant*
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# OCR 订单处理系统 - OpenClaw 对接指南 (v2.2)
|
||||||
|
|
||||||
|
## 1. 核心接口说明 (headless_api.py)
|
||||||
|
|
||||||
|
`headless_api.py` 是系统的统一命令行入口。它支持**智能文件类型与供应商识别**,OpenClaw 通常**无需携带任何功能参数**。
|
||||||
|
|
||||||
|
### 1.1 全自动智能模式 (推荐方式)
|
||||||
|
无论是收到**图片**还是 **Excel**,都可以直接调用。系统会自动判断文件类型:如果是 Excel 则自动识别供应商指纹(蓉城、烟草、杨碧月等)并处理;如果是图片则先 OCR 后再智能处理。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 自动处理 data/input 中最新的文件 (图片或 Excel)
|
||||||
|
python headless_api.py
|
||||||
|
|
||||||
|
# 处理指定的任意文件 (图片或 Excel)
|
||||||
|
python headless_api.py "data/input/my_file.jpg"
|
||||||
|
python headless_api.py "data/input/my_file.xlsx"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 显式特殊指令 (备用)
|
||||||
|
仅在自动识别失效或需要特殊操作时使用。
|
||||||
|
```bash
|
||||||
|
# 强制指定为 Excel 处理模式
|
||||||
|
python headless_api.py --excel
|
||||||
|
|
||||||
|
# --- 条码映射与特殊处理指令 ---
|
||||||
|
|
||||||
|
# 1. 简单的条码映射 (旧条码 -> 新条码)
|
||||||
|
python headless_api.py --update-mapping --barcode "123" --target "456"
|
||||||
|
|
||||||
|
# 2. 特殊倍数处理 (例如某条码识别为1件,实际需换算为30瓶)
|
||||||
|
python headless_api.py --update-mapping --barcode "690123" --multiplier 30 --unit "瓶"
|
||||||
|
|
||||||
|
# 3. 固定单价与规格
|
||||||
|
python headless_api.py --update-mapping --barcode "690123" --price 3.5 --spec "1*30"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 字段与逻辑变更
|
||||||
|
|
||||||
|
### 2.1 蓉城易购 (Rongcheng)
|
||||||
|
- **条码映射**:E列 (Index 4)。
|
||||||
|
- **数量逻辑**:N列 (Index 13)。直接提取,不进行单位换算。
|
||||||
|
- **条码分裂**:支持 `/` `,` `,` `、` 分隔符自动均分。
|
||||||
|
|
||||||
|
### 2.2 烟草公司 (Tobacco)
|
||||||
|
- **条码映射**:B列 (Index 1)。
|
||||||
|
- **数量逻辑**:G列 (订单量) **x 10**。
|
||||||
|
- **单价逻辑**:E列 (批发价) **/ 10**。
|
||||||
|
|
||||||
|
### 2.3 杨碧月 (Yang Biyue)
|
||||||
|
- **自动对齐**:自动识别经手人并对齐“商品条码”、“数量”、“单价”等标准列。
|
||||||
|
|
||||||
|
---
|
||||||
|
*版本:2.2 | 更新日期:2026-03-31*
|
||||||
@@ -1,243 +1,66 @@
|
|||||||
# 益选 OCR 订单处理系统
|
# 益选 OCR 订单处理系统
|
||||||
|
|
||||||
面向零售与分销场景的采购单处理工具,支持图片 OCR → Excel 规范化 → 模板填充 → 合并导出全流程,输出适配银豹 (PosPal) POS 系统。
|
## 概览
|
||||||
|
- 面向零售与分销场景的采购单处理工具,支持图片 OCR → Excel 规范化 → 模板填充 → 合并导出全流程
|
||||||
|
- 通过供应商管理与规则引擎,适配不同供应商的格式差异(数量含单位、名称包含规格、缺失列补齐)
|
||||||
|
- 提供验证匹配面板与单价校验机制,确保输出与既定模板一致且价格合理
|
||||||
|
|
||||||
## 核心功能
|
## 核心功能
|
||||||
|
- **全自动智能识别**:系统现在能自动识别 Excel 内容特征(如:蓉城易购 RCDH、烟草公司专卖证号、杨碧月经手人),并自动路由至专用预处理流程,无需手动干预。
|
||||||
|
- **图片/Excel处理**:支持拖拽或选择文件,一键生成标准银豹采购单。
|
||||||
|
- **极简 UI 体验**:移除了冗余的供应商特定按钮,所有 Excel 统一走智能路由处理。
|
||||||
|
- **无界面自动化接口 (headless_api.py)**:专为 OpenClaw 等平台设计,支持全自动文件类型与供应商识别。
|
||||||
|
- **单价预警机制**:自动比对 `templates/商品资料.xlsx`,若价差超过 1.0 元则触发警告。
|
||||||
|
|
||||||
- **智能供应商识别**:自动扫描 Excel 前 50 行内容特征,路由到对应的预处理逻辑(蓉城易购、烟草公司、杨碧月等)
|
## 供应商专用逻辑 (v2.2)
|
||||||
- **图片 OCR**:调用百度 OCR 表格识别 API,将采购单图片转为结构化 Excel
|
### 蓉城易购 (Rongcheng)
|
||||||
- **规则引擎**:支持列映射、数据清洗、单位转换、规格推断、赠品标记等自动化规则
|
- **精准映射**:商品条码(E列)、数量(N列)、单价(Q列)、金额(S列)。
|
||||||
- **条码映射**:可配置的条码转换规则,支持运行时编辑和云端同步
|
- **条码分裂**:支持多条码行(如 `条码1/条码2`)自动均分数量。
|
||||||
- **单价校验**:自动比对 `商品资料.xlsx`,价差超过 1.0 元触发预警
|
- **逻辑简化**:直接提取原始数量,不进行多余的单位或包装换算。
|
||||||
- **云端同步**:通过 Gitea REST API 在多台设备间同步配置文件(条码映射、供应商配置、商品资料、采购模板)
|
|
||||||
- **拖拽一键处理**:拖入图片或 Excel 自动走完 OCR → 规范化 → 合并全流程
|
|
||||||
- **CLI 接口**:`headless_api.py` 支持无界面自动化调用
|
|
||||||
|
|
||||||
## 快速开始
|
### 烟草公司 (Tobacco)
|
||||||
|
- **换算逻辑**:条码(B列)、数量(G列 x 10)、单价(E列 / 10)、总额(H列)。
|
||||||
|
- **智能跳行**:自动识别并跳过合计行及非数据行。
|
||||||
|
|
||||||
### 桌面端 (GUI / CLI)
|
### 杨碧月订单
|
||||||
|
- **自动对齐**:识别到经手人“杨碧月”后,自动将非标 Excel 转换为标准列格式,支持单位换算(件 -> 瓶)。
|
||||||
|
|
||||||
```bash
|
## 关键适配(蓉城易购)
|
||||||
# 安装依赖
|
- 新模板(如“订单1765440157955.xlsx”):
|
||||||
pip install -r requirements.txt
|
- 使用第三行作为表头(`header=2`)
|
||||||
|
- 关键词选列并重命名到期望字段:商品条码(小条码)、订购数量(小单位)、单价(小单位)、优惠后金额(小单位)、单位
|
||||||
|
- 单位优先匹配“单位(订购单位)”列,清洗为去空白并将“件”替换为“份”
|
||||||
|
- 多条码行(逗号/顿号/斜杠/空格分隔)拆分为多行,数量均分并重算金额,单位保持订购单位
|
||||||
|
- 新模板映射:将“优惠后金额(小单位)”作为单价,“出库小计(元)”作为金额来源
|
||||||
|
|
||||||
# GUI 模式
|
## 使用说明
|
||||||
python 启动器.py
|
1. 运行程序(EXE或源码运行)
|
||||||
|
2. 在主界面:
|
||||||
|
- 拖拽或选择图片/Excel进行处理
|
||||||
|
- 系统设置 → 供应商管理:配置供应商、列映射与规则;使用规则预览查看规范化效果
|
||||||
|
- 系统设置 → 验证匹配:选择原始与期望文件,差异对比;应用建议后重载配置
|
||||||
|
3. 处理成功后,采购单保存到 `data/output` 或 `data/result`,最近文件列表可双击打开查看
|
||||||
|
|
||||||
# CLI 模式
|
## 构建与打包
|
||||||
python headless_api.py data/input/xxx.xlsx
|
- 依赖:Python 3.9+,虚拟环境建议
|
||||||
python headless_api.py data/input/xxx.jpg --barcode 6920584471055 --target 6920584471017
|
- 安装打包工具:`pip install pyinstaller`
|
||||||
|
- 运行打包脚本:`python build_exe.py`
|
||||||
|
- 生成 EXE:`dist/OCR订单处理系统.exe`
|
||||||
|
- 生成便携包:`release/OCR订单处理系统.exe`(包含 `templates/银豹-采购单模板.xls` 与 `templates/商品资料.xlsx`)
|
||||||
|
|
||||||
# 打包 EXE
|
## Git 提交建议
|
||||||
python build_exe.py
|
- 建议忽略构建目录与运行输出(见 `.gitignore`)
|
||||||
```
|
- 保留模板与配置:`templates/银豹-采购单模板.xls`、`templates/商品资料.xlsx`、`config/config.ini`、`config/barcode_mappings.json`
|
||||||
|
|
||||||
### Web 端
|
## 常见问题
|
||||||
|
- 表头识别失败:在供应商管理的“列映射与表头”页预览表头行并选择正确行号
|
||||||
|
- 数量含单位:启用 `split_quantity_unit` 与 `normalize_unit` 规则并配置单位同义词与包装倍数
|
||||||
|
- 名称中规格:配置 `ignore_words` 与 `name_patterns`,使用步骤预览确认解析效果
|
||||||
|
- 单价校验未提示:确认 `templates/商品资料.xlsx` 存在且列名包含“进货价”与条码列(`商品条码/条码/barcode`)
|
||||||
|
|
||||||
```bash
|
## 变更记录(近期)
|
||||||
# 后端依赖
|
- 新增验证匹配面板与建议修复
|
||||||
cd web/backend && pip install -r requirements.txt
|
- 规则预设与步骤预览(原始→逐步→规范化)
|
||||||
|
- 单价校验机制(价差>1元提示)
|
||||||
|
- 蓉城易购新模板适配(第三行表头、单位(订购单位)、多条码拆分、金额映射)
|
||||||
|
|
||||||
# 前端依赖
|
|
||||||
cd web/frontend && npm install
|
|
||||||
|
|
||||||
# 启动后端 (端口 8000)
|
|
||||||
cd web && python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
|
||||||
|
|
||||||
# 启动前端开发服务器 (端口 5173)
|
|
||||||
cd web/frontend && npm run dev
|
|
||||||
|
|
||||||
# 构建前端到后端静态目录
|
|
||||||
cd web/frontend && npm run build
|
|
||||||
# 构建后直接访问 http://localhost:8000 即可
|
|
||||||
|
|
||||||
# 生产部署 (仅后端,前端已内嵌)
|
|
||||||
cd web && python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
**默认账号:** `admin` / `admin123`(首次登录后建议修改密码)
|
|
||||||
|
|
||||||
### Docker 部署 (生产环境)
|
|
||||||
|
|
||||||
**环境要求:** Docker + Docker Compose
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 克隆代码
|
|
||||||
git clone https://gitea.94kan.cn/houhuan/orc-order-v2.git
|
|
||||||
cd orc-order-v2
|
|
||||||
|
|
||||||
# 2. 配置环境变量(百度 OCR API 密钥、Gitea Token 等)
|
|
||||||
cp .env.example .env # 若无 .env.example,手动创建 .env
|
|
||||||
# 编辑 .env 填入:
|
|
||||||
# BAIDU_OCR_API_KEY=xxx
|
|
||||||
# BAIDU_OCR_SECRET_KEY=xxx
|
|
||||||
# GITEA_TOKEN=xxx
|
|
||||||
|
|
||||||
# 3. 构建并启动
|
|
||||||
docker-compose up -d --build
|
|
||||||
|
|
||||||
# 4. 访问
|
|
||||||
# 前端: http://服务器IP:18888
|
|
||||||
# 后端 API: http://服务器IP:18889
|
|
||||||
# 默认账号: admin / admin123(首次登录后建议修改密码)
|
|
||||||
|
|
||||||
# 常用命令
|
|
||||||
docker-compose logs -f # 查看日志
|
|
||||||
docker-compose restart # 重启服务
|
|
||||||
docker-compose down # 停止服务
|
|
||||||
docker-compose up -d --build # 重新构建并启动
|
|
||||||
```
|
|
||||||
|
|
||||||
**端口说明:**
|
|
||||||
- `18888` — 前端 (Nginx),对外访问入口
|
|
||||||
- `18889` — 后端 API (FastAPI),前端自动代理,无需直接访问
|
|
||||||
|
|
||||||
**数据持久化:** `data/` 目录挂载到宿主机,SQLite 数据库、上传文件、处理结果不会因容器重建而丢失。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
├── 启动器.py # GUI 入口
|
|
||||||
├── headless_api.py # CLI 自动化接口
|
|
||||||
├── config.ini # 全局配置(API密钥、路径、Gitea)
|
|
||||||
├── config/
|
|
||||||
│ ├── config.ini # 配置副本
|
|
||||||
│ ├── barcode_mappings.json # 条码映射规则
|
|
||||||
│ └── suppliers_config.json # 供应商配置(列映射/规则引擎)
|
|
||||||
├── app/
|
|
||||||
│ ├── config/ # 配置管理(ConfigManager 单例)
|
|
||||||
│ ├── core/
|
|
||||||
│ │ ├── excel/ # Excel 处理(标准化、转换、合并、校验)
|
|
||||||
│ │ ├── handlers/ # 规则引擎、列映射、数据清洗、计算器
|
|
||||||
│ │ ├── ocr/ # 百度 OCR 客户端
|
|
||||||
│ │ ├── processors/ # 处理器(通用/烟草/OCR)
|
|
||||||
│ │ └── utils/ # 工具(日志、文件、字符串、云端同步、对话框)
|
|
||||||
│ ├── services/ # 业务服务(订单、OCR、处理器调度)
|
|
||||||
│ └── ui/ # GUI 模块(主题、日志、快捷键、主窗口)
|
|
||||||
├── web/ # Web 端
|
|
||||||
│ ├── backend/
|
|
||||||
│ │ ├── main.py # FastAPI 入口
|
|
||||||
│ │ ├── auth/ # JWT 认证(登录、Token、权限)
|
|
||||||
│ │ ├── routers/ # API 路由(文件、处理、记忆、条码、同步、任务、日志)
|
|
||||||
│ │ ├── services/ # 后端服务(任务管理、数据库、文件同步)
|
|
||||||
│ │ └── middleware/ # HTTP 日志中间件
|
|
||||||
│ └── frontend/
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── views/ # 页面(Dashboard、Layout、文件管理、任务、日志等)
|
|
||||||
│ │ ├── stores/ # Pinia 状态管理(auth、processing)
|
|
||||||
│ │ ├── composables/ # 共享逻辑(useDebounce、useFileUtils、useFilePreview)
|
|
||||||
│ │ ├── api.ts # Axios 封装
|
|
||||||
│ │ └── router/ # Vue Router 路由
|
|
||||||
│ ├── package.json
|
|
||||||
│ └── vite.config.ts
|
|
||||||
├── templates/
|
|
||||||
│ ├── 银豹-采购单模板.xls # 输出模板(条码/采购量/赠送量/单价)
|
|
||||||
│ └── 商品资料.xlsx # 单价校验参考数据
|
|
||||||
├── data/
|
|
||||||
│ ├── input/ # 输入文件
|
|
||||||
│ ├── output/ # OCR 输出
|
|
||||||
│ ├── result/ # 最终采购单
|
|
||||||
│ └── web_data.db # Web 端数据库(SQLite)
|
|
||||||
└── tests/ # 单元测试(191 个)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Web 端功能
|
|
||||||
|
|
||||||
基于 Vue 3 + Element Plus + FastAPI 的浏览器端管理界面,与桌面端共享同一个 `data/` 目录。
|
|
||||||
|
|
||||||
### 处理中心 (Dashboard)
|
|
||||||
|
|
||||||
- **一键全流程**:上传图片或 Excel 后,一键完成 OCR → 标准化 → 合并全流程
|
|
||||||
- **批量 OCR / 批量处理**:可单独执行 OCR 识别或 Excel 标准化步骤
|
|
||||||
- **实时进度**:WebSocket 推送任务进度、日志、状态变更
|
|
||||||
- **多任务监控**:同时查看多个运行中任务的进度和日志
|
|
||||||
- **任务重试**:失败任务可查看错误详情并一键重试
|
|
||||||
|
|
||||||
### 文件管理
|
|
||||||
|
|
||||||
- **图片处理**:管理 `data/input/` 中的图片文件,支持上传、预览、批量 OCR、批量生成采购单
|
|
||||||
- **表格处理**:管理 `data/output/` 中的 Excel 文件,支持上传、预览、批量标准化处理
|
|
||||||
- **采购单管理**:管理 `data/result/` 中的采购单,支持预览、下载、合并、批量删除
|
|
||||||
- **实时同步**:页面加载时自动同步磁盘文件到数据库,新文件立即可见
|
|
||||||
- **清除处理缓存**:删除已处理的输出文件,允许重新处理
|
|
||||||
|
|
||||||
### 任务与日志
|
|
||||||
|
|
||||||
- **任务历史**:查看所有处理任务的状态、进度、日志,支持按状态和类型筛选
|
|
||||||
- **HTTP 日志**:记录所有 API 请求,支持按方法和状态码筛选
|
|
||||||
|
|
||||||
### 记忆库
|
|
||||||
|
|
||||||
- **产品记忆**:自动从 OCR 和处理结果中学习产品信息
|
|
||||||
- **置信度系统**:根据出现次数自动评估记忆可靠度
|
|
||||||
- **搜索与管理**:支持搜索、编辑、删除记忆条目
|
|
||||||
|
|
||||||
### 条码映射
|
|
||||||
|
|
||||||
- **映射规则管理**:添加、编辑、删除条码转换规则
|
|
||||||
- **批量操作**:支持批量导入和删除映射
|
|
||||||
|
|
||||||
### 云端同步
|
|
||||||
|
|
||||||
- **Gitea 同步**:通过 Gitea REST API 在多台设备间同步配置文件
|
|
||||||
- **一键推拉**:选择文件推送或拉取,无需 git 客户端
|
|
||||||
|
|
||||||
### 系统配置
|
|
||||||
|
|
||||||
- **配置编辑**:在浏览器中编辑系统配置(API 密钥、路径、参数)
|
|
||||||
- **修改密码**:支持修改 Web 端登录密码
|
|
||||||
|
|
||||||
### UI/UX
|
|
||||||
|
|
||||||
- **响应式布局**:适配桌面和移动端,小屏幕自动切换为抽屉式导航
|
|
||||||
- **全局错误处理**:未捕获的 Vue 错误自动显示用户提示
|
|
||||||
- **表单验证**:修改密码等操作有完整的输入验证
|
|
||||||
|
|
||||||
## 供应商智能路由
|
|
||||||
|
|
||||||
| 供应商 | 识别特征 | 处理逻辑 |
|
|
||||||
|--------|----------|----------|
|
|
||||||
| 烟草公司 | "专卖证号" 或 "510109104938" | B/E/G/H 列映射,数量×10,单价÷10 |
|
|
||||||
| 蓉城易购 | "RCDH" | E/N/Q/S 列映射,多条码分裂均分数量 |
|
|
||||||
| 杨碧月 | "经手人" + "杨碧月" | 列对齐,单位转换(件→瓶) |
|
|
||||||
| 通用供应商 | `suppliers_config.json` 配置 | 列映射 + 规则引擎 |
|
|
||||||
|
|
||||||
## 云端同步
|
|
||||||
|
|
||||||
通过 Gitea REST API 在多台设备间同步配置,无需 git 客户端。
|
|
||||||
|
|
||||||
**支持同步的文件:**
|
|
||||||
- 条码映射 (`barcode_mappings.json`)
|
|
||||||
- 供应商配置 (`suppliers_config.json`)
|
|
||||||
- 商品资料 (`templates/商品资料.xlsx`)
|
|
||||||
- 采购单模板 (`templates/银豹-采购单模板.xls`)
|
|
||||||
|
|
||||||
**配置方式:**
|
|
||||||
1. 系统设置 → 填入 Gitea 地址、仓库信息、Access Token
|
|
||||||
2. 主窗口 → "云端同步" 按钮 → 选择文件推拉
|
|
||||||
|
|
||||||
**Gitea 仓库:** `https://gitea.94kan.cn/houhuan/yixuan-sync-data`
|
|
||||||
|
|
||||||
## 配置说明
|
|
||||||
|
|
||||||
| 配置项 | 文件 | 说明 |
|
|
||||||
|--------|------|------|
|
|
||||||
| API 密钥 | `.env` 或 `config.ini` | 百度 OCR API,优先从环境变量读取 |
|
|
||||||
| Gitea Token | `.env` 或 `config.ini` | 云端同步 Token,优先从环境变量读取 |
|
|
||||||
| 供应商规则 | `config/suppliers_config.json` | 列映射、清洗规则、计算规则 |
|
|
||||||
| 条码映射 | `config/barcode_mappings.json` | 条码转换规则,运行时可更新 |
|
|
||||||
|
|
||||||
## 构建打包
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install pyinstaller
|
|
||||||
python build_exe.py
|
|
||||||
# 输出: dist/OCR订单处理系统.exe
|
|
||||||
# 便携包: release/OCR订单处理系统.exe(含模板和商品资料)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/ -v
|
|
||||||
```
|
|
||||||
|
|||||||
+3
-16
@@ -12,20 +12,14 @@ DEFAULT_CONFIG = {
|
|||||||
'timeout': '30',
|
'timeout': '30',
|
||||||
'max_retries': '3',
|
'max_retries': '3',
|
||||||
'retry_delay': '2',
|
'retry_delay': '2',
|
||||||
'api_url': 'https://aip.baidubce.com/rest/2.0/ocr/v1/table',
|
'api_url': 'https://aip.baidubce.com/rest/2.0/ocr/v1/table'
|
||||||
'token_url': 'https://aip.baidubce.com/oauth/2.0/token',
|
|
||||||
'form_ocr_url': 'https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result'
|
|
||||||
},
|
},
|
||||||
'Paths': {
|
'Paths': {
|
||||||
'input_folder': 'data/input',
|
'input_folder': 'data/input',
|
||||||
'output_folder': 'data/output',
|
'output_folder': 'data/output',
|
||||||
'result_folder': 'data/result',
|
|
||||||
'temp_folder': 'data/temp',
|
'temp_folder': 'data/temp',
|
||||||
'template_folder': 'templates',
|
'template_folder': 'templates',
|
||||||
'template_file': 'templates/银豹-采购单模板.xls',
|
'processed_record': 'data/processed_files.json'
|
||||||
'processed_record': 'data/processed_files.json',
|
|
||||||
'data_dir': 'data',
|
|
||||||
'product_db': 'data/product_cache.db'
|
|
||||||
},
|
},
|
||||||
'Performance': {
|
'Performance': {
|
||||||
'max_workers': '4',
|
'max_workers': '4',
|
||||||
@@ -38,13 +32,6 @@ DEFAULT_CONFIG = {
|
|||||||
'max_file_size_mb': '4'
|
'max_file_size_mb': '4'
|
||||||
},
|
},
|
||||||
'Templates': {
|
'Templates': {
|
||||||
'purchase_order': '银豹-采购单模板.xls',
|
'purchase_order': '银豹-采购单模板.xls'
|
||||||
'item_data': '商品资料.xlsx'
|
|
||||||
},
|
|
||||||
'Gitea': {
|
|
||||||
'base_url': 'https://gitea.94kan.cn',
|
|
||||||
'owner': 'houhuan',
|
|
||||||
'repo': 'yixuan-sync-data',
|
|
||||||
'token': ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-54
@@ -6,16 +6,12 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import configparser
|
import configparser
|
||||||
|
import logging
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from .defaults import DEFAULT_CONFIG
|
||||||
from app.core.utils.log_utils import get_logger
|
|
||||||
from app.config.defaults import DEFAULT_CONFIG
|
|
||||||
|
|
||||||
# 加载 .env 文件
|
logger = logging.getLogger(__name__)
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
"""
|
"""
|
||||||
@@ -33,23 +29,13 @@ class ConfigManager:
|
|||||||
|
|
||||||
def _init(self, config_file):
|
def _init(self, config_file):
|
||||||
"""初始化配置管理器"""
|
"""初始化配置管理器"""
|
||||||
# 计算应用根目录(不依赖 os.getcwd())
|
self.config_file = config_file or 'config.ini'
|
||||||
import sys
|
|
||||||
if getattr(sys, 'frozen', False):
|
|
||||||
# PyInstaller 打包后,根目录是 exe 所在目录
|
|
||||||
self.app_root = os.path.dirname(sys.executable)
|
|
||||||
else:
|
|
||||||
# 源码运行,根目录是 app/config/ 的上两级
|
|
||||||
self.app_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
self.config_file = config_file or os.path.join(self.app_root, 'config.ini')
|
|
||||||
self.config = configparser.ConfigParser()
|
self.config = configparser.ConfigParser()
|
||||||
self.load_config()
|
self.load_config()
|
||||||
|
|
||||||
def load_config(self) -> None:
|
def load_config(self) -> None:
|
||||||
"""
|
"""
|
||||||
加载配置文件,如果不存在则创建默认配置
|
加载配置文件,如果不存在则创建默认配置
|
||||||
API 密钥优先从环境变量 (.env) 读取
|
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(self.config_file):
|
if not os.path.exists(self.config_file):
|
||||||
self.create_default_config()
|
self.create_default_config()
|
||||||
@@ -67,9 +53,6 @@ class ConfigManager:
|
|||||||
if not self.config.has_option(section, option):
|
if not self.config.has_option(section, option):
|
||||||
self.config.set(section, option, value)
|
self.config.set(section, option, value)
|
||||||
|
|
||||||
# API 密钥优先从环境变量读取
|
|
||||||
self._override_from_env()
|
|
||||||
|
|
||||||
# 保存更新后的配置
|
# 保存更新后的配置
|
||||||
self.save_config()
|
self.save_config()
|
||||||
logger.info(f"已加载并更新配置文件: {self.config_file}")
|
logger.info(f"已加载并更新配置文件: {self.config_file}")
|
||||||
@@ -78,18 +61,6 @@ class ConfigManager:
|
|||||||
logger.info("使用默认配置")
|
logger.info("使用默认配置")
|
||||||
self.create_default_config(save=False)
|
self.create_default_config(save=False)
|
||||||
|
|
||||||
def _override_from_env(self) -> None:
|
|
||||||
"""从环境变量覆盖敏感配置"""
|
|
||||||
env_mapping = {
|
|
||||||
('API', 'api_key'): 'BAIDU_API_KEY',
|
|
||||||
('API', 'secret_key'): 'BAIDU_SECRET_KEY',
|
|
||||||
('Gitea', 'token'): 'GITEA_TOKEN',
|
|
||||||
}
|
|
||||||
for (section, option), env_key in env_mapping.items():
|
|
||||||
env_val = os.getenv(env_key, '').strip()
|
|
||||||
if env_val:
|
|
||||||
self.config.set(section, option, env_val)
|
|
||||||
|
|
||||||
def create_default_config(self, save: bool = True) -> None:
|
def create_default_config(self, save: bool = True) -> None:
|
||||||
"""创建默认配置"""
|
"""创建默认配置"""
|
||||||
for section, options in DEFAULT_CONFIG.items():
|
for section, options in DEFAULT_CONFIG.items():
|
||||||
@@ -104,29 +75,13 @@ class ConfigManager:
|
|||||||
logger.info(f"已创建默认配置文件: {self.config_file}")
|
logger.info(f"已创建默认配置文件: {self.config_file}")
|
||||||
|
|
||||||
def save_config(self) -> None:
|
def save_config(self) -> None:
|
||||||
"""保存配置到文件(API 密钥在磁盘上保留,环境变量可临时覆盖)。
|
"""保存配置到文件"""
|
||||||
|
|
||||||
行为:
|
|
||||||
- 写入磁盘前:保留内存中已存在的 api_key/secret_key
|
|
||||||
- 用户在配置文件里手工写入的 key 会被持久化
|
|
||||||
- 环境变量 (.env) 仅在内存中覆盖
|
|
||||||
"""
|
|
||||||
saved_keys = {}
|
|
||||||
for option in ('api_key', 'secret_key'):
|
|
||||||
try:
|
|
||||||
saved_keys[option] = self.config.get('API', option, fallback='')
|
|
||||||
except Exception:
|
|
||||||
saved_keys[option] = ''
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||||
self.config.write(f)
|
self.config.write(f)
|
||||||
logger.info(f"配置已保存到: {self.config_file}")
|
logger.info(f"配置已保存到: {self.config_file}")
|
||||||
finally:
|
except Exception as e:
|
||||||
# 恢复内存中的值(即使写入失败也恢复)
|
logger.error(f"保存配置文件时出错: {e}")
|
||||||
for option, val in saved_keys.items():
|
|
||||||
if val:
|
|
||||||
self.config.set('API', option, val)
|
|
||||||
|
|
||||||
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
||||||
"""获取配置值"""
|
"""获取配置值"""
|
||||||
@@ -167,8 +122,8 @@ class ConfigManager:
|
|||||||
path = Path(path_str)
|
path = Path(path_str)
|
||||||
|
|
||||||
if not path.is_absolute():
|
if not path.is_absolute():
|
||||||
# 相对路径,转为绝对路径(相对于应用根目录)
|
# 相对路径,转为绝对路径(相对于项目根目录)
|
||||||
path = Path(self.app_root) / path
|
path = Path(os.getcwd()) / path
|
||||||
|
|
||||||
if create:
|
if create:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
"""单据元信息 SQLite 存储。
|
|
||||||
|
|
||||||
独立于 ProductDatabase,与 product_cache.db 共用文件。
|
|
||||||
提供:save / get / list_all / count。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderMetadataDB:
|
|
||||||
"""单据元信息库(共用 product_cache.db 文件,独立表 order_metadata)。"""
|
|
||||||
|
|
||||||
SCHEMA = """
|
|
||||||
CREATE TABLE IF NOT EXISTS order_metadata (
|
|
||||||
file_hash TEXT PRIMARY KEY,
|
|
||||||
supplier TEXT DEFAULT '',
|
|
||||||
bill_date TEXT DEFAULT '',
|
|
||||||
total_amount REAL DEFAULT 0.0,
|
|
||||||
raw_supplier_text TEXT DEFAULT '',
|
|
||||||
source_image TEXT DEFAULT '',
|
|
||||||
created_at TEXT DEFAULT '',
|
|
||||||
updated_at TEXT DEFAULT ''
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_path: str):
|
|
||||||
self.db_path = db_path
|
|
||||||
self._ensure_table()
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
return sqlite3.connect(self.db_path)
|
|
||||||
|
|
||||||
def _ensure_table(self):
|
|
||||||
"""幂等创建表。"""
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(self.db_path) or '.', exist_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.execute(self.SCHEMA)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def save(self, file_hash: str, supplier: str = '', bill_date: str = '',
|
|
||||||
total_amount: float = 0.0, raw_supplier_text: str = '',
|
|
||||||
source_image: str = '') -> bool:
|
|
||||||
"""保存或更新单据元信息(PK = file_hash)。"""
|
|
||||||
if not file_hash:
|
|
||||||
return False
|
|
||||||
now = datetime.now().isoformat(timespec='seconds')
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
# 使用 UPSERT 语义
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO order_metadata
|
|
||||||
(file_hash, supplier, bill_date, total_amount,
|
|
||||||
raw_supplier_text, source_image, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(file_hash) DO UPDATE SET
|
|
||||||
supplier = excluded.supplier,
|
|
||||||
bill_date = excluded.bill_date,
|
|
||||||
total_amount = excluded.total_amount,
|
|
||||||
raw_supplier_text = excluded.raw_supplier_text,
|
|
||||||
source_image = excluded.source_image,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""", (file_hash, supplier, bill_date, total_amount,
|
|
||||||
raw_supplier_text, source_image, now, now))
|
|
||||||
conn.commit()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"save_order_metadata 失败: {e}")
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get(self, file_hash: str) -> Optional[Dict]:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM order_metadata WHERE file_hash=?",
|
|
||||||
(file_hash,)).fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def list_all(self, limit: int = 1000) -> List[Dict]:
|
|
||||||
"""列出所有元信息,按 updated_at 倒序。"""
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT * FROM order_metadata ORDER BY updated_at DESC LIMIT ?",
|
|
||||||
(limit,)).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
return conn.execute("SELECT COUNT(*) FROM order_metadata").fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def delete(self, file_hash: str) -> bool:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.execute("DELETE FROM order_metadata WHERE file_hash=?", (file_hash,))
|
|
||||||
conn.commit()
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
@@ -1,690 +0,0 @@
|
|||||||
"""
|
|
||||||
商品资料 SQLite 数据库 + 商品记忆库
|
|
||||||
|
|
||||||
记忆库功能:
|
|
||||||
- 处理每步后自动学习商品数据(置信度+一致性加速)
|
|
||||||
- OCR 字段缺失时用记忆库补全 (conf > 50 直接采用)
|
|
||||||
- 价格异常检测:偏差 > 2倍触发补全,偏差 > 50% 记录预警
|
|
||||||
- 批量预加载 → 内存操作 → 批量写回,保障性能
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Optional, Tuple, Callable
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
|
||||||
from app.core.utils.file_utils import smart_read_excel
|
|
||||||
from app.core.handlers.column_mapper import ColumnMapper
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProductDatabase:
|
|
||||||
"""商品资料 SQLite 数据库 + 商品记忆库"""
|
|
||||||
|
|
||||||
SCHEMA = """
|
|
||||||
CREATE TABLE IF NOT EXISTS products (
|
|
||||||
barcode TEXT PRIMARY KEY,
|
|
||||||
name TEXT DEFAULT '',
|
|
||||||
price REAL DEFAULT 0.0,
|
|
||||||
unit TEXT DEFAULT '',
|
|
||||||
updated_at TEXT,
|
|
||||||
specification TEXT DEFAULT '',
|
|
||||||
source TEXT DEFAULT 'template',
|
|
||||||
confidence INTEGER DEFAULT 0,
|
|
||||||
usage_count INTEGER DEFAULT 0,
|
|
||||||
last_seen TEXT,
|
|
||||||
avg_price REAL DEFAULT 0.0,
|
|
||||||
min_price REAL DEFAULT 0.0,
|
|
||||||
max_price REAL DEFAULT 0.0,
|
|
||||||
price_count INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS missing_barcodes (
|
|
||||||
barcode TEXT PRIMARY KEY,
|
|
||||||
name TEXT DEFAULT '',
|
|
||||||
last_seen TEXT,
|
|
||||||
source_file TEXT,
|
|
||||||
count INTEGER DEFAULT 1
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
|
|
||||||
_NEW_COLUMNS = {
|
|
||||||
'specification': "TEXT DEFAULT ''",
|
|
||||||
'source': "TEXT DEFAULT 'template'",
|
|
||||||
'confidence': 'INTEGER DEFAULT 0',
|
|
||||||
'usage_count': 'INTEGER DEFAULT 0',
|
|
||||||
'last_seen': 'TEXT',
|
|
||||||
'avg_price': 'REAL DEFAULT 0.0',
|
|
||||||
'min_price': 'REAL DEFAULT 0.0',
|
|
||||||
'max_price': 'REAL DEFAULT 0.0',
|
|
||||||
'price_count': 'INTEGER DEFAULT 0',
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, db_path: str, excel_source: str):
|
|
||||||
self.db_path = db_path
|
|
||||||
self.excel_source = excel_source
|
|
||||||
self._ensure_db()
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
return sqlite3.connect(self.db_path)
|
|
||||||
|
|
||||||
def _ensure_db(self):
|
|
||||||
if os.path.exists(self.db_path):
|
|
||||||
self._migrate_schema()
|
|
||||||
return
|
|
||||||
if not os.path.exists(self.excel_source):
|
|
||||||
logger.warning(f"商品资料 Excel 不存在: {self.excel_source}")
|
|
||||||
self._create_empty_db()
|
|
||||||
return
|
|
||||||
logger.info(f"首次运行,从 Excel 导入商品资料: {self.excel_source}")
|
|
||||||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
|
||||||
self._create_empty_db()
|
|
||||||
count = self.import_from_excel(self.excel_source)
|
|
||||||
logger.info(f"商品资料导入完成: {count} 条记录")
|
|
||||||
|
|
||||||
def _create_empty_db(self):
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.executescript(self.SCHEMA)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _migrate_schema(self):
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
# 迁移 products 表
|
|
||||||
cursor = conn.execute("PRAGMA table_info(products)")
|
|
||||||
existing_cols = {row[1] for row in cursor.fetchall()}
|
|
||||||
for col_name, col_type in self._NEW_COLUMNS.items():
|
|
||||||
if col_name not in existing_cols:
|
|
||||||
conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}")
|
|
||||||
logger.info(f"数据库迁移: 添加列 {col_name}")
|
|
||||||
|
|
||||||
# 确保 missing_barcodes 表存在
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS missing_barcodes (
|
|
||||||
barcode TEXT PRIMARY KEY,
|
|
||||||
name TEXT DEFAULT '',
|
|
||||||
last_seen TEXT,
|
|
||||||
source_file TEXT,
|
|
||||||
count INTEGER DEFAULT 1
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 缺失条码记录
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def record_missing_barcode(self, barcode: str, name: str = '', source_file: str = ''):
|
|
||||||
"""记录缺失条码。"""
|
|
||||||
barcode = str(barcode).strip()
|
|
||||||
if not barcode:
|
|
||||||
return
|
|
||||||
now = datetime.now().isoformat(timespec='seconds')
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO missing_barcodes (barcode, name, last_seen, source_file, count)
|
|
||||||
VALUES (?, ?, ?, ?, 1)
|
|
||||||
ON CONFLICT(barcode) DO UPDATE SET
|
|
||||||
name = CASE WHEN excluded.name != '' THEN excluded.name ELSE name END,
|
|
||||||
last_seen = excluded.last_seen,
|
|
||||||
source_file = excluded.source_file,
|
|
||||||
count = count + 1
|
|
||||||
""", (barcode, name, now, os.path.basename(source_file)))
|
|
||||||
conn.commit()
|
|
||||||
logger.info(f"已记录缺失条码: {barcode} ({name})")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"记录缺失条码失败: {e}")
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_missing_barcodes(self, limit: int = 100) -> List[Dict]:
|
|
||||||
"""获取缺失条码列表。"""
|
|
||||||
conn = self._connect()
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT * FROM missing_barcodes ORDER BY last_seen DESC LIMIT ?",
|
|
||||||
(limit,)).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 导入
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def import_from_excel(self, excel_path: str) -> int:
|
|
||||||
df = smart_read_excel(excel_path)
|
|
||||||
if df is None or df.empty:
|
|
||||||
return 0
|
|
||||||
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
|
|
||||||
if not barcode_col:
|
|
||||||
return 0
|
|
||||||
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price')
|
|
||||||
if not price_col:
|
|
||||||
for col in df.columns:
|
|
||||||
if '进货价' in str(col).strip():
|
|
||||||
price_col = col
|
|
||||||
break
|
|
||||||
name_col = ColumnMapper.find_column(list(df.columns), 'name')
|
|
||||||
unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
|
|
||||||
spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
|
|
||||||
|
|
||||||
now = datetime.now().isoformat()
|
|
||||||
rows = []
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
barcode = str(row.get(barcode_col, '')).strip()
|
|
||||||
if not barcode or barcode == 'nan':
|
|
||||||
continue
|
|
||||||
price = 0.0
|
|
||||||
if price_col:
|
|
||||||
try:
|
|
||||||
p = row.get(price_col)
|
|
||||||
if p is not None and str(p).strip() not in ('', 'nan', 'None'):
|
|
||||||
price = float(p)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
name = str(row.get(name_col, '')).strip() if name_col else ''
|
|
||||||
if name == 'nan': name = ''
|
|
||||||
unit = str(row.get(unit_col, '')).strip() if unit_col else ''
|
|
||||||
if unit == 'nan': unit = ''
|
|
||||||
spec = str(row.get(spec_col, '')).strip() if spec_col else ''
|
|
||||||
if spec == 'nan': spec = ''
|
|
||||||
# template 源置信度 50
|
|
||||||
rows.append((barcode, name, price, unit, now, spec, 'template', 50, 0, now,
|
|
||||||
price, price, price, 1 if price > 0 else 0))
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return 0
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.executemany(
|
|
||||||
"INSERT OR REPLACE INTO products "
|
|
||||||
"(barcode, name, price, unit, updated_at, specification, source, confidence, "
|
|
||||||
"usage_count, last_seen, avg_price, min_price, max_price, price_count) "
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
rows)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return len(rows)
|
|
||||||
|
|
||||||
def reimport(self) -> int:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.execute("DELETE FROM products")
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return self.import_from_excel(self.excel_source)
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 查询
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def get_price(self, barcode: str) -> Optional[float]:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
row = conn.execute("SELECT avg_price FROM products WHERE barcode=?",
|
|
||||||
(str(barcode).strip(),)).fetchone()
|
|
||||||
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], 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, {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:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
return conn.execute("SELECT COUNT(*) FROM products").fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_memory(self, barcode: str) -> Optional[Dict]:
|
|
||||||
conn = self._connect()
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
row = conn.execute("SELECT * FROM products WHERE barcode=?",
|
|
||||||
(str(barcode).strip(),)).fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_memories(self, barcodes: List[str]) -> Dict[str, Dict]:
|
|
||||||
if not barcodes:
|
|
||||||
return {}
|
|
||||||
conn = self._connect()
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
placeholders = ','.join('?' * len(barcodes))
|
|
||||||
rows = conn.execute(
|
|
||||||
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
|
|
||||||
[str(b).strip() for b in barcodes]).fetchall()
|
|
||||||
return {r['barcode']: dict(r) for r in rows}
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def get_all_memories(self) -> List[Dict]:
|
|
||||||
conn = self._connect()
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
return [dict(row) for row in
|
|
||||||
conn.execute("SELECT * FROM products ORDER BY usage_count DESC, barcode").fetchall()]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 批量预加载 — 性能核心
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def load_batch(self, barcodes: List[str]) -> Dict[str, Dict]:
|
|
||||||
"""批量预加载条码记忆到 dict — 单次 SQL,后续纯内存操作"""
|
|
||||||
if not barcodes:
|
|
||||||
return {}
|
|
||||||
conn = self._connect()
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
placeholders = ','.join('?' * len(barcodes))
|
|
||||||
rows = conn.execute(
|
|
||||||
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
|
|
||||||
[str(b).strip() for b in barcodes]).fetchall()
|
|
||||||
return {r['barcode']: dict(r) for r in rows}
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 学习逻辑 — 一致性加速 + 价格区间
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def learn_from_product(self, product: Dict, source: str = 'ocr',
|
|
||||||
memory: Dict[str, Dict] = None,
|
|
||||||
add_log: Callable = None) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
从处理结果中学习,返回日志字符串。
|
|
||||||
memory: 可选的预加载批量内存,传入则零 DB 查询。
|
|
||||||
"""
|
|
||||||
barcode = str(product.get('barcode', '')).strip()
|
|
||||||
if not barcode:
|
|
||||||
return None
|
|
||||||
|
|
||||||
name = str(product.get('name', ''))
|
|
||||||
spec = str(product.get('specification', ''))
|
|
||||||
unit = str(product.get('unit', ''))
|
|
||||||
price = float(product.get('price', 0))
|
|
||||||
now = datetime.now().isoformat()
|
|
||||||
|
|
||||||
# 查现有记录(优先从内存查)
|
|
||||||
if memory is not None and barcode in memory:
|
|
||||||
row = memory[barcode]
|
|
||||||
old_name = row.get('name', '')
|
|
||||||
old_spec = row.get('specification', '')
|
|
||||||
old_unit = row.get('unit', '')
|
|
||||||
old_conf = row.get('confidence', 0)
|
|
||||||
old_count = row.get('usage_count', 0)
|
|
||||||
old_avg = row.get('avg_price', 0) or 0
|
|
||||||
old_min = row.get('min_price') or price
|
|
||||||
old_max = row.get('max_price') or price
|
|
||||||
pc = row.get('price_count', 0) or 0
|
|
||||||
exists = True
|
|
||||||
else:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT name, specification, unit, confidence, usage_count, "
|
|
||||||
"avg_price, min_price, max_price, price_count FROM products WHERE barcode=?",
|
|
||||||
(barcode,)).fetchone()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
if cursor is None:
|
|
||||||
exists = False
|
|
||||||
else:
|
|
||||||
old_name, old_spec, old_unit, old_conf, old_count, old_avg, old_min, old_max, pc = cursor
|
|
||||||
old_avg = old_avg or 0
|
|
||||||
pc = pc or 0
|
|
||||||
old_min = old_min if old_min is not None else price
|
|
||||||
old_max = old_max if old_max is not None else price
|
|
||||||
exists = True
|
|
||||||
|
|
||||||
new_count = old_count + 1 if exists else 1
|
|
||||||
|
|
||||||
# ── 置信度 ──
|
|
||||||
if source == 'user_confirmed':
|
|
||||||
new_conf = 90
|
|
||||||
elif source == 'template':
|
|
||||||
new_conf = 50
|
|
||||||
elif exists and old_conf < 50:
|
|
||||||
# 一致性加速
|
|
||||||
spec_match = bool(spec and old_spec and spec == old_spec)
|
|
||||||
unit_match = bool(unit and old_unit and unit == old_unit)
|
|
||||||
if spec_match and unit_match:
|
|
||||||
boost = 10
|
|
||||||
elif unit_match:
|
|
||||||
boost = 5
|
|
||||||
else:
|
|
||||||
boost = 3
|
|
||||||
new_conf = min(50, old_conf + boost)
|
|
||||||
elif exists:
|
|
||||||
new_conf = old_conf # > 50 稳定不变
|
|
||||||
else:
|
|
||||||
new_conf = 10 # 新 OCR 记录
|
|
||||||
|
|
||||||
# ── 价格区间 ──
|
|
||||||
if price > 0:
|
|
||||||
new_pc = (pc if exists else 0) + 1
|
|
||||||
new_avg = ((old_avg * (new_pc - 1)) + price) / new_pc if exists else price
|
|
||||||
new_min = min(old_min, price) if exists else price
|
|
||||||
new_max = max(old_max, price) if exists else price
|
|
||||||
else:
|
|
||||||
new_avg = old_avg if exists else 0
|
|
||||||
new_min = old_min if exists else 0
|
|
||||||
new_max = old_max if exists else 0
|
|
||||||
new_pc = pc if exists else 0
|
|
||||||
|
|
||||||
# ── 写入 ──
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
if not exists:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO products (barcode, name, specification, unit, price, "
|
|
||||||
"source, confidence, usage_count, last_seen, updated_at, "
|
|
||||||
"avg_price, min_price, max_price, price_count) "
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(barcode, name, spec, unit, price, source, new_conf, 1, now, now,
|
|
||||||
new_avg, new_min, new_max, new_pc))
|
|
||||||
log = f"记忆库新增: {barcode} {name} 源={source} 可信度={new_conf}"
|
|
||||||
else:
|
|
||||||
# 高可信度源全字段覆盖;低可信度仅填空
|
|
||||||
if source in ('template', 'user_confirmed') or new_conf > 50:
|
|
||||||
# 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 "
|
|
||||||
"name=CASE WHEN name='' THEN ? ELSE name END, "
|
|
||||||
"specification=CASE WHEN specification='' THEN ? ELSE specification END, "
|
|
||||||
"unit=CASE WHEN unit='' THEN ? ELSE unit END, "
|
|
||||||
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
|
|
||||||
"avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
|
|
||||||
(name, spec, unit, source, new_conf, new_count, now, now,
|
|
||||||
new_avg, new_min, new_max, new_pc, barcode))
|
|
||||||
log = f"记忆库更新: {barcode} 可信度{old_conf if exists else 0}→{new_conf}"
|
|
||||||
if price > 0:
|
|
||||||
log += f" 均价{new_avg:.4f}({new_pc}次)"
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# 更新内存 dict(如果传入了)
|
|
||||||
if memory is not None and barcode in memory:
|
|
||||||
memory[barcode].update({
|
|
||||||
'confidence': new_conf, 'usage_count': new_count,
|
|
||||||
'avg_price': new_avg, 'min_price': new_min,
|
|
||||||
'max_price': new_max, 'price_count': new_pc,
|
|
||||||
'name': name or old_name,
|
|
||||||
'specification': spec or old_spec,
|
|
||||||
'unit': unit or old_unit,
|
|
||||||
})
|
|
||||||
|
|
||||||
if add_log:
|
|
||||||
add_log(log)
|
|
||||||
return log
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def learn_from_products(self, products: List[Dict], source: str = 'ocr',
|
|
||||||
add_log: Callable = None) -> int:
|
|
||||||
"""批量学习 — 先批量预加载,再逐条处理,返回更新条数"""
|
|
||||||
barcodes = [str(p.get('barcode', '')) for p in products if p.get('barcode')]
|
|
||||||
memory = self.load_batch(barcodes)
|
|
||||||
count = 0
|
|
||||||
for p in products:
|
|
||||||
try:
|
|
||||||
result = self.learn_from_product(p, source, memory=memory, add_log=add_log)
|
|
||||||
if result:
|
|
||||||
count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"学习商品记忆失败: {e}")
|
|
||||||
return count
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 记忆辅助 — OCR 补全
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _price_anomaly(self, product: Dict, mem: Dict) -> bool:
|
|
||||||
"""价格异常:> 2倍偏差"""
|
|
||||||
price = float(product.get('price', 0))
|
|
||||||
avg = mem.get('avg_price', 0)
|
|
||||||
if not price or not avg:
|
|
||||||
return False
|
|
||||||
return price > avg * 2 or price < avg * 0.5
|
|
||||||
|
|
||||||
def fill_from_memory(self, barcode: str, ocr_result: Dict,
|
|
||||||
memory: Dict[str, Dict] = None) -> Tuple[Dict, str]:
|
|
||||||
"""用记忆库补全 OCR 缺失字段。返回 (补全后的dict, 日志字符串)"""
|
|
||||||
if memory:
|
|
||||||
mem = memory.get(barcode)
|
|
||||||
else:
|
|
||||||
mem = self.get_memory(barcode)
|
|
||||||
|
|
||||||
if not mem or mem.get('confidence', 0) < 10:
|
|
||||||
return ocr_result, ""
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
result = dict(ocr_result)
|
|
||||||
conf = mem.get('confidence', 0)
|
|
||||||
|
|
||||||
has_spec = result.get('specification')
|
|
||||||
has_unit = result.get('unit')
|
|
||||||
price = float(result.get('price', 0))
|
|
||||||
|
|
||||||
if conf > 50 and not has_spec and mem.get('specification'):
|
|
||||||
result['specification'] = mem['specification']
|
|
||||||
logs.append(f"规格补全(可信{conf}): {barcode} → {mem['specification']}")
|
|
||||||
elif not has_spec and mem.get('specification') and self._price_anomaly(result, mem):
|
|
||||||
result['specification'] = mem['specification']
|
|
||||||
logs.append(f"价格异常→规格补全: {barcode} 本次{price:.2f} vs 均价{mem['avg_price']:.2f} → {mem['specification']}")
|
|
||||||
|
|
||||||
if conf > 50 and not has_unit and mem.get('unit'):
|
|
||||||
result['unit'] = mem['unit']
|
|
||||||
logs.append(f"单位补全(可信{conf}): {barcode} → {mem['unit']}")
|
|
||||||
elif not has_unit and mem.get('unit') and self._price_anomaly(result, mem):
|
|
||||||
result['unit'] = mem['unit']
|
|
||||||
logs.append(f"价格异常→单位补全: {barcode} → {mem['unit']}")
|
|
||||||
|
|
||||||
return result, "; ".join(logs)
|
|
||||||
|
|
||||||
def price_warning(self, barcode: str, price: float,
|
|
||||||
memory: Dict[str, Dict] = None) -> Optional[str]:
|
|
||||||
"""价格预警。> 50% 偏差告警"""
|
|
||||||
if memory:
|
|
||||||
mem = memory.get(barcode)
|
|
||||||
else:
|
|
||||||
mem = self.get_memory(barcode)
|
|
||||||
if not mem or not mem.get('avg_price'):
|
|
||||||
return None
|
|
||||||
avg = mem['avg_price']
|
|
||||||
min_p = mem.get('min_price', avg)
|
|
||||||
max_p = mem.get('max_price', avg)
|
|
||||||
pc = mem.get('price_count', 0)
|
|
||||||
if price > avg * 1.5 or price < avg * 0.5:
|
|
||||||
return (f"单价预警: {barcode} 本次{price:.4f}元 vs "
|
|
||||||
f"历史均价{avg:.4f} (范围{min_p:.4f}~{max_p:.4f}, {pc}次)")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 手动编辑
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def update_memory(self, barcode: str, fields: Dict) -> bool:
|
|
||||||
barcode = str(barcode).strip()
|
|
||||||
if not barcode:
|
|
||||||
return False
|
|
||||||
allowed = {'name', 'specification', 'unit', 'price', 'confidence'}
|
|
||||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
|
||||||
if not updates:
|
|
||||||
return False
|
|
||||||
now = datetime.now().isoformat()
|
|
||||||
set_clause = ', '.join(f"{k}=?" for k in updates)
|
|
||||||
values = list(updates.values())
|
|
||||||
extra_sql = ", source='user_confirmed'"
|
|
||||||
if 'confidence' not in updates:
|
|
||||||
extra_sql += ", confidence=90"
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
f"UPDATE products SET {set_clause}{extra_sql}, updated_at=? WHERE barcode=?",
|
|
||||||
values + [now, barcode])
|
|
||||||
conn.commit()
|
|
||||||
return conn.total_changes > 0
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def delete_memory(self, barcode: str) -> bool:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
conn.execute("DELETE FROM products WHERE barcode=?", (str(barcode).strip(),))
|
|
||||||
conn.commit()
|
|
||||||
return conn.total_changes > 0
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 云端同步
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def export_for_sync(self) -> Dict:
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT barcode, name, specification, unit, price, source, "
|
|
||||||
"confidence, usage_count, last_seen, avg_price, min_price, max_price, price_count "
|
|
||||||
"FROM products")
|
|
||||||
result = {}
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
result[row[0]] = {
|
|
||||||
'name': row[1], 'specification': row[2], 'unit': row[3],
|
|
||||||
'price': row[4], 'source': row[5], 'confidence': row[6],
|
|
||||||
'usage_count': row[7], 'last_seen': row[8],
|
|
||||||
'avg_price': row[9], 'min_price': row[10],
|
|
||||||
'max_price': row[11], 'price_count': row[12],
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def import_from_sync(self, data: Dict) -> int:
|
|
||||||
now = datetime.now().isoformat()
|
|
||||||
count = 0
|
|
||||||
conn = self._connect()
|
|
||||||
try:
|
|
||||||
for barcode, info in data.items():
|
|
||||||
barcode = str(barcode).strip()
|
|
||||||
if not barcode:
|
|
||||||
continue
|
|
||||||
name = str(info.get('name', ''))
|
|
||||||
spec = str(info.get('specification', ''))
|
|
||||||
unit = str(info.get('unit', ''))
|
|
||||||
price = float(info.get('price', 0))
|
|
||||||
remote_source = str(info.get('source', 'ocr'))
|
|
||||||
remote_conf = int(info.get('confidence', 50))
|
|
||||||
remote_count = int(info.get('usage_count', 1))
|
|
||||||
remote_seen = str(info.get('last_seen', now))
|
|
||||||
remote_avg = float(info.get('avg_price', price))
|
|
||||||
remote_min = float(info.get('min_price', price))
|
|
||||||
remote_max = float(info.get('max_price', price))
|
|
||||||
remote_pc = int(info.get('price_count', 1))
|
|
||||||
|
|
||||||
row = conn.execute("SELECT confidence FROM products WHERE barcode=?",
|
|
||||||
(barcode,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO products (barcode, name, specification, unit, price, "
|
|
||||||
"source, confidence, usage_count, last_seen, updated_at, "
|
|
||||||
"avg_price, min_price, max_price, price_count) "
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(barcode, name, spec, unit, price, remote_source, remote_conf,
|
|
||||||
remote_count, remote_seen, now,
|
|
||||||
remote_avg, remote_min, remote_max, remote_pc))
|
|
||||||
count += 1
|
|
||||||
else:
|
|
||||||
local_conf = row[0]
|
|
||||||
if remote_conf > local_conf:
|
|
||||||
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, spec, unit, price, remote_source, remote_conf,
|
|
||||||
remote_count, remote_seen, now,
|
|
||||||
remote_avg, remote_min, remote_max, remote_pc, barcode))
|
|
||||||
count += 1
|
|
||||||
elif remote_conf == local_conf:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE products SET "
|
|
||||||
"name=CASE WHEN name='' THEN ? ELSE name END, "
|
|
||||||
"specification=CASE WHEN specification='' THEN ? ELSE specification END, "
|
|
||||||
"unit=CASE WHEN unit='' THEN ? ELSE unit END, "
|
|
||||||
"usage_count=MAX(usage_count, ?), updated_at=? WHERE barcode=?",
|
|
||||||
(name, spec, unit, remote_count, now, barcode))
|
|
||||||
count += 1
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return count
|
|
||||||
|
|
||||||
def _export_memory_json(self, json_path=None):
|
|
||||||
"""导出记忆库为 JSON(兼容旧代码调用)"""
|
|
||||||
import os as _os
|
|
||||||
if json_path is None:
|
|
||||||
json_path = _os.path.join(_os.path.dirname(self.db_path), 'product_memory.json')
|
|
||||||
data = self.export_for_sync()
|
|
||||||
_os.makedirs(_os.path.dirname(json_path), exist_ok=True)
|
|
||||||
with open(json_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
||||||
return json_path
|
|
||||||
@@ -10,13 +10,13 @@ import os
|
|||||||
import json
|
import json
|
||||||
from typing import Dict, Tuple, Optional, Any, List, Union
|
from typing import Dict, Tuple, Optional, Any, List, Union
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
from ..utils.log_utils import get_logger
|
||||||
from app.core.excel.handlers.barcode_mapper import BarcodeMapper
|
from .handlers.barcode_mapper import BarcodeMapper
|
||||||
from app.core.excel.handlers.unit_converter_handlers import (
|
from .handlers.unit_converter_handlers import (
|
||||||
JianUnitHandler, BoxUnitHandler, TiHeUnitHandler,
|
JianUnitHandler, BoxUnitHandler, TiHeUnitHandler,
|
||||||
GiftUnitHandler, UnitHandler
|
GiftUnitHandler, UnitHandler
|
||||||
)
|
)
|
||||||
from app.core.excel.validators import ProductValidator
|
from .validators import ProductValidator
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
# 导出所有处理程序类
|
# 导出所有处理程序类
|
||||||
from app.core.excel.handlers.barcode_mapper import BarcodeMapper
|
from .barcode_mapper import BarcodeMapper
|
||||||
from app.core.excel.handlers.unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler
|
from .unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, Optional, Any
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...utils.log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import logging
|
|||||||
from typing import Dict, Optional, Any, Tuple, Protocol
|
from typing import Dict, Optional, Any, Tuple, Protocol
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...utils.log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
+71
-26
@@ -14,17 +14,16 @@ from xlutils.copy import copy as xlcopy
|
|||||||
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
from ...config.settings import ConfigManager
|
||||||
from app.core.utils.log_utils import get_logger
|
from ..utils.log_utils import get_logger
|
||||||
from app.core.handlers.column_mapper import ColumnMapper
|
from ..utils.file_utils import (
|
||||||
from app.core.utils.file_utils import (
|
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
get_file_extension,
|
get_file_extension,
|
||||||
get_files_by_extensions,
|
get_files_by_extensions,
|
||||||
load_json,
|
load_json,
|
||||||
save_json
|
save_json
|
||||||
)
|
)
|
||||||
from app.core.utils.string_utils import (
|
from ..utils.string_utils import (
|
||||||
clean_string,
|
clean_string,
|
||||||
clean_barcode,
|
clean_barcode,
|
||||||
format_barcode
|
format_barcode
|
||||||
@@ -49,7 +48,7 @@ class PurchaseOrderMerger:
|
|||||||
# 修复ConfigParser对象没有get_path方法的问题
|
# 修复ConfigParser对象没有get_path方法的问题
|
||||||
try:
|
try:
|
||||||
# 获取输出目录
|
# 获取输出目录
|
||||||
self.output_dir = config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/output')
|
self.output_dir = config.get('Paths', 'output_folder', fallback='data/output')
|
||||||
|
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
os.makedirs(self.output_dir, exist_ok=True)
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
@@ -96,8 +95,8 @@ class PurchaseOrderMerger:
|
|||||||
Returns:
|
Returns:
|
||||||
采购单文件路径列表
|
采购单文件路径列表
|
||||||
"""
|
"""
|
||||||
# 采购单文件保存在result目录
|
# 采购单文件保存在data/result目录
|
||||||
result_dir = self.config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/result')
|
result_dir = "data/result"
|
||||||
logger.info(f"搜索目录 {result_dir} 中的采购单Excel文件")
|
logger.info(f"搜索目录 {result_dir} 中的采购单Excel文件")
|
||||||
|
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
@@ -141,12 +140,19 @@ class PurchaseOrderMerger:
|
|||||||
logger.debug(f"Excel文件的列名: {df.columns.tolist()}")
|
logger.debug(f"Excel文件的列名: {df.columns.tolist()}")
|
||||||
|
|
||||||
# 处理特殊情况:检查是否需要读取指定行作为标题行
|
# 处理特殊情况:检查是否需要读取指定行作为标题行
|
||||||
header_row_idx = ColumnMapper.detect_header_row(df, max_rows=5, min_matches=3)
|
for header_row_idx in range(5): # 检查前5行
|
||||||
if header_row_idx >= 0:
|
if len(df) <= header_row_idx:
|
||||||
|
continue
|
||||||
|
|
||||||
|
potential_header = df.iloc[header_row_idx].astype(str)
|
||||||
|
header_keywords = ['条码', '条形码', '商品条码', '商品名称', '规格', '单价', '数量', '金额', '单位', '必填']
|
||||||
|
matches = sum(1 for keyword in header_keywords if any(keyword in str(val) for val in potential_header.values))
|
||||||
|
|
||||||
|
if matches >= 3: # 如果至少匹配3个关键词,认为是表头
|
||||||
logger.info(f"检测到表头在第 {header_row_idx+1} 行")
|
logger.info(f"检测到表头在第 {header_row_idx+1} 行")
|
||||||
|
|
||||||
# 使用此行作为列名,数据从下一行开始
|
# 使用此行作为列名,数据从下一行开始
|
||||||
header_row = df.iloc[header_row_idx].astype(str)
|
header_row = potential_header
|
||||||
data_rows = df.iloc[header_row_idx+1:].reset_index(drop=True)
|
data_rows = df.iloc[header_row_idx+1:].reset_index(drop=True)
|
||||||
|
|
||||||
# 为每一列分配名称(避免重复的列名)
|
# 为每一列分配名称(避免重复的列名)
|
||||||
@@ -162,24 +168,63 @@ class PurchaseOrderMerger:
|
|||||||
data_rows.columns = new_columns
|
data_rows.columns = new_columns
|
||||||
df = data_rows
|
df = data_rows
|
||||||
logger.debug(f"重新构建的数据帧列名: {df.columns.tolist()}")
|
logger.debug(f"重新构建的数据帧列名: {df.columns.tolist()}")
|
||||||
|
break
|
||||||
|
|
||||||
# 使用 ColumnMapper 统一查找列名(保留中文键名以兼容下游代码)
|
# 定义可能的列名映射
|
||||||
|
column_mapping = {
|
||||||
|
'条码': ['条码', '条形码', '商品条码', 'barcode', '商品条形码', '条形码', '商品条码', '商品编码', '商品编号', '条形码', '条码(必填)'],
|
||||||
|
'采购量': ['数量', '采购数量', '购买数量', '采购数量', '订单数量', '采购数量', '采购量(必填)', '采购量', '数量(必填)'],
|
||||||
|
'采购单价': ['单价', '价格', '采购单价', '销售价', '采购单价(必填)', '单价(必填)', '价格(必填)'],
|
||||||
|
'赠送量': ['赠送量', '赠品数量', '赠送数量', '赠品']
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示所有列名,用于调试
|
||||||
all_columns = df.columns.tolist()
|
all_columns = df.columns.tolist()
|
||||||
logger.info(f"列名: {all_columns}")
|
logger.info(f"列名: {all_columns}")
|
||||||
|
|
||||||
standard_to_chinese = {
|
# 映射实际的列名
|
||||||
'barcode': '条码',
|
|
||||||
'quantity': '采购量',
|
|
||||||
'unit_price': '采购单价',
|
|
||||||
'gift_quantity': '赠送量',
|
|
||||||
}
|
|
||||||
|
|
||||||
mapped_columns = {}
|
mapped_columns = {}
|
||||||
for std_name, chinese_name in standard_to_chinese.items():
|
for target_col, possible_names in column_mapping.items():
|
||||||
matched = ColumnMapper.find_column(all_columns, std_name)
|
for col in all_columns:
|
||||||
if matched:
|
# 清理列名以进行匹配
|
||||||
mapped_columns[chinese_name] = matched
|
col_str = str(col).strip()
|
||||||
logger.info(f"列名映射: {matched} -> {chinese_name}")
|
|
||||||
|
# 直接匹配整个列名
|
||||||
|
if col_str in possible_names:
|
||||||
|
mapped_columns[target_col] = col
|
||||||
|
logger.info(f"直接匹配列名: {col_str} -> {target_col}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 移除列名中的空白字符进行比较
|
||||||
|
clean_col = re.sub(r'\s+', '', col_str)
|
||||||
|
for name in possible_names:
|
||||||
|
clean_name = re.sub(r'\s+', '', name)
|
||||||
|
# 完全匹配
|
||||||
|
if clean_col == clean_name:
|
||||||
|
mapped_columns[target_col] = col
|
||||||
|
logger.info(f"清理后匹配列名: {col_str} -> {target_col}")
|
||||||
|
break
|
||||||
|
# 部分匹配(列名包含关键词)
|
||||||
|
elif clean_name in clean_col:
|
||||||
|
mapped_columns[target_col] = col
|
||||||
|
logger.info(f"部分匹配列名: {col_str} -> {target_col}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if target_col in mapped_columns:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 如果没有找到匹配,尝试模糊匹配
|
||||||
|
if target_col not in mapped_columns:
|
||||||
|
for col in all_columns:
|
||||||
|
col_str = str(col).strip().lower()
|
||||||
|
for name in possible_names:
|
||||||
|
name_lower = name.lower()
|
||||||
|
if name_lower in col_str:
|
||||||
|
mapped_columns[target_col] = col
|
||||||
|
logger.info(f"模糊匹配列名: {col} -> {target_col}")
|
||||||
|
break
|
||||||
|
if target_col in mapped_columns:
|
||||||
|
break
|
||||||
|
|
||||||
# 如果找到了必要的列,重命名列
|
# 如果找到了必要的列,重命名列
|
||||||
if mapped_columns:
|
if mapped_columns:
|
||||||
@@ -354,9 +399,9 @@ class PurchaseOrderMerger:
|
|||||||
# 采购单价(必填)- E列(4)
|
# 采购单价(必填)- E列(4)
|
||||||
output_sheet.write(r, price_col, float(row['采购单价']), price_style)
|
output_sheet.write(r, price_col, float(row['采购单价']), price_style)
|
||||||
|
|
||||||
# 生成输出文件名,保存到result目录
|
# 生成输出文件名,保存到data/result目录
|
||||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
result_dir = self.config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/result')
|
result_dir = "data/result"
|
||||||
os.makedirs(result_dir, exist_ok=True)
|
os.makedirs(result_dir, exist_ok=True)
|
||||||
output_file = os.path.join(result_dir, f"合并采购单_{timestamp}.xls")
|
output_file = os.path.join(result_dir, f"合并采购单_{timestamp}.xls")
|
||||||
|
|
||||||
|
|||||||
+227
-163
@@ -14,23 +14,22 @@ from xlutils.copy import copy as xlcopy
|
|||||||
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
from ...config.settings import ConfigManager
|
||||||
from app.core.utils.log_utils import get_logger
|
from ..utils.log_utils import get_logger
|
||||||
from app.core.utils.file_utils import (
|
from ..utils.file_utils import (
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
get_file_extension,
|
get_file_extension,
|
||||||
get_latest_file,
|
get_latest_file,
|
||||||
load_json,
|
load_json,
|
||||||
save_json
|
save_json
|
||||||
)
|
)
|
||||||
from app.core.utils.string_utils import (
|
from ..utils.string_utils import (
|
||||||
clean_string,
|
clean_string,
|
||||||
|
clean_barcode,
|
||||||
extract_number,
|
extract_number,
|
||||||
format_barcode,
|
format_barcode
|
||||||
parse_monetary_string
|
|
||||||
)
|
)
|
||||||
from app.core.excel.converter import UnitConverter
|
from .converter import UnitConverter
|
||||||
from app.core.handlers.column_mapper import ColumnMapper
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -40,24 +39,19 @@ class ExcelProcessor:
|
|||||||
提取条码、单价和数量,并按照采购单模板的格式填充
|
提取条码、单价和数量,并按照采购单模板的格式填充
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config, product_db=None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None):
|
def __init__(self, config):
|
||||||
"""
|
"""
|
||||||
初始化Excel处理器
|
初始化Excel处理器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: 配置信息
|
config: 配置信息
|
||||||
product_db: 商品数据库实例(可选,由外部传入以共享)
|
|
||||||
missing_barcodes_cb: 缺失条码的回调函数,接收条码列表
|
|
||||||
"""
|
"""
|
||||||
self.config = config
|
self.config = config
|
||||||
self.missing_barcodes_cb = missing_barcodes_cb
|
|
||||||
self.current_missing_barcodes = [] # 记录当前文件处理中缺失的条码
|
|
||||||
self.current_file_path = "" # 记录当前处理的文件路径
|
|
||||||
|
|
||||||
# 修复ConfigParser对象没有get_path方法的问题
|
# 修复ConfigParser对象没有get_path方法的问题
|
||||||
try:
|
try:
|
||||||
# 获取输入和输出目录
|
# 获取输入和输出目录
|
||||||
self.output_dir = config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/output')
|
self.output_dir = config.get('Paths', 'output_folder', fallback='data/output')
|
||||||
self.temp_dir = config.get('Paths', 'temp_folder', fallback='data/temp')
|
self.temp_dir = config.get('Paths', 'temp_folder', fallback='data/temp')
|
||||||
|
|
||||||
# 获取模板文件路径
|
# 获取模板文件路径
|
||||||
@@ -66,7 +60,7 @@ class ExcelProcessor:
|
|||||||
logger.warning(f"模板文件不存在: {self.template_path}")
|
logger.warning(f"模板文件不存在: {self.template_path}")
|
||||||
|
|
||||||
# 设置缓存文件路径
|
# 设置缓存文件路径
|
||||||
self.cache_file = os.path.join(self.output_dir, "excel_process_records.json")
|
self.cache_file = os.path.join(self.output_dir, "processed_files.json")
|
||||||
self.processed_files = self._load_processed_files()
|
self.processed_files = self._load_processed_files()
|
||||||
|
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
@@ -79,18 +73,6 @@ class ExcelProcessor:
|
|||||||
|
|
||||||
# 加载单位转换器和配置
|
# 加载单位转换器和配置
|
||||||
self.unit_converter = UnitConverter()
|
self.unit_converter = UnitConverter()
|
||||||
|
|
||||||
# 商品记忆库
|
|
||||||
if product_db is not None:
|
|
||||||
self.product_db = product_db
|
|
||||||
else:
|
|
||||||
from app.core.db.product_db import ProductDatabase
|
|
||||||
db_path = config.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(config, 'get_path') else 'data/product_cache.db'
|
|
||||||
tpl_folder = config.get('Paths', 'template_folder', fallback='templates')
|
|
||||||
item_data = config.get('Templates', 'item_data', fallback='商品资料.xlsx')
|
|
||||||
tpl_path = os.path.join(tpl_folder, item_data)
|
|
||||||
self.product_db = ProductDatabase(db_path, tpl_path)
|
|
||||||
|
|
||||||
logger.info(f"初始化ExcelProcessor完成,模板文件: {self.template_path}")
|
logger.info(f"初始化ExcelProcessor完成,模板文件: {self.template_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"初始化ExcelProcessor失败: {e}")
|
logger.error(f"初始化ExcelProcessor失败: {e}")
|
||||||
@@ -139,6 +121,48 @@ class ExcelProcessor:
|
|||||||
logger.info(f"找到最新的Excel文件: {latest_file}")
|
logger.info(f"找到最新的Excel文件: {latest_file}")
|
||||||
return latest_file
|
return latest_file
|
||||||
|
|
||||||
|
def validate_barcode(self, barcode: Any) -> bool:
|
||||||
|
"""
|
||||||
|
验证条码是否有效
|
||||||
|
新增功能:如果条码是"仓库",则返回False以避免误认为有效条码
|
||||||
|
|
||||||
|
Args:
|
||||||
|
barcode: 条码值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
条码是否有效
|
||||||
|
"""
|
||||||
|
# 处理"仓库"特殊情况
|
||||||
|
if isinstance(barcode, str) and barcode.strip() in ["仓库", "仓库全名"]:
|
||||||
|
logger.warning(f"条码为仓库标识: {barcode}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 清理条码格式
|
||||||
|
barcode_clean = clean_barcode(barcode)
|
||||||
|
|
||||||
|
# 对特定的错误条码进行修正(开头改6开头)
|
||||||
|
if len(barcode_clean) > 8 and barcode_clean.startswith('5') and not barcode_clean.startswith('53'):
|
||||||
|
barcode_clean = '6' + barcode_clean[1:]
|
||||||
|
logger.info(f"修正条码前缀 5->6: {barcode} -> {barcode_clean}")
|
||||||
|
|
||||||
|
# 验证条码长度
|
||||||
|
if len(barcode_clean) < 8 or len(barcode_clean) > 13:
|
||||||
|
logger.warning(f"条码长度异常: {barcode_clean}, 长度={len(barcode_clean)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 验证条码是否全为数字
|
||||||
|
if not barcode_clean.isdigit():
|
||||||
|
logger.warning(f"条码包含非数字字符: {barcode_clean}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 对于序号9的特殊情况,允许其条码格式
|
||||||
|
if barcode_clean == "5321545613":
|
||||||
|
logger.info(f"特殊条码验证通过: {barcode_clean}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.debug(f"条码验证通过: {barcode_clean}")
|
||||||
|
return True
|
||||||
|
|
||||||
def extract_barcode(self, df: pd.DataFrame) -> List[str]:
|
def extract_barcode(self, df: pd.DataFrame) -> List[str]:
|
||||||
"""
|
"""
|
||||||
从数据帧中提取条码列名
|
从数据帧中提取条码列名
|
||||||
@@ -149,7 +173,12 @@ class ExcelProcessor:
|
|||||||
Returns:
|
Returns:
|
||||||
可能的条码列名列表
|
可能的条码列名列表
|
||||||
"""
|
"""
|
||||||
possible_barcode_columns = ColumnMapper.STANDARD_COLUMNS['barcode']
|
possible_barcode_columns = [
|
||||||
|
'条码', '条形码', '商品条码', '商品条形码',
|
||||||
|
'商品编码', '商品编号', '条形码', '条码(必填)',
|
||||||
|
'barcode', 'Barcode', '编码', '条形码', '电脑条码',
|
||||||
|
'条码ID', '产品条码', 'BarCode'
|
||||||
|
]
|
||||||
|
|
||||||
found_columns = []
|
found_columns = []
|
||||||
|
|
||||||
@@ -225,21 +254,6 @@ class ExcelProcessor:
|
|||||||
if not product['barcode']:
|
if not product['barcode']:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 检查条码是否存在于数据库(商品资料)
|
|
||||||
bc = product['barcode']
|
|
||||||
mem = self.product_db.get_memory(bc)
|
|
||||||
if not mem or mem.get('confidence', 0) < 50:
|
|
||||||
# 如果不存在,或者置信度低(说明不是来自商品资料模板),记录为缺失
|
|
||||||
if bc not in self.current_missing_barcodes:
|
|
||||||
self.current_missing_barcodes.append(bc)
|
|
||||||
# 记录到数据库
|
|
||||||
self.product_db.record_missing_barcode(
|
|
||||||
bc,
|
|
||||||
product.get('name', ''),
|
|
||||||
self.current_file_path
|
|
||||||
)
|
|
||||||
logger.warning(f"条码缺失: {bc} ({product.get('name', '')})")
|
|
||||||
|
|
||||||
# 检查备注列,过滤换货、退货、作废等非采购行
|
# 检查备注列,过滤换货、退货、作废等非采购行
|
||||||
skip_row = False
|
skip_row = False
|
||||||
for col in df.columns:
|
for col in df.columns:
|
||||||
@@ -288,11 +302,25 @@ class ExcelProcessor:
|
|||||||
if amt is None:
|
if amt is None:
|
||||||
is_amt_gift = True
|
is_amt_gift = True
|
||||||
elif isinstance(amt, str):
|
elif isinstance(amt, str):
|
||||||
parsed = parse_monetary_string(amt)
|
s = amt.strip()
|
||||||
is_amt_gift = (parsed is None or parsed == 0.0)
|
if s == '' or s.lower() == 'o' or s == '0' or s == '○':
|
||||||
|
is_amt_gift = True
|
||||||
else:
|
else:
|
||||||
parsed = parse_monetary_string(amt)
|
amt_clean = re.sub(r'[^\d\.,]', '', s)
|
||||||
is_amt_gift = (parsed is not None and parsed == 0.0)
|
if ',' in amt_clean and '.' not in amt_clean:
|
||||||
|
amt_clean = amt_clean.replace(',', '.')
|
||||||
|
elif ',' in amt_clean and '.' in amt_clean:
|
||||||
|
amt_clean = amt_clean.replace(',', '')
|
||||||
|
if amt_clean:
|
||||||
|
try:
|
||||||
|
is_amt_gift = float(amt_clean) == 0.0
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
is_amt_gift = float(amt) == 0.0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
if is_amt_gift:
|
if is_amt_gift:
|
||||||
product['is_gift'] = True
|
product['is_gift'] = True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -371,30 +399,50 @@ class ExcelProcessor:
|
|||||||
product['package_quantity'] = package_quantity
|
product['package_quantity'] = package_quantity
|
||||||
logger.info(f"解析已设置的规格: {product['specification']} -> 包装数量={package_quantity}")
|
logger.info(f"解析已设置的规格: {product['specification']} -> 包装数量={package_quantity}")
|
||||||
|
|
||||||
# 如果数量为0但单价和金额都存在,先算数量(必须在 unit 推断之前,否则 unit 推断条件因 quantity=0 被跳过)
|
# 新增逻辑:根据规格推断单位为"件"
|
||||||
if (product['quantity'] == 0 or product['quantity'] is None) and product['price'] and product['amount']:
|
if not product['unit'] and product.get('barcode') and product.get('specification') and product.get('quantity') and product.get('price') is not None:
|
||||||
try:
|
# 检查规格是否符合容量*数量格式
|
||||||
amount = parse_monetary_string(product['amount'])
|
|
||||||
if amount is not None and amount > 0:
|
|
||||||
quantity = amount / product['price']
|
|
||||||
logger.info(f"数量为空或为0,通过金额({amount})和单价({product['price']})计算得出数量: {quantity}")
|
|
||||||
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+)'
|
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+)'
|
simple_pattern = r'(\d+)[*×xX](\d+)'
|
||||||
if re.search(volume_pattern, product['specification']) or re.search(simple_pattern, product['specification']):
|
match = re.search(simple_pattern, product['specification'])
|
||||||
|
if match:
|
||||||
product['unit'] = '件'
|
product['unit'] = '件'
|
||||||
logger.info(f"根据规格推断单位: {product['specification']} -> 单位=件")
|
logger.info(f"根据规格推断单位: {product['specification']} -> 单位=件")
|
||||||
|
|
||||||
# 应用单位转换规则
|
# 应用单位转换规则
|
||||||
product = self.unit_converter.process_unit_conversion(product)
|
product = self.unit_converter.process_unit_conversion(product)
|
||||||
|
|
||||||
# 应用记忆库补全
|
# 如果数量为0但单价和金额都存在,计算数量 = 金额/单价
|
||||||
product = self._apply_memory(product)
|
if (product['quantity'] == 0 or product['quantity'] is None) and product['price'] > 0 and product['amount']:
|
||||||
|
try:
|
||||||
|
# 确保金额是数字
|
||||||
|
if isinstance(product['amount'], str):
|
||||||
|
# 移除货币符号和非数字字符,保留数字、小数点和逗号
|
||||||
|
amount_str = re.sub(r'[^\d\.,]', '', product['amount'].strip())
|
||||||
|
# 替换逗号为小数点(如果逗号作为小数分隔符)
|
||||||
|
if ',' in amount_str and '.' not in amount_str:
|
||||||
|
amount_str = amount_str.replace(',', '.')
|
||||||
|
# 处理既有逗号又有小数点的情况(通常逗号是千位分隔符)
|
||||||
|
elif ',' in amount_str and '.' in amount_str:
|
||||||
|
amount_str = amount_str.replace(',', '')
|
||||||
|
amount = float(amount_str)
|
||||||
|
else:
|
||||||
|
amount = float(product['amount'])
|
||||||
|
# 计算数量
|
||||||
|
if amount > 0:
|
||||||
|
quantity = amount / product['price']
|
||||||
|
logger.info(f"数量为空或为0,通过金额({amount})和单价({product['price']})计算得出数量: {quantity}")
|
||||||
|
product['quantity'] = quantity
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"通过金额和单价计算数量失败: {e}")
|
||||||
|
|
||||||
products.append(product)
|
products.append(product)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -404,59 +452,6 @@ class ExcelProcessor:
|
|||||||
logger.info(f"提取到 {len(products)} 个商品信息")
|
logger.info(f"提取到 {len(products)} 个商品信息")
|
||||||
return products
|
return products
|
||||||
|
|
||||||
def _apply_memory(self, product: Dict) -> Dict:
|
|
||||||
"""查记忆库,补全 OCR 缺失/错误的字段"""
|
|
||||||
barcode = product.get('barcode', '')
|
|
||||||
if not barcode:
|
|
||||||
return product
|
|
||||||
|
|
||||||
try:
|
|
||||||
memory = self.product_db.get_memory(barcode)
|
|
||||||
except Exception:
|
|
||||||
return product
|
|
||||||
|
|
||||||
if memory is None or memory.get('confidence', 0) < 80:
|
|
||||||
return product
|
|
||||||
|
|
||||||
# 补全规格
|
|
||||||
ocr_spec = product.get('specification', '')
|
|
||||||
mem_spec = memory.get('specification', '') or ''
|
|
||||||
if mem_spec and (not ocr_spec or self._is_spec_suspicious(ocr_spec)):
|
|
||||||
product['specification'] = mem_spec
|
|
||||||
logger.info(f"记忆修正规格: {barcode} '{ocr_spec}' -> '{mem_spec}'")
|
|
||||||
|
|
||||||
# 补全名称
|
|
||||||
ocr_name = product.get('name', '')
|
|
||||||
mem_name = memory.get('name', '') or ''
|
|
||||||
if mem_name and not ocr_name:
|
|
||||||
product['name'] = mem_name
|
|
||||||
logger.info(f"记忆修正名称: {barcode} -> '{mem_name}'")
|
|
||||||
|
|
||||||
# 补全单位
|
|
||||||
ocr_unit = product.get('unit', '')
|
|
||||||
mem_unit = memory.get('unit', '') or ''
|
|
||||||
if mem_unit and not ocr_unit:
|
|
||||||
product['unit'] = mem_unit
|
|
||||||
logger.info(f"记忆修正单位: {barcode} -> '{mem_unit}'")
|
|
||||||
|
|
||||||
# 不改数量和单价(每单不同)
|
|
||||||
return product
|
|
||||||
|
|
||||||
def _is_spec_suspicious(self, spec: str) -> bool:
|
|
||||||
"""检测规格是否像 OCR 垃圾"""
|
|
||||||
if not spec:
|
|
||||||
return True
|
|
||||||
# IL*12(I 和 1 混淆)
|
|
||||||
if re.search(r'^[Ii][Ll*]', spec):
|
|
||||||
return True
|
|
||||||
# 4.51*4(L 被识别为 1)
|
|
||||||
if re.search(r'\d+\.\d+1\*\d+', spec):
|
|
||||||
return True
|
|
||||||
# 包含非常规字符(排除常见规格字符)
|
|
||||||
if re.search(r'[^\d.*xX\-LlKkGgMm升毫瓶桶盒箱件提\s]', spec):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def fill_template(self, products: List[Dict], output_file_path: str) -> bool:
|
def fill_template(self, products: List[Dict], output_file_path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
填充采购单模板
|
填充采购单模板
|
||||||
@@ -499,8 +494,21 @@ class ExcelProcessor:
|
|||||||
# 如果数量为0但单价和金额都存在,计算数量 = 金额/单价
|
# 如果数量为0但单价和金额都存在,计算数量 = 金额/单价
|
||||||
if (quantity == 0 or quantity is None) and price > 0 and amount:
|
if (quantity == 0 or quantity is None) and price > 0 and amount:
|
||||||
try:
|
try:
|
||||||
amount = parse_monetary_string(amount)
|
# 确保金额是数字
|
||||||
if amount is not None and amount > 0:
|
if isinstance(amount, str):
|
||||||
|
# 移除货币符号和非数字字符,保留数字、小数点和逗号
|
||||||
|
amount_str = re.sub(r'[^\d\.,]', '', amount.strip())
|
||||||
|
# 替换逗号为小数点(如果逗号作为小数分隔符)
|
||||||
|
if ',' in amount_str and '.' not in amount_str:
|
||||||
|
amount_str = amount_str.replace(',', '.')
|
||||||
|
# 处理既有逗号又有小数点的情况(通常逗号是千位分隔符)
|
||||||
|
elif ',' in amount_str and '.' in amount_str:
|
||||||
|
amount_str = amount_str.replace(',', '')
|
||||||
|
amount = float(amount_str)
|
||||||
|
else:
|
||||||
|
amount = float(amount)
|
||||||
|
# 计算数量
|
||||||
|
if amount > 0:
|
||||||
quantity = amount / price
|
quantity = amount / price
|
||||||
logger.info(f"数量为空或为0,通过金额({amount})和单价({price})计算得出数量: {quantity}")
|
logger.info(f"数量为空或为0,通过金额({amount})和单价({price})计算得出数量: {quantity}")
|
||||||
product['quantity'] = quantity
|
product['quantity'] = quantity
|
||||||
@@ -597,16 +605,72 @@ class ExcelProcessor:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _find_header_row(self, df: pd.DataFrame) -> Optional[int]:
|
def _find_header_row(self, df: pd.DataFrame) -> Optional[int]:
|
||||||
"""自动识别表头行,委托给 ColumnMapper.detect_header_row"""
|
"""
|
||||||
result = ColumnMapper.detect_header_row(df, max_rows=30)
|
自动识别表头行
|
||||||
if result >= 0:
|
|
||||||
logger.info(f"找到表头行: 第{result+1}行")
|
通过多种规则识别表头:
|
||||||
return result
|
1. 检查行是否包含典型的表头关键词(条码、商品名称、数量等)
|
||||||
# 回退:找第一个非空行
|
2. 检查是否是第一个非空行
|
||||||
|
3. 检查行是否有较多的字符串类型单元格(表头通常是字符串)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: 数据帧
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
表头行索引,如果未找到则返回None
|
||||||
|
"""
|
||||||
|
header_keywords = [
|
||||||
|
'条码', '条形码', '商品条码', '商品名称', '名称', '数量', '单位', '单价',
|
||||||
|
'规格', '商品编码', '采购数量', '采购单位', '商品', '品名',
|
||||||
|
'金额', '小计', '总计', '合计', '合计金额'
|
||||||
|
]
|
||||||
|
|
||||||
|
# 存储每行的匹配分数
|
||||||
|
row_scores = []
|
||||||
|
|
||||||
|
max_rows_to_check = min(30, len(df))
|
||||||
|
for row in range(max_rows_to_check):
|
||||||
|
row_data = df.iloc[row]
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# 检查1: 关键词匹配
|
||||||
|
for cell in row_data:
|
||||||
|
if isinstance(cell, str):
|
||||||
|
cell_clean = str(cell).strip().lower()
|
||||||
|
for keyword in header_keywords:
|
||||||
|
if keyword.lower() in cell_clean:
|
||||||
|
score += 5 # 每匹配一个关键词加5分
|
||||||
|
|
||||||
|
# 检查2: 非空单元格比例
|
||||||
|
non_empty_cells = row_data.count()
|
||||||
|
if non_empty_cells / len(row_data) > 0.5: # 如果超过一半的单元格有内容
|
||||||
|
score += 2
|
||||||
|
|
||||||
|
# 检查3: 字符串类型单元格比例
|
||||||
|
string_cells = sum(1 for cell in row_data if isinstance(cell, str))
|
||||||
|
if string_cells / len(row_data) > 0.5: # 如果超过一半的单元格是字符串
|
||||||
|
score += 3
|
||||||
|
|
||||||
|
row_scores.append((row, score))
|
||||||
|
|
||||||
|
# 日志记录每行的评分情况
|
||||||
|
logger.debug(f"第{row+1}行评分: {score},内容: {row_data.values}")
|
||||||
|
|
||||||
|
# 按评分排序
|
||||||
|
row_scores.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
# 如果最高分达到一定阈值,认为是表头
|
||||||
|
if row_scores and row_scores[0][1] >= 5:
|
||||||
|
best_row = row_scores[0][0]
|
||||||
|
logger.info(f"找到可能的表头行: 第{best_row+1}行,评分: {row_scores[0][1]}")
|
||||||
|
return best_row
|
||||||
|
|
||||||
|
# 如果没有找到明确的表头,尝试找第一个非空行
|
||||||
for row in range(len(df)):
|
for row in range(len(df)):
|
||||||
if df.iloc[row].notna().sum() > 3:
|
if df.iloc[row].notna().sum() > 3: # 至少有3个非空单元格
|
||||||
logger.info(f"未找到明确表头,使用第一个有效行: 第{row+1}行")
|
logger.info(f"未找到明确表头,使用第一个有效行: 第{row+1}行")
|
||||||
return row
|
return row
|
||||||
|
|
||||||
logger.warning("无法识别表头行")
|
logger.warning("无法识别表头行")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -626,9 +690,6 @@ class ExcelProcessor:
|
|||||||
logger.error(f"文件不存在: {file_path}")
|
logger.error(f"文件不存在: {file_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.current_missing_barcodes = [] # 重置缺失列表
|
|
||||||
self.current_file_path = file_path # 设置当前处理文件路径
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 读取Excel文件时不立即指定表头
|
# 读取Excel文件时不立即指定表头
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -672,36 +733,20 @@ class ExcelProcessor:
|
|||||||
logger.warning("未提取到有效商品信息")
|
logger.warning("未提取到有效商品信息")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 生成输出文件名,保存到result目录
|
# 生成输出文件名,保存到data/result目录
|
||||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
result_dir = self.config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/result')
|
result_dir = "data/result"
|
||||||
os.makedirs(result_dir, exist_ok=True)
|
os.makedirs(result_dir, exist_ok=True)
|
||||||
output_file = os.path.join(result_dir, f"采购单_{file_name}.xls")
|
output_file = os.path.join(result_dir, f"采购单_{file_name}.xls")
|
||||||
|
|
||||||
# 填充模板并保存
|
# 填充模板并保存
|
||||||
if self.fill_template(products, output_file):
|
if self.fill_template(products, output_file):
|
||||||
# 从处理结果中学习商品记忆
|
|
||||||
try:
|
|
||||||
self.product_db.learn_from_products(products, source='ocr')
|
|
||||||
self.product_db._export_memory_json()
|
|
||||||
logger.info(f"已从处理结果学习 {len(products)} 条商品记忆")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"学习商品记忆失败: {e}")
|
|
||||||
|
|
||||||
# 记录已处理文件
|
# 记录已处理文件
|
||||||
self.processed_files[file_path] = output_file
|
self.processed_files[file_path] = output_file
|
||||||
self._save_processed_files()
|
self._save_processed_files()
|
||||||
|
|
||||||
# 不再自动打开输出目录
|
# 不再自动打开输出目录
|
||||||
logger.info(f"采购单已保存到: {output_file}")
|
logger.info(f"采购单已保存到: {output_file}")
|
||||||
|
|
||||||
# 处理完成,如果有缺失条码,触发回调
|
|
||||||
if self.current_missing_barcodes and self.missing_barcodes_cb:
|
|
||||||
try:
|
|
||||||
self.missing_barcodes_cb(self.current_missing_barcodes)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"触发缺失条码回调失败: {e}")
|
|
||||||
|
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
try:
|
try:
|
||||||
progress_cb(100)
|
progress_cb(100)
|
||||||
@@ -750,25 +795,44 @@ class ExcelProcessor:
|
|||||||
logger.error("未找到条码列,无法处理")
|
logger.error("未找到条码列,无法处理")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# 使用 ColumnMapper 统一查找列名
|
# 定义列名映射
|
||||||
mapped_columns = {'barcode': barcode_cols[0]}
|
column_mapping = {
|
||||||
|
'name': ['商品名称', '名称', '品名', '商品', '商品名', '商品或服务名称', '品项名', '产品名称', '品项', '名 称'],
|
||||||
|
'specification': ['规格', '规格型号', '型号', '商品规格', '产品规格', '包装规格','规 格'],
|
||||||
|
'quantity': ['数量', '采购数量', '购买数量', '采购数量', '订单数量', '数量(必填)', '入库数', '入库数量','数 量'],
|
||||||
|
'unit': ['单位', '采购单位', '计量单位', '单位(必填)', '单位名称', '计价单位','单 位'],
|
||||||
|
'price': ['单价', '价格', '采购单价', '销售价', '进货价', '单价(必填)', '采购价', '参考价', '入库单价','单 价'],
|
||||||
|
'amount': ['金额', '小计', '总价', '合计金额', '小计金额', '金额(元)', '金额合计', '合计', '总额']
|
||||||
|
}
|
||||||
|
|
||||||
|
# 映射列名到标准名称
|
||||||
|
mapped_columns = {'barcode': barcode_cols[0]} # 使用第一个找到的条码列
|
||||||
|
|
||||||
|
# 记录列名映射详情
|
||||||
logger.info(f"使用条码列: {mapped_columns['barcode']}")
|
logger.info(f"使用条码列: {mapped_columns['barcode']}")
|
||||||
|
|
||||||
# 内部键名 -> 标准列名映射 (processor.py 使用 price/amount 作为内部键名)
|
for target, possible_names in column_mapping.items():
|
||||||
field_map = [
|
for col in df.columns:
|
||||||
('name', 'name'),
|
col_str = str(col).strip()
|
||||||
('specification', 'specification'),
|
for name in possible_names:
|
||||||
('quantity', 'quantity'),
|
if col_str == name:
|
||||||
('unit', 'unit'),
|
mapped_columns[target] = col
|
||||||
('price', 'unit_price'),
|
logger.info(f"找到{target}列: {col}")
|
||||||
('amount', 'total_price'),
|
break
|
||||||
]
|
if target in mapped_columns:
|
||||||
|
break
|
||||||
|
|
||||||
for internal_key, standard_name in field_map:
|
# 如果没有找到精确匹配,尝试部分匹配
|
||||||
matched = ColumnMapper.find_column(list(df.columns), standard_name)
|
if target not in mapped_columns:
|
||||||
if matched:
|
for col in df.columns:
|
||||||
mapped_columns[internal_key] = matched
|
col_str = str(col).strip().lower()
|
||||||
logger.info(f"找到{internal_key}列: {matched}")
|
for name in possible_names:
|
||||||
|
if name.lower() in col_str:
|
||||||
|
mapped_columns[target] = col
|
||||||
|
logger.info(f"找到{target}列(部分匹配): {col}")
|
||||||
|
break
|
||||||
|
if target in mapped_columns:
|
||||||
|
break
|
||||||
|
|
||||||
return mapped_columns
|
return mapped_columns
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import re
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional, List, Tuple, Union
|
from typing import Dict, Any, Optional, List, Tuple, Union
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
from ..utils.log_utils import get_logger
|
||||||
from app.core.utils.string_utils import parse_monetary_string
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -157,8 +156,23 @@ class ProductValidator:
|
|||||||
if price_str in ["赠品", "gift", "赠送", "0", ""]:
|
if price_str in ["赠品", "gift", "赠送", "0", ""]:
|
||||||
return True, 0.0, True, None
|
return True, 0.0, True, None
|
||||||
|
|
||||||
price_value = parse_monetary_string(price_str)
|
# 去除空白和非数字字符(保留小数点和逗号)
|
||||||
if price_value is None:
|
price_clean = re.sub(r'[^\d\.,]', '', price_str)
|
||||||
|
|
||||||
|
# 处理小数点和逗号
|
||||||
|
if ',' in price_clean and '.' not in price_clean:
|
||||||
|
# 如果只有逗号没有小数点,将逗号视为小数点
|
||||||
|
price_clean = price_clean.replace(',', '.')
|
||||||
|
elif ',' in price_clean and '.' in price_clean:
|
||||||
|
# 如果既有逗号又有小数点,移除逗号(认为逗号是千位分隔符)
|
||||||
|
price_clean = price_clean.replace(',', '')
|
||||||
|
|
||||||
|
if not price_clean:
|
||||||
|
return False, 0.0, True, "单价不包含数字,视为赠品"
|
||||||
|
|
||||||
|
try:
|
||||||
|
price_value = float(price_clean)
|
||||||
|
except ValueError:
|
||||||
return False, 0.0, True, f"无法将单价 '{price}' 转换为数字,视为赠品"
|
return False, 0.0, True, f"无法将单价 '{price}' 转换为数字,视为赠品"
|
||||||
else:
|
else:
|
||||||
# 尝试直接转换
|
# 尝试直接转换
|
||||||
@@ -215,9 +229,28 @@ class ProductValidator:
|
|||||||
amount = product.get('amount', None)
|
amount = product.get('amount', None)
|
||||||
try:
|
try:
|
||||||
is_amount_gift = False
|
is_amount_gift = False
|
||||||
parsed_amount = parse_monetary_string(amount)
|
if amount is None:
|
||||||
if parsed_amount is None or parsed_amount == 0.0:
|
|
||||||
is_amount_gift = True
|
is_amount_gift = True
|
||||||
|
elif isinstance(amount, str):
|
||||||
|
s = amount.strip()
|
||||||
|
if s == '' or s.lower() == 'o' or s == '0':
|
||||||
|
is_amount_gift = True
|
||||||
|
else:
|
||||||
|
amt_clean = re.sub(r'[^\d\.,]', '', s)
|
||||||
|
if ',' in amt_clean and '.' not in amt_clean:
|
||||||
|
amt_clean = amt_clean.replace(',', '.')
|
||||||
|
elif ',' in amt_clean and '.' in amt_clean:
|
||||||
|
amt_clean = amt_clean.replace(',', '')
|
||||||
|
if amt_clean:
|
||||||
|
try:
|
||||||
|
is_amount_gift = float(amt_clean) == 0.0
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
is_amount_gift = float(amount) == 0.0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
if is_amount_gift:
|
if is_amount_gift:
|
||||||
validated_product['is_gift'] = True
|
validated_product['is_gift'] = True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -236,9 +269,18 @@ class ProductValidator:
|
|||||||
if fixed_price > 0 and amount is not None:
|
if fixed_price > 0 and amount is not None:
|
||||||
try:
|
try:
|
||||||
# 确保金额是数字
|
# 确保金额是数字
|
||||||
amount = parse_monetary_string(amount)
|
if isinstance(amount, str):
|
||||||
if amount is None:
|
# 移除货币符号和非数字字符,保留数字、小数点和逗号
|
||||||
raise ValueError("无法解析金额")
|
amount_str = re.sub(r'[^\d\.,]', '', amount.strip())
|
||||||
|
# 替换逗号为小数点(如果逗号作为小数分隔符)
|
||||||
|
if ',' in amount_str and '.' not in amount_str:
|
||||||
|
amount_str = amount_str.replace(',', '.')
|
||||||
|
# 处理既有逗号又有小数点的情况(通常逗号是千位分隔符)
|
||||||
|
elif ',' in amount_str and '.' in amount_str:
|
||||||
|
amount_str = amount_str.replace(',', '')
|
||||||
|
amount = float(amount_str)
|
||||||
|
else:
|
||||||
|
amount = float(amount)
|
||||||
|
|
||||||
# 计算数量 = 金额 / 单价
|
# 计算数量 = 金额 / 单价
|
||||||
if amount > 0:
|
if amount > 0:
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
数据处理handlers模块初始化文件
|
数据处理handlers模块初始化文件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.core.handlers.data_cleaner import DataCleaner
|
from .data_cleaner import DataCleaner
|
||||||
from app.core.handlers.column_mapper import ColumnMapper
|
from .column_mapper import ColumnMapper
|
||||||
from app.core.handlers.calculator import DataCalculator
|
from .calculator import DataCalculator
|
||||||
|
|
||||||
__all__ = ['DataCleaner', 'ColumnMapper', 'DataCalculator']
|
__all__ = ['DataCleaner', 'ColumnMapper', 'DataCalculator']
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Dict, Any, Optional, List, Union
|
from typing import Dict, Any, Optional, List, Union
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...core.utils.log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
提供列名映射和转换功能,支持不同供应商的列名标准化
|
提供列名映射和转换功能,支持不同供应商的列名标准化
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from typing import Dict, Any, Optional, List, Union
|
from typing import Dict, Any, Optional, List, Union
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...core.utils.log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -18,47 +17,18 @@ class ColumnMapper:
|
|||||||
提供列名标准化功能,将不同供应商的列名映射到标准列名
|
提供列名标准化功能,将不同供应商的列名映射到标准列名
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 标准列名定义(所有列名别名的唯一来源)
|
# 标准列名定义
|
||||||
STANDARD_COLUMNS = {
|
STANDARD_COLUMNS = {
|
||||||
'barcode': [
|
'barcode': ['条码', '条形码', '商品条码', '产品条码', '条码(必填)', 'barcode', 'code'],
|
||||||
'条码', '条形码', '商品条码', '商品条形码', '产品条码', '商品编码',
|
'name': ['商品名称', '产品名称', '名称', '商品', '产品', 'name', 'product_name'],
|
||||||
'商品编号', '条码(必填)', '电脑条码', '条码ID', '单品条码',
|
'specification': ['规格', '规格型号', '型号', 'specification', 'spec', 'model'],
|
||||||
'barcode', 'Barcode', 'BarCode', 'code', '编码',
|
'quantity': ['数量', '采购量', '订货数量', '订单量', '需求量', 'quantity', 'qty', '采购量(必填)'],
|
||||||
],
|
'unit': ['单位', '计量单位', 'unit', 'units'],
|
||||||
'name': [
|
'unit_price': ['单价', '价格', '采购单价', '进货价', 'unit_price', 'price', '采购单价(必填)'],
|
||||||
'商品名称', '产品名称', '名称', '商品', '产品', '商品名', '品名',
|
'total_price': ['总价', '金额', '小计', 'total_price', 'total', 'amount'],
|
||||||
'品项名', '商品或服务名称', '品项', '名 称',
|
|
||||||
'name', 'product_name',
|
|
||||||
],
|
|
||||||
'specification': [
|
|
||||||
'规格', '规格型号', '型号', '商品规格', '产品规格', '包装规格', '规 格',
|
|
||||||
'specification', 'spec', 'model',
|
|
||||||
],
|
|
||||||
'quantity': [
|
|
||||||
'数量', '采购量', '订货数量', '订单量', '需求量', '采购数量', '购买数量',
|
|
||||||
'订单数量', '数量(必填)', '采购量(必填)', '入库数', '入库数量', '数 量',
|
|
||||||
'quantity', 'qty',
|
|
||||||
],
|
|
||||||
'unit': [
|
|
||||||
'单位', '计量单位', '采购单位', '单位(必填)', '单位名称', '计价单位', '单 位',
|
|
||||||
'unit', 'units',
|
|
||||||
],
|
|
||||||
'unit_price': [
|
|
||||||
'单价', '价格', '采购单价', '进货价', '销售价', '采购价', '参考价',
|
|
||||||
'入库单价', '单价(必填)', '采购单价(必填)', '价格(必填)', '单 价',
|
|
||||||
'unit_price', 'price',
|
|
||||||
],
|
|
||||||
'total_price': [
|
|
||||||
'总价', '金额', '小计', '合计金额', '小计金额', '金额(元)',
|
|
||||||
'金额合计', '合计', '总额',
|
|
||||||
'total_price', 'total', 'amount',
|
|
||||||
],
|
|
||||||
'gift_quantity': [
|
|
||||||
'赠送量', '赠品数量', '赠送数量', '赠品',
|
|
||||||
],
|
|
||||||
'category': ['类别', '分类', '商品类别', 'category', 'type'],
|
'category': ['类别', '分类', '商品类别', 'category', 'type'],
|
||||||
'brand': ['品牌', '商标', 'brand'],
|
'brand': ['品牌', '商标', 'brand'],
|
||||||
'supplier': ['供应商', '供货商', 'supplier', 'vendor'],
|
'supplier': ['供应商', '供货商', 'supplier', 'vendor']
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, mapping_config: Optional[Dict[str, Any]] = None):
|
def __init__(self, mapping_config: Optional[Dict[str, Any]] = None):
|
||||||
@@ -304,79 +274,3 @@ class ColumnMapper:
|
|||||||
result['warnings'].append(f"列 '{col}' 不是数值类型")
|
result['warnings'].append(f"列 '{col}' 不是数值类型")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def find_column(cls, columns: List[str], standard_name: str) -> Optional[str]:
|
|
||||||
"""在列名列表中查找匹配标准列名的列
|
|
||||||
|
|
||||||
匹配策略: 精确匹配 → 忽略空白匹配 → 子串匹配
|
|
||||||
|
|
||||||
Args:
|
|
||||||
columns: 实际列名列表
|
|
||||||
standard_name: 标准列名 (STANDARD_COLUMNS 的键)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
匹配到的实际列名,未找到返回 None
|
|
||||||
"""
|
|
||||||
candidates = cls.STANDARD_COLUMNS.get(standard_name, [])
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
|
|
||||||
columns_str = [str(c) for c in columns]
|
|
||||||
|
|
||||||
# 精确匹配
|
|
||||||
for col in columns_str:
|
|
||||||
col_clean = col.strip()
|
|
||||||
for candidate in candidates:
|
|
||||||
if col_clean == candidate:
|
|
||||||
return col
|
|
||||||
|
|
||||||
# 忽略空白匹配
|
|
||||||
for col in columns_str:
|
|
||||||
col_clean = re.sub(r'\s+', '', col.strip())
|
|
||||||
for candidate in candidates:
|
|
||||||
if col_clean == re.sub(r'\s+', '', candidate):
|
|
||||||
return col
|
|
||||||
|
|
||||||
# 子串匹配 (候选名包含在列名中)
|
|
||||||
for col in columns_str:
|
|
||||||
col_lower = col.strip().lower()
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.lower() in col_lower:
|
|
||||||
return col
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def detect_header_row(df: pd.DataFrame, max_rows: int = 10, min_matches: int = 3) -> int:
|
|
||||||
"""检测表头所在行
|
|
||||||
|
|
||||||
扫描前 max_rows 行,返回包含最多关键词匹配的行索引。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
df: 数据框
|
|
||||||
max_rows: 最大扫描行数
|
|
||||||
min_matches: 最少关键词匹配数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
表头行索引,未找到返回 -1
|
|
||||||
"""
|
|
||||||
header_keywords = [
|
|
||||||
'条码', '条形码', '商品条码', '商品名称', '名称', '规格',
|
|
||||||
'单价', '数量', '金额', '单位', '必填', '编码', '单品条码', '序号',
|
|
||||||
]
|
|
||||||
|
|
||||||
best_row = -1
|
|
||||||
best_matches = 0
|
|
||||||
|
|
||||||
for row_idx in range(min(max_rows, len(df))):
|
|
||||||
row_values = df.iloc[row_idx].astype(str)
|
|
||||||
matches = sum(
|
|
||||||
1 for kw in header_keywords
|
|
||||||
if any(kw in str(val) for val in row_values.values)
|
|
||||||
)
|
|
||||||
if matches >= min_matches and matches > best_matches:
|
|
||||||
best_matches = matches
|
|
||||||
best_row = row_idx
|
|
||||||
|
|
||||||
return best_row
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from typing import Dict, Any, Optional, List, Union
|
from typing import Dict, Any, Optional, List, Union
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...core.utils.log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def _split_quantity_unit(df: pd.DataFrame, source: str, dictionary: Optional[Dic
|
|||||||
try:
|
try:
|
||||||
nums.append(float(v))
|
nums.append(float(v))
|
||||||
units.append(unit_synonyms.get(default_unit, default_unit))
|
units.append(unit_synonyms.get(default_unit, default_unit))
|
||||||
except Exception:
|
except:
|
||||||
nums.append(0.0)
|
nums.append(0.0)
|
||||||
units.append(unit_synonyms.get(default_unit, default_unit))
|
units.append(unit_synonyms.get(default_unit, default_unit))
|
||||||
df["quantity"] = nums
|
df["quantity"] = nums
|
||||||
@@ -44,7 +44,7 @@ def _extract_spec_from_name(df: pd.DataFrame, source: str, dictionary: Optional[
|
|||||||
if m and len(m.groups()) >= 2:
|
if m and len(m.groups()) >= 2:
|
||||||
try:
|
try:
|
||||||
qty = int(m.group(len(m.groups())))
|
qty = int(m.group(len(m.groups())))
|
||||||
except Exception:
|
except:
|
||||||
qty = None
|
qty = None
|
||||||
specs.append(s)
|
specs.append(s)
|
||||||
packs.append(qty)
|
packs.append(qty)
|
||||||
|
|||||||
+11
-191
@@ -4,25 +4,24 @@
|
|||||||
提供百度OCR API的访问和调用功能。
|
提供百度OCR API的访问和调用功能。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import base64
|
import base64
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, Optional, Union, List
|
import logging
|
||||||
|
from typing import Dict, Optional, Any, Union
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...config.settings import ConfigManager
|
||||||
|
from ..utils.log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Token 过期相关常量
|
|
||||||
_DEFAULT_TOKEN_LIFETIME = 30 * 24 * 3600 # 30天(秒)
|
|
||||||
_TOKEN_EARLY_EXPIRY = 3600 # 提前1小时刷新(秒)
|
|
||||||
|
|
||||||
class TokenManager:
|
class TokenManager:
|
||||||
"""
|
"""
|
||||||
令牌管理类,负责获取和刷新百度API访问令牌
|
令牌管理类,负责获取和刷新百度API访问令牌
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, api_key: str, secret_key: str, max_retries: int = 3, retry_delay: int = 2, token_url: str = None):
|
def __init__(self, api_key: str, secret_key: str, max_retries: int = 3, retry_delay: int = 2):
|
||||||
"""
|
"""
|
||||||
初始化令牌管理器
|
初始化令牌管理器
|
||||||
|
|
||||||
@@ -31,13 +30,11 @@ class TokenManager:
|
|||||||
secret_key: 百度Secret Key
|
secret_key: 百度Secret Key
|
||||||
max_retries: 最大重试次数
|
max_retries: 最大重试次数
|
||||||
retry_delay: 重试延迟(秒)
|
retry_delay: 重试延迟(秒)
|
||||||
token_url: 令牌获取地址
|
|
||||||
"""
|
"""
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.secret_key = secret_key
|
self.secret_key = secret_key
|
||||||
self.max_retries = max_retries
|
self.max_retries = max_retries
|
||||||
self.retry_delay = retry_delay
|
self.retry_delay = retry_delay
|
||||||
self.token_url = token_url or 'https://aip.baidubce.com/oauth/2.0/token'
|
|
||||||
self.access_token = None
|
self.access_token = None
|
||||||
self.token_expiry = 0
|
self.token_expiry = 0
|
||||||
|
|
||||||
@@ -72,7 +69,7 @@ class TokenManager:
|
|||||||
Returns:
|
Returns:
|
||||||
新的访问令牌,如果获取失败则返回None
|
新的访问令牌,如果获取失败则返回None
|
||||||
"""
|
"""
|
||||||
url = self.token_url
|
url = "https://aip.baidubce.com/oauth/2.0/token"
|
||||||
params = {
|
params = {
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"client_id": self.api_key,
|
"client_id": self.api_key,
|
||||||
@@ -87,7 +84,7 @@ class TokenManager:
|
|||||||
if "access_token" in result:
|
if "access_token" in result:
|
||||||
self.access_token = result["access_token"]
|
self.access_token = result["access_token"]
|
||||||
# 设置令牌过期时间(默认30天,提前1小时过期以确保安全)
|
# 设置令牌过期时间(默认30天,提前1小时过期以确保安全)
|
||||||
self.token_expiry = time.time() + result.get("expires_in", _DEFAULT_TOKEN_LIFETIME) - _TOKEN_EARLY_EXPIRY
|
self.token_expiry = time.time() + result.get("expires_in", 2592000) - 3600
|
||||||
logger.info("成功获取访问令牌")
|
logger.info("成功获取访问令牌")
|
||||||
return self.access_token
|
return self.access_token
|
||||||
|
|
||||||
@@ -147,8 +144,7 @@ class BaiduOCRClient:
|
|||||||
self.api_key,
|
self.api_key,
|
||||||
self.secret_key,
|
self.secret_key,
|
||||||
self.max_retries,
|
self.max_retries,
|
||||||
self.retry_delay,
|
self.retry_delay
|
||||||
token_url=config.get('API', 'token_url', fallback='https://aip.baidubce.com/oauth/2.0/token')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 验证API配置
|
# 验证API配置
|
||||||
@@ -229,19 +225,10 @@ class BaiduOCRClient:
|
|||||||
logger.debug(f"百度OCR API返回结果: {result}")
|
logger.debug(f"百度OCR API返回结果: {result}")
|
||||||
|
|
||||||
if 'error_code' in result:
|
if 'error_code' in result:
|
||||||
error_code = result.get('error_code')
|
|
||||||
error_msg = result.get('error_msg', '未知错误')
|
error_msg = result.get('error_msg', '未知错误')
|
||||||
|
|
||||||
# 如果是 QPS 限制 (18),增加延迟并重试
|
|
||||||
if error_code == 18:
|
|
||||||
wait_time = self.retry_delay * (attempt + 1)
|
|
||||||
logger.warning(f"触发 QPS 限制,将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})")
|
|
||||||
time.sleep(wait_time)
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.error(f"百度OCR API错误: {error_msg}")
|
logger.error(f"百度OCR API错误: {error_msg}")
|
||||||
# 如果是授权错误,尝试刷新令牌
|
# 如果是授权错误,尝试刷新令牌
|
||||||
if error_code in [110, 111]: # 授权相关错误码
|
if result.get('error_code') in [110, 111]: # 授权相关错误码
|
||||||
logger.info("尝试刷新访问令牌...")
|
logger.info("尝试刷新访问令牌...")
|
||||||
self.token_manager.refresh_token()
|
self.token_manager.refresh_token()
|
||||||
return None
|
return None
|
||||||
@@ -264,172 +251,6 @@ class BaiduOCRClient:
|
|||||||
logger.error("表格识别失败")
|
logger.error("表格识别失败")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def recognize_general(self, image_data: Union[str, bytes]) -> Optional[List[Dict]]:
|
|
||||||
"""通用文字识别(高精度版):识别图片整图文字(含表格外的手写抬头/日期等)。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
[{'words': '...', 'probability': {...}, 'location': {...}}, ...]
|
|
||||||
"""
|
|
||||||
access_token = self.token_manager.get_token()
|
|
||||||
if not access_token:
|
|
||||||
logger.error("无法获取访问令牌,无法进行通用识别")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(image_data, str):
|
|
||||||
image_data = self.read_image(image_data)
|
|
||||||
if image_data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
url = self.config.get('API', 'general_ocr_url',
|
|
||||||
fallback='https://aip.baidubce.com/rest/2.0/ocr/v1/accurate')
|
|
||||||
url = f"{url}?access_token={access_token}"
|
|
||||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'image': image_base64,
|
|
||||||
'language_type': 'CHN_ENG',
|
|
||||||
'detect_direction': 'true',
|
|
||||||
'probability': 'true',
|
|
||||||
}
|
|
||||||
headers = {'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Accept': 'application/json'}
|
|
||||||
|
|
||||||
for attempt in range(self.max_retries):
|
|
||||||
try:
|
|
||||||
response = requests.post(url, data=payload, headers=headers,
|
|
||||||
timeout=self.timeout)
|
|
||||||
if response.status_code == 200:
|
|
||||||
result = response.json()
|
|
||||||
if 'error_code' in result:
|
|
||||||
error_code = result.get('error_code')
|
|
||||||
error_msg = result.get('error_msg', '未知错误')
|
|
||||||
|
|
||||||
# 如果是 QPS 限制 (18),增加延迟并重试
|
|
||||||
if error_code == 18:
|
|
||||||
wait_time = self.retry_delay * (attempt + 1)
|
|
||||||
logger.warning(f"触发 QPS 限制 (General),将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})")
|
|
||||||
time.sleep(wait_time)
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.warning(f"通用识别错误: {error_msg}")
|
|
||||||
if error_code in (110, 111):
|
|
||||||
self.token_manager.refresh_token()
|
|
||||||
return None
|
|
||||||
words_list = result.get('words_result') or []
|
|
||||||
logger.debug(f"通用识别返回 {len(words_list)} 行文字")
|
|
||||||
return words_list
|
|
||||||
logger.warning(f"通用识别请求失败 (尝试 {attempt+1}): {response.text[:200]}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"通用识别异常 (尝试 {attempt+1}): {e}")
|
|
||||||
if attempt < self.max_retries - 1:
|
|
||||||
time.sleep(self.retry_delay * (2 ** attempt))
|
|
||||||
logger.error("通用识别失败")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def recognize_ticket(self, image_data: Union[str, bytes]) -> Optional[List[Dict]]:
|
|
||||||
"""通用票务识别:用于精准捕获表头抬头、日期等关键信息。
|
|
||||||
使用用户指定的 URL: https://aip.baidubce.com/rest/2.0/ocr/v1/general_ocr
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
[{'words': '...'}, ...]
|
|
||||||
"""
|
|
||||||
access_token = self.token_manager.get_token()
|
|
||||||
if not access_token:
|
|
||||||
logger.error("无法获取访问令牌,无法进行票务识别")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(image_data, str):
|
|
||||||
image_data = self.read_image(image_data)
|
|
||||||
if image_data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 使用用户指定的通用票务识别接口
|
|
||||||
url = self.config.get('API', 'ticket_ocr_url',
|
|
||||||
fallback='https://aip.baidubce.com/rest/2.0/ocr/v1/general_ocr')
|
|
||||||
url = f"{url}?access_token={access_token}"
|
|
||||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'image': image_base64,
|
|
||||||
'detect_direction': 'true', # 开启方向检测,应对旋转的图片
|
|
||||||
}
|
|
||||||
headers = {'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Accept': 'application/json'}
|
|
||||||
|
|
||||||
for attempt in range(self.max_retries):
|
|
||||||
try:
|
|
||||||
response = requests.post(url, data=payload, headers=headers,
|
|
||||||
timeout=self.timeout)
|
|
||||||
if response.status_code == 200:
|
|
||||||
result = response.json()
|
|
||||||
if 'error_code' in result:
|
|
||||||
error_code = result.get('error_code')
|
|
||||||
error_msg = result.get('error_msg', '未知错误')
|
|
||||||
|
|
||||||
# 如果是 QPS 限制 (18),增加延迟并重试
|
|
||||||
if error_code == 18:
|
|
||||||
wait_time = self.retry_delay * (attempt + 1)
|
|
||||||
logger.warning(f"触发 QPS 限制 (Ticket),将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})")
|
|
||||||
time.sleep(wait_time)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 如果是权限错误 (6),记录详细日志告知用户
|
|
||||||
if error_code == 6:
|
|
||||||
logger.error("权限错误 (6): 您的百度 API Key 未开启【通用卡证票据识别】(General OCR) 服务。请前往百度云控制台手动开启该服务,否则无法精准识别表头。")
|
|
||||||
|
|
||||||
logger.warning(f"票务识别错误: {error_msg}")
|
|
||||||
if error_code in (110, 111):
|
|
||||||
self.token_manager.refresh_token()
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 1. 优先处理 results 结构 (通用卡证票据识别的新结构)
|
|
||||||
if 'results' in result and isinstance(result['results'], dict):
|
|
||||||
extracted = []
|
|
||||||
# 通常只有一个结果 "0"
|
|
||||||
for res_id, res_content in result['results'].items():
|
|
||||||
if not isinstance(res_content, dict): continue
|
|
||||||
for k, v_list in res_content.items():
|
|
||||||
if isinstance(v_list, list):
|
|
||||||
for item in v_list:
|
|
||||||
if isinstance(item, dict) and 'words' in item:
|
|
||||||
words_val = item['words']
|
|
||||||
if isinstance(words_val, list):
|
|
||||||
for w in words_val:
|
|
||||||
extracted.append({'words': f"{k}: {w}"})
|
|
||||||
else:
|
|
||||||
extracted.append({'words': f"{k}: {words_val}"})
|
|
||||||
return extracted
|
|
||||||
|
|
||||||
# 2. 通用票据/票务识别通常也返回 words_result
|
|
||||||
words_list = result.get('words_result')
|
|
||||||
|
|
||||||
# 如果返回的是结构化字典,提取其中的文字行
|
|
||||||
if isinstance(words_list, dict):
|
|
||||||
extracted = []
|
|
||||||
for k, v in words_list.items():
|
|
||||||
if isinstance(v, dict) and 'words' in v:
|
|
||||||
words_val = v['words']
|
|
||||||
if isinstance(words_val, list):
|
|
||||||
for w in words_val:
|
|
||||||
extracted.append({'words': f"{k}: {w}"})
|
|
||||||
else:
|
|
||||||
extracted.append({'words': f"{k}: {words_val}"})
|
|
||||||
elif isinstance(v, str):
|
|
||||||
extracted.append({'words': f"{k}: {v}"})
|
|
||||||
return extracted
|
|
||||||
|
|
||||||
return words_list or []
|
|
||||||
|
|
||||||
logger.warning(f"票务识别请求失败 (尝试 {attempt+1}): {response.text[:200]}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"票务识别异常 (尝试 {attempt+1}): {e}")
|
|
||||||
|
|
||||||
if attempt < self.max_retries - 1:
|
|
||||||
time.sleep(self.retry_delay * (2 ** attempt))
|
|
||||||
|
|
||||||
logger.error("票务识别失败")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_excel_result(self, request_id_or_result: Union[str, Dict]) -> Optional[bytes]:
|
def get_excel_result(self, request_id_or_result: Union[str, Dict]) -> Optional[bytes]:
|
||||||
"""
|
"""
|
||||||
获取Excel结果
|
获取Excel结果
|
||||||
@@ -478,8 +299,7 @@ class BaiduOCRClient:
|
|||||||
logger.error(f"无法从结果中提取有效的request_id: {request_id_or_result}")
|
logger.error(f"无法从结果中提取有效的request_id: {request_id_or_result}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
base_url = self.config.get('API', 'form_ocr_url', fallback='https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result')
|
url = f"https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result?access_token={access_token}"
|
||||||
url = f"{base_url}?access_token={access_token}"
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
|
|||||||
@@ -1,353 +0,0 @@
|
|||||||
"""单据元信息识别器:从 OCR 文本中提取供应商、单据日期、应付金额。
|
|
||||||
|
|
||||||
识别逻辑:
|
|
||||||
- 供应商:取 OCR 顶部前 20 行,关键词匹配(供货单/供应商/供货方/批发/酒行/商行等)
|
|
||||||
- 日期:全文正则匹配,标准化为 YYYYMMDD
|
|
||||||
- 总金额:关键词优先(应付金额/实付金额/金额合计),兜底取总计行最大金额
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field, asdict
|
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
# 供应商关键词(命中后取该行清理结果)
|
|
||||||
SUPPLIER_KEYWORDS = (
|
|
||||||
"供货单", "供应商", "供货方", "批发", "酒行", "商行",
|
|
||||||
"经销", "专卖店", "配送单", "送货单", "采购单", "订单",
|
|
||||||
"销售单", "出库单", "经营部", "商贸", "有限公司", "发证机构",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 供应商名称中需要剔除的冗余噪声词
|
|
||||||
SUPPLIER_NOISE_WORDS = (
|
|
||||||
"标题", "采购单", "销售单", "入库单", "出库单", "送货单",
|
|
||||||
"单据", "订单", "配送单", "清单", "供货单", "预览", "详情",
|
|
||||||
"打印", "副本", "记账", "联", "存根",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 总金额关键词(命中后取该行最近一个金额数字)
|
|
||||||
AMOUNT_KEYWORDS = (
|
|
||||||
"应付金额", "实付金额", "金额合计", "应付合计", "合计金额",
|
|
||||||
"合计", "总计", "总计金额",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 日期关键词(用于辅助定位日期)
|
|
||||||
DATE_KEYWORDS = (
|
|
||||||
"单据日期", "下单时间", "日期", "时间", "开单日期", "制单日期", "打印时间", "业务日期", "下单日期",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 日期正则:4 种格式
|
|
||||||
DATE_PATTERNS = [
|
|
||||||
# 2026年07月17日 / 2026年7月17日
|
|
||||||
re.compile(r'(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日?'),
|
|
||||||
# 2026-07-17 / 2026/07/17 / 2026.07.17
|
|
||||||
re.compile(r'(\d{4})[-./](\d{1,2})[-./](\d{1,2})'),
|
|
||||||
# 20260717 紧邻 8 位数字
|
|
||||||
re.compile(r'(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)'),
|
|
||||||
]
|
|
||||||
|
|
||||||
# 金额数字正则(保留 2 位小数或整数)
|
|
||||||
AMOUNT_PATTERN = re.compile(r'(\d{1,7}(?:,\d{3})*(?:\.\d+)?)')
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
# 排除关键词:包含这些词的行绝对不是供应商抬头
|
|
||||||
EXCLUDE_KEYWORDS = (
|
|
||||||
"购货单位", "客户名称", "收货地址", "联系电话", "经手人",
|
|
||||||
"地址", "电话", "传真", "邮编", "网址", "开户行", "账号",
|
|
||||||
"税号", "业务员", "联系人", "单据编号", "流水号", "页码",
|
|
||||||
"四川省", "成都市", "武侯区", "双流区", "高新区", "金牛区",
|
|
||||||
"成华区", "锦江区", "龙泉驿区", "青羊区", "新都区", "温江区",
|
|
||||||
"街道", "社区", "楼", "号", "室", "路", "巷", "段",
|
|
||||||
)
|
|
||||||
|
|
||||||
class OrderMetadataExtractor:
|
|
||||||
"""单据元信息识别器。"""
|
|
||||||
|
|
||||||
# 标题区域行数(采购单抬头 + 日期 + 供应商一般在前 20 行)
|
|
||||||
HEADER_LINE_LIMIT = 20
|
|
||||||
|
|
||||||
# 供应商名最大长度
|
|
||||||
MAX_SUPPLIER_LEN = 50
|
|
||||||
|
|
||||||
def extract(self, ocr_text: str, ocr_rows: Optional[List[List[str]]] = None, general_text: Optional[str] = None) -> OrderMetadata:
|
|
||||||
"""从 OCR 原始文本和/或解析后的二维数组提取三字段。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ocr_text: OCR 原始字符串全文(从表格 OCR 提取)
|
|
||||||
ocr_rows: 解析后的二维数组(可选)
|
|
||||||
general_text: 从通用票据识别接口 (/v1/general_ocr) 提取的文本,优先级最高
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OrderMetadata
|
|
||||||
"""
|
|
||||||
# 优先使用通用票据识别的文本进行供应商和日期提取
|
|
||||||
supplier_source = general_text if general_text else ocr_text
|
|
||||||
date_source = general_text if general_text else ocr_text
|
|
||||||
|
|
||||||
# 金额通常在表格内,优先使用 ocr_text
|
|
||||||
amount_source = ocr_text or general_text or ''
|
|
||||||
|
|
||||||
supplier, raw_supplier = self._extract_supplier(supplier_source, ocr_rows)
|
|
||||||
bill_date = self._extract_bill_date(date_source)
|
|
||||||
total_amount = self._extract_total_amount(amount_source, ocr_rows)
|
|
||||||
|
|
||||||
# 如果通用票据识别没抓到供应商,尝试用表格 OCR 的文本补位
|
|
||||||
if not supplier and general_text and ocr_text:
|
|
||||||
supplier, raw_supplier = self._extract_supplier(ocr_text, ocr_rows)
|
|
||||||
|
|
||||||
# 如果通用票据识别没抓到日期,尝试用表格 OCR 的文本补位
|
|
||||||
if not bill_date and general_text and ocr_text:
|
|
||||||
bill_date = self._extract_bill_date(ocr_text)
|
|
||||||
|
|
||||||
return OrderMetadata(
|
|
||||||
supplier=supplier,
|
|
||||||
bill_date=bill_date,
|
|
||||||
total_amount=total_amount,
|
|
||||||
raw_supplier_text=raw_supplier,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ───────────────────────────────────────────────
|
|
||||||
# Supplier
|
|
||||||
# ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _extract_supplier(self, text: str, rows: Optional[List[List[str]]]) -> Tuple[str, str]:
|
|
||||||
"""提取供应商名称。返回 (cleaned_name, raw_text)。"""
|
|
||||||
# 1) 关键词匹配:取顶部 N 行
|
|
||||||
header_lines = self._get_header_lines(text, self.HEADER_LINE_LIMIT)
|
|
||||||
for line in header_lines:
|
|
||||||
cleaned = self._clean_supplier_line(line)
|
|
||||||
if not cleaned:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 严格排除:购货单位、地址、电话等干扰项
|
|
||||||
if any(k in cleaned for k in EXCLUDE_KEYWORDS):
|
|
||||||
continue
|
|
||||||
|
|
||||||
for kw in SUPPLIER_KEYWORDS:
|
|
||||||
if kw in cleaned:
|
|
||||||
final_name = self._final_cleanup_supplier(cleaned)
|
|
||||||
return self._truncate(final_name), cleaned
|
|
||||||
|
|
||||||
# 2) 兜底:取顶部第一个"含中文且无数字行号"且长度 ≥ 4 的非空行
|
|
||||||
# 但排除"纯日期行"(避免把日期当供应商)
|
|
||||||
for line in header_lines:
|
|
||||||
cleaned = self._clean_supplier_line(line)
|
|
||||||
if not cleaned:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 兜底也要严格排除地址和电话行
|
|
||||||
if any(k in cleaned for k in EXCLUDE_KEYWORDS):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not (re.search(r'[\u4e00-\u9fa5]', cleaned) and len(cleaned) >= 4):
|
|
||||||
continue
|
|
||||||
# 排除:纯日期(YYYY-MM-DD / YYYY/MM/DD / YYYY年MM月DD日 / YYYYMMDD)
|
|
||||||
date_only = re.match(r'^\s*[\d年月日/\-\.]+\s*$', cleaned)
|
|
||||||
if date_only:
|
|
||||||
continue
|
|
||||||
# 排除:以"日期"开头的行(属于日期标注)
|
|
||||||
if re.match(r'^\s*日期[::]?', cleaned):
|
|
||||||
continue
|
|
||||||
# 排除:以"单据"开头的行
|
|
||||||
if re.match(r'^\s*单据[::]?', cleaned):
|
|
||||||
continue
|
|
||||||
|
|
||||||
final_name = self._final_cleanup_supplier(cleaned)
|
|
||||||
if final_name:
|
|
||||||
return self._truncate(final_name), cleaned
|
|
||||||
|
|
||||||
return '', ''
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _final_cleanup_supplier(s: str) -> str:
|
|
||||||
"""最后的供应商名称深度清理:去除“标题”、“采购单”等噪声。"""
|
|
||||||
if not s:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
# 1. 统一处理全角和常见符号
|
|
||||||
s = s.replace(':', ':').replace('(', '(').replace(')', ')')
|
|
||||||
|
|
||||||
# 2. 去除“标题:”或“名称:”这类前缀
|
|
||||||
s = re.sub(r'^(标题|名称|供应商|供货方|单位|商户)[:\s]*', '', s)
|
|
||||||
|
|
||||||
# 3. 循环去除噪声词
|
|
||||||
noise_sorted = sorted(SUPPLIER_NOISE_WORDS, key=len, reverse=True)
|
|
||||||
|
|
||||||
changed = True
|
|
||||||
while changed:
|
|
||||||
original = s
|
|
||||||
for noise in noise_sorted:
|
|
||||||
# 简单替换所有匹配的噪声词
|
|
||||||
s = s.replace(noise, '')
|
|
||||||
|
|
||||||
# 去除括号及其中间的噪声词(如 (采购单) )
|
|
||||||
s = re.sub(r'\(\s*\)', '', s)
|
|
||||||
|
|
||||||
# 去除首尾残留标点和空白
|
|
||||||
s = re.sub(r'^[::\s\-_\|\.\(\)]+', '', s)
|
|
||||||
s = re.sub(r'[::\s\-_\|\.\(\)]+$', '', s)
|
|
||||||
|
|
||||||
changed = (s != original)
|
|
||||||
|
|
||||||
return s.strip()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _clean_supplier_line(line: str) -> str:
|
|
||||||
"""清理一行文本:去前后空白、去首尾日期/编号/电话/标点。"""
|
|
||||||
s = line.strip()
|
|
||||||
if not s:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
# 统一全角冒号
|
|
||||||
s = s.replace(':', ':')
|
|
||||||
|
|
||||||
# 如果是“标题: 新双利采购单”,保留冒号后面的部分进行初步处理
|
|
||||||
if ':' in s:
|
|
||||||
parts = s.split(':', 1)
|
|
||||||
# 如果冒号前面是“标题”、“名称”等词,则取后面
|
|
||||||
if any(k in parts[0] for k in ("标题", "名称", "供应商", "商户")):
|
|
||||||
s = parts[1].strip()
|
|
||||||
|
|
||||||
# 去行首日期/编号前缀
|
|
||||||
s = re.sub(r'^[\s\d\-\.\/年月日::]+', '', s)
|
|
||||||
# 去行尾标点
|
|
||||||
s = re.sub(r'[\s\.,;::\-_/\\|]+$', '', s)
|
|
||||||
return s.strip()
|
|
||||||
|
|
||||||
def _truncate(self, s: str) -> str:
|
|
||||||
return s[:self.MAX_SUPPLIER_LEN] if len(s) > self.MAX_SUPPLIER_LEN else s
|
|
||||||
|
|
||||||
# ───────────────────────────────────────────────
|
|
||||||
# BillDate
|
|
||||||
# ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _extract_bill_date(self, text: str) -> str:
|
|
||||||
"""提取单据日期,标准化为 YYYYMMDD。"""
|
|
||||||
if not text:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
lines = text.splitlines()
|
|
||||||
|
|
||||||
# 1. 优先尝试关键词定位逻辑(如用户建议:搜索“时间”或“日期”)
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
line_clean = line.strip().replace(':', ':')
|
|
||||||
|
|
||||||
# 检查是否包含日期相关关键词
|
|
||||||
for kw in DATE_KEYWORDS:
|
|
||||||
if kw in line_clean:
|
|
||||||
# a) 尝试在当前行找日期
|
|
||||||
for pat in DATE_PATTERNS:
|
|
||||||
m = pat.search(line_clean)
|
|
||||||
if m:
|
|
||||||
y, mo, d = m.group(1), m.group(2), m.group(3)
|
|
||||||
if self._is_valid_date(y, mo, d):
|
|
||||||
return f"{y}{int(mo):02d}{int(d):02d}"
|
|
||||||
|
|
||||||
# b) 如果当前行没找到,尝试在下一行找(针对表格 OCR 错位情况)
|
|
||||||
if i + 1 < len(lines):
|
|
||||||
next_line = lines[i+1].strip()
|
|
||||||
for pat in DATE_PATTERNS:
|
|
||||||
m = pat.search(next_line)
|
|
||||||
if m:
|
|
||||||
y, mo, d = m.group(1), m.group(2), m.group(3)
|
|
||||||
if self._is_valid_date(y, mo, d):
|
|
||||||
return f"{y}{int(mo):02d}{int(d):02d}"
|
|
||||||
|
|
||||||
# 2. 兜底策略:全文正则匹配
|
|
||||||
for pat in DATE_PATTERNS:
|
|
||||||
# 优先找包含 202 开头的年份(更像当前日期)
|
|
||||||
matches = pat.finditer(text)
|
|
||||||
for m in matches:
|
|
||||||
y, mo, d = m.group(1), m.group(2), m.group(3)
|
|
||||||
if self._is_valid_date(y, mo, d):
|
|
||||||
# 如果年份以 202 开头,优先返回
|
|
||||||
if y.startswith('202'):
|
|
||||||
return f"{y}{int(mo):02d}{int(d):02d}"
|
|
||||||
# 否则记录下来作为候选
|
|
||||||
last_valid = f"{y}{int(mo):02d}{int(d):02d}"
|
|
||||||
|
|
||||||
# 如果没有 202 开头的,返回最后一个有效的
|
|
||||||
try:
|
|
||||||
return last_valid
|
|
||||||
except NameError:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_valid_date(y: str, m: str, d: str) -> bool:
|
|
||||||
try:
|
|
||||||
yi, mi, di = int(y), int(m), int(d)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
if not (1900 <= yi <= 2100):
|
|
||||||
return False
|
|
||||||
if not (1 <= mi <= 12):
|
|
||||||
return False
|
|
||||||
if not (1 <= di <= 31):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ───────────────────────────────────────────────
|
|
||||||
# TotalAmount
|
|
||||||
# ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _extract_total_amount(self, text: str, rows: Optional[List[List[str]]]) -> float:
|
|
||||||
"""提取总金额(应付/实付/合计/总计)。"""
|
|
||||||
# 1) 关键词行:取该行最大数字
|
|
||||||
for kw in AMOUNT_KEYWORDS:
|
|
||||||
for line in text.splitlines():
|
|
||||||
if kw in line:
|
|
||||||
nums = AMOUNT_PATTERN.findall(line)
|
|
||||||
if nums:
|
|
||||||
# 取最大的(应付通常在末尾)
|
|
||||||
try:
|
|
||||||
vals = [float(n.replace(',', '')) for n in nums]
|
|
||||||
return max(vals)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 2) 兜底:取全文最大金额数字(>= 1.0 且含小数点,像金额)
|
|
||||||
all_nums = AMOUNT_PATTERN.findall(text)
|
|
||||||
candidates = []
|
|
||||||
for n in all_nums:
|
|
||||||
try:
|
|
||||||
v = float(n.replace(',', ''))
|
|
||||||
if v >= 1.0 and '.' in n:
|
|
||||||
candidates.append(v)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return max(candidates) if candidates else 0.0
|
|
||||||
|
|
||||||
# ───────────────────────────────────────────────
|
|
||||||
# Helpers
|
|
||||||
# ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_header_lines(text: str, n: int) -> List[str]:
|
|
||||||
lines = [l for l in text.splitlines() if l.strip()]
|
|
||||||
return lines[:n]
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_for_filename(s: str) -> str:
|
|
||||||
"""清理字符串为合法文件名片段:替换 Windows 非法字符为下划线。"""
|
|
||||||
if not s:
|
|
||||||
return ''
|
|
||||||
# Windows 非法字符
|
|
||||||
s = re.sub(r'[\\/:*?"<>|]', '_', s)
|
|
||||||
# 多余空白
|
|
||||||
s = re.sub(r'\s+', ' ', s).strip()
|
|
||||||
return s
|
|
||||||
+24
-28
@@ -5,13 +5,17 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
import base64
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Dict, List, Optional, Tuple, Callable
|
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...config.settings import ConfigManager
|
||||||
from app.core.utils.file_utils import (
|
from ..utils.log_utils import get_logger
|
||||||
|
from ..utils.file_utils import (
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
get_file_extension,
|
get_file_extension,
|
||||||
get_files_by_extensions,
|
get_files_by_extensions,
|
||||||
@@ -20,8 +24,7 @@ from app.core.utils.file_utils import (
|
|||||||
load_json,
|
load_json,
|
||||||
save_json
|
save_json
|
||||||
)
|
)
|
||||||
from app.config.settings import ConfigManager
|
from .baidu_ocr import BaiduOCRClient
|
||||||
from app.core.ocr.baidu_ocr import BaiduOCRClient
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -103,22 +106,21 @@ class OCRProcessor:
|
|||||||
OCR处理器,负责协调OCR识别和结果处理
|
OCR处理器,负责协调OCR识别和结果处理
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: Optional[ConfigManager] = None):
|
def __init__(self, config):
|
||||||
"""
|
"""
|
||||||
初始化OCR处理器
|
初始化OCR处理器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: 配置管理器
|
config: 配置信息
|
||||||
"""
|
"""
|
||||||
self.config = config or ConfigManager()
|
self.config = config
|
||||||
self.ocr_client = None
|
|
||||||
|
|
||||||
# 修复ConfigParser对象没有get_path方法的问题
|
# 修复ConfigParser对象没有get_path方法的问题
|
||||||
try:
|
try:
|
||||||
# 获取输入和输出目录
|
# 获取输入和输出目录
|
||||||
self.input_folder = self.config.get_path('Paths', 'input_folder', fallback='data/input', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/input')
|
self.input_folder = config.get('Paths', 'input_folder', fallback='data/input')
|
||||||
self.output_folder = self.config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/output')
|
self.output_folder = config.get('Paths', 'output_folder', fallback='data/output')
|
||||||
self.temp_folder = self.config.get_path('Paths', 'temp_folder', fallback='data/temp', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/temp')
|
self.temp_folder = config.get('Paths', 'temp_folder', fallback='data/temp')
|
||||||
|
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
os.makedirs(self.input_folder, exist_ok=True)
|
os.makedirs(self.input_folder, exist_ok=True)
|
||||||
@@ -126,13 +128,13 @@ class OCRProcessor:
|
|||||||
os.makedirs(self.temp_folder, exist_ok=True)
|
os.makedirs(self.temp_folder, exist_ok=True)
|
||||||
|
|
||||||
# 获取文件类型列表
|
# 获取文件类型列表
|
||||||
allowed_extensions_str = self.config.get('File', 'allowed_extensions', fallback='.jpg,.jpeg,.png,.bmp')
|
allowed_extensions_str = config.get('File', 'allowed_extensions', fallback='.jpg,.jpeg,.png,.bmp')
|
||||||
self.file_types = [ext.strip() for ext in allowed_extensions_str.split(',') if ext.strip()]
|
self.file_types = [ext.strip() for ext in allowed_extensions_str.split(',') if ext.strip()]
|
||||||
if not self.file_types:
|
if not self.file_types:
|
||||||
self.file_types = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tif', '.tiff']
|
self.file_types = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tif', '.tiff']
|
||||||
|
|
||||||
# 初始化OCR客户端
|
# 初始化OCR客户端
|
||||||
self._ensure_ocr_client()
|
self.ocr_client = BaiduOCRClient(self.config)
|
||||||
|
|
||||||
# 记录实际路径
|
# 记录实际路径
|
||||||
logger.info(f"使用输入目录: {os.path.abspath(self.input_folder)}")
|
logger.info(f"使用输入目录: {os.path.abspath(self.input_folder)}")
|
||||||
@@ -152,11 +154,6 @@ class OCRProcessor:
|
|||||||
logger.error(f"初始化OCRProcessor失败: {e}")
|
logger.error(f"初始化OCRProcessor失败: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _ensure_ocr_client(self):
|
|
||||||
"""确保OCR客户端已初始化"""
|
|
||||||
if self.ocr_client is None:
|
|
||||||
self.ocr_client = BaiduOCRClient(self.config)
|
|
||||||
|
|
||||||
def _load_processed_files(self) -> Dict[str, str]:
|
def _load_processed_files(self) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
加载已处理的文件记录
|
加载已处理的文件记录
|
||||||
@@ -180,7 +177,7 @@ class OCRProcessor:
|
|||||||
skip_existing = True
|
skip_existing = True
|
||||||
try:
|
try:
|
||||||
skip_existing = self.config.getboolean('Performance', 'skip_existing', fallback=True)
|
skip_existing = self.config.getboolean('Performance', 'skip_existing', fallback=True)
|
||||||
except Exception:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if skip_existing:
|
if skip_existing:
|
||||||
@@ -217,7 +214,7 @@ class OCRProcessor:
|
|||||||
max_size_mb = 4.0
|
max_size_mb = 4.0
|
||||||
try:
|
try:
|
||||||
max_size_mb = float(self.config.get('File', 'max_file_size_mb', fallback='4.0'))
|
max_size_mb = float(self.config.get('File', 'max_file_size_mb', fallback='4.0'))
|
||||||
except Exception:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not is_file_size_valid(image_path, max_size_mb):
|
if not is_file_size_valid(image_path, max_size_mb):
|
||||||
@@ -244,7 +241,7 @@ class OCRProcessor:
|
|||||||
skip_existing = True
|
skip_existing = True
|
||||||
try:
|
try:
|
||||||
skip_existing = self.config.getboolean('Performance', 'skip_existing', fallback=True)
|
skip_existing = self.config.getboolean('Performance', 'skip_existing', fallback=True)
|
||||||
except Exception:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 如果需要跳过已处理的文件
|
# 如果需要跳过已处理的文件
|
||||||
@@ -260,7 +257,7 @@ class OCRProcessor:
|
|||||||
excel_extension = '.xlsx'
|
excel_extension = '.xlsx'
|
||||||
try:
|
try:
|
||||||
excel_extension = self.config.get('File', 'excel_extension', fallback='.xlsx')
|
excel_extension = self.config.get('File', 'excel_extension', fallback='.xlsx')
|
||||||
except Exception:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 生成输出文件路径
|
# 生成输出文件路径
|
||||||
@@ -350,15 +347,14 @@ class OCRProcessor:
|
|||||||
if batch_size is None:
|
if batch_size is None:
|
||||||
try:
|
try:
|
||||||
batch_size = self.config.getint('Performance', 'batch_size', fallback=5)
|
batch_size = self.config.getint('Performance', 'batch_size', fallback=5)
|
||||||
except Exception:
|
except:
|
||||||
batch_size = 5
|
batch_size = 5
|
||||||
|
|
||||||
if max_workers is None:
|
if max_workers is None:
|
||||||
try:
|
try:
|
||||||
# 强制设为 1 以严格遵守百度 QPS=2 的限制(一张图调两个接口就满了)
|
max_workers = self.config.getint('Performance', 'max_workers', fallback=4)
|
||||||
max_workers = self.config.getint('Performance', 'max_workers', fallback=1)
|
except:
|
||||||
except Exception:
|
max_workers = 4
|
||||||
max_workers = 1
|
|
||||||
|
|
||||||
# 获取未处理的图片
|
# 获取未处理的图片
|
||||||
unprocessed_images = self.get_unprocessed_images()
|
unprocessed_images = self.get_unprocessed_images()
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
处理器模块初始化文件
|
处理器模块初始化文件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.core.processors.base import BaseProcessor
|
from .base import BaseProcessor
|
||||||
from app.core.processors.ocr_processor import OCRProcessor
|
from .ocr_processor import OCRProcessor
|
||||||
from app.core.processors.tobacco_processor import TobaccoProcessor
|
from .tobacco_processor import TobaccoProcessor
|
||||||
|
|
||||||
__all__ = ['BaseProcessor', 'OCRProcessor', 'TobaccoProcessor']
|
__all__ = ['BaseProcessor', 'OCRProcessor', 'TobaccoProcessor']
|
||||||
@@ -8,11 +8,8 @@ from abc import ABC, abstractmethod
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import logging
|
import logging
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BaseProcessor(ABC):
|
class BaseProcessor(ABC):
|
||||||
@@ -119,31 +116,6 @@ class BaseProcessor(ABC):
|
|||||||
"""
|
"""
|
||||||
return f"{input_file.stem}{suffix}{input_file.suffix}"
|
return f"{input_file.stem}{suffix}{input_file.suffix}"
|
||||||
|
|
||||||
def _read_excel_safely(self, file_path: Path, **kwargs) -> pd.DataFrame:
|
|
||||||
"""根据扩展名选择合适的读取引擎
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: 文件路径
|
|
||||||
**kwargs: 传递给 pd.read_excel 的参数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DataFrame
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: 读取失败时抛出
|
|
||||||
"""
|
|
||||||
suffix = file_path.suffix.lower()
|
|
||||||
if suffix == '.xlsx':
|
|
||||||
return pd.read_excel(file_path, engine='openpyxl', **kwargs)
|
|
||||||
elif suffix == '.xls':
|
|
||||||
try:
|
|
||||||
return pd.read_excel(file_path, engine='xlrd', **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"读取xls失败,可能缺少xlrd: {e}")
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
return pd.read_excel(file_path, **kwargs)
|
|
||||||
|
|
||||||
def log_processing_start(self, input_file: Path):
|
def log_processing_start(self, input_file: Path):
|
||||||
"""记录处理开始日志"""
|
"""记录处理开始日志"""
|
||||||
self.logger.info(f"开始处理文件: {input_file}")
|
self.logger.info(f"开始处理文件: {input_file}")
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
from app.core.processors.base import BaseProcessor
|
from .base import BaseProcessor
|
||||||
from app.services.ocr_service import OCRService
|
from ...services.ocr_service import OCRService
|
||||||
from app.services.order_service import OrderService
|
from ...services.order_service import OrderService
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...core.utils.log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
供应商处理器模块初始化文件
|
供应商处理器模块初始化文件
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor
|
from .generic_supplier_processor import GenericSupplierProcessor
|
||||||
|
|
||||||
__all__ = ['GenericSupplierProcessor']
|
__all__ = ['GenericSupplierProcessor']
|
||||||
@@ -9,12 +9,9 @@ import pandas as pd
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.processors.base import BaseProcessor
|
from ..base import BaseProcessor
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...utils.log_utils import get_logger
|
||||||
from app.core.handlers.rule_engine import apply_rules
|
from ...handlers.rule_engine import apply_rules
|
||||||
from app.core.handlers.column_mapper import ColumnMapper
|
|
||||||
from app.core.handlers.data_cleaner import DataCleaner
|
|
||||||
from app.core.handlers.calculator import DataCalculator
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -240,9 +237,57 @@ class GenericSupplierProcessor(BaseProcessor):
|
|||||||
self.logger.error(f"读取数据失败: {e}")
|
self.logger.error(f"读取数据失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _read_excel_safely(self, file_path: Path, **kwargs) -> pd.DataFrame:
|
||||||
|
"""根据扩展名选择合适的读取引擎并带有回退"""
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
|
try:
|
||||||
|
if suffix == '.xlsx':
|
||||||
|
return pd.read_excel(file_path, engine='openpyxl', **kwargs)
|
||||||
|
elif suffix == '.xls':
|
||||||
|
try:
|
||||||
|
return pd.read_excel(file_path, engine='xlrd', **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"读取xls失败,可能缺少xlrd: {e}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
return pd.read_excel(file_path, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"读取Excel失败: {file_path} - {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def _find_header_row(self, df: pd.DataFrame) -> Optional[int]:
|
def _find_header_row(self, df: pd.DataFrame) -> Optional[int]:
|
||||||
result = ColumnMapper.detect_header_row(df, max_rows=30)
|
try:
|
||||||
return result if result >= 0 else None
|
header_keywords = [
|
||||||
|
'条码','条形码','商品编码','商品名称','名称','数量','单位','单价','规格',
|
||||||
|
'金额','小计','总计','合计','合计金额'
|
||||||
|
]
|
||||||
|
scores = []
|
||||||
|
rows_to_check = min(30, len(df))
|
||||||
|
for r in range(rows_to_check):
|
||||||
|
row = df.iloc[r]
|
||||||
|
score = 0
|
||||||
|
for cell in row:
|
||||||
|
if isinstance(cell, str):
|
||||||
|
s = cell.strip().lower()
|
||||||
|
for kw in header_keywords:
|
||||||
|
if kw.lower() in s:
|
||||||
|
score += 5
|
||||||
|
non_empty = row.count()
|
||||||
|
if non_empty / max(1, len(row)) > 0.5:
|
||||||
|
score += 2
|
||||||
|
str_count = sum(1 for c in row if isinstance(c, str))
|
||||||
|
if str_count / max(1, len(row)) > 0.5:
|
||||||
|
score += 3
|
||||||
|
scores.append((r, score))
|
||||||
|
scores.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
if scores and scores[0][1] >= 5:
|
||||||
|
return scores[0][0]
|
||||||
|
for r in range(len(df)):
|
||||||
|
if df.iloc[r].notna().sum() > 3:
|
||||||
|
return r
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _apply_column_mapping(self, df: pd.DataFrame) -> Optional[pd.DataFrame]:
|
def _apply_column_mapping(self, df: pd.DataFrame) -> Optional[pd.DataFrame]:
|
||||||
"""应用列映射
|
"""应用列映射
|
||||||
@@ -280,33 +325,131 @@ class GenericSupplierProcessor(BaseProcessor):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _apply_data_cleaning(self, df: pd.DataFrame) -> Optional[pd.DataFrame]:
|
def _apply_data_cleaning(self, df: pd.DataFrame) -> Optional[pd.DataFrame]:
|
||||||
"""应用数据清洗规则,委托给 DataCleaner"""
|
"""应用数据清洗规则
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: 映射后的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
清洗后的数据或None
|
||||||
|
"""
|
||||||
if not self.cleaning_rules:
|
if not self.cleaning_rules:
|
||||||
self.logger.info("没有数据清洗规则")
|
self.logger.info("没有数据清洗规则")
|
||||||
return df
|
return df
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cleaner = DataCleaner()
|
df_cleaned = df.copy()
|
||||||
|
|
||||||
for rule in self.cleaning_rules:
|
for rule in self.cleaning_rules:
|
||||||
cleaner.add_rule(rule.get('type'), **{k: v for k, v in rule.items() if k != 'type'})
|
rule_type = rule.get('type')
|
||||||
result = cleaner.clean(df)
|
|
||||||
self.logger.info(f"数据清洗完成,数据形状: {result.shape}")
|
if rule_type == 'remove_rows':
|
||||||
return result
|
# 删除行
|
||||||
|
condition = rule.get('condition')
|
||||||
|
if condition:
|
||||||
|
before_count = len(df_cleaned)
|
||||||
|
df_cleaned = df_cleaned.query(condition)
|
||||||
|
after_count = len(df_cleaned)
|
||||||
|
self.logger.info(f"删除行规则: {condition}, 删除数量: {before_count - after_count}")
|
||||||
|
|
||||||
|
elif rule_type == 'fill_na':
|
||||||
|
# 填充空值,兼容单列和多列
|
||||||
|
columns = rule.get('columns') or [rule.get('column')] if rule.get('column') else []
|
||||||
|
value = rule.get('value', 0)
|
||||||
|
for col in columns:
|
||||||
|
if col and col in df_cleaned.columns:
|
||||||
|
na_count = df_cleaned[col].isna().sum()
|
||||||
|
df_cleaned[col] = df_cleaned[col].fillna(value)
|
||||||
|
self.logger.info(f"填充空值: {col} -> {value}, 填充数量: {na_count}")
|
||||||
|
|
||||||
|
elif rule_type == 'convert_type':
|
||||||
|
# 类型转换,兼容单列和多列
|
||||||
|
target_type = rule.get('target_type', 'float')
|
||||||
|
columns = rule.get('columns') or [rule.get('column')] if rule.get('column') else []
|
||||||
|
for col in columns:
|
||||||
|
if col and col in df_cleaned.columns:
|
||||||
|
try:
|
||||||
|
if target_type == 'float':
|
||||||
|
df_cleaned[col] = pd.to_numeric(df_cleaned[col], errors='coerce')
|
||||||
|
elif target_type == 'int':
|
||||||
|
df_cleaned[col] = pd.to_numeric(df_cleaned[col], errors='coerce').astype('Int64')
|
||||||
|
self.logger.info(f"类型转换: {col} -> {target_type}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"类型转换失败: {col} -> {target_type}: {e}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"未知的清洗规则类型: {rule_type}")
|
||||||
|
|
||||||
|
self.logger.info(f"数据清洗完成,数据形状: {df_cleaned.shape}")
|
||||||
|
return df_cleaned
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"数据清洗失败: {e}")
|
self.logger.error(f"数据清洗失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _apply_calculations(self, df: pd.DataFrame) -> Optional[pd.DataFrame]:
|
def _apply_calculations(self, df: pd.DataFrame) -> Optional[pd.DataFrame]:
|
||||||
"""应用计算处理,委托给 DataCalculator"""
|
"""应用计算处理
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: 清洗后的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
计算后的数据或None
|
||||||
|
"""
|
||||||
if not self.calculations:
|
if not self.calculations:
|
||||||
self.logger.info("没有计算规则")
|
self.logger.info("没有计算规则")
|
||||||
return df
|
return df
|
||||||
|
|
||||||
try:
|
try:
|
||||||
calculator = DataCalculator()
|
df_calculated = df.copy()
|
||||||
for calc in self.calculations:
|
|
||||||
calculator.add_rule(calc.get('type'), **{k: v for k, v in calc.items() if k != 'type'})
|
for calculation in self.calculations:
|
||||||
result = calculator.calculate(df)
|
calc_type = calculation.get('type')
|
||||||
self.logger.info(f"计算处理完成,数据形状: {result.shape}")
|
|
||||||
return result
|
if calc_type == 'multiply':
|
||||||
|
# 乘法计算
|
||||||
|
source_column = calculation.get('source_column')
|
||||||
|
target_column = calculation.get('target_column')
|
||||||
|
factor = calculation.get('factor', 1)
|
||||||
|
|
||||||
|
if source_column and target_column:
|
||||||
|
if source_column in df_calculated.columns:
|
||||||
|
df_calculated[target_column] = df_calculated[source_column] * factor
|
||||||
|
self.logger.info(f"乘法计算: {source_column} * {factor} -> {target_column}")
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"源列不存在: {source_column}")
|
||||||
|
|
||||||
|
elif calc_type == 'divide':
|
||||||
|
# 除法计算
|
||||||
|
source_column = calculation.get('source_column')
|
||||||
|
target_column = calculation.get('target_column')
|
||||||
|
divisor = calculation.get('divisor', 1)
|
||||||
|
|
||||||
|
if source_column and target_column and divisor != 0:
|
||||||
|
if source_column in df_calculated.columns:
|
||||||
|
df_calculated[target_column] = df_calculated[source_column] / divisor
|
||||||
|
self.logger.info(f"除法计算: {source_column} / {divisor} -> {target_column}")
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"源列不存在: {source_column}")
|
||||||
|
|
||||||
|
elif calc_type == 'formula':
|
||||||
|
# 公式计算
|
||||||
|
formula = calculation.get('formula')
|
||||||
|
target_column = calculation.get('target_column')
|
||||||
|
|
||||||
|
if formula and target_column:
|
||||||
|
try:
|
||||||
|
df_calculated[target_column] = df_calculated.eval(formula)
|
||||||
|
self.logger.info(f"公式计算: {formula} -> {target_column}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"公式计算失败: {formula}: {e}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"未知的计算类型: {calc_type}")
|
||||||
|
|
||||||
|
self.logger.info(f"计算处理完成,数据形状: {df_calculated.shape}")
|
||||||
|
return df_calculated
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"计算处理失败: {e}")
|
self.logger.error(f"计算处理失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ from openpyxl import load_workbook
|
|||||||
from typing import Optional, Dict, Any, List, Tuple
|
from typing import Optional, Dict, Any, List, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.processors.base import BaseProcessor
|
from .base import BaseProcessor
|
||||||
from app.core.utils.log_utils import get_logger
|
from ...core.utils.log_utils import get_logger
|
||||||
from app.core.utils.string_utils import parse_monetary_string
|
from ...core.utils.dialog_utils import show_custom_dialog
|
||||||
from app.core.utils.dialog_utils import show_custom_dialog
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ class TobaccoProcessor(BaseProcessor):
|
|||||||
self.template_file = config.get('Paths', 'template_file', fallback='templates/银豹-采购单模板.xls')
|
self.template_file = config.get('Paths', 'template_file', fallback='templates/银豹-采购单模板.xls')
|
||||||
|
|
||||||
# 输出目录配置
|
# 输出目录配置
|
||||||
self.result_dir = Path(config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/result'))
|
self.result_dir = Path("data/result")
|
||||||
self.result_dir.mkdir(exist_ok=True)
|
self.result_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
# 默认输出文件名
|
# 默认输出文件名
|
||||||
@@ -207,6 +206,19 @@ class TobaccoProcessor(BaseProcessor):
|
|||||||
self.logger.error(f"读取订单数据失败: {e}")
|
self.logger.error(f"读取订单数据失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _read_excel_safely(self, file_path: Path, **kwargs) -> pd.DataFrame:
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
|
if suffix == '.xlsx':
|
||||||
|
return pd.read_excel(file_path, engine='openpyxl', **kwargs)
|
||||||
|
elif suffix == '.xls':
|
||||||
|
try:
|
||||||
|
return pd.read_excel(file_path, engine='xlrd', **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"读取xls失败,可能缺少xlrd: {e}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
return pd.read_excel(file_path, **kwargs)
|
||||||
|
|
||||||
def _generate_pospal_order(self, order_data: pd.DataFrame, order_time: str, output_file: Path) -> bool:
|
def _generate_pospal_order(self, order_data: pd.DataFrame, order_time: str, output_file: Path) -> bool:
|
||||||
"""生成银豹采购单
|
"""生成银豹采购单
|
||||||
|
|
||||||
@@ -284,9 +296,12 @@ class TobaccoProcessor(BaseProcessor):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 格式化金额显示
|
# 格式化金额显示
|
||||||
parsed = parse_monetary_string(total_amount)
|
try:
|
||||||
total_amount = parsed if parsed is not None else 0.0
|
if isinstance(total_amount, str):
|
||||||
|
total_amount = float(total_amount.replace(',', ''))
|
||||||
amount_display = f"¥{total_amount:.2f}"
|
amount_display = f"¥{total_amount:.2f}"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
amount_display = f"¥{total_amount}"
|
||||||
|
|
||||||
# 显示自定义对话框
|
# 显示自定义对话框
|
||||||
show_custom_dialog(
|
show_custom_dialog(
|
||||||
@@ -316,7 +331,7 @@ class TobaccoProcessor(BaseProcessor):
|
|||||||
today_start = datetime.datetime.combine(today, datetime.time.min).timestamp()
|
today_start = datetime.datetime.combine(today, datetime.time.min).timestamp()
|
||||||
|
|
||||||
# 查找订单明细文件
|
# 查找订单明细文件
|
||||||
result_dir = Path(self.config.get_path('Paths', 'output_folder', fallback='data/output') if hasattr(self.config, 'get_path') else os.path.abspath('data/output'))
|
result_dir = Path("data/output")
|
||||||
if not result_dir.exists():
|
if not result_dir.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
"""云端同步模块 — 基于 Gitea REST API 的文件同步"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaSync:
|
|
||||||
"""通过 Gitea REST API 读写仓库文件"""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str, owner: str, repo: str, token: str, timeout: int = 15):
|
|
||||||
self.base_url = base_url.rstrip("/")
|
|
||||||
self.owner = owner
|
|
||||||
self.repo = repo
|
|
||||||
self.token = token
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _headers(self) -> dict:
|
|
||||||
return {"Authorization": f"token {self.token}"}
|
|
||||||
|
|
||||||
def _api_url(self, path: str) -> str:
|
|
||||||
return f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/contents/{path}"
|
|
||||||
|
|
||||||
def pull_file(self, remote_path: str) -> Optional[Tuple[bytes, str]]:
|
|
||||||
"""从仓库下载文件
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(content_bytes, sha) 或 None(文件不存在或失败)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
resp = requests.get(
|
|
||||||
self._api_url(remote_path),
|
|
||||||
headers=self._headers,
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if resp.status_code == 404:
|
|
||||||
logger.info(f"云端文件不存在: {remote_path}")
|
|
||||||
return None
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning(f"拉取文件失败: {resp.status_code} {resp.text[:200]}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
sha = data.get("sha", "")
|
|
||||||
content_b64 = data.get("content", "")
|
|
||||||
# Gitea 返回的 base64 可能含换行
|
|
||||||
content_bytes = base64.b64decode(content_b64.replace("\n", ""))
|
|
||||||
logger.info(f"拉取文件成功: {remote_path} ({len(content_bytes)} bytes)")
|
|
||||||
return content_bytes, sha
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.error(f"拉取文件网络错误: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def push_file(
|
|
||||||
self,
|
|
||||||
remote_path: str,
|
|
||||||
content: bytes,
|
|
||||||
message: str,
|
|
||||||
sha: Optional[str] = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""上传或更新文件到仓库
|
|
||||||
|
|
||||||
Args:
|
|
||||||
remote_path: 仓库中的文件路径
|
|
||||||
content: 文件内容(bytes)
|
|
||||||
message: commit message
|
|
||||||
sha: 文件当前 sha(更新时必传,新建时省略)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
新的 sha,失败返回 None
|
|
||||||
"""
|
|
||||||
payload = {
|
|
||||||
"message": message,
|
|
||||||
"content": base64.b64encode(content).decode("ascii"),
|
|
||||||
}
|
|
||||||
if sha:
|
|
||||||
payload["sha"] = sha
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = requests.put(
|
|
||||||
self._api_url(remote_path),
|
|
||||||
headers={**self._headers, "Content-Type": "application/json"},
|
|
||||||
json=payload,
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if resp.status_code not in (200, 201):
|
|
||||||
logger.warning(f"推送文件失败: {resp.status_code} {resp.text[:200]}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
new_sha = resp.json().get("content", {}).get("sha", "")
|
|
||||||
logger.info(f"推送文件成功: {remote_path} (sha={new_sha[:12]})")
|
|
||||||
return new_sha
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.error(f"推送文件网络错误: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def file_exists(self, remote_path: str) -> Optional[str]:
|
|
||||||
"""检查文件是否存在
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
文件 sha(存在)或 None(不存在)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
resp = requests.head(
|
|
||||||
self._api_url(remote_path),
|
|
||||||
headers=self._headers,
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
# HEAD 不返回 body,需要 GET 获取 sha
|
|
||||||
result = self.pull_file(remote_path)
|
|
||||||
return result[1] if result else None
|
|
||||||
return None
|
|
||||||
except requests.RequestException:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def pull_json(self, remote_path: str) -> Optional[Tuple[dict, str]]:
|
|
||||||
"""拉取并解析 JSON 文件
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(parsed_dict, sha) 或 None
|
|
||||||
"""
|
|
||||||
result = self.pull_file(remote_path)
|
|
||||||
if result is None:
|
|
||||||
return None
|
|
||||||
content_bytes, sha = result
|
|
||||||
try:
|
|
||||||
data = json.loads(content_bytes)
|
|
||||||
return data, sha
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"解析 JSON 失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def push_json(self, remote_path: str, data: dict, message: str, sha: Optional[str] = None) -> Optional[str]:
|
|
||||||
"""将 dict 序列化为 JSON 并推送
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
新的 sha,失败返回 None
|
|
||||||
"""
|
|
||||||
content = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
|
||||||
return self.push_file(remote_path, content, message, sha)
|
|
||||||
|
|
||||||
def push_binary(self, remote_path: str, local_path: str, message: str) -> Optional[str]:
|
|
||||||
"""读取本地二进制文件并推送到云端
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
新的 sha,失败返回 None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with open(local_path, "rb") as f:
|
|
||||||
content = f.read()
|
|
||||||
except OSError as e:
|
|
||||||
logger.error(f"读取本地文件失败: {local_path} — {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
existing_sha = self.file_exists(remote_path)
|
|
||||||
return self.push_file(remote_path, content, message, sha=existing_sha)
|
|
||||||
|
|
||||||
def push(self) -> str:
|
|
||||||
"""推送本地数据到云端:product_cache.json + barcode_mappings.json"""
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
|
||||||
|
|
||||||
results = []
|
|
||||||
# 1. Product cache
|
|
||||||
from app.core.db.product_db import ProductDatabase
|
|
||||||
excel_source = str(project_root / "templates" / "商品资料.xlsx")
|
|
||||||
db_path = str(project_root / "data" / "product_cache.db")
|
|
||||||
product_db = ProductDatabase(db_path, excel_source)
|
|
||||||
product_data = product_db.export_for_sync()
|
|
||||||
sha = self.push_json("product_cache.json", product_data, "sync: update product cache")
|
|
||||||
results.append(f"product_cache: {'ok' if sha else 'skip'}")
|
|
||||||
|
|
||||||
# 2. Barcode mappings
|
|
||||||
barcode_path = project_root / "config" / "barcode_mappings.json"
|
|
||||||
if barcode_path.exists():
|
|
||||||
with open(barcode_path, "r", encoding="utf-8") as f:
|
|
||||||
barcode_data = json.loads(f.read())
|
|
||||||
sha = self.push_json("barcode_mappings.json", barcode_data, "sync: update barcode mappings")
|
|
||||||
results.append(f"barcode_mappings: {'ok' if sha else 'skip'}")
|
|
||||||
|
|
||||||
return "; ".join(results) if results else "无数据需要同步"
|
|
||||||
|
|
||||||
def pull(self) -> str:
|
|
||||||
"""从云端拉取数据并写入本地文件"""
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
|
||||||
|
|
||||||
results = []
|
|
||||||
# 1. Product cache
|
|
||||||
result = self.pull_json("product_cache.json")
|
|
||||||
if result is not None:
|
|
||||||
data, sha = result
|
|
||||||
from app.core.db.product_db import ProductDatabase
|
|
||||||
excel_source = str(project_root / "templates" / "商品资料.xlsx")
|
|
||||||
db_path = str(project_root / "data" / "product_cache.db")
|
|
||||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
||||||
product_db = ProductDatabase(db_path, excel_source)
|
|
||||||
count = product_db.import_from_sync(data)
|
|
||||||
results.append(f"product_cache: 导入 {count} 条")
|
|
||||||
else:
|
|
||||||
results.append("product_cache: 云端无数据")
|
|
||||||
|
|
||||||
# 2. Barcode mappings
|
|
||||||
barcode_result = self.pull_json("barcode_mappings.json")
|
|
||||||
if barcode_result is not None:
|
|
||||||
barcode_data, sha = barcode_result
|
|
||||||
barcode_path = project_root / "config" / "barcode_mappings.json"
|
|
||||||
barcode_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(barcode_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(barcode_data, f, ensure_ascii=False, indent=2)
|
|
||||||
results.append(f"barcode_mappings: 已更新")
|
|
||||||
else:
|
|
||||||
results.append("barcode_mappings: 云端无数据")
|
|
||||||
|
|
||||||
return "; ".join(results) if results else "无数据需要同步"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_config(cls, config) -> Optional["GiteaSync"]:
|
|
||||||
"""从 ConfigManager 创建实例
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
GiteaSync 实例,配置不完整时返回 None
|
|
||||||
"""
|
|
||||||
base_url = config.get("Gitea", "base_url", fallback="").strip()
|
|
||||||
owner = config.get("Gitea", "owner", fallback="").strip()
|
|
||||||
repo = config.get("Gitea", "repo", fallback="").strip()
|
|
||||||
token = config.get("Gitea", "token", fallback="").strip()
|
|
||||||
|
|
||||||
if not all([base_url, owner, repo, token]):
|
|
||||||
logger.debug("Gitea 配置不完整,跳过云端同步")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return cls(base_url=base_url, owner=owner, repo=repo, token=token)
|
|
||||||
@@ -8,14 +8,10 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import messagebox, ttk, simpledialog
|
from tkinter import messagebox, ttk, simpledialog
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.utils.cloud_sync import GiteaSync
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
|
|
||||||
def create_custom_dialog(title="提示", message="", result_file=None, time_info=None,
|
def create_custom_dialog(title="提示", message="", result_file=None, time_info=None,
|
||||||
count_info=None, amount_info=None, additional_info=None):
|
count_info=None, amount_info=None, additional_info=None):
|
||||||
"""
|
"""
|
||||||
@@ -82,12 +78,11 @@ def create_custom_dialog(title="提示", message="", result_file=None, time_info
|
|||||||
file_size = os.path.getsize(result_file)
|
file_size = os.path.getsize(result_file)
|
||||||
file_time = datetime.fromtimestamp(os.path.getmtime(result_file))
|
file_time = datetime.fromtimestamp(os.path.getmtime(result_file))
|
||||||
|
|
||||||
from app.core.utils.file_utils import format_file_size
|
size_text = f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB"
|
||||||
size_text = format_file_size(file_size)
|
|
||||||
|
|
||||||
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
||||||
tk.Label(file_frame, text=f"创建时间: {file_time.strftime('%Y-%m-%d %H:%M:%S')}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
tk.Label(file_frame, text=f"创建时间: {file_time.strftime('%Y-%m-%d %H:%M:%S')}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
||||||
except Exception:
|
except:
|
||||||
tk.Label(file_frame, text="无法获取文件信息", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
tk.Label(file_frame, text="无法获取文件信息", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
||||||
|
|
||||||
# 添加按钮
|
# 添加按钮
|
||||||
@@ -107,7 +102,7 @@ def create_custom_dialog(title="提示", message="", result_file=None, time_info
|
|||||||
button_frame = tk.Frame(dialog)
|
button_frame = tk.Frame(dialog)
|
||||||
button_frame.pack(pady=10)
|
button_frame.pack(pady=10)
|
||||||
|
|
||||||
tk.Button(button_frame, text="打开输出目录", command=lambda: os.startfile(ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True))).pack(side=tk.LEFT, padx=5)
|
tk.Button(button_frame, text="打开输出目录", command=lambda: os.startfile(os.path.abspath("data/output"))).pack(side=tk.LEFT, padx=5)
|
||||||
tk.Button(button_frame, text="关闭", command=dialog.destroy).pack(side=tk.LEFT, padx=5)
|
tk.Button(button_frame, text="关闭", command=dialog.destroy).pack(side=tk.LEFT, padx=5)
|
||||||
|
|
||||||
# 确保窗口显示在最前
|
# 确保窗口显示在最前
|
||||||
@@ -488,135 +483,6 @@ def create_barcode_mapping_dialog(parent=None, on_save=None, current_mappings=No
|
|||||||
cancel_btn = tk.Button(bottom_frame, text="取消", command=cancel)
|
cancel_btn = tk.Button(bottom_frame, text="取消", command=cancel)
|
||||||
cancel_btn.pack(side=tk.RIGHT, padx=5)
|
cancel_btn.pack(side=tk.RIGHT, padx=5)
|
||||||
|
|
||||||
# ---- 云端同步按钮 ----
|
|
||||||
def _build_current_mappings():
|
|
||||||
"""从弹窗当前数据构建 mappings dict(与 save_mappings 逻辑相同)"""
|
|
||||||
mappings = {}
|
|
||||||
for source, target in mapping_list:
|
|
||||||
mappings[source] = {
|
|
||||||
'map_to': target,
|
|
||||||
'description': f'条码映射:{source} -> {target}'
|
|
||||||
}
|
|
||||||
for barcode, multiplier, unit, price, spec, desc in special_list:
|
|
||||||
if barcode not in mappings:
|
|
||||||
mappings[barcode] = {}
|
|
||||||
if multiplier:
|
|
||||||
try:
|
|
||||||
if isinstance(multiplier, str):
|
|
||||||
mappings[barcode]['multiplier'] = float(multiplier) if '.' in multiplier else int(multiplier)
|
|
||||||
else:
|
|
||||||
mappings[barcode]['multiplier'] = multiplier
|
|
||||||
except ValueError:
|
|
||||||
mappings[barcode]['multiplier'] = multiplier
|
|
||||||
if unit:
|
|
||||||
mappings[barcode]['target_unit'] = unit
|
|
||||||
if price:
|
|
||||||
try:
|
|
||||||
mappings[barcode]['fixed_price'] = float(price)
|
|
||||||
except ValueError:
|
|
||||||
mappings[barcode]['fixed_price'] = price
|
|
||||||
if spec:
|
|
||||||
mappings[barcode]['specification'] = spec
|
|
||||||
if desc and "映射到:" in desc:
|
|
||||||
parts = desc.split("映射到:")
|
|
||||||
base_desc = parts[0].strip()
|
|
||||||
target_barcode = parts[1].strip()
|
|
||||||
if base_desc:
|
|
||||||
mappings[barcode]['description'] = base_desc
|
|
||||||
mappings[barcode]['map_to'] = target_barcode
|
|
||||||
elif desc:
|
|
||||||
mappings[barcode]['description'] = desc
|
|
||||||
return mappings
|
|
||||||
|
|
||||||
def _get_sync():
|
|
||||||
"""获取 GiteaSync 实例,配置不完整时提示用户"""
|
|
||||||
sync = GiteaSync.from_config(ConfigManager())
|
|
||||||
if sync is None:
|
|
||||||
messagebox.showwarning("云端同步", "请先在「系统设置」中配置 Gitea 云端同步参数(token)")
|
|
||||||
return sync
|
|
||||||
|
|
||||||
def _refresh_trees(new_mappings):
|
|
||||||
"""用新数据刷新两个 Treeview"""
|
|
||||||
# 清空
|
|
||||||
for item in mapping_tree.get_children():
|
|
||||||
mapping_tree.delete(item)
|
|
||||||
mapping_list.clear()
|
|
||||||
for item in special_tree.get_children():
|
|
||||||
special_tree.delete(item)
|
|
||||||
special_list.clear()
|
|
||||||
# 重新填充
|
|
||||||
if new_mappings:
|
|
||||||
for barcode, data in new_mappings.items():
|
|
||||||
if 'map_to' in data and 'multiplier' not in data:
|
|
||||||
mapping_list.append((barcode, data['map_to']))
|
|
||||||
mapping_tree.insert('', 'end', values=(barcode, data['map_to']))
|
|
||||||
else:
|
|
||||||
mult = data.get('multiplier', '')
|
|
||||||
unit = data.get('target_unit', '')
|
|
||||||
price = data.get('fixed_price', '')
|
|
||||||
spec = data.get('specification', '')
|
|
||||||
desc = data.get('description', '')
|
|
||||||
if 'map_to' in data:
|
|
||||||
desc = f"{desc} 映射到: {data['map_to']}" if desc else f"映射到: {data['map_to']}"
|
|
||||||
special_list.append((barcode, mult, unit, price, spec, desc))
|
|
||||||
tags = ("mapped",) if 'map_to' in data else ()
|
|
||||||
special_tree.insert('', 'end', values=(barcode, mult, unit, price, spec, desc), tags=tags)
|
|
||||||
if any('map_to' in d for d in new_mappings.values()):
|
|
||||||
special_tree.tag_configure("mapped", foreground="blue")
|
|
||||||
|
|
||||||
def push_to_cloud():
|
|
||||||
sync = _get_sync()
|
|
||||||
if not sync:
|
|
||||||
return
|
|
||||||
mappings = _build_current_mappings()
|
|
||||||
if not mappings:
|
|
||||||
messagebox.showwarning("同步到云端", "当前没有映射数据可同步")
|
|
||||||
return
|
|
||||||
# 先获取当前 sha(如果文件已存在)
|
|
||||||
sha = None
|
|
||||||
existing = sync.pull_file("barcode_mappings.json")
|
|
||||||
if existing:
|
|
||||||
sha = existing[1]
|
|
||||||
new_sha = sync.push_json(
|
|
||||||
"barcode_mappings.json",
|
|
||||||
mappings,
|
|
||||||
f"同步条码映射 ({len(mappings)} 条)",
|
|
||||||
sha=sha,
|
|
||||||
)
|
|
||||||
if new_sha:
|
|
||||||
messagebox.showinfo("同步成功", f"已推送 {len(mappings)} 条映射到云端")
|
|
||||||
else:
|
|
||||||
messagebox.showerror("同步失败", "推送到云端失败,请检查网络和 Gitea 配置")
|
|
||||||
|
|
||||||
def pull_from_cloud():
|
|
||||||
sync = _get_sync()
|
|
||||||
if not sync:
|
|
||||||
return
|
|
||||||
result = sync.pull_json("barcode_mappings.json")
|
|
||||||
if result is None:
|
|
||||||
messagebox.showwarning("拉取失败", "云端没有找到条码映射文件,或网络错误")
|
|
||||||
return
|
|
||||||
data, sha = result
|
|
||||||
if not isinstance(data, dict) or len(data) == 0:
|
|
||||||
messagebox.showwarning("拉取失败", "云端数据格式异常")
|
|
||||||
return
|
|
||||||
# 同时保存到本地
|
|
||||||
from app.core.excel.converter import UnitConverter
|
|
||||||
uc = UnitConverter()
|
|
||||||
uc.update_barcode_mappings(data)
|
|
||||||
# 刷新弹窗
|
|
||||||
_refresh_trees(data)
|
|
||||||
messagebox.showinfo("拉取成功", f"已从云端拉取 {len(data)} 条映射,本地已同步更新")
|
|
||||||
|
|
||||||
sync_frame = tk.Frame(bottom_frame)
|
|
||||||
sync_frame.pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
push_btn = tk.Button(sync_frame, text="同步到云端", command=push_to_cloud, fg="white", bg="#4a90d9")
|
|
||||||
push_btn.pack(side=tk.LEFT, padx=3)
|
|
||||||
|
|
||||||
pull_btn = tk.Button(sync_frame, text="从云端拉取", command=pull_from_cloud, fg="white", bg="#5cb85c")
|
|
||||||
pull_btn.pack(side=tk.LEFT, padx=3)
|
|
||||||
|
|
||||||
# 导入当前映射数据
|
# 导入当前映射数据
|
||||||
if current_mappings:
|
if current_mappings:
|
||||||
for barcode, data in current_mappings.items():
|
for barcode, data in current_mappings.items():
|
||||||
@@ -800,362 +666,5 @@ def show_config_dialog(parent, config_manager, on_save=None):
|
|||||||
dialog.transient(parent)
|
dialog.transient(parent)
|
||||||
dialog.grab_set()
|
dialog.grab_set()
|
||||||
|
|
||||||
|
# 等待窗口关闭
|
||||||
# ──────────────────────────────────────────────────────────────
|
parent.wait_window(dialog)
|
||||||
# 云端同步管理对话框
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
SYNC_FILES = [
|
|
||||||
{
|
|
||||||
"name": "条码映射",
|
|
||||||
"remote": "barcode_mappings.json",
|
|
||||||
"local": "config/barcode_mappings.json",
|
|
||||||
"type": "json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "供应商配置",
|
|
||||||
"remote": "suppliers_config.json",
|
|
||||||
"local": "config/suppliers_config.json",
|
|
||||||
"type": "json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "商品资料",
|
|
||||||
"remote": "templates/商品资料.xlsx",
|
|
||||||
"local": "templates/商品资料.xlsx",
|
|
||||||
"type": "binary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "采购单模板",
|
|
||||||
"remote": "templates/银豹-采购单模板.xls",
|
|
||||||
"local": "templates/银豹-采购单模板.xls",
|
|
||||||
"type": "binary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "商品记忆库 (DB)",
|
|
||||||
"remote": "product_cache.db",
|
|
||||||
"local": "data/product_cache.db",
|
|
||||||
"type": "binary",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _format_size(path: str) -> str:
|
|
||||||
try:
|
|
||||||
size = os.path.getsize(path)
|
|
||||||
if size < 1024 * 1024:
|
|
||||||
return f"{size / 1024:.1f} KB"
|
|
||||||
return f"{size / (1024 * 1024):.1f} MB"
|
|
||||||
except OSError:
|
|
||||||
return "—"
|
|
||||||
|
|
||||||
|
|
||||||
def show_cloud_sync_dialog(parent=None):
|
|
||||||
"""统一云端同步管理对话框"""
|
|
||||||
|
|
||||||
sync = GiteaSync.from_config(ConfigManager())
|
|
||||||
if sync is None:
|
|
||||||
messagebox.showwarning(
|
|
||||||
"配置不完整",
|
|
||||||
"请先在「系统设置」中配置 Gitea 地址和 Access Token",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
dlg = tk.Toplevel(parent)
|
|
||||||
dlg.title("云端同步管理")
|
|
||||||
dlg.geometry("620x440")
|
|
||||||
dlg.resizable(False, False)
|
|
||||||
|
|
||||||
# 居中
|
|
||||||
dlg.update_idletasks()
|
|
||||||
x = (dlg.winfo_screenwidth() - 620) // 2
|
|
||||||
y = (dlg.winfo_screenheight() - 440) // 2
|
|
||||||
dlg.geometry(f"620x440+{x}+{y}")
|
|
||||||
|
|
||||||
# ── Treeview ──
|
|
||||||
columns = ("name", "local_status", "cloud_status")
|
|
||||||
tree = ttk.Treeview(dlg, columns=columns, show="headings", height=6)
|
|
||||||
tree.heading("name", text="文件")
|
|
||||||
tree.heading("local_status", text="本地状态")
|
|
||||||
tree.heading("cloud_status", text="云端状态")
|
|
||||||
tree.column("name", width=140)
|
|
||||||
tree.column("local_status", width=220)
|
|
||||||
tree.column("cloud_status", width=220)
|
|
||||||
tree.pack(fill=tk.BOTH, expand=True, padx=16, pady=(16, 8))
|
|
||||||
|
|
||||||
# tag 颜色
|
|
||||||
tree.tag_configure("synced", foreground="#2e7d32")
|
|
||||||
tree.tag_configure("cloud_only", foreground="#e65100")
|
|
||||||
tree.tag_configure("local_only", foreground="#1565c0")
|
|
||||||
tree.tag_configure("missing", foreground="#999999")
|
|
||||||
|
|
||||||
# 用 iid = remote_path 标识每行
|
|
||||||
cloud_sha_cache: dict = {} # remote_path -> sha
|
|
||||||
|
|
||||||
def _load_local_status():
|
|
||||||
"""仅加载本地状态,不发网络请求"""
|
|
||||||
for item in tree.get_children():
|
|
||||||
tree.delete(item)
|
|
||||||
for entry in SYNC_FILES:
|
|
||||||
local = entry["local"]
|
|
||||||
if os.path.exists(local):
|
|
||||||
if entry["type"] == "json":
|
|
||||||
try:
|
|
||||||
with open(local, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
local_text = f"{len(data)} 项"
|
|
||||||
elif isinstance(data, list):
|
|
||||||
local_text = f"{len(data)} 条记录"
|
|
||||||
else:
|
|
||||||
local_text = "已存在"
|
|
||||||
except Exception:
|
|
||||||
local_text = "已存在(解析异常)"
|
|
||||||
else:
|
|
||||||
local_text = _format_size(local)
|
|
||||||
tag = "local_only"
|
|
||||||
else:
|
|
||||||
local_text = "不存在"
|
|
||||||
tag = "missing"
|
|
||||||
tree.insert(
|
|
||||||
"", tk.END,
|
|
||||||
iid=entry["remote"],
|
|
||||||
values=(entry["name"], local_text, "点「刷新状态」检查"),
|
|
||||||
tags=(tag,),
|
|
||||||
)
|
|
||||||
|
|
||||||
def refresh_status():
|
|
||||||
"""刷新每行的本地/云端状态"""
|
|
||||||
cloud_sha_cache.clear()
|
|
||||||
for item in tree.get_children():
|
|
||||||
tree.delete(item)
|
|
||||||
|
|
||||||
for entry in SYNC_FILES:
|
|
||||||
remote = entry["remote"]
|
|
||||||
local = entry["local"]
|
|
||||||
|
|
||||||
# 本地状态
|
|
||||||
if os.path.exists(local):
|
|
||||||
if entry["type"] == "json":
|
|
||||||
try:
|
|
||||||
with open(local, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
local_text = f"{len(data)} 项"
|
|
||||||
elif isinstance(data, list):
|
|
||||||
local_text = f"{len(data)} 条记录"
|
|
||||||
else:
|
|
||||||
local_text = "已存在"
|
|
||||||
except Exception:
|
|
||||||
local_text = "已存在(解析异常)"
|
|
||||||
else:
|
|
||||||
local_text = _format_size(local)
|
|
||||||
else:
|
|
||||||
local_text = "不存在"
|
|
||||||
|
|
||||||
# 云端状态 — 网络请求,可能慢
|
|
||||||
sha = sync.file_exists(remote)
|
|
||||||
if sha:
|
|
||||||
cloud_sha_cache[remote] = sha
|
|
||||||
cloud_text = "已存在"
|
|
||||||
else:
|
|
||||||
cloud_text = "未上传"
|
|
||||||
|
|
||||||
# tag
|
|
||||||
local_ok = os.path.exists(local)
|
|
||||||
cloud_ok = sha is not None
|
|
||||||
if local_ok and cloud_ok:
|
|
||||||
tag = "synced"
|
|
||||||
elif cloud_ok and not local_ok:
|
|
||||||
tag = "cloud_only"
|
|
||||||
elif local_ok and not cloud_ok:
|
|
||||||
tag = "local_only"
|
|
||||||
else:
|
|
||||||
tag = "missing"
|
|
||||||
|
|
||||||
tree.insert(
|
|
||||||
"", tk.END,
|
|
||||||
iid=remote,
|
|
||||||
values=(entry["name"], local_text, cloud_text),
|
|
||||||
tags=(tag,),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 操作函数 ──
|
|
||||||
def _get_selected_entries():
|
|
||||||
"""获取选中的文件条目列表"""
|
|
||||||
selected = tree.selection()
|
|
||||||
if not selected:
|
|
||||||
messagebox.showinfo("提示", "请先选中要操作的文件")
|
|
||||||
return []
|
|
||||||
return [e for e in SYNC_FILES if e["remote"] in selected]
|
|
||||||
|
|
||||||
def push_selected():
|
|
||||||
entries = _get_selected_entries()
|
|
||||||
if not entries:
|
|
||||||
return
|
|
||||||
ok, fail = 0, 0
|
|
||||||
for entry in entries:
|
|
||||||
local, remote = entry["local"], entry["remote"]
|
|
||||||
if not os.path.exists(local):
|
|
||||||
messagebox.showwarning("跳过", f"本地文件不存在: {local}")
|
|
||||||
fail += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if entry["type"] == "json":
|
|
||||||
try:
|
|
||||||
with open(local, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
sha = cloud_sha_cache.get(remote)
|
|
||||||
result = sync.push_json(remote, data, f"同步 {entry['name']}", sha=sha)
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("推送失败", f"{entry['name']}: {e}")
|
|
||||||
fail += 1
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
result = sync.push_binary(remote, local, f"同步 {entry['name']}")
|
|
||||||
|
|
||||||
if result:
|
|
||||||
ok += 1
|
|
||||||
else:
|
|
||||||
fail += 1
|
|
||||||
|
|
||||||
if ok:
|
|
||||||
messagebox.showinfo("推送完成", f"成功 {ok} 个" + (f",失败 {fail} 个" if fail else ""))
|
|
||||||
refresh_status()
|
|
||||||
|
|
||||||
def pull_selected():
|
|
||||||
entries = _get_selected_entries()
|
|
||||||
if not entries:
|
|
||||||
return
|
|
||||||
ok, fail = 0, 0
|
|
||||||
for entry in entries:
|
|
||||||
remote, local = entry["remote"], entry["local"]
|
|
||||||
|
|
||||||
if entry["type"] == "json":
|
|
||||||
result = sync.pull_json(remote)
|
|
||||||
if result is None:
|
|
||||||
messagebox.showwarning("拉取失败", f"云端文件不存在: {entry['name']}")
|
|
||||||
fail += 1
|
|
||||||
continue
|
|
||||||
content, sha = result
|
|
||||||
# 写入本地
|
|
||||||
os.makedirs(os.path.dirname(local) or ".", exist_ok=True)
|
|
||||||
with open(local, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(content, f, ensure_ascii=False, indent=2)
|
|
||||||
# 特殊后处理
|
|
||||||
_post_pull(entry, content)
|
|
||||||
else:
|
|
||||||
result = sync.pull_file(remote)
|
|
||||||
if result is None:
|
|
||||||
messagebox.showwarning("拉取失败", f"云端文件不存在: {entry['name']}")
|
|
||||||
fail += 1
|
|
||||||
continue
|
|
||||||
content, sha = result
|
|
||||||
os.makedirs(os.path.dirname(local) or ".", exist_ok=True)
|
|
||||||
with open(local, "wb") as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
ok += 1
|
|
||||||
|
|
||||||
if ok:
|
|
||||||
messagebox.showinfo("拉取完成", f"成功 {ok} 个" + (f",失败 {fail} 个" if fail else ""))
|
|
||||||
refresh_status()
|
|
||||||
|
|
||||||
def _post_pull(entry, data):
|
|
||||||
"""拉取 JSON 文件后的特殊处理"""
|
|
||||||
if entry["remote"] == "barcode_mappings.json":
|
|
||||||
try:
|
|
||||||
from app.core.excel.converter import UnitConverter
|
|
||||||
UnitConverter().update_barcode_mappings(data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif entry["remote"] == "suppliers_config.json":
|
|
||||||
try:
|
|
||||||
from app.services.processor_service import ProcessorService
|
|
||||||
ProcessorService(ConfigManager()).reload_processors()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif entry["remote"] == "product_memory.json":
|
|
||||||
try:
|
|
||||||
from app.core.db.product_db import ProductDatabase
|
|
||||||
cfg = ConfigManager()
|
|
||||||
db_path = cfg.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(cfg, 'get_path') else 'data/product_cache.db'
|
|
||||||
tpl_folder = cfg.get('Paths', 'template_folder', fallback='templates')
|
|
||||||
item_data = cfg.get('Templates', 'item_data', fallback='商品资料.xlsx')
|
|
||||||
tpl_path = os.path.join(tpl_folder, item_data)
|
|
||||||
db = ProductDatabase(db_path, tpl_path)
|
|
||||||
count = db.import_from_sync(data)
|
|
||||||
logger.info(f"从云端导入商品记忆: {count} 条")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def push_all():
|
|
||||||
ok, fail = 0, 0
|
|
||||||
for entry in SYNC_FILES:
|
|
||||||
local, remote = entry["local"], entry["remote"]
|
|
||||||
if not os.path.exists(local):
|
|
||||||
fail += 1
|
|
||||||
continue
|
|
||||||
if entry["type"] == "json":
|
|
||||||
try:
|
|
||||||
with open(local, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
sha = cloud_sha_cache.get(remote)
|
|
||||||
result = sync.push_json(remote, data, f"批量同步 {entry['name']}", sha=sha)
|
|
||||||
except Exception:
|
|
||||||
fail += 1
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
result = sync.push_binary(remote, local, f"批量同步 {entry['name']}")
|
|
||||||
if result:
|
|
||||||
ok += 1
|
|
||||||
else:
|
|
||||||
fail += 1
|
|
||||||
messagebox.showinfo("批量推送完成", f"成功 {ok} 个,失败 {fail} 个")
|
|
||||||
refresh_status()
|
|
||||||
|
|
||||||
def pull_all():
|
|
||||||
ok, fail = 0, 0
|
|
||||||
for entry in SYNC_FILES:
|
|
||||||
remote, local = entry["remote"], entry["local"]
|
|
||||||
if entry["type"] == "json":
|
|
||||||
result = sync.pull_json(remote)
|
|
||||||
if result is None:
|
|
||||||
fail += 1
|
|
||||||
continue
|
|
||||||
content, sha = result
|
|
||||||
os.makedirs(os.path.dirname(local) or ".", exist_ok=True)
|
|
||||||
with open(local, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(content, f, ensure_ascii=False, indent=2)
|
|
||||||
_post_pull(entry, content)
|
|
||||||
else:
|
|
||||||
result = sync.pull_file(remote)
|
|
||||||
if result is None:
|
|
||||||
fail += 1
|
|
||||||
continue
|
|
||||||
content, sha = result
|
|
||||||
os.makedirs(os.path.dirname(local) or ".", exist_ok=True)
|
|
||||||
with open(local, "wb") as f:
|
|
||||||
f.write(content)
|
|
||||||
ok += 1
|
|
||||||
messagebox.showinfo("批量拉取完成", f"成功 {ok} 个,失败 {fail} 个")
|
|
||||||
refresh_status()
|
|
||||||
|
|
||||||
# ── 按钮区域 ──
|
|
||||||
btn_frame = ttk.Frame(dlg)
|
|
||||||
btn_frame.pack(fill=tk.X, padx=16, pady=(4, 16))
|
|
||||||
|
|
||||||
# 左侧:批量操作
|
|
||||||
ttk.Button(btn_frame, text="全部推送到云端", command=push_all).pack(side=tk.LEFT, padx=4)
|
|
||||||
ttk.Button(btn_frame, text="全部从云端拉取", command=pull_all).pack(side=tk.LEFT, padx=4)
|
|
||||||
|
|
||||||
# 右侧:选中操作 + 刷新 + 关闭
|
|
||||||
ttk.Button(btn_frame, text="关闭", command=dlg.destroy).pack(side=tk.RIGHT, padx=4)
|
|
||||||
ttk.Button(btn_frame, text="刷新状态", command=refresh_status).pack(side=tk.RIGHT, padx=4)
|
|
||||||
tk.Button(btn_frame, text="推送到云端", command=push_selected, fg="white", bg="#4a90d9").pack(side=tk.RIGHT, padx=4)
|
|
||||||
tk.Button(btn_frame, text="从云端拉取", command=pull_selected, fg="white", bg="#5cb85c").pack(side=tk.RIGHT, padx=4)
|
|
||||||
|
|
||||||
# 仅显示本地状态,云端状态需手动点"刷新状态"
|
|
||||||
_load_local_status()
|
|
||||||
|
|
||||||
dlg.transient(parent)
|
|
||||||
dlg.grab_set()
|
|
||||||
@@ -12,7 +12,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Union, Any
|
from typing import Dict, List, Optional, Union, Any
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
from .log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -277,10 +277,3 @@ def is_file_size_valid(file_path: str, max_size_mb: float) -> bool:
|
|||||||
size_bytes = get_file_size(file_path)
|
size_bytes = get_file_size(file_path)
|
||||||
max_size_bytes = max_size_mb * 1024 * 1024
|
max_size_bytes = max_size_mb * 1024 * 1024
|
||||||
return size_bytes <= max_size_bytes
|
return size_bytes <= max_size_bytes
|
||||||
|
|
||||||
|
|
||||||
def format_file_size(size_bytes: int) -> str:
|
|
||||||
"""将字节数格式化为可读的文件大小字符串(KB/MB)"""
|
|
||||||
if size_bytes < 1024 * 1024:
|
|
||||||
return f"{size_bytes / 1024:.1f} KB"
|
|
||||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Tuple, Any
|
from typing import Dict, List, Optional, Tuple, Any, Match, Pattern
|
||||||
|
|
||||||
def clean_string(text: str) -> str:
|
def clean_string(text: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -192,51 +192,6 @@ def is_scientific_notation(value: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
return bool(re.match(r'^-?\d+(\.\d+)?[eE][+-]?\d+$', str(value)))
|
return bool(re.match(r'^-?\d+(\.\d+)?[eE][+-]?\d+$', str(value)))
|
||||||
|
|
||||||
def parse_monetary_string(value: Any) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
解析金额/数量字符串为浮点数。
|
|
||||||
处理: 货币符号(¥/$)、逗号作小数点、逗号作千位分隔符、中文"元"后缀等。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: 金额值(字符串、数字或其他类型)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
解析后的浮点数,无法解析则返回 None
|
|
||||||
"""
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
return float(value)
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
|
|
||||||
s = value.strip()
|
|
||||||
if not s or s.lower() in ('o', 'none', 'null', '-', '--'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 移除非数字字符,保留数字、小数点、逗号和负号
|
|
||||||
cleaned = re.sub(r'[^\d\.\-,]', '', s)
|
|
||||||
if not cleaned or cleaned in ('-', '.', '-.', ','):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 逗号处理策略:
|
|
||||||
# 多个逗号 -> 千位分隔符,全部移除 (如 "1,234,567" = 1234567)
|
|
||||||
# 一个逗号 + 无小数点 -> 逗号当小数点 (如 "1,5" = 1.5)
|
|
||||||
# 一个逗号 + 有小数点 -> 千位分隔符,移除 (如 "1,234.56" = 1234.56)
|
|
||||||
comma_count = cleaned.count(',')
|
|
||||||
if comma_count > 1:
|
|
||||||
cleaned = cleaned.replace(',', '')
|
|
||||||
elif comma_count == 1 and '.' not in cleaned:
|
|
||||||
cleaned = cleaned.replace(',', '.')
|
|
||||||
elif comma_count == 1 and '.' in cleaned:
|
|
||||||
cleaned = cleaned.replace(',', '')
|
|
||||||
|
|
||||||
try:
|
|
||||||
return float(cleaned)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def format_barcode(barcode: Any) -> str:
|
def format_barcode(barcode: Any) -> str:
|
||||||
"""
|
"""
|
||||||
格式化条码,处理科学计数法
|
格式化条码,处理科学计数法
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
"""批量处理服务:扫描 data/input/ 下图片,串行跑完整 OCR → 采购单流程。
|
|
||||||
|
|
||||||
- 不合并(每张图片单独出一个 result)
|
|
||||||
- 默认串行(避免百度 OCR 限流)
|
|
||||||
- 返回 dict 汇总:total / success / failed / results[]
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
from app.core.utils.log_utils import get_logger
|
|
||||||
from app.services.ocr_service import OCRService
|
|
||||||
from app.services.order_service import OrderService
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.bmp', '.webp', '.tif', '.tiff'}
|
|
||||||
|
|
||||||
|
|
||||||
class BatchService:
|
|
||||||
"""批量处理服务。"""
|
|
||||||
|
|
||||||
def __init__(self, config: Optional[ConfigManager] = None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None):
|
|
||||||
self.config = config or ConfigManager()
|
|
||||||
self.missing_barcodes_cb = missing_barcodes_cb
|
|
||||||
self.ocr_service = OCRService(self.config)
|
|
||||||
self.order_service = OrderService(self.config, missing_barcodes_cb=self.missing_barcodes_cb)
|
|
||||||
self._input_folder = self.config.get_path(
|
|
||||||
'Paths', 'input_folder', fallback='data/input', create=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def input_folder(self) -> str:
|
|
||||||
return self._input_folder
|
|
||||||
|
|
||||||
def list_input_images(self) -> List[str]:
|
|
||||||
"""列出 data/input/ 下所有待处理图片(按 mtime 升序,先入先出)。"""
|
|
||||||
if not os.path.isdir(self._input_folder):
|
|
||||||
return []
|
|
||||||
files = []
|
|
||||||
for name in os.listdir(self._input_folder):
|
|
||||||
p = os.path.join(self._input_folder, name)
|
|
||||||
if not os.path.isfile(p):
|
|
||||||
continue
|
|
||||||
ext = os.path.splitext(name)[1].lower()
|
|
||||||
if ext in IMAGE_EXTS:
|
|
||||||
files.append(p)
|
|
||||||
files.sort(key=lambda p: os.path.getmtime(p))
|
|
||||||
return files
|
|
||||||
|
|
||||||
def process_all_inputs(
|
|
||||||
self,
|
|
||||||
progress_cb: Optional[Callable[[int, int, dict], None]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""批量处理 data/input/ 下全部图片。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_cb: 可选回调 fn(done_count, total_count, latest_result_dict)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
'total': int,
|
|
||||||
'success': int,
|
|
||||||
'failed': int,
|
|
||||||
'results': [
|
|
||||||
{
|
|
||||||
'image': str,
|
|
||||||
'hash': str,
|
|
||||||
'status': 'success' | 'failed',
|
|
||||||
'supplier': str,
|
|
||||||
'bill_date': str,
|
|
||||||
'total_amount': float,
|
|
||||||
'result_file': str | None,
|
|
||||||
'error': str | None,
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
images = self.list_input_images()
|
|
||||||
total = len(images)
|
|
||||||
results: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
logger.info("data/input/ 下无待处理图片")
|
|
||||||
return {'total': 0, 'success': 0, 'failed': 0, 'results': []}
|
|
||||||
|
|
||||||
logger.info(f"批量处理开始: 共 {total} 张图片")
|
|
||||||
success = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
for idx, image_path in enumerate(images, start=1):
|
|
||||||
entry: Dict[str, Any] = {
|
|
||||||
'image': image_path,
|
|
||||||
'hash': '',
|
|
||||||
'status': 'failed',
|
|
||||||
'supplier': '',
|
|
||||||
'bill_date': '',
|
|
||||||
'total_amount': 0.0,
|
|
||||||
'result_file': None,
|
|
||||||
'error': None,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
file_hash = Path(image_path).stem
|
|
||||||
entry['hash'] = file_hash
|
|
||||||
|
|
||||||
# 1) OCR(写 data/output/{hash}.xlsx + .meta.json)
|
|
||||||
excel_path = self.ocr_service.process_image(image_path)
|
|
||||||
if not excel_path:
|
|
||||||
entry['error'] = 'OCR 失败'
|
|
||||||
failed += 1
|
|
||||||
results.append(entry)
|
|
||||||
if progress_cb:
|
|
||||||
progress_cb(idx, total, entry)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 2) process_excel(识别元信息 + 重命名 + 落库)
|
|
||||||
result_file = self.order_service.process_excel(excel_path)
|
|
||||||
if not result_file:
|
|
||||||
entry['error'] = '处理失败'
|
|
||||||
failed += 1
|
|
||||||
results.append(entry)
|
|
||||||
if progress_cb:
|
|
||||||
progress_cb(idx, total, entry)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 3) 回查元信息(process_excel 内部已落库)
|
|
||||||
meta_row = self.order_service.metadata_db.get(file_hash) or {}
|
|
||||||
entry['result_file'] = result_file
|
|
||||||
entry['supplier'] = meta_row.get('supplier', '') or ''
|
|
||||||
entry['bill_date'] = meta_row.get('bill_date', '') or ''
|
|
||||||
entry['total_amount'] = float(meta_row.get('total_amount') or 0.0)
|
|
||||||
entry['status'] = 'success'
|
|
||||||
success += 1
|
|
||||||
logger.info(
|
|
||||||
f"[{idx}/{total}] 处理完成: {Path(image_path).name} -> "
|
|
||||||
f"{Path(result_file).name} | 供应商={entry['supplier']!r} "
|
|
||||||
f"日期={entry['bill_date']!r} 金额={entry['total_amount']:.2f}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[{idx}/{total}] 处理异常: {image_path}: {e}", exc_info=True)
|
|
||||||
entry['error'] = str(e)
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
results.append(entry)
|
|
||||||
if progress_cb:
|
|
||||||
progress_cb(idx, total, entry)
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
'total': total,
|
|
||||||
'success': success,
|
|
||||||
'failed': failed,
|
|
||||||
'results': results,
|
|
||||||
}
|
|
||||||
logger.info(
|
|
||||||
f"批量处理完成: 总 {total} 张, 成功 {success}, 失败 {failed}"
|
|
||||||
)
|
|
||||||
return summary
|
|
||||||
+11
-138
@@ -7,9 +7,9 @@ OCR服务模块
|
|||||||
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
from ..config.settings import ConfigManager
|
||||||
from app.core.utils.log_utils import get_logger
|
from ..core.utils.log_utils import get_logger
|
||||||
from app.core.ocr.table_ocr import OCRProcessor
|
from ..core.ocr.table_ocr import OCRProcessor
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -63,9 +63,11 @@ class OCRService:
|
|||||||
logger.error(f"不支持的文件类型: {image_path}")
|
logger.error(f"不支持的文件类型: {image_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 不再做 xlsx 已存在就跳过的判断(xlsx 可能被业务层重命名过),
|
# 检查是否已处理
|
||||||
# 跳过逻辑交给 OCRProcessor 内部的 record_manager.is_processed(image_path)
|
excel_file = self._get_excel_path(image_path)
|
||||||
# —— 由它读 processed_files.json(业务层会同步更新 xlsx 路径)
|
if os.path.exists(excel_file):
|
||||||
|
logger.info(f"文件已处理过,跳过OCR识别: {image_path}")
|
||||||
|
return excel_file
|
||||||
|
|
||||||
# 执行OCR识别
|
# 执行OCR识别
|
||||||
result = self.ocr_processor.process_image(image_path)
|
result = self.ocr_processor.process_image(image_path)
|
||||||
@@ -79,9 +81,6 @@ class OCRService:
|
|||||||
logger.error(f"生成Excel文件失败: {image_path}")
|
logger.error(f"生成Excel文件失败: {image_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 写 meta.json(从 ocr_client 再做一次通用文字识别获取抬头/日期)
|
|
||||||
self._write_meta_json(excel_file, {}, image_path)
|
|
||||||
|
|
||||||
logger.info(f"处理完成: {image_path} -> {excel_file}")
|
logger.info(f"处理完成: {image_path} -> {excel_file}")
|
||||||
return excel_file
|
return excel_file
|
||||||
|
|
||||||
@@ -155,7 +154,7 @@ class OCRService:
|
|||||||
# 获取文件名(不含扩展名)
|
# 获取文件名(不含扩展名)
|
||||||
base_name = os.path.splitext(os.path.basename(image_path))[0]
|
base_name = os.path.splitext(os.path.basename(image_path))[0]
|
||||||
# 生成Excel文件路径
|
# 生成Excel文件路径
|
||||||
output_dir = self.config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/output')
|
output_dir = self.config.get('Paths', 'output_folder', fallback='data/output')
|
||||||
excel_path = os.path.join(output_dir, f"{base_name}.xlsx")
|
excel_path = os.path.join(output_dir, f"{base_name}.xlsx")
|
||||||
return excel_path
|
return excel_path
|
||||||
|
|
||||||
@@ -182,7 +181,8 @@ class OCRService:
|
|||||||
if success:
|
if success:
|
||||||
return excel_path
|
return excel_path
|
||||||
else:
|
else:
|
||||||
# OCR 处理器(table_ocr.OCRProcessor)已直接生成 xlsx
|
# 如果OCR处理器没有generate_excel方法,直接返回路径
|
||||||
|
# 假设OCR处理器已经生成了Excel文件
|
||||||
if os.path.exists(excel_path):
|
if os.path.exists(excel_path):
|
||||||
return excel_path
|
return excel_path
|
||||||
|
|
||||||
@@ -191,130 +191,3 @@ class OCRService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"生成Excel文件时发生错误: {e}", exc_info=True)
|
logger.error(f"生成Excel文件时发生错误: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _write_meta_json(self, excel_path: str, ocr_result: dict, image_path: str) -> None:
|
|
||||||
"""从 OCR 结果中抽取原始文本,写入与 Excel 同名的 .meta.json。
|
|
||||||
|
|
||||||
用途:供元信息提取器(OrderMetadataExtractor)使用,无需重新 OCR。
|
|
||||||
失败不影响主流程。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
base = Path(excel_path)
|
|
||||||
meta_path = base.with_suffix('.meta.json')
|
|
||||||
|
|
||||||
# 0) 优先:调百度通用票务识别(/general_ocr),精准捕获表头(含手写抬头/日期)
|
|
||||||
general_text = ''
|
|
||||||
general_lines = []
|
|
||||||
try:
|
|
||||||
client = getattr(self.ocr_processor, 'ocr_client', None)
|
|
||||||
if client is None:
|
|
||||||
try:
|
|
||||||
self.ocr_processor._ensure_ocr_client()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
client = getattr(self.ocr_processor, 'ocr_client', None)
|
|
||||||
|
|
||||||
# 关键:在进行第二次 OCR (票务识别) 前稍微等待,避免触发 QPS 限制
|
|
||||||
import time
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
# 优先使用票务识别 (Ticket OCR)
|
|
||||||
if client and hasattr(client, 'recognize_ticket') and image_path and os.path.exists(str(image_path)):
|
|
||||||
logger.info("使用通用票务识别捕获表头元数据...")
|
|
||||||
words = client.recognize_ticket(str(image_path))
|
|
||||||
if words:
|
|
||||||
general_lines = [(w.get('words') or '').strip() for w in words if (w.get('words') or '').strip()]
|
|
||||||
general_text = '\n'.join(general_lines)
|
|
||||||
logger.info(f"票务识别获取 {len(general_lines)} 行文字")
|
|
||||||
|
|
||||||
# 如果票务识别无结果或不可用,则兜底使用高精度文字识别
|
|
||||||
if not general_lines and client and hasattr(client, 'recognize_general') and image_path and os.path.exists(str(image_path)):
|
|
||||||
logger.info("尝试高精度文字识别作为兜底...")
|
|
||||||
words = client.recognize_general(str(image_path))
|
|
||||||
if words:
|
|
||||||
def _top(w):
|
|
||||||
loc = w.get('location') or {}
|
|
||||||
try:
|
|
||||||
return float(loc.get('top', 0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 0.0
|
|
||||||
words_sorted = sorted(words, key=_top)
|
|
||||||
general_lines = [(w.get('words') or '').strip() for w in words_sorted if (w.get('words') or '').strip()]
|
|
||||||
general_text = '\n'.join(general_lines)
|
|
||||||
logger.info(f"通用高精度识别获取 {len(general_lines)} 行文字")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"获取元数据识别失败(不影响主流程): {e}")
|
|
||||||
|
|
||||||
# 1) 表格识别 header/body 拼表内文字(已在前面逻辑处理)
|
|
||||||
ocr_text = ''
|
|
||||||
ocr_rows = []
|
|
||||||
for key in ('text', 'raw_text', 'ocr_text', 'content'):
|
|
||||||
v = ocr_result.get(key)
|
|
||||||
if isinstance(v, str) and v.strip():
|
|
||||||
ocr_text = v
|
|
||||||
break
|
|
||||||
|
|
||||||
# 2) 如果没有,尝试从 tables_result 拼
|
|
||||||
if not ocr_text and isinstance(ocr_result, dict):
|
|
||||||
tables = ocr_result.get('tables_result') or []
|
|
||||||
if isinstance(tables, list):
|
|
||||||
lines = []
|
|
||||||
for t in tables:
|
|
||||||
if not isinstance(t, dict):
|
|
||||||
continue
|
|
||||||
# 关键:从 header / body / footer 都提取,header 含供应商/日期抬头
|
|
||||||
for region in ('header', 'body', 'footer'):
|
|
||||||
region_data = t.get(region) or []
|
|
||||||
if not isinstance(region_data, list):
|
|
||||||
continue
|
|
||||||
for cell in region_data:
|
|
||||||
if not isinstance(cell, dict):
|
|
||||||
continue
|
|
||||||
words = cell.get('words') or cell.get('word') or ''
|
|
||||||
if not words:
|
|
||||||
continue
|
|
||||||
row = cell.get('row') or []
|
|
||||||
col = cell.get('column') or []
|
|
||||||
# 按行归一化拼成文本
|
|
||||||
if isinstance(row, list) and row:
|
|
||||||
row_key = f"r{row[0]}"
|
|
||||||
else:
|
|
||||||
row_key = f"r{len(lines)}"
|
|
||||||
# 简化:直接每行一个 cell
|
|
||||||
lines.append(str(words).strip())
|
|
||||||
ocr_rows.append([str(words)])
|
|
||||||
if not ocr_text and lines:
|
|
||||||
ocr_text = '\n'.join(lines)
|
|
||||||
|
|
||||||
# 3) 兜底:从 Excel 文件读 cell 拼
|
|
||||||
if not ocr_text:
|
|
||||||
try:
|
|
||||||
import xlrd
|
|
||||||
rb = xlrd.open_workbook(str(excel_path))
|
|
||||||
ws = rb.sheet_by_index(0)
|
|
||||||
lines = []
|
|
||||||
for r in range(ws.nrows):
|
|
||||||
row_vals = [str(ws.cell_value(r, c)) for c in range(ws.ncols)]
|
|
||||||
ocr_rows.append(row_vals)
|
|
||||||
lines.append(' '.join(row_vals))
|
|
||||||
ocr_text = '\n'.join(lines)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"从 xlsx 读 OCR 文本失败: {e}")
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'excel_path': str(excel_path),
|
|
||||||
'image_path': str(image_path),
|
|
||||||
'ocr_text': ocr_text,
|
|
||||||
'ocr_rows': ocr_rows,
|
|
||||||
'general_text': general_text,
|
|
||||||
'general_lines': general_lines,
|
|
||||||
'created_at': __import__('datetime').datetime.now().isoformat(timespec='seconds'),
|
|
||||||
}
|
|
||||||
meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
||||||
logger.debug(f"meta.json 已写入: {meta_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"写 meta.json 失败(不影响主流程): {e}")
|
|
||||||
|
|||||||
+54
-387
@@ -5,20 +5,12 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
from ..config.settings import ConfigManager
|
||||||
from app.core.utils.log_utils import get_logger
|
from ..core.utils.log_utils import get_logger
|
||||||
from app.core.excel.processor import ExcelProcessor
|
from ..core.excel.processor import ExcelProcessor
|
||||||
from app.core.excel.merger import PurchaseOrderMerger
|
from ..core.excel.merger import PurchaseOrderMerger
|
||||||
from app.core.db.product_db import ProductDatabase
|
|
||||||
from app.core.db.order_metadata_db import OrderMetadataDB
|
|
||||||
from app.core.ocr.metadata_extractor import OrderMetadataExtractor, sanitize_for_filename
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -27,37 +19,20 @@ class OrderService:
|
|||||||
订单服务:协调Excel处理和订单合并流程
|
订单服务:协调Excel处理和订单合并流程
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: Optional[ConfigManager] = None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None):
|
def __init__(self, config: Optional[ConfigManager] = None):
|
||||||
"""
|
"""
|
||||||
初始化订单服务
|
初始化订单服务
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: 配置管理器,如果为None则创建新的
|
config: 配置管理器,如果为None则创建新的
|
||||||
missing_barcodes_cb: 缺失条码的回调函数
|
|
||||||
"""
|
"""
|
||||||
logger.info("初始化OrderService")
|
logger.info("初始化OrderService")
|
||||||
self.config = config or ConfigManager()
|
self.config = config or ConfigManager()
|
||||||
self.missing_barcodes_cb = missing_barcodes_cb
|
|
||||||
|
|
||||||
# 创建共享的商品数据库实例
|
|
||||||
db_path = self.config.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(self.config, 'get_path') else 'data/product_cache.db'
|
|
||||||
tpl_folder = self.config.get('Paths', 'template_folder', fallback='templates')
|
|
||||||
item_data = self.config.get('Templates', 'item_data', fallback='商品资料.xlsx')
|
|
||||||
tpl_path = os.path.join(tpl_folder, item_data)
|
|
||||||
self.product_db = ProductDatabase(db_path, tpl_path)
|
|
||||||
|
|
||||||
# 创建Excel处理器和采购单合并器
|
# 创建Excel处理器和采购单合并器
|
||||||
self.excel_processor = ExcelProcessor(
|
self.excel_processor = ExcelProcessor(self.config)
|
||||||
self.config,
|
|
||||||
product_db=self.product_db,
|
|
||||||
missing_barcodes_cb=self.missing_barcodes_cb
|
|
||||||
)
|
|
||||||
self.order_merger = PurchaseOrderMerger(self.config)
|
self.order_merger = PurchaseOrderMerger(self.config)
|
||||||
|
|
||||||
# 元信息识别器 + 单据元信息库
|
|
||||||
self.extractor = OrderMetadataExtractor()
|
|
||||||
self.metadata_db = OrderMetadataDB(db_path)
|
|
||||||
|
|
||||||
logger.info("OrderService初始化完成")
|
logger.info("OrderService初始化完成")
|
||||||
|
|
||||||
def get_latest_excel(self) -> Optional[str]:
|
def get_latest_excel(self) -> Optional[str]:
|
||||||
@@ -90,7 +65,7 @@ class OrderService:
|
|||||||
|
|
||||||
# 检查是否需要特殊的供应商预处理(如杨碧月)
|
# 检查是否需要特殊的供应商预处理(如杨碧月)
|
||||||
try:
|
try:
|
||||||
from app.services.special_suppliers_service import SpecialSuppliersService
|
from .special_suppliers_service import SpecialSuppliersService
|
||||||
special_service = SpecialSuppliersService(self.config)
|
special_service = SpecialSuppliersService(self.config)
|
||||||
|
|
||||||
# 尝试识别并预处理(注意:这里不再传入 progress_cb 避免无限递归或重复进度条,
|
# 尝试识别并预处理(注意:这里不再传入 progress_cb 避免无限递归或重复进度条,
|
||||||
@@ -106,21 +81,7 @@ class OrderService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"检查特殊预处理时出错: {e}")
|
logger.error(f"检查特殊预处理时出错: {e}")
|
||||||
|
|
||||||
result_path = self.excel_processor.process_specific_file(file_path, progress_cb=progress_cb)
|
return self.excel_processor.process_specific_file(file_path, progress_cb=progress_cb)
|
||||||
if not result_path:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 应用单据元信息识别 + 重命名 result 与原图
|
|
||||||
try:
|
|
||||||
meta = self._extract_and_save_metadata(file_path)
|
|
||||||
if meta:
|
|
||||||
result_path = self._apply_metadata_to_filenames(
|
|
||||||
result_path, file_path, meta
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"应用单据元信息失败(不影响 result 文件): {e}")
|
|
||||||
|
|
||||||
return result_path
|
|
||||||
|
|
||||||
def _check_special_preprocess(self, file_path: str) -> Optional[str]:
|
def _check_special_preprocess(self, file_path: str) -> Optional[str]:
|
||||||
"""检查并执行特殊的预处理(支持杨碧月、烟草公司、蓉城易购)"""
|
"""检查并执行特殊的预处理(支持杨碧月、烟草公司、蓉城易购)"""
|
||||||
@@ -138,7 +99,7 @@ class OrderService:
|
|||||||
is_tobacco = df_str.apply(lambda x: x.str.contains('专卖证号|510109104938')).any().any()
|
is_tobacco = df_str.apply(lambda x: x.str.contains('专卖证号|510109104938')).any().any()
|
||||||
if is_tobacco:
|
if is_tobacco:
|
||||||
logger.info("识别到烟草公司订单,执行专用预处理...")
|
logger.info("识别到烟草公司订单,执行专用预处理...")
|
||||||
from app.services.tobacco_service import TobaccoService
|
from .tobacco_service import TobaccoService
|
||||||
tobacco_svc = TobaccoService(self.config)
|
tobacco_svc = TobaccoService(self.config)
|
||||||
return tobacco_svc.preprocess_tobacco_order(file_path)
|
return tobacco_svc.preprocess_tobacco_order(file_path)
|
||||||
|
|
||||||
@@ -147,7 +108,7 @@ class OrderService:
|
|||||||
is_rongcheng = df_str.apply(lambda x: x.str.contains('RCDH')).any().any()
|
is_rongcheng = df_str.apply(lambda x: x.str.contains('RCDH')).any().any()
|
||||||
if is_rongcheng:
|
if is_rongcheng:
|
||||||
logger.info("识别到蓉城易购订单,执行专用预处理...")
|
logger.info("识别到蓉城易购订单,执行专用预处理...")
|
||||||
from app.services.special_suppliers_service import SpecialSuppliersService
|
from .special_suppliers_service import SpecialSuppliersService
|
||||||
special_svc = SpecialSuppliersService(self.config)
|
special_svc = SpecialSuppliersService(self.config)
|
||||||
return special_svc.preprocess_rongcheng_yigou(file_path)
|
return special_svc.preprocess_rongcheng_yigou(file_path)
|
||||||
|
|
||||||
@@ -164,7 +125,7 @@ class OrderService:
|
|||||||
# 检查该列是否有“杨碧月”
|
# 检查该列是否有“杨碧月”
|
||||||
if df_head[handler_col].astype(str).str.contains('杨碧月').any():
|
if df_head[handler_col].astype(str).str.contains('杨碧月').any():
|
||||||
logger.info("识别到杨碧月订单,执行专用预处理...")
|
logger.info("识别到杨碧月订单,执行专用预处理...")
|
||||||
from app.services.special_suppliers_service import SpecialSuppliersService
|
from .special_suppliers_service import SpecialSuppliersService
|
||||||
special_svc = SpecialSuppliersService(self.config)
|
special_svc = SpecialSuppliersService(self.config)
|
||||||
return special_svc.process_yang_biyue_only(file_path)
|
return special_svc.process_yang_biyue_only(file_path)
|
||||||
|
|
||||||
@@ -233,352 +194,58 @@ class OrderService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import os
|
||||||
from app.core.utils.file_utils import smart_read_excel
|
from app.core.utils.file_utils import smart_read_excel
|
||||||
from app.core.handlers.column_mapper import ColumnMapper as CM
|
|
||||||
|
|
||||||
# 使用共享的商品数据库实例
|
item_path = os.path.join('templates', '商品资料.xlsx')
|
||||||
product_db = self.product_db
|
if not os.path.exists(item_path):
|
||||||
|
logger.warning(f"未找到商品资料文件: {item_path}")
|
||||||
# 读取待校验的采购单
|
|
||||||
df_res = smart_read_excel(result_path)
|
|
||||||
|
|
||||||
res_barcode_col = CM.find_column(list(df_res.columns), 'barcode')
|
|
||||||
res_price_col = CM.find_column(list(df_res.columns), 'unit_price')
|
|
||||||
|
|
||||||
if not res_barcode_col or not res_price_col:
|
|
||||||
logger.warning("未能在采购单中找到条码或单价列")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 批量查询进货价
|
df_item = smart_read_excel(item_path)
|
||||||
barcodes = df_res[res_barcode_col].astype(str).str.strip().tolist()
|
df_res = smart_read_excel(result_path)
|
||||||
item_prices = product_db.get_prices(barcodes)
|
|
||||||
|
def _find_col(df, candidates, contains=None):
|
||||||
|
cols = list(df.columns)
|
||||||
|
for c in candidates:
|
||||||
|
if c in cols:
|
||||||
|
return c
|
||||||
|
if contains:
|
||||||
|
for c in cols:
|
||||||
|
if contains in str(c):
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
item_barcode_col = _find_col(df_item, ['商品条码','商品条码(小条码)','条码','barcode'], contains='条码')
|
||||||
|
item_price_col = _find_col(df_item, ['进货价','进货价(必填)'], contains='进货价')
|
||||||
|
res_barcode_col = _find_col(df_res, ['条码','barcode'], contains='条码')
|
||||||
|
res_price_col = _find_col(df_res, ['采购单价','unit_price','单价'], contains='单价')
|
||||||
|
|
||||||
|
if not all([item_barcode_col, item_price_col, res_barcode_col, res_price_col]):
|
||||||
|
logger.warning("未能在文件和商品资料中找到完整的校验列(条码、单价)")
|
||||||
|
return []
|
||||||
|
|
||||||
|
item_map = df_item[[item_barcode_col, item_price_col]].dropna()
|
||||||
|
item_map[item_price_col] = pd.to_numeric(item_map[item_price_col], errors='coerce')
|
||||||
|
item_map = item_map.dropna()
|
||||||
|
imap = dict(zip(item_map[item_barcode_col].astype(str).str.strip(), item_map[item_price_col]))
|
||||||
|
|
||||||
|
df_res['_bc_'] = df_res[res_barcode_col].astype(str).str.strip()
|
||||||
|
df_res['_res_price_'] = pd.to_numeric(df_res[res_price_col], errors='coerce')
|
||||||
|
df_res['_item_price_'] = df_res['_bc_'].map(imap)
|
||||||
|
|
||||||
|
df_check = df_res.dropna(subset=['_res_price_','_item_price_'])
|
||||||
|
df_check['_diff_'] = (df_check['_res_price_'] - df_check['_item_price_']).abs()
|
||||||
|
bad = df_check[df_check['_diff_'] > 1.0]
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for _, row in df_res.iterrows():
|
if not bad.empty:
|
||||||
bc = str(row[res_barcode_col]).strip()
|
for i in range(len(bad)):
|
||||||
if bc not in item_prices:
|
r = bad.iloc[i]
|
||||||
continue
|
results.append(f"条码 {r['_bc_']}: 采购单价={r['_res_price_']} vs 进货价={r['_item_price_']} 差异={r['_diff_']:.2f}")
|
||||||
|
|
||||||
try:
|
|
||||||
res_price = float(row[res_price_col])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
item_price = item_prices[bc]
|
|
||||||
diff = abs(res_price - item_price)
|
|
||||||
if diff > 1.0:
|
|
||||||
results.append(f"条码 {bc}: 采购单价={res_price} vs 进货价={item_price} 差异={diff:.2f}")
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"单价校验过程中发生错误: {e}")
|
logger.error(f"单价校验过程中发生错误: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
# 单据元信息识别 + 文件重命名
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _extract_and_save_metadata(self, ocr_excel_path: str) -> Optional[Any]:
|
|
||||||
"""从 OCR 输出的 xlsx 同目录的 .meta.json 提取元信息,写入 SQLite。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OrderMetadata 或 None(失败时)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
base = Path(ocr_excel_path)
|
|
||||||
file_hash = base.stem
|
|
||||||
meta_path = base.with_suffix('.meta.json')
|
|
||||||
|
|
||||||
ocr_text = ''
|
|
||||||
ocr_rows: List[List[str]] = []
|
|
||||||
source_image = ''
|
|
||||||
|
|
||||||
if meta_path.exists():
|
|
||||||
try:
|
|
||||||
payload = json.loads(meta_path.read_text(encoding='utf-8'))
|
|
||||||
# 区分表格 OCR 文本和通用识别文本
|
|
||||||
ocr_text = payload.get('ocr_text', '') or ''
|
|
||||||
general_text = payload.get('general_text', '') or ''
|
|
||||||
ocr_rows = payload.get('ocr_rows', []) or []
|
|
||||||
source_image = payload.get('image_path', '') or ''
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"读 meta.json 失败: {e}")
|
|
||||||
|
|
||||||
# 兜底:从 xlsx 拼文本(与 OCRService._write_meta_json 的兜底一致)
|
|
||||||
if not ocr_text and not general_text:
|
|
||||||
try:
|
|
||||||
import xlrd
|
|
||||||
rb = xlrd.open_workbook(str(ocr_excel_path))
|
|
||||||
ws = rb.sheet_by_index(0)
|
|
||||||
lines = []
|
|
||||||
for r in range(ws.nrows):
|
|
||||||
row_vals = [str(ws.cell_value(r, c)) for c in range(ws.ncols)]
|
|
||||||
ocr_rows.append(row_vals)
|
|
||||||
lines.append(' '.join(row_vals))
|
|
||||||
ocr_text = '\n'.join(lines)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"从 xlsx 拼 OCR 文本失败: {e}")
|
|
||||||
|
|
||||||
meta = self.extractor.extract(ocr_text, ocr_rows, general_text=general_text)
|
|
||||||
self.metadata_db.save(
|
|
||||||
file_hash=file_hash,
|
|
||||||
supplier=meta.supplier,
|
|
||||||
bill_date=meta.bill_date,
|
|
||||||
total_amount=meta.total_amount,
|
|
||||||
raw_supplier_text=meta.raw_supplier_text,
|
|
||||||
source_image=source_image,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"元信息识别: hash={file_hash} supplier={meta.supplier!r} "
|
|
||||||
f"bill_date={meta.bill_date!r} total_amount={meta.total_amount:.2f}"
|
|
||||||
)
|
|
||||||
return meta
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"_extract_and_save_metadata 失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _apply_metadata_to_filenames(self, result_path: str,
|
|
||||||
ocr_excel_path: str,
|
|
||||||
meta) -> str:
|
|
||||||
"""应用新文件名规则(按供应商+日期):
|
|
||||||
- result (xls):{供应商}_{日期}.xls
|
|
||||||
- output xlsx: {供应商}_{日期}.xlsx
|
|
||||||
- 原图: {供应商}_{日期}.{ext}
|
|
||||||
|
|
||||||
冲突时加 _2 / _3 ...;任一步骤失败不影响整体。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
新 result 路径(无论重命名是否成功都返回;失败时返回原路径)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 1. 提取并清理元数据
|
|
||||||
supplier_clean = sanitize_for_filename(meta.supplier) or '未知供应商'
|
|
||||||
date_part = meta.bill_date or '未知日期'
|
|
||||||
|
|
||||||
# 2. 构造新的基础文件名:[供应商]_[日期]
|
|
||||||
# 按照用户要求:按照供应商名称加日期进行修改,且不含“采购单”等冗余字眼
|
|
||||||
base_name = f"{supplier_clean}_{date_part}"
|
|
||||||
|
|
||||||
file_hash = Path(ocr_excel_path).stem # 仅用作 SQLite PK,不入文件名
|
|
||||||
logger.info(f"生成标准化文件名: {base_name}")
|
|
||||||
|
|
||||||
# ── 1. result 重命名 ──
|
|
||||||
new_result_path = self._safe_rename(
|
|
||||||
result_path, f"{base_name}.xls"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 2. output xlsx 重命名(保证 output/ 与 result/ 文件名一致)──
|
|
||||||
new_output_path = self._safe_rename(
|
|
||||||
ocr_excel_path, f"{base_name}.xlsx"
|
|
||||||
)
|
|
||||||
# 同步 meta.json
|
|
||||||
old_meta = Path(ocr_excel_path).with_suffix('.meta.json')
|
|
||||||
if old_meta.exists():
|
|
||||||
new_meta = Path(new_output_path).with_suffix('.meta.json')
|
|
||||||
if str(new_meta) != str(old_meta):
|
|
||||||
try:
|
|
||||||
if new_meta.exists(): os.remove(new_meta)
|
|
||||||
os.rename(old_meta, new_meta)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"meta.json 重命名失败: {e}")
|
|
||||||
|
|
||||||
# ── 2.5 同步 processed_files.json(避免下次 OCR 重新识别)──
|
|
||||||
if str(new_output_path) != str(ocr_excel_path):
|
|
||||||
self._sync_processed_record(Path(ocr_excel_path), Path(new_output_path))
|
|
||||||
# 也要同步 ExcelProcessor 的记录
|
|
||||||
self._update_excel_process_record(Path(ocr_excel_path), Path(new_output_path))
|
|
||||||
|
|
||||||
# ── 3. 原图重命名 ──
|
|
||||||
try:
|
|
||||||
# 尝试从 processed_files.json 反查原图路径
|
|
||||||
src_image = self._find_input_image_for_output(str(ocr_excel_path))
|
|
||||||
|
|
||||||
# 如果没找到,再从 DB 拿
|
|
||||||
if not src_image:
|
|
||||||
meta_row = self.metadata_db.get(file_hash)
|
|
||||||
src_image = (meta_row or {}).get('source_image', '')
|
|
||||||
|
|
||||||
if src_image and os.path.exists(src_image):
|
|
||||||
src_p = Path(src_image)
|
|
||||||
ext = src_p.suffix
|
|
||||||
# 严禁包含原文件名,统一格式: 采购单_YYYYMMDD_供应商.ext
|
|
||||||
new_image_name = f"{base_name}{ext}"
|
|
||||||
new_image_path = self._safe_rename(src_image, new_image_name)
|
|
||||||
|
|
||||||
# ── 3.5 同步 processed_files.json 的 Key (原图路径) ──
|
|
||||||
if str(new_image_path) != str(src_p):
|
|
||||||
self._sync_processed_key(src_p, Path(new_image_path))
|
|
||||||
|
|
||||||
# 更新 source_image 路径到 DB
|
|
||||||
if str(new_image_path) != str(src_p):
|
|
||||||
self.metadata_db.save(
|
|
||||||
file_hash=file_hash,
|
|
||||||
supplier=meta.supplier,
|
|
||||||
bill_date=meta.bill_date,
|
|
||||||
total_amount=meta.total_amount,
|
|
||||||
raw_supplier_text=meta.raw_supplier_text,
|
|
||||||
source_image=str(new_image_path),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(f"找不到原图,跳过重命名: {src_image}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"原图重命名失败: {e}")
|
|
||||||
|
|
||||||
return str(new_result_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"_apply_metadata_to_filenames 失败: {e}")
|
|
||||||
return result_path
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _safe_rename(src: str, new_basename: str) -> str:
|
|
||||||
"""把 src 重命名为 src.parent / new_basename。
|
|
||||||
|
|
||||||
- 目标已存在时加 _2 / _3 ... 后缀避免覆盖
|
|
||||||
- 失败返回原路径,不抛异常
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
src_p = Path(src)
|
|
||||||
if not src_p.exists():
|
|
||||||
return src
|
|
||||||
new_p = src_p.parent / new_basename
|
|
||||||
if str(new_p) == str(src_p):
|
|
||||||
return src
|
|
||||||
if new_p.exists():
|
|
||||||
# 冲突去重
|
|
||||||
stem, suffix = new_p.stem, new_p.suffix
|
|
||||||
n = 2
|
|
||||||
while True:
|
|
||||||
cand = src_p.parent / f"{stem}_{n}{suffix}"
|
|
||||||
if not cand.exists():
|
|
||||||
new_p = cand
|
|
||||||
break
|
|
||||||
n += 1
|
|
||||||
os.rename(src_p, new_p)
|
|
||||||
logger.info(f"重命名: {src_p.name} -> {new_p.name}")
|
|
||||||
return str(new_p)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"重命名失败 {src} -> {new_basename}: {e}")
|
|
||||||
return src
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dedup_path(p: Path) -> Path:
|
|
||||||
"""路径冲突时加 _N 后缀。"""
|
|
||||||
stem, suffix = p.stem, p.suffix
|
|
||||||
parent = p.parent
|
|
||||||
n = 1
|
|
||||||
while True:
|
|
||||||
cand = parent / f"{stem}_{n}{suffix}"
|
|
||||||
if not cand.exists():
|
|
||||||
return cand
|
|
||||||
n += 1
|
|
||||||
|
|
||||||
def _find_input_image_for_output(self, output_xlsx: str) -> Optional[str]:
|
|
||||||
"""从 processed_files.json 反查生成该 output_xlsx 的原图路径。"""
|
|
||||||
try:
|
|
||||||
output_dir = self.config.get_path('Paths', 'output_folder', fallback='data/output')
|
|
||||||
record_file = os.path.join(output_dir, 'processed_files.json')
|
|
||||||
|
|
||||||
if not os.path.exists(record_file):
|
|
||||||
logger.debug(f"记录文件不存在: {record_file}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
with open(record_file, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
target_output = os.path.normpath(output_xlsx)
|
|
||||||
found_img = None
|
|
||||||
|
|
||||||
# 1. 尝试直接匹配和规范化路径匹配
|
|
||||||
for img_path, out_path in data.items():
|
|
||||||
if os.path.normpath(out_path) == target_output:
|
|
||||||
found_img = img_path
|
|
||||||
break
|
|
||||||
|
|
||||||
if found_img:
|
|
||||||
if os.path.exists(found_img):
|
|
||||||
return found_img
|
|
||||||
|
|
||||||
# 2. 模糊匹配:如果记录中的路径不存在,尝试在同目录下找 stem 匹配的文件
|
|
||||||
# (解决因之前重命名导致的路径不一致问题)
|
|
||||||
img_p = Path(found_img)
|
|
||||||
parent = img_p.parent
|
|
||||||
if parent.exists():
|
|
||||||
stem = img_p.stem
|
|
||||||
logger.debug(f"尝试模糊匹配原图: stem={stem[:20]}... in {parent}")
|
|
||||||
# 尝试寻找以原 stem 开头的文件
|
|
||||||
for cand in parent.iterdir():
|
|
||||||
if cand.is_file() and cand.stem.startswith(stem[:20]):
|
|
||||||
logger.info(f"模糊匹配成功: {cand.name}")
|
|
||||||
return str(cand)
|
|
||||||
logger.debug("模糊匹配失败")
|
|
||||||
else:
|
|
||||||
logger.debug(f"在 processed_files.json 中未找到输出文件 {output_xlsx} 对应的原图记录")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"反查原图路径失败: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _sync_processed_record(self, old_output: Path, new_output: Path) -> None:
|
|
||||||
"""重命名 output xlsx 后,把 processed_files.json 里所有 value 为旧路径的项改成新路径。"""
|
|
||||||
self._update_processed_json(old_path=old_output, new_path=new_output, is_key=False)
|
|
||||||
|
|
||||||
def _sync_processed_key(self, old_input: Path, new_input: Path) -> None:
|
|
||||||
"""重命名原图后,把 processed_files.json 里的 key 从旧路径改为新路径。"""
|
|
||||||
self._update_processed_json(old_path=old_input, new_path=new_input, is_key=True)
|
|
||||||
|
|
||||||
def _update_excel_process_record(self, old_xlsx: Path, new_xlsx: Path) -> None:
|
|
||||||
"""重命名 xlsx 后,同步 excel_process_records.json (ExcelProcessor 使用的记录)。"""
|
|
||||||
self._update_processed_json(
|
|
||||||
old_path=old_xlsx,
|
|
||||||
new_path=new_xlsx,
|
|
||||||
is_key=True,
|
|
||||||
filename='excel_process_records.json'
|
|
||||||
)
|
|
||||||
|
|
||||||
def _update_processed_json(self, old_path: Path, new_path: Path, is_key: bool = False, filename: str = 'processed_files.json') -> None:
|
|
||||||
"""更新处理记录 JSON。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
old_path: 旧路径
|
|
||||||
new_path: 新路径
|
|
||||||
is_key: True 更新 key, False 更新 value
|
|
||||||
filename: JSON 文件名
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 确定文件路径
|
|
||||||
output_dir = self.config.get_path('Paths', 'output_folder', fallback='data/output')
|
|
||||||
record_file = os.path.join(output_dir, filename)
|
|
||||||
|
|
||||||
if not os.path.exists(record_file):
|
|
||||||
return
|
|
||||||
|
|
||||||
with open(record_file, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
old_str = str(old_path)
|
|
||||||
new_str = str(new_path)
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
# 规范化路径以便匹配
|
|
||||||
old_norm = os.path.normpath(old_str)
|
|
||||||
|
|
||||||
if is_key:
|
|
||||||
# 更新 Key
|
|
||||||
for k in list(data.keys()):
|
|
||||||
if k == old_str or os.path.normpath(k) == old_norm:
|
|
||||||
data[new_str] = data.pop(k)
|
|
||||||
changed = True
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# 更新 Value
|
|
||||||
for k, v in list(data.items()):
|
|
||||||
if v == old_str or os.path.normpath(v) == old_norm:
|
|
||||||
data[k] = new_str
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
with open(record_file, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
||||||
logger.info(f"{filename} 已同步 ({'Key' if is_key else 'Value'}): {old_str} -> {new_str}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"更新 {filename} 失败: {e}")
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import logging
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.processors.base import BaseProcessor
|
from ..core.processors.base import BaseProcessor
|
||||||
from app.core.processors.tobacco_processor import TobaccoProcessor
|
from ..core.processors.tobacco_processor import TobaccoProcessor
|
||||||
from app.core.processors.ocr_processor import OCRProcessor
|
from ..core.processors.ocr_processor import OCRProcessor
|
||||||
from app.core.utils.log_utils import get_logger
|
from ..core.utils.log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ class ProcessorService:
|
|||||||
|
|
||||||
for supplier_config in supplier_configs:
|
for supplier_config in supplier_configs:
|
||||||
try:
|
try:
|
||||||
from app.core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor
|
from ..core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor
|
||||||
processor = GenericSupplierProcessor(self.config, supplier_config)
|
processor = GenericSupplierProcessor(self.config, supplier_config)
|
||||||
self.processors.append(processor)
|
self.processors.append(processor)
|
||||||
logger.info(f"加载供应商处理器: {processor.name}")
|
logger.info(f"加载供应商处理器: {processor.name}")
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import os
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import logging
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
class SpecialSuppliersService:
|
class SpecialSuppliersService:
|
||||||
"""
|
"""
|
||||||
@@ -86,13 +85,8 @@ class SpecialSuppliersService:
|
|||||||
# 过滤掉空的条码行
|
# 过滤掉空的条码行
|
||||||
df_clean = df_clean.dropna(subset=['商品条码'])
|
df_clean = df_clean.dropna(subset=['商品条码'])
|
||||||
|
|
||||||
# 保存预处理文件到输出目录(而非源文件目录)
|
# 保存预处理文件
|
||||||
if self.config_manager and hasattr(self.config_manager, 'get_path'):
|
out_dir = os.path.dirname(src_path)
|
||||||
out_dir = self.config_manager.get_path('Paths', 'output_folder', fallback='data/output', create=True)
|
|
||||||
else:
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
out_dir = ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True)
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
base = os.path.basename(src_path)
|
base = os.path.basename(src_path)
|
||||||
final_path = os.path.join(out_dir, f"预处理之后_{base}")
|
final_path = os.path.join(out_dir, f"预处理之后_{base}")
|
||||||
df_clean.to_excel(final_path, index=False)
|
df_clean.to_excel(final_path, index=False)
|
||||||
@@ -200,20 +194,14 @@ class SpecialSuppliersService:
|
|||||||
try:
|
try:
|
||||||
up = float(new_row['单价'] or 0)
|
up = float(new_row['单价'] or 0)
|
||||||
new_row['金额'] = up * current_qty
|
new_row['金额'] = up * current_qty
|
||||||
except Exception:
|
except: pass
|
||||||
pass
|
|
||||||
rows.append(new_row)
|
rows.append(new_row)
|
||||||
continue
|
continue
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
df2 = pd.DataFrame(rows)
|
df2 = pd.DataFrame(rows)
|
||||||
|
|
||||||
# 保存预处理文件到输出目录(而非源文件目录)
|
# 保存预处理文件
|
||||||
if self.config_manager and hasattr(self.config_manager, 'get_path'):
|
out_dir = os.path.dirname(src_path)
|
||||||
out_dir = self.config_manager.get_path('Paths', 'output_folder', fallback='data/output', create=True)
|
|
||||||
else:
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
out_dir = ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True)
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
base = os.path.basename(src_path)
|
base = os.path.basename(src_path)
|
||||||
final_path = os.path.join(out_dir, f"预处理之后_{base}")
|
final_path = os.path.join(out_dir, f"预处理之后_{base}")
|
||||||
df2.to_excel(final_path, index=False)
|
df2.to_excel(final_path, index=False)
|
||||||
@@ -231,7 +219,5 @@ class SpecialSuppliersService:
|
|||||||
"""
|
"""
|
||||||
cleaned_path = self.preprocess_rongcheng_yigou(src_path, progress_cb)
|
cleaned_path = self.preprocess_rongcheng_yigou(src_path, progress_cb)
|
||||||
if cleaned_path:
|
if cleaned_path:
|
||||||
from app.services.order_service import OrderService
|
return self.order_service.process_excel(cleaned_path, progress_cb=lambda p: progress_cb(60 + int(p*0.4), "生成采购单中...") if progress_cb else None)
|
||||||
order_service = OrderService(self.config_manager)
|
|
||||||
return order_service.process_excel(cleaned_path, progress_cb=lambda p: progress_cb(60 + int(p*0.4), "生成采购单中...") if progress_cb else None)
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ from xlutils.copy import copy
|
|||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
from typing import Optional, Dict, Any, List, Tuple
|
from typing import Optional, Dict, Any, List, Tuple
|
||||||
from app.core.utils.log_utils import get_logger
|
from app.core.utils.log_utils import get_logger
|
||||||
from app.core.utils.string_utils import parse_monetary_string
|
|
||||||
from app.core.utils.dialog_utils import show_custom_dialog # 导入自定义弹窗工具
|
from app.core.utils.dialog_utils import show_custom_dialog # 导入自定义弹窗工具
|
||||||
from app.config.settings import ConfigManager
|
from ..config.settings import ConfigManager
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -36,10 +35,10 @@ class TobaccoService:
|
|||||||
"""
|
"""
|
||||||
self.config = config
|
self.config = config
|
||||||
# 修复配置获取方式,使用fallback机制
|
# 修复配置获取方式,使用fallback机制
|
||||||
self.output_dir = config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/output')
|
self.output_dir = config.get('Paths', 'output_folder', fallback='data/output')
|
||||||
self.template_file = config.get('Paths', 'template_file', fallback='templates/银豹-采购单模板.xls')
|
self.template_file = config.get('Paths', 'template_file', fallback='templates/银豹-采购单模板.xls')
|
||||||
# 将烟草订单保存到result目录
|
# 将烟草订单保存到result目录
|
||||||
result_dir = config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/result')
|
result_dir = "data/result"
|
||||||
os.makedirs(result_dir, exist_ok=True)
|
os.makedirs(result_dir, exist_ok=True)
|
||||||
self.output_file = os.path.join(result_dir, '银豹采购单_烟草公司.xls')
|
self.output_file = os.path.join(result_dir, '银豹采购单_烟草公司.xls')
|
||||||
|
|
||||||
@@ -132,9 +131,8 @@ class TobaccoService:
|
|||||||
final_cols = ['商品条码', '商品名称', '数量', '单价', '金额']
|
final_cols = ['商品条码', '商品名称', '数量', '单价', '金额']
|
||||||
df_final = df[final_cols].copy()
|
df_final = df[final_cols].copy()
|
||||||
|
|
||||||
# 保存预处理文件到输出目录(而非源文件目录)
|
# 保存预处理文件
|
||||||
out_dir = self.output_dir
|
out_dir = os.path.dirname(file_path)
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
base = os.path.basename(file_path)
|
base = os.path.basename(file_path)
|
||||||
final_path = os.path.join(out_dir, f"预处理之后_{base}")
|
final_path = os.path.join(out_dir, f"预处理之后_{base}")
|
||||||
df_final.to_excel(final_path, index=False)
|
df_final.to_excel(final_path, index=False)
|
||||||
@@ -318,9 +316,13 @@ class TobaccoService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 确保 total_amount 是数字类型
|
# 确保 total_amount 是数字类型
|
||||||
parsed = parse_monetary_string(total_amount)
|
try:
|
||||||
total_amount = parsed if parsed is not None else 0.0
|
if isinstance(total_amount, str):
|
||||||
|
total_amount = float(total_amount.replace(',', ''))
|
||||||
amount_display = f"¥{total_amount:.2f}"
|
amount_display = f"¥{total_amount:.2f}"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# 如果转换失败,直接使用原始值
|
||||||
|
amount_display = f"¥{total_amount}"
|
||||||
|
|
||||||
# 显示自定义对话框
|
# 显示自定义对话框
|
||||||
show_custom_dialog(
|
show_custom_dialog(
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""益选-OCR订单处理系统 UI 模块"""
|
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""业务操作处理模块"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
from app.services.ocr_service import OCRService
|
|
||||||
from app.services.order_service import OrderService
|
|
||||||
from app.core.utils.log_utils import get_logger
|
|
||||||
|
|
||||||
from app.ui.logging_ui import add_to_log, init_gui_logger, dispose_gui_logger, GUILogHandler
|
|
||||||
from app.ui.ui_widgets import ProgressReporter
|
|
||||||
from app.ui.error_utils import show_error_dialog, get_error_suggestion
|
|
||||||
from app.ui.result_previews import show_ocr_result_preview, show_excel_result_preview, show_merge_result_preview
|
|
||||||
from app.ui.user_settings import add_recent_file
|
|
||||||
from app.ui.command_runner import get_running_task, set_running_task
|
|
||||||
from app.ui.file_operations import select_file, select_excel_file, validate_unit_price_against_item_data
|
|
||||||
|
|
||||||
|
|
||||||
def _get_missing_barcodes_callback(log_widget):
|
|
||||||
"""创建并返回缺失条码的回调函数"""
|
|
||||||
def callback(missing_barcodes):
|
|
||||||
if not missing_barcodes:
|
|
||||||
return
|
|
||||||
|
|
||||||
msg = f"发现以下条码不在商品资料中,已记录到数据库:\n\n" + "\n".join([f"• {bc}" for bc in missing_barcodes])
|
|
||||||
# 在主线程中弹出对话框
|
|
||||||
def show_msg():
|
|
||||||
messagebox.showwarning("发现缺失条码", msg)
|
|
||||||
|
|
||||||
# 尝试使用 log_widget 的 winfo_toplevel() 来调用 after,确保在 UI 线程执行
|
|
||||||
try:
|
|
||||||
log_widget.after(0, show_msg)
|
|
||||||
except Exception:
|
|
||||||
# 降级处理
|
|
||||||
show_msg()
|
|
||||||
|
|
||||||
return callback
|
|
||||||
|
|
||||||
|
|
||||||
def _ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=False):
|
|
||||||
"""弹窗询问是否合并采购单,返回合并结果路径或 None。
|
|
||||||
|
|
||||||
用于 run_pipeline_directly 和 batch_process_orders_with_status 的共享逻辑。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
purchase_orders = order_service.get_purchase_orders()
|
|
||||||
|
|
||||||
if len(purchase_orders) == 0:
|
|
||||||
add_to_log(log_widget, "没有找到采购单文件,跳过合并步骤\n", "info")
|
|
||||||
elif len(purchase_orders) == 1:
|
|
||||||
add_to_log(log_widget, f"只有1个采购单文件,无需合并: {os.path.basename(purchase_orders[0])}\n", "info")
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, f"找到{len(purchase_orders)}个采购单文件\n", "info")
|
|
||||||
|
|
||||||
file_list = "\n".join([f"• {os.path.basename(f)}" for f in purchase_orders])
|
|
||||||
merge_choice = messagebox.askyesnocancel(
|
|
||||||
"采购单合并选择",
|
|
||||||
f"发现{len(purchase_orders)}个采购单文件:\n\n{file_list}\n\n是否需要合并这些采购单?\n\n• 选择'是':合并所有采购单\n• 选择'否':保持文件分离\n• 选择'取消':跳过此步骤",
|
|
||||||
icon='question'
|
|
||||||
)
|
|
||||||
|
|
||||||
if merge_choice is True:
|
|
||||||
add_to_log(log_widget, "开始合并采购单...\n", "info")
|
|
||||||
merge_result = order_service.merge_all_purchase_orders()
|
|
||||||
if merge_result:
|
|
||||||
add_to_log(log_widget, "采购单合并完成\n", "success")
|
|
||||||
if add_to_recent:
|
|
||||||
try:
|
|
||||||
add_recent_file(merge_result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
return merge_result
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, "合并失败\n", "warning")
|
|
||||||
elif merge_choice is False:
|
|
||||||
add_to_log(log_widget, "用户选择不合并采购单,保持文件分离\n", "info")
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, "用户取消合并操作\n", "info")
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"合并过程出现问题: {str(e)}\n", "warning")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def process_single_image_with_status(log_widget, status_bar):
|
|
||||||
status_bar.set_status("选择图片中...")
|
|
||||||
file_path = select_file(log_widget, [("图片文件", "*.jpg *.jpeg *.png *.bmp"), ("所有文件", "*.*")], "选择图片")
|
|
||||||
if not file_path:
|
|
||||||
status_bar.set_status("操作已取消")
|
|
||||||
add_to_log(log_widget, "未选择文件,操作已取消\n", "warning")
|
|
||||||
return
|
|
||||||
|
|
||||||
def run_in_thread():
|
|
||||||
try:
|
|
||||||
status_bar.set_running(True)
|
|
||||||
status_bar.set_status("开始处理图片...")
|
|
||||||
|
|
||||||
gui_handler = GUILogHandler(log_widget)
|
|
||||||
gui_handler.setLevel(logging.INFO)
|
|
||||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
gui_handler.setFormatter(formatter)
|
|
||||||
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
for handler in root_logger.handlers[:]:
|
|
||||||
if isinstance(handler, logging.StreamHandler):
|
|
||||||
root_logger.removeHandler(handler)
|
|
||||||
root_logger.addHandler(gui_handler)
|
|
||||||
root_logger.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
ocr_service = OCRService()
|
|
||||||
add_to_log(log_widget, f"开始处理图片: {file_path}\n", "info")
|
|
||||||
try:
|
|
||||||
add_recent_file(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
excel_path = ocr_service.process_image(file_path)
|
|
||||||
|
|
||||||
if excel_path:
|
|
||||||
add_to_log(log_widget, "图片OCR处理完成\n", "success")
|
|
||||||
preview_output = f"采购单已保存到: {excel_path}\n"
|
|
||||||
show_excel_result_preview(preview_output)
|
|
||||||
try:
|
|
||||||
add_recent_file(excel_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, "图片OCR处理失败\n", "error")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"处理单个图片时出错: {str(e)}\n", "error")
|
|
||||||
sugg = get_error_suggestion(str(e))
|
|
||||||
if sugg:
|
|
||||||
show_error_dialog("OCR处理错误", str(e), sugg)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
for handler in root_logger.handlers[:]:
|
|
||||||
if isinstance(handler, GUILogHandler):
|
|
||||||
root_logger.removeHandler(handler)
|
|
||||||
handler.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"清理日志处理器失败: {e}")
|
|
||||||
status_bar.set_running(False)
|
|
||||||
status_bar.set_status("就绪")
|
|
||||||
|
|
||||||
thread = Thread(target=run_in_thread)
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline_directly(log_widget, status_bar):
|
|
||||||
"""运行完整处理流程:先选择图片,再执行 OCR+Excel 处理"""
|
|
||||||
if get_running_task() is not None:
|
|
||||||
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 先选择图片
|
|
||||||
file_path = select_file(
|
|
||||||
log_widget,
|
|
||||||
[("支持文件", "*.jpg *.jpeg *.png *.bmp *.xlsx *.xls"), ("图片文件", "*.jpg *.jpeg *.png *.bmp"), ("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")],
|
|
||||||
"选择待处理文件"
|
|
||||||
)
|
|
||||||
if not file_path:
|
|
||||||
add_to_log(log_widget, "未选择文件,一键处理已取消\n", "warning")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 复用拖拽处理的逻辑,实现“先选图,后全流程”
|
|
||||||
process_dropped_file(log_widget, status_bar, file_path)
|
|
||||||
|
|
||||||
|
|
||||||
def batch_ocr_with_status(log_widget, status_bar):
|
|
||||||
"""OCR批量识别"""
|
|
||||||
def run_in_thread():
|
|
||||||
try:
|
|
||||||
reporter = ProgressReporter(status_bar)
|
|
||||||
reporter.running()
|
|
||||||
reporter.set("正在进行OCR批量识别...", 10)
|
|
||||||
add_to_log(log_widget, "开始OCR批量识别\n", "info")
|
|
||||||
|
|
||||||
init_gui_logger(log_widget)
|
|
||||||
|
|
||||||
ocr_service = OCRService()
|
|
||||||
|
|
||||||
result = ocr_service.batch_process()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
add_to_log(log_widget, "OCR批量识别完成\n", "success")
|
|
||||||
show_ocr_result_preview("OCR批量识别成功完成")
|
|
||||||
reporter.set("批量识别完成", 100)
|
|
||||||
try:
|
|
||||||
processed_map = {}
|
|
||||||
config = ConfigManager()
|
|
||||||
pjson = config.get('Paths', 'processed_record', fallback='data/processed_files.json')
|
|
||||||
if os.path.exists(pjson):
|
|
||||||
with open(pjson, 'r', encoding='utf-8') as f:
|
|
||||||
processed_map = json.load(f)
|
|
||||||
outputs = list(processed_map.values())
|
|
||||||
for p in outputs[-10:]:
|
|
||||||
if p:
|
|
||||||
add_recent_file(p)
|
|
||||||
inputs = list(processed_map.keys())
|
|
||||||
for p in inputs[-10:]:
|
|
||||||
if p:
|
|
||||||
add_recent_file(p)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"加载已处理文件记录失败: {e}")
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, "OCR批量识别失败\n", "error")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"OCR批量识别出错: {str(e)}\n", "error")
|
|
||||||
sugg = get_error_suggestion(str(e))
|
|
||||||
if sugg:
|
|
||||||
show_error_dialog("OCR处理错误", str(e), sugg)
|
|
||||||
finally:
|
|
||||||
dispose_gui_logger()
|
|
||||||
reporter.done()
|
|
||||||
|
|
||||||
thread = Thread(target=run_in_thread)
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def batch_process_all_inputs(log_widget, status_bar):
|
|
||||||
"""一键处理 data/input/ 下全部图片:每张独立 OCR + 采购单,默认不合并。"""
|
|
||||||
if get_running_task() is not None:
|
|
||||||
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
|
|
||||||
return
|
|
||||||
|
|
||||||
def run_in_thread():
|
|
||||||
set_running_task("batch_all_inputs")
|
|
||||||
try:
|
|
||||||
if status_bar:
|
|
||||||
status_bar.set_running(True)
|
|
||||||
reporter = ProgressReporter(status_bar)
|
|
||||||
reporter.running()
|
|
||||||
reporter.set("正在扫描待处理图片...", 0)
|
|
||||||
add_to_log(log_widget, "\n========== 一键处理全部图片 ==========\n", "info")
|
|
||||||
|
|
||||||
init_gui_logger(log_widget)
|
|
||||||
|
|
||||||
from app.services.batch_service import BatchService
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
from app.ui.memory_editor import show_memory_editor # noqa: F401 触发 import 顺序
|
|
||||||
cfg = ConfigManager()
|
|
||||||
|
|
||||||
svc = BatchService(cfg, missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
|
|
||||||
|
|
||||||
def progress(done, total, entry):
|
|
||||||
pct = int(done / total * 100) if total else 100
|
|
||||||
name = os.path.basename(entry.get('image', ''))
|
|
||||||
reporter.set(f"处理中 {done}/{total}: {name}", pct)
|
|
||||||
if entry.get('status') == 'success':
|
|
||||||
add_to_log(
|
|
||||||
log_widget,
|
|
||||||
f" ✓ {name} → 供应商={entry.get('supplier') or '?'} "
|
|
||||||
f"日期={entry.get('bill_date') or '?'} "
|
|
||||||
f"金额={entry.get('total_amount', 0):.2f}\n",
|
|
||||||
"success"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
add_to_log(
|
|
||||||
log_widget,
|
|
||||||
f" ✗ {name}: {entry.get('error') or '未知错误'}\n",
|
|
||||||
"error"
|
|
||||||
)
|
|
||||||
|
|
||||||
summary = svc.process_all_inputs(progress_cb=progress)
|
|
||||||
|
|
||||||
add_to_log(log_widget, "\n========== 处理汇总 ==========\n", "info")
|
|
||||||
add_to_log(
|
|
||||||
log_widget,
|
|
||||||
f"共 {summary['total']} 张 | 成功 {summary['success']} | 失败 {summary['failed']}\n",
|
|
||||||
"info"
|
|
||||||
)
|
|
||||||
for r in summary['results']:
|
|
||||||
line = (
|
|
||||||
f" · {os.path.basename(r['image'])}\n"
|
|
||||||
f" hash={r['hash'][:8]}... status={r['status']}\n"
|
|
||||||
f" 供应商={r.get('supplier') or '未知'} "
|
|
||||||
f"日期={r.get('bill_date') or '未知'} "
|
|
||||||
f"金额={r.get('total_amount', 0):.2f}\n"
|
|
||||||
)
|
|
||||||
if r['status'] == 'success' and r.get('result_file'):
|
|
||||||
line += f" result: {os.path.basename(r['result_file'])}\n"
|
|
||||||
else:
|
|
||||||
line += f" error: {r.get('error') or '-'}\n"
|
|
||||||
add_to_log(log_widget, line,
|
|
||||||
"success" if r['status'] == 'success' else "error")
|
|
||||||
|
|
||||||
failed = [r for r in summary['results'] if r['status'] != 'success']
|
|
||||||
if failed:
|
|
||||||
names = "\n".join(os.path.basename(r['image']) for r in failed)
|
|
||||||
messagebox.showwarning(
|
|
||||||
"处理完成(含失败)",
|
|
||||||
f"共 {summary['total']} 张,成功 {summary['success']},失败 {summary['failed']}\n\n"
|
|
||||||
f"失败清单:\n{names}"
|
|
||||||
)
|
|
||||||
elif summary['total'] == 0:
|
|
||||||
messagebox.showinfo("无待处理图片", "data/input/ 目录下没有待处理的图片。")
|
|
||||||
else:
|
|
||||||
messagebox.showinfo(
|
|
||||||
"处理完成",
|
|
||||||
f"共 {summary['total']} 张,全部处理成功。\n请查看 data/result/ 目录。"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"一键处理失败: {e}", exc_info=True)
|
|
||||||
add_to_log(log_widget, f"一键处理异常: {e}\n", "error")
|
|
||||||
finally:
|
|
||||||
dispose_gui_logger()
|
|
||||||
reporter.done()
|
|
||||||
if status_bar:
|
|
||||||
status_bar.set_running(False)
|
|
||||||
status_bar.set_status("就绪")
|
|
||||||
set_running_task(None)
|
|
||||||
|
|
||||||
thread = Thread(target=run_in_thread)
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def batch_process_orders_with_status(log_widget, status_bar):
|
|
||||||
"""批量处理订单(仅Excel处理,包含合并确认)"""
|
|
||||||
def run_in_thread():
|
|
||||||
try:
|
|
||||||
reporter = ProgressReporter(status_bar)
|
|
||||||
reporter.running()
|
|
||||||
reporter.set("正在批量处理订单...", 10)
|
|
||||||
add_to_log(log_widget, "开始批量处理订单\n", "info")
|
|
||||||
|
|
||||||
init_gui_logger(log_widget)
|
|
||||||
|
|
||||||
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
|
|
||||||
|
|
||||||
add_to_log(log_widget, "开始Excel处理...\n", "info")
|
|
||||||
try:
|
|
||||||
latest_input = order_service.get_latest_excel()
|
|
||||||
if latest_input:
|
|
||||||
add_recent_file(latest_input)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"获取最新Excel失败: {e}")
|
|
||||||
result = order_service.process_excel(progress_cb=lambda p: reporter.set("Excel处理中...", p))
|
|
||||||
|
|
||||||
if result:
|
|
||||||
add_to_log(log_widget, "Excel处理完成\n", "success")
|
|
||||||
try:
|
|
||||||
validate_unit_price_against_item_data(result, log_widget)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"单价校验失败: {e}")
|
|
||||||
|
|
||||||
reporter.set("检查是否需要合并采购单...", 70)
|
|
||||||
add_to_log(log_widget, "检查是否需要合并采购单...\n", "info")
|
|
||||||
_ask_and_merge_purchase_orders(order_service, log_widget)
|
|
||||||
|
|
||||||
add_to_log(log_widget, "批量处理订单完成\n", "success")
|
|
||||||
reporter.set("批量处理订单完成", 100)
|
|
||||||
show_excel_result_preview(f"采购单已保存到: {result}\n")
|
|
||||||
try:
|
|
||||||
add_recent_file(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, "批量处理订单失败\n", "error")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"批量处理订单时出错: {str(e)}\n", "error")
|
|
||||||
sugg = get_error_suggestion(str(e))
|
|
||||||
if sugg:
|
|
||||||
show_error_dialog("Excel处理错误", str(e), sugg)
|
|
||||||
finally:
|
|
||||||
dispose_gui_logger()
|
|
||||||
reporter.done()
|
|
||||||
|
|
||||||
thread = Thread(target=run_in_thread)
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def merge_orders_with_status(log_widget, status_bar):
|
|
||||||
"""合并采购单"""
|
|
||||||
def run_in_thread():
|
|
||||||
try:
|
|
||||||
reporter = ProgressReporter(status_bar)
|
|
||||||
reporter.running()
|
|
||||||
reporter.set("正在合并采购单...", 10)
|
|
||||||
add_to_log(log_widget, "开始合并采购单\n", "info")
|
|
||||||
|
|
||||||
init_gui_logger(log_widget)
|
|
||||||
|
|
||||||
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
|
|
||||||
|
|
||||||
result = order_service.merge_all_purchase_orders(progress_cb=lambda p: reporter.set("合并处理中...", p))
|
|
||||||
|
|
||||||
if result:
|
|
||||||
add_to_log(log_widget, "采购单合并完成\n", "success")
|
|
||||||
show_merge_result_preview(f"已保存到: {result}\n")
|
|
||||||
try:
|
|
||||||
add_recent_file(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
try:
|
|
||||||
validate_unit_price_against_item_data(result, log_widget)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"单价校验失败: {e}")
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, "采购单合并失败\n", "error")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"采购单合并出错: {str(e)}\n", "error")
|
|
||||||
sugg = get_error_suggestion(str(e))
|
|
||||||
if sugg:
|
|
||||||
show_error_dialog("合并错误", str(e), sugg)
|
|
||||||
finally:
|
|
||||||
dispose_gui_logger()
|
|
||||||
reporter.done()
|
|
||||||
|
|
||||||
thread = Thread(target=run_in_thread)
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def process_excel_file_with_status(log_widget, status_bar):
|
|
||||||
"""处理Excel文件"""
|
|
||||||
def run_in_thread():
|
|
||||||
try:
|
|
||||||
status_bar.set_running(True)
|
|
||||||
status_bar.set_status("选择Excel文件中...")
|
|
||||||
file_path = select_excel_file(log_widget)
|
|
||||||
|
|
||||||
if file_path:
|
|
||||||
status_bar.set_status("开始处理Excel文件...")
|
|
||||||
add_to_log(log_widget, f"开始处理Excel文件: {file_path}\n", "info")
|
|
||||||
else:
|
|
||||||
status_bar.set_status("操作已取消")
|
|
||||||
add_to_log(log_widget, "未选择文件,操作已取消\n", "warning")
|
|
||||||
return
|
|
||||||
|
|
||||||
init_gui_logger(log_widget)
|
|
||||||
|
|
||||||
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
|
|
||||||
|
|
||||||
if file_path:
|
|
||||||
try:
|
|
||||||
add_recent_file(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
result = order_service.process_excel(file_path, progress_cb=lambda p: status_bar.set_status("Excel处理中...", p))
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
latest_input = order_service.get_latest_excel()
|
|
||||||
if latest_input:
|
|
||||||
add_recent_file(latest_input)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"获取最新Excel失败: {e}")
|
|
||||||
result = order_service.process_excel(progress_cb=lambda p: status_bar.set_status("Excel处理中...", p))
|
|
||||||
|
|
||||||
if result:
|
|
||||||
add_to_log(log_widget, "Excel文件处理完成\n", "success")
|
|
||||||
show_excel_result_preview(f"采购单已保存到: {result}\n")
|
|
||||||
try:
|
|
||||||
add_recent_file(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
try:
|
|
||||||
validate_unit_price_against_item_data(result, log_widget)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"单价校验失败: {e}")
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, "Excel文件处理失败\n", "error")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"Excel文件处理出错: {str(e)}\n", "error")
|
|
||||||
msg = str(e)
|
|
||||||
suggestion = None
|
|
||||||
if 'openpyxl' in msg or 'engine' in msg:
|
|
||||||
suggestion = "安装依赖:pip install openpyxl"
|
|
||||||
elif 'xlrd' in msg:
|
|
||||||
suggestion = "安装依赖:pip install xlrd"
|
|
||||||
if suggestion:
|
|
||||||
show_error_dialog("Excel处理错误", msg, suggestion)
|
|
||||||
finally:
|
|
||||||
dispose_gui_logger()
|
|
||||||
|
|
||||||
status_bar.set_running(False)
|
|
||||||
status_bar.set_status("就绪")
|
|
||||||
|
|
||||||
thread = Thread(target=run_in_thread)
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def process_dropped_file(log_widget, status_bar, file_path):
|
|
||||||
try:
|
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
|
||||||
if ext in ['.jpg', '.jpeg', '.png', '.bmp']:
|
|
||||||
def _run_img():
|
|
||||||
try:
|
|
||||||
reporter = ProgressReporter(status_bar)
|
|
||||||
reporter.running()
|
|
||||||
init_gui_logger(log_widget)
|
|
||||||
add_to_log(log_widget, f"开始一键处理图片: {file_path}\n", "info")
|
|
||||||
try:
|
|
||||||
add_recent_file(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
|
|
||||||
# 步骤1: OCR识别
|
|
||||||
reporter.set("OCR识别中...", 10)
|
|
||||||
ocr_service = OCRService()
|
|
||||||
excel_path = ocr_service.process_image(file_path)
|
|
||||||
if not excel_path:
|
|
||||||
add_to_log(log_widget, "图片OCR处理失败\n", "error")
|
|
||||||
return
|
|
||||||
add_to_log(log_widget, f"OCR识别完成: {excel_path}\n", "success")
|
|
||||||
|
|
||||||
# 步骤2: Excel处理
|
|
||||||
reporter.set("Excel处理中...", 40)
|
|
||||||
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
|
|
||||||
result = order_service.process_excel(excel_path, progress_cb=lambda p: reporter.set("Excel处理中...", p))
|
|
||||||
if not result:
|
|
||||||
add_to_log(log_widget, "Excel处理失败\n", "error")
|
|
||||||
return
|
|
||||||
add_to_log(log_widget, f"Excel处理完成: {result}\n", "success")
|
|
||||||
try:
|
|
||||||
add_recent_file(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
try:
|
|
||||||
validate_unit_price_against_item_data(result, log_widget)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"单价校验失败: {e}")
|
|
||||||
|
|
||||||
# 步骤3: 合并采购单
|
|
||||||
reporter.set("检查合并采购单...", 80)
|
|
||||||
_ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=True)
|
|
||||||
|
|
||||||
reporter.set("处理完成", 100)
|
|
||||||
add_to_log(log_widget, "一键处理完成!\n", "success")
|
|
||||||
finally:
|
|
||||||
dispose_gui_logger()
|
|
||||||
reporter.done()
|
|
||||||
t = Thread(target=_run_img)
|
|
||||||
t.daemon = True
|
|
||||||
t.start()
|
|
||||||
elif ext in ['.xlsx', '.xls']:
|
|
||||||
def _run_xls():
|
|
||||||
try:
|
|
||||||
reporter = ProgressReporter(status_bar)
|
|
||||||
reporter.running()
|
|
||||||
init_gui_logger(log_widget)
|
|
||||||
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
|
|
||||||
add_to_log(log_widget, f"开始一键处理Excel文件: {file_path}\n", "info")
|
|
||||||
try:
|
|
||||||
add_recent_file(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
|
|
||||||
# 步骤1: Excel处理
|
|
||||||
reporter.set("Excel处理中...", 20)
|
|
||||||
result = order_service.process_excel(file_path, progress_cb=lambda p: reporter.set("Excel处理中...", p))
|
|
||||||
if not result:
|
|
||||||
add_to_log(log_widget, "Excel文件处理失败\n", "error")
|
|
||||||
return
|
|
||||||
add_to_log(log_widget, f"Excel处理完成: {result}\n", "success")
|
|
||||||
try:
|
|
||||||
add_recent_file(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
try:
|
|
||||||
validate_unit_price_against_item_data(result, log_widget)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"单价校验失败: {e}")
|
|
||||||
|
|
||||||
# 步骤2: 合并采购单
|
|
||||||
reporter.set("检查合并采购单...", 80)
|
|
||||||
_ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=True)
|
|
||||||
|
|
||||||
reporter.set("处理完成", 100)
|
|
||||||
add_to_log(log_widget, "一键处理完成!\n", "success")
|
|
||||||
finally:
|
|
||||||
dispose_gui_logger()
|
|
||||||
reporter.done()
|
|
||||||
t = Thread(target=_run_xls)
|
|
||||||
t.daemon = True
|
|
||||||
t.start()
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, f"不支持的文件类型: {file_path}\n", "warning")
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"处理拖拽文件失败: {str(e)}\n", "error")
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""条码映射编辑模块"""
|
|
||||||
|
|
||||||
from tkinter import messagebox
|
|
||||||
|
|
||||||
from app.core.excel.converter import UnitConverter
|
|
||||||
from app.core.utils.dialog_utils import show_barcode_mapping_dialog
|
|
||||||
|
|
||||||
from app.ui.logging_ui import add_to_log
|
|
||||||
|
|
||||||
|
|
||||||
def edit_barcode_mappings(log_widget):
|
|
||||||
"""编辑条码映射配置"""
|
|
||||||
try:
|
|
||||||
add_to_log(log_widget, "正在加载条码映射配置...\n", "info")
|
|
||||||
|
|
||||||
unit_converter = UnitConverter()
|
|
||||||
|
|
||||||
current_mappings = unit_converter.special_barcodes
|
|
||||||
|
|
||||||
def save_mappings(new_mappings):
|
|
||||||
success = unit_converter.update_barcode_mappings(new_mappings)
|
|
||||||
if success:
|
|
||||||
add_to_log(log_widget, f"成功保存条码映射配置,共{len(new_mappings)}项\n", "success")
|
|
||||||
else:
|
|
||||||
add_to_log(log_widget, "保存条码映射配置失败\n", "error")
|
|
||||||
|
|
||||||
show_barcode_mapping_dialog(None, save_mappings, current_mappings)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"编辑条码映射时出错: {str(e)}\n", "error")
|
|
||||||
messagebox.showerror("错误", f"编辑条码映射时出错: {str(e)}")
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""命令执行器模块"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import subprocess
|
|
||||||
import datetime
|
|
||||||
import re
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
from app.ui.logging_ui import LogRedirector
|
|
||||||
from app.ui.result_previews import show_result_preview
|
|
||||||
|
|
||||||
# 任务状态跟踪
|
|
||||||
_RUNNING_TASK = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_running_task():
|
|
||||||
return _RUNNING_TASK
|
|
||||||
|
|
||||||
|
|
||||||
def set_running_task(val):
|
|
||||||
global _RUNNING_TASK
|
|
||||||
_RUNNING_TASK = val
|
|
||||||
|
|
||||||
|
|
||||||
def run_command_with_logging(command, log_widget, status_bar=None, on_complete=None):
|
|
||||||
"""运行命令并将输出重定向到日志窗口"""
|
|
||||||
if _RUNNING_TASK is not None:
|
|
||||||
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
|
|
||||||
return
|
|
||||||
|
|
||||||
def run_in_thread():
|
|
||||||
global _RUNNING_TASK
|
|
||||||
_RUNNING_TASK = command
|
|
||||||
|
|
||||||
if status_bar:
|
|
||||||
status_bar.set_running(True)
|
|
||||||
|
|
||||||
start_time = datetime.datetime.now()
|
|
||||||
start_perf = time.perf_counter()
|
|
||||||
log_widget.configure(state=tk.NORMAL)
|
|
||||||
log_widget.delete(1.0, tk.END)
|
|
||||||
log_widget.insert(tk.END, f"执行命令: {' '.join(command)}\n", "command")
|
|
||||||
log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
|
|
||||||
log_widget.insert(tk.END, "=" * 50 + "\n\n", "separator")
|
|
||||||
log_widget.configure(state=tk.DISABLED)
|
|
||||||
|
|
||||||
old_stdout = sys.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
|
|
||||||
log_redirector = LogRedirector(log_widget)
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
try:
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
cfg = ConfigManager()
|
|
||||||
env["OCR_OUTPUT_DIR"] = cfg.get_path('Paths', 'output_folder', fallback='data/output', create=True)
|
|
||||||
env["OCR_INPUT_DIR"] = cfg.get_path('Paths', 'input_folder', fallback='data/input', create=True)
|
|
||||||
env["OCR_TEMP_DIR"] = cfg.get_path('Paths', 'temp_folder', fallback='data/temp', create=True)
|
|
||||||
except Exception:
|
|
||||||
# 回退:使用 exe/脚本所在目录
|
|
||||||
app_root = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
env["OCR_OUTPUT_DIR"] = os.path.join(app_root, "data", "output")
|
|
||||||
env["OCR_INPUT_DIR"] = os.path.join(app_root, "data", "input")
|
|
||||||
env["OCR_TEMP_DIR"] = os.path.join(app_root, "data", "temp")
|
|
||||||
env["OCR_LOG_LEVEL"] = "DEBUG"
|
|
||||||
|
|
||||||
try:
|
|
||||||
sys.stdout = log_redirector
|
|
||||||
sys.stderr = log_redirector
|
|
||||||
|
|
||||||
print("日志重定向已启动,现在同时输出到终端和GUI")
|
|
||||||
|
|
||||||
process = subprocess.Popen(
|
|
||||||
command,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
universal_newlines=True,
|
|
||||||
env=env
|
|
||||||
)
|
|
||||||
|
|
||||||
output_data = []
|
|
||||||
for line in process.stdout:
|
|
||||||
output_data.append(line)
|
|
||||||
print(line.rstrip())
|
|
||||||
|
|
||||||
if status_bar:
|
|
||||||
progress = extract_progress_from_log(line)
|
|
||||||
if progress is not None:
|
|
||||||
log_widget.after(0, lambda p=progress: status_bar.set_status(f"处理中: {p}%完成", p))
|
|
||||||
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
end_time = datetime.datetime.now()
|
|
||||||
duration_sec = max(0.0, time.perf_counter() - start_perf)
|
|
||||||
|
|
||||||
print(f"\n{'=' * 50}")
|
|
||||||
print(f"执行完毕!返回码: {process.returncode}")
|
|
||||||
print(f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
print(f"耗时: {duration_sec:.2f} 秒")
|
|
||||||
|
|
||||||
output_text = ''.join(output_data)
|
|
||||||
|
|
||||||
is_pipeline = "pipeline" in command
|
|
||||||
no_merge_files = "未找到采购单文件" in output_text
|
|
||||||
single_file = "只有1个采购单文件" in output_text
|
|
||||||
|
|
||||||
if is_pipeline and (no_merge_files or single_file):
|
|
||||||
print("完整流程中没有需要合并的文件,但其他步骤执行成功,视为成功完成")
|
|
||||||
if status_bar:
|
|
||||||
log_widget.after(0, lambda: status_bar.set_status("处理完成", 100))
|
|
||||||
log_widget.after(0, lambda: show_result_preview(command, output_text))
|
|
||||||
else:
|
|
||||||
if on_complete:
|
|
||||||
log_widget.after(0, lambda: on_complete(process.returncode, output_text))
|
|
||||||
elif process.returncode == 0:
|
|
||||||
if status_bar:
|
|
||||||
log_widget.after(0, lambda: status_bar.set_status("处理完成", 100))
|
|
||||||
log_widget.after(0, lambda: show_result_preview(command, output_text))
|
|
||||||
else:
|
|
||||||
if status_bar:
|
|
||||||
log_widget.after(0, lambda: status_bar.set_status(f"处理失败 (返回码: {process.returncode})", 0))
|
|
||||||
log_widget.after(0, lambda: messagebox.showerror("操作失败", f"处理失败,返回码:{process.returncode}"))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n执行出错: {str(e)}")
|
|
||||||
if status_bar:
|
|
||||||
log_widget.after(0, lambda: status_bar.set_status(f"执行出错: {str(e)}", 0))
|
|
||||||
log_widget.after(0, lambda: messagebox.showerror("执行错误", f"执行命令时出错: {str(e)}"))
|
|
||||||
finally:
|
|
||||||
sys.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
|
|
||||||
_RUNNING_TASK = None
|
|
||||||
if status_bar:
|
|
||||||
log_widget.after(0, lambda: status_bar.set_running(False))
|
|
||||||
|
|
||||||
Thread(target=run_in_thread).start()
|
|
||||||
|
|
||||||
|
|
||||||
def extract_progress_from_log(log_line):
|
|
||||||
"""从日志行中提取进度信息"""
|
|
||||||
batch_match = re.search(r'处理批次 (\d+)/(\d+)', log_line)
|
|
||||||
if batch_match:
|
|
||||||
current = int(batch_match.group(1))
|
|
||||||
total = int(batch_match.group(2))
|
|
||||||
return int(current / total * 100)
|
|
||||||
|
|
||||||
percent_match = re.search(r'(\d+)%', log_line)
|
|
||||||
if percent_match:
|
|
||||||
return int(percent_match.group(1))
|
|
||||||
|
|
||||||
return None
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""系统设置对话框模块"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox, filedialog, ttk
|
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
|
|
||||||
from app.ui.user_settings import load_user_settings, save_user_settings
|
|
||||||
from app.ui.ui_widgets import center_window
|
|
||||||
from app.core.utils.dialog_utils import show_cloud_sync_dialog
|
|
||||||
|
|
||||||
|
|
||||||
def show_config_dialog(root, cfg: ConfigManager):
|
|
||||||
|
|
||||||
settings = load_user_settings()
|
|
||||||
dlg = tk.Toplevel(root)
|
|
||||||
dlg.title("系统设置")
|
|
||||||
dlg.geometry("700x460")
|
|
||||||
center_window(dlg)
|
|
||||||
|
|
||||||
content = ttk.Frame(dlg)
|
|
||||||
content.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
|
|
||||||
content.columnconfigure(0, weight=1)
|
|
||||||
|
|
||||||
# ── 辅助函数 ──
|
|
||||||
def _add_pair(parent, row, col, label_text, widget, label_width=None):
|
|
||||||
"""在 parent 的 (row, col*2) 放 label, (row, col*2+1) 放 widget"""
|
|
||||||
lbl = ttk.Label(parent, text=label_text)
|
|
||||||
if label_width:
|
|
||||||
lbl.configure(width=label_width)
|
|
||||||
lbl.grid(row=row, column=col * 2, sticky='w', padx=(6, 2), pady=3)
|
|
||||||
widget.grid(row=row, column=col * 2 + 1, sticky='ew', padx=(2, 6), pady=3)
|
|
||||||
|
|
||||||
def _make_dir_widget(parent, var, label):
|
|
||||||
f = ttk.Frame(parent)
|
|
||||||
e = ttk.Entry(f, textvariable=var)
|
|
||||||
e.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
||||||
|
|
||||||
def _select_dir():
|
|
||||||
d = filedialog.askdirectory(title=f"选择{label}")
|
|
||||||
if d:
|
|
||||||
try:
|
|
||||||
var.set(os.path.relpath(d, os.getcwd()))
|
|
||||||
except Exception:
|
|
||||||
var.set(d)
|
|
||||||
|
|
||||||
ttk.Button(f, text="选择", command=_select_dir).pack(side=tk.LEFT, padx=4)
|
|
||||||
return f
|
|
||||||
|
|
||||||
# ── 当前值 ──
|
|
||||||
log_level_val = tk.StringVar(value=settings.get('log_level', 'INFO'))
|
|
||||||
max_workers_val = tk.StringVar(value=str(settings.get('concurrency_max_workers', cfg.getint('Performance', 'max_workers', 4))))
|
|
||||||
batch_size_val = tk.StringVar(value=str(settings.get('concurrency_batch_size', cfg.getint('Performance', 'batch_size', 5))))
|
|
||||||
template_path_val = tk.StringVar(value=settings.get('template_path', os.path.join(cfg.get('Paths', 'template_folder', 'templates'), cfg.get('Templates', 'purchase_order', '银豹-采购单模板.xls'))))
|
|
||||||
input_dir_val = tk.StringVar(value=settings.get('input_folder', cfg.get('Paths', 'input_folder', 'data/input')))
|
|
||||||
output_dir_val = tk.StringVar(value=settings.get('output_folder', cfg.get('Paths', 'output_folder', 'data/output')))
|
|
||||||
result_dir_val = tk.StringVar(value=settings.get('result_folder', 'data/result'))
|
|
||||||
api_key_val = tk.StringVar(value=settings.get('api_key', cfg.get('API', 'api_key', '')))
|
|
||||||
secret_key_val = tk.StringVar(value=settings.get('secret_key', cfg.get('API', 'secret_key', '')))
|
|
||||||
timeout_val = tk.StringVar(value=str(settings.get('timeout', cfg.getint('API', 'timeout', 30))))
|
|
||||||
max_retries_val = tk.StringVar(value=str(settings.get('max_retries', cfg.getint('API', 'max_retries', 3))))
|
|
||||||
retry_delay_val = tk.StringVar(value=str(settings.get('retry_delay', cfg.getint('API', 'retry_delay', 2))))
|
|
||||||
api_url_val = tk.StringVar(value=settings.get('api_url', cfg.get('API', 'api_url', '')))
|
|
||||||
gitea_url_val = tk.StringVar(value=cfg.get('Gitea', 'base_url', fallback='https://gitea.94kan.cn'))
|
|
||||||
gitea_owner_val = tk.StringVar(value=cfg.get('Gitea', 'owner', fallback='houhuan'))
|
|
||||||
gitea_repo_val = tk.StringVar(value=cfg.get('Gitea', 'repo', fallback='yixuan-sync-data'))
|
|
||||||
gitea_token_val = tk.StringVar(value=cfg.get('Gitea', 'token', fallback=''))
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
# 区块 1: 基本设置
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
f1 = ttk.LabelFrame(content, text=" 基本设置 ", padding=(8, 4))
|
|
||||||
f1.pack(fill=tk.X, pady=(0, 6))
|
|
||||||
for c in range(4):
|
|
||||||
f1.columnconfigure(c, weight=1 if c % 2 == 1 else 0)
|
|
||||||
|
|
||||||
lvl = ttk.Combobox(f1, textvariable=log_level_val, values=['DEBUG', 'INFO', 'WARNING', 'ERROR'], state='readonly', width=12)
|
|
||||||
_add_pair(f1, 0, 0, "日志级别", lvl)
|
|
||||||
_add_pair(f1, 0, 1, "最大并发", ttk.Entry(f1, textvariable=max_workers_val, width=6))
|
|
||||||
_add_pair(f1, 1, 0, "批次大小", ttk.Entry(f1, textvariable=batch_size_val, width=6))
|
|
||||||
|
|
||||||
# 模板路径(带选择按钮,占右列)
|
|
||||||
tpl_frame = ttk.Frame(f1)
|
|
||||||
tpl_entry = ttk.Entry(tpl_frame, textvariable=template_path_val)
|
|
||||||
tpl_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
||||||
|
|
||||||
def _select_template():
|
|
||||||
p = filedialog.askopenfilename(title="选择模板文件", filetypes=[("Excel模板", "*.xls *.xlsx"), ("所有文件", "*.*")])
|
|
||||||
if p:
|
|
||||||
try:
|
|
||||||
template_path_val.set(os.path.relpath(p, os.getcwd()))
|
|
||||||
except Exception:
|
|
||||||
template_path_val.set(p)
|
|
||||||
|
|
||||||
ttk.Button(tpl_frame, text="选择", command=_select_template).pack(side=tk.LEFT, padx=4)
|
|
||||||
_add_pair(f1, 1, 1, "采购模板", tpl_frame)
|
|
||||||
|
|
||||||
_add_pair(f1, 2, 0, "输入目录", _make_dir_widget(f1, input_dir_val, "输入目录"))
|
|
||||||
_add_pair(f1, 2, 1, "输出目录", _make_dir_widget(f1, output_dir_val, "输出目录"))
|
|
||||||
_add_pair(f1, 3, 0, "结果目录", _make_dir_widget(f1, result_dir_val, "结果目录"))
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
# 区块 2: API 设置
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
f2 = ttk.LabelFrame(content, text=" API 设置 ", padding=(8, 4))
|
|
||||||
f2.pack(fill=tk.X, pady=(0, 6))
|
|
||||||
for c in range(4):
|
|
||||||
f2.columnconfigure(c, weight=1 if c % 2 == 1 else 0)
|
|
||||||
|
|
||||||
_add_pair(f2, 0, 0, "API Key", ttk.Entry(f2, textvariable=api_key_val))
|
|
||||||
secret_entry = ttk.Entry(f2, textvariable=secret_key_val, show='*')
|
|
||||||
_add_pair(f2, 0, 1, "Secret Key", secret_entry)
|
|
||||||
_add_pair(f2, 1, 0, "Timeout", ttk.Entry(f2, textvariable=timeout_val, width=6))
|
|
||||||
_add_pair(f2, 1, 1, "Max Retries", ttk.Entry(f2, textvariable=max_retries_val, width=6))
|
|
||||||
_add_pair(f2, 2, 0, "Retry Delay", ttk.Entry(f2, textvariable=retry_delay_val, width=6))
|
|
||||||
_add_pair(f2, 2, 1, "API URL", ttk.Entry(f2, textvariable=api_url_val))
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
# 区块 3: 云端同步 (Gitea)
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
f3 = ttk.LabelFrame(content, text=" 云端同步 (Gitea) ", padding=(8, 4))
|
|
||||||
f3.pack(fill=tk.X, pady=(0, 8))
|
|
||||||
for c in range(4):
|
|
||||||
f3.columnconfigure(c, weight=1 if c % 2 == 1 else 0)
|
|
||||||
|
|
||||||
_add_pair(f3, 0, 0, "Gitea 地址", ttk.Entry(f3, textvariable=gitea_url_val))
|
|
||||||
_add_pair(f3, 0, 1, "仓库所有者", ttk.Entry(f3, textvariable=gitea_owner_val))
|
|
||||||
_add_pair(f3, 1, 0, "仓库名称", ttk.Entry(f3, textvariable=gitea_repo_val))
|
|
||||||
_add_pair(f3, 1, 1, "Access Token", ttk.Entry(f3, textvariable=gitea_token_val, show='*'))
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
# 按钮区
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
btns = ttk.Frame(content)
|
|
||||||
btns.pack(fill=tk.X, pady=(4, 0))
|
|
||||||
|
|
||||||
def save_settings():
|
|
||||||
try:
|
|
||||||
s = load_user_settings()
|
|
||||||
s['log_level'] = log_level_val.get()
|
|
||||||
s['concurrency_max_workers'] = int(max_workers_val.get() or '4')
|
|
||||||
s['concurrency_batch_size'] = int(batch_size_val.get() or '5')
|
|
||||||
tp = template_path_val.get()
|
|
||||||
inp = input_dir_val.get()
|
|
||||||
outp = output_dir_val.get()
|
|
||||||
resp = result_dir_val.get()
|
|
||||||
try:
|
|
||||||
if tp:
|
|
||||||
tp = os.path.relpath(tp, os.getcwd()) if os.path.isabs(tp) else tp
|
|
||||||
if inp:
|
|
||||||
inp = os.path.relpath(inp, os.getcwd()) if os.path.isabs(inp) else inp
|
|
||||||
if outp:
|
|
||||||
outp = os.path.relpath(outp, os.getcwd()) if os.path.isabs(outp) else outp
|
|
||||||
if resp:
|
|
||||||
resp = os.path.relpath(resp, os.getcwd()) if os.path.isabs(resp) else resp
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
s['template_path'] = tp
|
|
||||||
s['input_folder'] = inp
|
|
||||||
s['output_folder'] = outp
|
|
||||||
s['result_folder'] = resp
|
|
||||||
save_user_settings(s)
|
|
||||||
try:
|
|
||||||
from app.core.utils.log_utils import set_log_level
|
|
||||||
set_log_level(s['log_level'])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
tpl_path = s['template_path']
|
|
||||||
tpl_dir = os.path.dirname(tpl_path)
|
|
||||||
tpl_name = os.path.basename(tpl_path)
|
|
||||||
cfg.update('Paths', 'template_folder', tpl_dir)
|
|
||||||
cfg.update('Templates', 'purchase_order', tpl_name)
|
|
||||||
try:
|
|
||||||
cfg.update('Paths', 'template_file', os.path.join(tpl_dir, tpl_name))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
cfg.update('Paths', 'input_folder', s['input_folder'])
|
|
||||||
cfg.update('Paths', 'output_folder', s['output_folder'])
|
|
||||||
cfg.update('Performance', 'max_workers', s['concurrency_max_workers'])
|
|
||||||
cfg.update('Performance', 'batch_size', s['concurrency_batch_size'])
|
|
||||||
cfg.update('API', 'api_key', api_key_val.get())
|
|
||||||
cfg.update('API', 'secret_key', secret_key_val.get())
|
|
||||||
cfg.update('API', 'timeout', timeout_val.get())
|
|
||||||
cfg.update('API', 'max_retries', max_retries_val.get())
|
|
||||||
cfg.update('API', 'retry_delay', retry_delay_val.get())
|
|
||||||
cfg.update('API', 'api_url', api_url_val.get())
|
|
||||||
cfg.update('Gitea', 'base_url', gitea_url_val.get())
|
|
||||||
cfg.update('Gitea', 'owner', gitea_owner_val.get())
|
|
||||||
cfg.update('Gitea', 'repo', gitea_repo_val.get())
|
|
||||||
cfg.update('Gitea', 'token', gitea_token_val.get())
|
|
||||||
cfg.save_config()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
messagebox.showinfo("设置已保存", "系统设置已更新并保存")
|
|
||||||
dlg.destroy()
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("保存失败", str(e))
|
|
||||||
|
|
||||||
ttk.Button(btns, text="云端同步", command=lambda: show_cloud_sync_dialog(dlg)).pack(side=tk.LEFT)
|
|
||||||
ttk.Button(btns, text="取消", command=dlg.destroy).pack(side=tk.RIGHT)
|
|
||||||
ttk.Button(btns, text="保存", command=save_settings).pack(side=tk.RIGHT, padx=6)
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""数据库内容查看器模块"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, messagebox
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, Dict, List, Any
|
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
from app.ui.ui_widgets import center_window
|
|
||||||
from app.ui.theme import THEMES, get_theme_mode
|
|
||||||
|
|
||||||
class DatabaseViewer:
|
|
||||||
"""通用数据库查看器,支持多表切换"""
|
|
||||||
|
|
||||||
def __init__(self, root, config: Optional[ConfigManager] = None):
|
|
||||||
self.root = root
|
|
||||||
self.config = config or ConfigManager()
|
|
||||||
self.db_path = self.config.get_path('Paths', 'product_db', fallback='data/product_cache.db')
|
|
||||||
|
|
||||||
# 确保路径是绝对路径
|
|
||||||
if not os.path.isabs(self.db_path):
|
|
||||||
app_root = getattr(self.config, 'app_root', os.getcwd())
|
|
||||||
self.db_path = os.path.join(app_root, self.db_path)
|
|
||||||
|
|
||||||
self.dlg = tk.Toplevel(root)
|
|
||||||
self.dlg.title("数据库内容查看器")
|
|
||||||
self.dlg.geometry("1000x600")
|
|
||||||
center_window(self.dlg)
|
|
||||||
|
|
||||||
theme = THEMES[get_theme_mode()]
|
|
||||||
self.dlg.configure(bg=theme["bg"])
|
|
||||||
|
|
||||||
# 使用 Notebook 实现多表切换
|
|
||||||
self.notebook = ttk.Notebook(self.dlg)
|
|
||||||
self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
||||||
|
|
||||||
# 定义要查看的表及其列信息
|
|
||||||
self.table_configs = {
|
|
||||||
"order_metadata": {
|
|
||||||
"title": "订单记录",
|
|
||||||
"columns": {
|
|
||||||
"bill_date": ("单据日期", 100),
|
|
||||||
"supplier": ("供应商", 200),
|
|
||||||
"total_amount": ("总金额", 80),
|
|
||||||
"updated_at": ("处理时间", 150),
|
|
||||||
"source_image": ("原始图片", 250),
|
|
||||||
"file_hash": ("Hash", 120)
|
|
||||||
},
|
|
||||||
"query": "SELECT * FROM order_metadata ORDER BY updated_at DESC"
|
|
||||||
},
|
|
||||||
"missing_barcodes": {
|
|
||||||
"title": "缺失条码",
|
|
||||||
"columns": {
|
|
||||||
"barcode": ("条码", 150),
|
|
||||||
"name": ("商品名称", 200),
|
|
||||||
"count": ("出现次数", 80),
|
|
||||||
"last_seen": ("最后发现", 150),
|
|
||||||
"source_file": ("来源文件", 250)
|
|
||||||
},
|
|
||||||
"query": "SELECT * FROM missing_barcodes ORDER BY last_seen DESC"
|
|
||||||
},
|
|
||||||
"products": {
|
|
||||||
"title": "商品记忆库",
|
|
||||||
"columns": {
|
|
||||||
"barcode": ("条码", 120),
|
|
||||||
"name": ("名称", 180),
|
|
||||||
"specification": ("规格", 80),
|
|
||||||
"unit": ("单位", 50),
|
|
||||||
"price": ("单价", 70),
|
|
||||||
"confidence": ("置信度", 60),
|
|
||||||
"usage_count": ("使用次数", 70),
|
|
||||||
"last_seen": ("最后使用", 140)
|
|
||||||
},
|
|
||||||
"query": "SELECT * FROM products ORDER BY last_seen DESC"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.trees = {}
|
|
||||||
self._init_tabs()
|
|
||||||
|
|
||||||
# 底部按钮
|
|
||||||
btn_frame = ttk.Frame(self.dlg)
|
|
||||||
btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
|
|
||||||
|
|
||||||
ttk.Button(btn_frame, text="刷新当前表", command=self.refresh_current_tab).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Button(btn_frame, text="清空记录 (慎用)", command=self.clear_current_table).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Button(btn_frame, text="关闭", command=self.dlg.destroy).pack(side=tk.RIGHT, padx=5)
|
|
||||||
|
|
||||||
def _init_tabs(self):
|
|
||||||
"""初始化各个标签页"""
|
|
||||||
for table_id, config in self.table_configs.items():
|
|
||||||
frame = ttk.Frame(self.notebook)
|
|
||||||
self.notebook.add(frame, text=config["title"])
|
|
||||||
|
|
||||||
# 搜索栏
|
|
||||||
search_frame = ttk.Frame(frame)
|
|
||||||
search_frame.pack(fill=tk.X, padx=5, pady=5)
|
|
||||||
|
|
||||||
ttk.Label(search_frame, text="搜索:").pack(side=tk.LEFT)
|
|
||||||
search_var = tk.StringVar()
|
|
||||||
search_entry = ttk.Entry(search_frame, textvariable=search_var, width=30)
|
|
||||||
search_entry.pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
# Treeview
|
|
||||||
cols = list(config["columns"].keys())
|
|
||||||
tree = ttk.Treeview(frame, columns=cols, show="headings")
|
|
||||||
|
|
||||||
for col, (text, width) in config["columns"].items():
|
|
||||||
tree.heading(col, text=text)
|
|
||||||
tree.column(col, width=width, anchor="center")
|
|
||||||
|
|
||||||
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=tree.yview)
|
|
||||||
tree.configure(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
|
|
||||||
# 双击复制单元格内容
|
|
||||||
tree.bind("<Double-1>", lambda e, tid=table_id: self.copy_cell_value(e, tid))
|
|
||||||
|
|
||||||
self.trees[table_id] = {
|
|
||||||
"tree": tree,
|
|
||||||
"search_var": search_var,
|
|
||||||
"config": config
|
|
||||||
}
|
|
||||||
|
|
||||||
# 绑定搜索事件
|
|
||||||
search_var.trace_add("write", lambda *args, tid=table_id: self.load_table_data(tid))
|
|
||||||
|
|
||||||
# 初始加载数据
|
|
||||||
self.load_table_data(table_id)
|
|
||||||
|
|
||||||
def load_table_data(self, table_id):
|
|
||||||
"""加载指定表的数据"""
|
|
||||||
if not os.path.exists(self.db_path):
|
|
||||||
return
|
|
||||||
|
|
||||||
info = self.trees[table_id]
|
|
||||||
tree = info["tree"]
|
|
||||||
config = info["config"]
|
|
||||||
search_text = info["search_var"].get().lower()
|
|
||||||
|
|
||||||
# 清空现有数据
|
|
||||||
for item in tree.get_children():
|
|
||||||
tree.delete(item)
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# 检查表是否存在
|
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_id,))
|
|
||||||
if not cursor.fetchone():
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
cursor.execute(config["query"])
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
cols = list(config["columns"].keys())
|
|
||||||
for row in rows:
|
|
||||||
vals = [row[c] if c in row.keys() else "" for c in cols]
|
|
||||||
|
|
||||||
# 简单搜索过滤
|
|
||||||
if search_text:
|
|
||||||
match = False
|
|
||||||
for val in vals:
|
|
||||||
if search_text in str(val).lower():
|
|
||||||
match = True
|
|
||||||
break
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 格式化金额
|
|
||||||
if "total_amount" in row.keys():
|
|
||||||
idx = cols.index("total_amount")
|
|
||||||
try:
|
|
||||||
vals[idx] = f"{float(vals[idx]):.2f}"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
tree.insert("", tk.END, values=vals)
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# 自动调整列宽
|
|
||||||
self.auto_resize_columns(table_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"加载表 {table_id} 数据失败: {e}")
|
|
||||||
|
|
||||||
def auto_resize_columns(self, table_id):
|
|
||||||
"""根据内容自动调整列宽"""
|
|
||||||
info = self.trees[table_id]
|
|
||||||
tree = info["tree"]
|
|
||||||
config = info["config"]
|
|
||||||
|
|
||||||
for col in list(config["columns"].keys()):
|
|
||||||
# 获取表头宽度
|
|
||||||
header_text = config["columns"][col][0]
|
|
||||||
max_w = len(header_text) * 12 + 20
|
|
||||||
|
|
||||||
# 获取内容宽度(检查前 20 行)
|
|
||||||
for item in tree.get_children()[:20]:
|
|
||||||
val = str(tree.set(item, col))
|
|
||||||
w = len(val) * 8 + 20
|
|
||||||
if w > max_w:
|
|
||||||
max_w = w
|
|
||||||
|
|
||||||
# 限制最大宽度
|
|
||||||
max_w = min(max_w, 400)
|
|
||||||
tree.column(col, width=max_w)
|
|
||||||
|
|
||||||
def copy_cell_value(self, event, table_id):
|
|
||||||
"""双击复制单元格内容到剪贴板"""
|
|
||||||
tree = self.trees[table_id]["tree"]
|
|
||||||
region = tree.identify_region(event.x, event.y)
|
|
||||||
if region == "cell":
|
|
||||||
column = tree.identify_column(event.x)
|
|
||||||
item = tree.identify_row(event.y)
|
|
||||||
value = tree.set(item, column)
|
|
||||||
|
|
||||||
self.root.clipboard_clear()
|
|
||||||
self.root.clipboard_append(value)
|
|
||||||
messagebox.showinfo("成功", f"内容已复制到剪贴板:\n{value}")
|
|
||||||
|
|
||||||
def refresh_current_tab(self):
|
|
||||||
"""刷新当前选中的标签页"""
|
|
||||||
current_tab_idx = self.notebook.index(self.notebook.select())
|
|
||||||
table_ids = list(self.table_configs.keys())
|
|
||||||
if current_tab_idx < len(table_ids):
|
|
||||||
self.load_table_data(table_ids[current_tab_idx])
|
|
||||||
|
|
||||||
def clear_current_table(self):
|
|
||||||
"""清空当前表的记录"""
|
|
||||||
current_tab_idx = self.notebook.index(self.notebook.select())
|
|
||||||
table_ids = list(self.table_configs.keys())
|
|
||||||
if current_tab_idx >= len(table_ids):
|
|
||||||
return
|
|
||||||
|
|
||||||
table_id = table_ids[current_tab_idx]
|
|
||||||
title = self.table_configs[table_id]["title"]
|
|
||||||
|
|
||||||
if not messagebox.askyesno("警告", f"确定要永久清空表 '{title}' 的所有记录吗?此操作不可恢复!"):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
conn.execute(f"DELETE FROM {table_id}")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
self.load_table_data(table_id)
|
|
||||||
messagebox.showinfo("成功", f"表 '{title}' 已清空")
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("错误", f"清空表失败: {e}")
|
|
||||||
|
|
||||||
def show_db_viewer(root, config: Optional[ConfigManager] = None):
|
|
||||||
"""显示数据库查看器"""
|
|
||||||
DatabaseViewer(root, config=config)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""错误处理工具模块"""
|
|
||||||
|
|
||||||
from tkinter import messagebox
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def show_error_dialog(title: str, message: str, suggestion: Optional[str] = None):
|
|
||||||
try:
|
|
||||||
full_msg = message
|
|
||||||
if suggestion:
|
|
||||||
full_msg = f"{message}\n\n建议操作:\n- {suggestion}"
|
|
||||||
messagebox.showerror(title, full_msg)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"显示错误对话框失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_error_suggestion(message: str) -> Optional[str]:
|
|
||||||
msg = (message or "").lower()
|
|
||||||
if 'openpyxl' in msg or ('engine' in msg and 'xlsx' in msg):
|
|
||||||
return '安装依赖:pip install openpyxl'
|
|
||||||
if 'xlrd' in msg or ('engine' in msg and 'xls' in msg):
|
|
||||||
return '安装依赖:pip install xlrd'
|
|
||||||
if 'timeout' in msg or 'timed out' in msg:
|
|
||||||
return '检查网络,增大API超时时间或稍后重试'
|
|
||||||
if 'invalid access_token' in msg or 'access token' in msg:
|
|
||||||
return '刷新百度OCR令牌或检查api_key/secret_key'
|
|
||||||
if '429' in msg or 'too many requests' in msg:
|
|
||||||
return '降低识别频率或稍后重试'
|
|
||||||
if '模板文件不存在' in msg or ('no such file' in msg and '模板' in msg):
|
|
||||||
return '在系统设置中选择正确的模板文件路径'
|
|
||||||
if '没有找到采购单' in msg or '未在' in msg and '找到采购单' in msg:
|
|
||||||
return '确认result目录内存在采购单文件'
|
|
||||||
if 'permission denied' in msg:
|
|
||||||
return '以管理员权限运行或更改目录写入权限'
|
|
||||||
return None
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""文件与目录操作模块"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox, filedialog, scrolledtext
|
|
||||||
|
|
||||||
from app.ui.logging_ui import add_to_log
|
|
||||||
from app.ui.ui_widgets import center_window
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
|
|
||||||
|
|
||||||
def select_file(log_widget, file_types=None, title="选择文件"):
|
|
||||||
"""通用文件选择对话框"""
|
|
||||||
if file_types is None:
|
|
||||||
file_types = [("所有文件", "*.*")]
|
|
||||||
|
|
||||||
# 获取默认输入目录
|
|
||||||
try:
|
|
||||||
config = ConfigManager()
|
|
||||||
initial_dir = config.get_path('Paths', 'input_folder', fallback='data/input')
|
|
||||||
if not os.path.exists(initial_dir):
|
|
||||||
initial_dir = os.getcwd()
|
|
||||||
except Exception:
|
|
||||||
initial_dir = os.getcwd()
|
|
||||||
|
|
||||||
file_path = filedialog.askopenfilename(title=title, filetypes=file_types, initialdir=initial_dir)
|
|
||||||
if file_path:
|
|
||||||
add_to_log(log_widget, f"已选择文件: {file_path}\n", "info")
|
|
||||||
return file_path
|
|
||||||
|
|
||||||
|
|
||||||
def select_excel_file(log_widget):
|
|
||||||
"""选择Excel文件"""
|
|
||||||
return select_file(
|
|
||||||
log_widget,
|
|
||||||
[("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")],
|
|
||||||
"选择Excel文件"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_directories():
|
|
||||||
"""确保必要的目录结构存在"""
|
|
||||||
config = ConfigManager()
|
|
||||||
directories = [
|
|
||||||
config.get_path('Paths', 'input_folder', fallback='data/input', create=True),
|
|
||||||
config.get_path('Paths', 'output_folder', fallback='data/output', create=True),
|
|
||||||
config.get_path('Paths', 'result_folder', fallback='data/result', create=True),
|
|
||||||
config.get_path('Paths', 'temp_folder', fallback='data/temp', create=True),
|
|
||||||
os.path.join(config.app_root, 'logs')
|
|
||||||
]
|
|
||||||
for directory in directories:
|
|
||||||
if not os.path.exists(directory):
|
|
||||||
os.makedirs(directory, exist_ok=True)
|
|
||||||
print(f"创建目录: {directory}")
|
|
||||||
|
|
||||||
|
|
||||||
def clean_cache(log_widget):
|
|
||||||
"""清除处理缓存"""
|
|
||||||
from app.ui.command_runner import set_running_task
|
|
||||||
try:
|
|
||||||
config = ConfigManager()
|
|
||||||
processed_record = config.get_path('Paths', 'processed_record', fallback='data/processed_files.json')
|
|
||||||
output_folder = config.get_path('Paths', 'output_folder', fallback='data/output')
|
|
||||||
cache_files = [
|
|
||||||
processed_record,
|
|
||||||
os.path.join(output_folder, "processed_files.json"),
|
|
||||||
os.path.join(output_folder, "merged_files.json")
|
|
||||||
]
|
|
||||||
|
|
||||||
for cache_file in cache_files:
|
|
||||||
if os.path.exists(cache_file):
|
|
||||||
os.remove(cache_file)
|
|
||||||
add_to_log(log_widget, f"已清除缓存文件: {cache_file}\n", "success")
|
|
||||||
|
|
||||||
temp_dir = config.get_path('Paths', 'temp_folder', fallback='data/temp')
|
|
||||||
if os.path.exists(temp_dir):
|
|
||||||
for file in os.listdir(temp_dir):
|
|
||||||
file_path = os.path.join(temp_dir, file)
|
|
||||||
try:
|
|
||||||
if os.path.isfile(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
add_to_log(log_widget, f"已清除临时文件: {file_path}\n", "info")
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"清除文件时出错: {file_path}, 错误: {str(e)}\n", "error")
|
|
||||||
|
|
||||||
log_dir = os.path.join(config.app_root, 'logs')
|
|
||||||
if os.path.exists(log_dir):
|
|
||||||
for file in os.listdir(log_dir):
|
|
||||||
if file.endswith(".active"):
|
|
||||||
file_path = os.path.join(log_dir, file)
|
|
||||||
try:
|
|
||||||
os.remove(file_path)
|
|
||||||
add_to_log(log_widget, f"已清除活动日志标记: {file_path}\n", "info")
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"清除文件时出错: {file_path}, 错误: {str(e)}\n", "error")
|
|
||||||
|
|
||||||
set_running_task(None)
|
|
||||||
|
|
||||||
add_to_log(log_widget, "缓存清除完成,系统将重新处理所有文件\n", "success")
|
|
||||||
messagebox.showinfo("缓存清除", "缓存已清除,系统将重新处理所有文件。")
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"清除缓存时出错: {str(e)}\n", "error")
|
|
||||||
messagebox.showerror("错误", f"清除缓存时出错: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def open_result_directory():
|
|
||||||
try:
|
|
||||||
config = ConfigManager()
|
|
||||||
result_dir = config.get_path('Paths', 'result_folder', fallback='data/result', create=True)
|
|
||||||
os.startfile(result_dir)
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("错误", f"无法打开结果目录: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def _open_directory_from_settings(config_key, default_path, label):
|
|
||||||
"""通用的从配置读取路径并打开目录"""
|
|
||||||
try:
|
|
||||||
config = ConfigManager()
|
|
||||||
path = config.get_path('Paths', config_key, fallback=default_path, create=True)
|
|
||||||
os.startfile(path)
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("错误", f"无法打开{label}: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def open_input_directory_from_settings():
|
|
||||||
_open_directory_from_settings('input_folder', 'data/input', '输入目录')
|
|
||||||
|
|
||||||
|
|
||||||
def open_output_directory_from_settings():
|
|
||||||
_open_directory_from_settings('output_folder', 'data/output', '输出目录')
|
|
||||||
|
|
||||||
|
|
||||||
def open_result_directory_from_settings():
|
|
||||||
_open_directory_from_settings('result_folder', 'data/result', '结果目录')
|
|
||||||
|
|
||||||
|
|
||||||
def clean_data_files(log_widget):
|
|
||||||
"""清理数据文件(仅清理input和output目录)"""
|
|
||||||
try:
|
|
||||||
if not messagebox.askyesno("确认清理", "确定要清理input和output目录的文件吗?这将删除所有输入和输出数据。"):
|
|
||||||
add_to_log(log_widget, "操作已取消\n", "info")
|
|
||||||
return
|
|
||||||
|
|
||||||
config = ConfigManager()
|
|
||||||
files_cleaned = 0
|
|
||||||
|
|
||||||
input_dir = config.get_path('Paths', 'input_folder', fallback='data/input')
|
|
||||||
if os.path.exists(input_dir):
|
|
||||||
for file in os.listdir(input_dir):
|
|
||||||
file_path = os.path.join(input_dir, file)
|
|
||||||
if os.path.isfile(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
files_cleaned += 1
|
|
||||||
add_to_log(log_widget, "已清理input目录\n", "info")
|
|
||||||
|
|
||||||
output_dir = config.get_path('Paths', 'output_folder', fallback='data/output')
|
|
||||||
if os.path.exists(output_dir):
|
|
||||||
for file in os.listdir(output_dir):
|
|
||||||
file_path = os.path.join(output_dir, file)
|
|
||||||
if os.path.isfile(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
files_cleaned += 1
|
|
||||||
add_to_log(log_widget, "已清理output目录\n", "info")
|
|
||||||
|
|
||||||
add_to_log(log_widget, f"清理完成,共清理 {files_cleaned} 个文件\n", "success")
|
|
||||||
messagebox.showinfo("清理完成", f"已成功清理 {files_cleaned} 个文件")
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"清理数据文件时出错: {str(e)}\n", "error")
|
|
||||||
messagebox.showerror("错误", f"清理数据文件时出错: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def clean_result_files(log_widget):
|
|
||||||
try:
|
|
||||||
if not messagebox.askyesno("确认清理", "确定要清理result目录的文件吗?这将删除所有已生成的采购单文件。"):
|
|
||||||
add_to_log(log_widget, "操作已取消\n", "info")
|
|
||||||
return
|
|
||||||
config = ConfigManager()
|
|
||||||
count = 0
|
|
||||||
result_dir = config.get_path('Paths', 'result_folder', fallback='data/result')
|
|
||||||
if os.path.exists(result_dir):
|
|
||||||
for file in os.listdir(result_dir):
|
|
||||||
file_path = os.path.join(result_dir, file)
|
|
||||||
if os.path.isfile(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
count += 1
|
|
||||||
add_to_log(log_widget, f"已清理result目录,共 {count} 个文件\n", "success")
|
|
||||||
messagebox.showinfo("清理完成", f"已清理result目录 {count} 个文件")
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_widget, f"清理result目录时出错: {str(e)}\n", "error")
|
|
||||||
messagebox.showerror("错误", f"清理result目录时出错: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_unit_price_against_item_data(result_path: str, log_widget=None):
|
|
||||||
try:
|
|
||||||
from app.services.order_service import OrderService
|
|
||||||
service = OrderService()
|
|
||||||
bad_results = service.validate_unit_price(result_path)
|
|
||||||
|
|
||||||
if bad_results:
|
|
||||||
display_count = min(len(bad_results), 10)
|
|
||||||
msg = f"存在{len(bad_results)}条单价与商品资料进货价差异超过1元:\n" + "\n".join(bad_results[:display_count])
|
|
||||||
if len(bad_results) > 10:
|
|
||||||
msg += f"\n...(其余 {len(bad_results) - 10} 条已省略)"
|
|
||||||
messagebox.showwarning("单价校验提示", msg)
|
|
||||||
if log_widget is not None:
|
|
||||||
add_to_log(log_widget, f"单价校验发现{len(bad_results)}条差异>1元\n", "warning")
|
|
||||||
else:
|
|
||||||
if log_widget is not None:
|
|
||||||
add_to_log(log_widget, "单价校验通过(差异<=1元)\n", "success")
|
|
||||||
except Exception as e:
|
|
||||||
if log_widget is not None:
|
|
||||||
add_to_log(log_widget, f"单价校验出错: {str(e)}\n", "error")
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""GUI日志处理模块"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import queue
|
|
||||||
import sys
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
# 全局日志队列,用于异步更新UI
|
|
||||||
LOG_QUEUE = queue.Queue()
|
|
||||||
|
|
||||||
|
|
||||||
class LogRedirector:
|
|
||||||
"""日志重定向器,用于捕获命令输出并显示到界面"""
|
|
||||||
def __init__(self, text_widget):
|
|
||||||
self.text_widget = text_widget
|
|
||||||
self.buffer = ""
|
|
||||||
self.terminal = sys.__stdout__
|
|
||||||
|
|
||||||
def write(self, string):
|
|
||||||
self.buffer += string
|
|
||||||
self.terminal.write(string)
|
|
||||||
self.text_widget.after(0, self.update_text_widget)
|
|
||||||
|
|
||||||
def update_text_widget(self):
|
|
||||||
self.text_widget.configure(state=tk.NORMAL)
|
|
||||||
|
|
||||||
if self.buffer.strip():
|
|
||||||
if any(marker in self.buffer.lower() for marker in ["错误", "error", "失败", "异常", "exception"]):
|
|
||||||
self.text_widget.insert(tk.END, self.buffer, "error")
|
|
||||||
elif any(marker in self.buffer.lower() for marker in ["警告", "warning"]):
|
|
||||||
self.text_widget.insert(tk.END, self.buffer, "warning")
|
|
||||||
elif any(marker in self.buffer.lower() for marker in ["成功", "success", "完成", "成功处理"]):
|
|
||||||
self.text_widget.insert(tk.END, self.buffer, "success")
|
|
||||||
elif any(marker in self.buffer.lower() for marker in ["info", "信息", "开始", "处理中"]):
|
|
||||||
self.text_widget.insert(tk.END, self.buffer, "info")
|
|
||||||
else:
|
|
||||||
self.text_widget.insert(tk.END, self.buffer, "normal")
|
|
||||||
else:
|
|
||||||
self.text_widget.insert(tk.END, self.buffer)
|
|
||||||
|
|
||||||
self.text_widget.see(tk.END)
|
|
||||||
self.text_widget.configure(state=tk.DISABLED)
|
|
||||||
self.buffer = ""
|
|
||||||
|
|
||||||
def flush(self):
|
|
||||||
self.terminal.flush()
|
|
||||||
|
|
||||||
|
|
||||||
class GUILogHandler(logging.Handler):
|
|
||||||
"""自定义日志处理器,将日志放入队列,由GUI主线程定时消费"""
|
|
||||||
def __init__(self, text_widget):
|
|
||||||
super().__init__()
|
|
||||||
self.text_widget = text_widget
|
|
||||||
|
|
||||||
def emit(self, record):
|
|
||||||
try:
|
|
||||||
msg = self.format(record)
|
|
||||||
if record.levelno >= logging.ERROR:
|
|
||||||
tag = "error"
|
|
||||||
elif record.levelno >= logging.WARNING:
|
|
||||||
tag = "warning"
|
|
||||||
elif record.levelno >= logging.INFO:
|
|
||||||
tag = "info"
|
|
||||||
else:
|
|
||||||
tag = "normal"
|
|
||||||
|
|
||||||
LOG_QUEUE.put((msg + "\n", tag))
|
|
||||||
except Exception:
|
|
||||||
self.handleError(record)
|
|
||||||
|
|
||||||
|
|
||||||
def poll_log_queue(text_widget):
|
|
||||||
"""定期从队列中读取日志并更新UI"""
|
|
||||||
try:
|
|
||||||
updated = False
|
|
||||||
while not LOG_QUEUE.empty():
|
|
||||||
msg, tag = LOG_QUEUE.get_nowait()
|
|
||||||
text_widget.configure(state=tk.NORMAL)
|
|
||||||
text_widget.insert(tk.END, msg, tag)
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
text_widget.see(tk.END)
|
|
||||||
text_widget.configure(state=tk.DISABLED)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
text_widget.after(100, lambda: poll_log_queue(text_widget))
|
|
||||||
|
|
||||||
|
|
||||||
def init_gui_logger(text_widget, level=logging.INFO):
|
|
||||||
handler = GUILogHandler(text_widget)
|
|
||||||
handler.setLevel(level)
|
|
||||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
for h in root_logger.handlers[:]:
|
|
||||||
if isinstance(h, logging.StreamHandler):
|
|
||||||
root_logger.removeHandler(h)
|
|
||||||
if not any(isinstance(h, GUILogHandler) for h in root_logger.handlers):
|
|
||||||
root_logger.addHandler(handler)
|
|
||||||
root_logger.setLevel(level)
|
|
||||||
return handler
|
|
||||||
|
|
||||||
|
|
||||||
def dispose_gui_logger():
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
for handler in root_logger.handlers[:]:
|
|
||||||
if isinstance(handler, GUILogHandler):
|
|
||||||
root_logger.removeHandler(handler)
|
|
||||||
try:
|
|
||||||
handler.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_log(log_widget, text, tag="normal"):
|
|
||||||
"""向日志队列添加文本,由 poll_log_queue 消费并更新 UI"""
|
|
||||||
if log_widget is None:
|
|
||||||
print(f"[{tag}] {text}", end="")
|
|
||||||
return
|
|
||||||
|
|
||||||
LOG_QUEUE.put((text, tag))
|
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""主窗口模块"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox, filedialog, scrolledtext
|
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
from app.core.utils.log_utils import set_log_level
|
|
||||||
|
|
||||||
from app.ui.theme import THEMES, get_theme_mode, set_theme_mode, create_modern_button, create_card_frame
|
|
||||||
from app.ui.logging_ui import add_to_log, poll_log_queue
|
|
||||||
from app.ui.ui_widgets import StatusBar, ToolTip
|
|
||||||
from app.ui.user_settings import (
|
|
||||||
load_user_settings, save_user_settings, refresh_recent_list_widget,
|
|
||||||
_extract_path_from_recent_item, clear_recent_files, RECENT_LIST_WIDGET,
|
|
||||||
)
|
|
||||||
from app.ui.file_operations import (
|
|
||||||
ensure_directories, open_result_directory, clean_cache,
|
|
||||||
clean_data_files, clean_result_files,
|
|
||||||
)
|
|
||||||
from app.ui.action_handlers import (
|
|
||||||
process_single_image_with_status, run_pipeline_directly,
|
|
||||||
batch_ocr_with_status, batch_process_orders_with_status,
|
|
||||||
merge_orders_with_status, process_excel_file_with_status,
|
|
||||||
process_dropped_file, batch_process_all_inputs,
|
|
||||||
)
|
|
||||||
from app.ui.memory_editor import show_memory_editor
|
|
||||||
from app.ui.config_dialog import show_config_dialog
|
|
||||||
from app.ui.barcode_editor import edit_barcode_mappings
|
|
||||||
from app.ui.shortcuts import bind_keyboard_shortcuts
|
|
||||||
from app.core.utils.dialog_utils import show_cloud_sync_dialog
|
|
||||||
from app.ui.db_viewer import show_db_viewer
|
|
||||||
|
|
||||||
|
|
||||||
def _init_window():
|
|
||||||
"""初始化窗口、主题和设置,返回 (root, theme, settings, dnd_supported)"""
|
|
||||||
ensure_directories()
|
|
||||||
|
|
||||||
dnd_supported = False
|
|
||||||
try:
|
|
||||||
from tkinterdnd2 import TkinterDnD, DND_FILES
|
|
||||||
root = TkinterDnD.Tk()
|
|
||||||
dnd_supported = True
|
|
||||||
except Exception:
|
|
||||||
root = tk.Tk()
|
|
||||||
|
|
||||||
settings = load_user_settings()
|
|
||||||
theme_mode = settings.get('theme_mode', get_theme_mode())
|
|
||||||
set_theme_mode(theme_mode)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cfg_for_title = ConfigManager()
|
|
||||||
ver = cfg_for_title.get('App', 'version', fallback='dev')
|
|
||||||
root.title(f"益选-OCR订单处理系统 v{ver} by 欢欢欢")
|
|
||||||
except Exception:
|
|
||||||
root.title("益选-OCR订单处理系统 by 欢欢欢")
|
|
||||||
|
|
||||||
root.geometry("900x600")
|
|
||||||
settings['window_size'] = "900x600"
|
|
||||||
theme = THEMES[get_theme_mode()]
|
|
||||||
root.configure(bg=theme["bg"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
log_level = settings.get('log_level')
|
|
||||||
if log_level:
|
|
||||||
set_log_level(log_level)
|
|
||||||
concurrency = settings.get('concurrency_max_workers')
|
|
||||||
if concurrency:
|
|
||||||
cfg = ConfigManager()
|
|
||||||
cfg.update('Performance', 'max_workers', str(concurrency))
|
|
||||||
cfg.save_config()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
root.iconbitmap(default="")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return root, theme, settings, dnd_supported
|
|
||||||
|
|
||||||
|
|
||||||
def _create_left_panel(content_frame, theme, log_text, status_bar):
|
|
||||||
"""创建左侧面板:完整流程、OCR处理、Excel处理、最近文件"""
|
|
||||||
left_panel = create_card_frame(content_frame)
|
|
||||||
left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=False, padx=(0, 5), pady=5)
|
|
||||||
left_panel.configure(width=160)
|
|
||||||
|
|
||||||
panel_content = tk.Frame(left_panel, bg=theme["card_bg"])
|
|
||||||
panel_content.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
|
|
||||||
|
|
||||||
# 完整流程区
|
|
||||||
pipeline_section = tk.LabelFrame(
|
|
||||||
panel_content, text="完整流程", bg=theme["card_bg"], fg=theme["fg"],
|
|
||||||
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
|
|
||||||
)
|
|
||||||
pipeline_section.pack(fill=tk.X, pady=(0, 8))
|
|
||||||
pipeline_frame = tk.Frame(pipeline_section, bg=theme["card_bg"])
|
|
||||||
pipeline_frame.pack(fill=tk.X, padx=8, pady=6)
|
|
||||||
|
|
||||||
btn_onekey = create_modern_button(pipeline_frame, "一键处理", lambda: run_pipeline_directly(log_text, status_bar), "primary", px_width=150, px_height=32)
|
|
||||||
btn_onekey.pack(anchor='w', pady=3)
|
|
||||||
ToolTip(btn_onekey.winfo_children()[0], "选择图片或Excel,自动完成OCR识别和采购单生成全流程")
|
|
||||||
|
|
||||||
btn_batch = create_modern_button(pipeline_frame, "批量处理", lambda: batch_process_all_inputs(log_text, status_bar), "primary", px_width=150, px_height=32)
|
|
||||||
btn_batch.pack(anchor='w', pady=3)
|
|
||||||
ToolTip(btn_batch.winfo_children()[0], "扫描 data/input 文件夹,处理所有未识别的图片")
|
|
||||||
|
|
||||||
# OCR处理区
|
|
||||||
core_section = tk.LabelFrame(
|
|
||||||
panel_content, text="OCR处理", bg=theme["card_bg"], fg=theme["fg"],
|
|
||||||
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
|
|
||||||
)
|
|
||||||
core_section.pack(fill=tk.X, pady=(0, 8))
|
|
||||||
core_buttons_frame = tk.Frame(core_section, bg=theme["card_bg"])
|
|
||||||
core_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
|
|
||||||
core_row1 = tk.Frame(core_buttons_frame, bg=theme["card_bg"])
|
|
||||||
core_row1.pack(fill=tk.X, pady=3)
|
|
||||||
create_modern_button(core_row1, "批量识别", lambda: batch_ocr_with_status(log_text, status_bar), "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(0, 3))
|
|
||||||
create_modern_button(core_row1, "单个识别", lambda: process_single_image_with_status(log_text, status_bar), "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
|
|
||||||
|
|
||||||
# Excel处理区
|
|
||||||
ocr_section = tk.LabelFrame(
|
|
||||||
panel_content, text="Excel处理", bg=theme["card_bg"], fg=theme["fg"],
|
|
||||||
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
|
|
||||||
)
|
|
||||||
ocr_section.pack(fill=tk.X, pady=(0, 8))
|
|
||||||
ocr_buttons_frame = tk.Frame(ocr_section, bg=theme["card_bg"])
|
|
||||||
ocr_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
|
|
||||||
ocr_row1 = tk.Frame(ocr_buttons_frame, bg=theme["card_bg"])
|
|
||||||
ocr_row1.pack(fill=tk.X, pady=3)
|
|
||||||
create_modern_button(ocr_row1, "批量处理", lambda: batch_process_orders_with_status(log_text, status_bar), "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(0, 3))
|
|
||||||
create_modern_button(ocr_row1, "单个处理", lambda: process_excel_file_with_status(log_text, status_bar), "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
|
|
||||||
|
|
||||||
# 最近文件区
|
|
||||||
_create_recent_files_section(panel_content, theme, log_text)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_recent_files_section(parent, theme, log_text):
|
|
||||||
"""创建最近文件列表区域"""
|
|
||||||
recent_section = tk.LabelFrame(
|
|
||||||
parent, text="最近文件", bg=theme["card_bg"], fg=theme["fg"],
|
|
||||||
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
|
|
||||||
)
|
|
||||||
recent_section.pack(fill=tk.BOTH, pady=(0, 12))
|
|
||||||
recent_frame = tk.Frame(recent_section, bg=theme["card_bg"])
|
|
||||||
recent_frame.pack(fill=tk.BOTH, padx=8, pady=6)
|
|
||||||
recent_top = tk.Frame(recent_frame, bg=theme["card_bg"])
|
|
||||||
recent_top.pack(fill=tk.X)
|
|
||||||
|
|
||||||
def _resize_recent_top(e):
|
|
||||||
try:
|
|
||||||
h = max(int(e.height * 0.85), 180)
|
|
||||||
recent_top.configure(height=h)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
recent_top.pack_propagate(False)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
recent_frame.bind('<Configure>', _resize_recent_top)
|
|
||||||
|
|
||||||
recent_rect = tk.Frame(recent_top, bg=theme["card_bg"], highlightbackground=theme["border"], highlightthickness=1)
|
|
||||||
recent_rect.pack(fill=tk.BOTH, expand=True)
|
|
||||||
recent_list = tk.Listbox(recent_rect, height=20)
|
|
||||||
recent_scrollbar = tk.Scrollbar(recent_rect)
|
|
||||||
recent_list.configure(yscrollcommand=recent_scrollbar.set)
|
|
||||||
recent_scrollbar.configure(command=recent_list.yview)
|
|
||||||
recent_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
recent_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
|
|
||||||
import app.ui.user_settings as _us_mod
|
|
||||||
_us_mod.RECENT_LIST_WIDGET = recent_list
|
|
||||||
|
|
||||||
def _open_selected_event(evt=None):
|
|
||||||
try:
|
|
||||||
idxs = recent_list.curselection()
|
|
||||||
if not idxs:
|
|
||||||
return
|
|
||||||
p = _extract_path_from_recent_item(recent_list.get(idxs[0]))
|
|
||||||
if os.path.exists(p):
|
|
||||||
os.startfile(p)
|
|
||||||
else:
|
|
||||||
messagebox.showwarning("文件不存在", p)
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("打开失败", str(e))
|
|
||||||
|
|
||||||
recent_list.bind('<Double-Button-1>', _open_selected_event)
|
|
||||||
refresh_recent_list_widget()
|
|
||||||
rf_btns = tk.Frame(recent_frame, bg=theme["card_bg"])
|
|
||||||
rf_btns.pack(fill=tk.X, pady=6)
|
|
||||||
|
|
||||||
def clear_list():
|
|
||||||
clear_recent_files()
|
|
||||||
recent_list.delete(0, tk.END)
|
|
||||||
|
|
||||||
create_modern_button(rf_btns, "清空列表", clear_list, "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
|
|
||||||
|
|
||||||
def purge_invalid():
|
|
||||||
try:
|
|
||||||
kept = []
|
|
||||||
for i in range(recent_list.size()):
|
|
||||||
item = recent_list.get(i)
|
|
||||||
p = _extract_path_from_recent_item(item)
|
|
||||||
if os.path.exists(p):
|
|
||||||
kept.append(p)
|
|
||||||
try:
|
|
||||||
kept_sorted = sorted(kept, key=lambda p: os.path.getmtime(p), reverse=True)
|
|
||||||
except Exception:
|
|
||||||
kept_sorted = kept
|
|
||||||
s = load_user_settings()
|
|
||||||
s['recent_files'] = kept_sorted
|
|
||||||
save_user_settings(s)
|
|
||||||
recent_list.delete(0, tk.END)
|
|
||||||
for i, p in enumerate(s['recent_files'][:recent_list.size() or len(s['recent_files'])], start=1):
|
|
||||||
recent_list.insert(tk.END, f"{i}. {p}")
|
|
||||||
refresh_recent_list_widget()
|
|
||||||
add_to_log(log_text, "已清理无效的最近文件条目\n", "success")
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("清理失败", str(e))
|
|
||||||
|
|
||||||
create_modern_button(rf_btns, "清理无效", purge_invalid, "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def _create_right_panel(content_frame, theme, log_text, root, status_bar, config):
|
|
||||||
"""创建右侧面板:快捷操作、系统设置"""
|
|
||||||
right_panel = create_card_frame(content_frame)
|
|
||||||
right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=False, padx=(5, 0), pady=5)
|
|
||||||
right_panel.configure(width=380)
|
|
||||||
|
|
||||||
right_panel_content = tk.Frame(right_panel, bg=theme["card_bg"])
|
|
||||||
right_panel_content.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
|
|
||||||
|
|
||||||
# 工具功能区
|
|
||||||
tools_section = tk.LabelFrame(
|
|
||||||
right_panel_content, text="快捷操作", bg=theme["card_bg"], fg=theme["fg"],
|
|
||||||
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
|
|
||||||
)
|
|
||||||
tools_section.pack(fill=tk.X, pady=(0, 8))
|
|
||||||
tools_buttons_frame = tk.Frame(tools_section, bg=theme["card_bg"])
|
|
||||||
tools_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
|
|
||||||
tk.Frame(tools_buttons_frame, bg=theme["card_bg"]).pack(fill=tk.X, pady=3)
|
|
||||||
|
|
||||||
create_modern_button(tools_buttons_frame, "打开结果目录", lambda: open_result_directory(), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(tools_buttons_frame, "打开输出目录", lambda: os.startfile(config.get_path('Paths', 'output_folder', fallback='data/output', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(tools_buttons_frame, "打开输入目录", lambda: os.startfile(config.get_path('Paths', 'input_folder', fallback='data/input', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, status_bar), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(tools_buttons_frame, "数据库内容", lambda: show_db_viewer(root, config=config), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(tools_buttons_frame, "清除缓存", lambda: clean_cache(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(tools_buttons_frame, "清理input/out文件", lambda: clean_data_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(tools_buttons_frame, "清理结果文件", lambda: clean_result_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
|
|
||||||
# 系统设置区
|
|
||||||
settings_section = tk.LabelFrame(
|
|
||||||
right_panel_content, text="系统设置", bg=theme["card_bg"], fg=theme["fg"],
|
|
||||||
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
|
|
||||||
)
|
|
||||||
settings_section.pack(fill=tk.X, pady=(0, 8))
|
|
||||||
settings_buttons_frame = tk.Frame(settings_section, bg=theme["card_bg"])
|
|
||||||
settings_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
|
|
||||||
create_modern_button(settings_buttons_frame, "系统设置", lambda: show_config_dialog(root, config), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(settings_buttons_frame, "条码映射", lambda: edit_barcode_mappings(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(settings_buttons_frame, "云端同步", lambda: show_cloud_sync_dialog(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
create_modern_button(settings_buttons_frame, "商品记忆库", lambda: show_memory_editor(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_drag_area(mid_container, theme, dnd_supported, log_text, status_bar):
|
|
||||||
"""创建拖拽/点击选择文件区域"""
|
|
||||||
drag_panel = create_card_frame(mid_container)
|
|
||||||
drag_panel.pack(side=tk.TOP, fill=tk.X, padx=(5, 5), pady=(0, 5))
|
|
||||||
drag_panel_content = tk.Frame(drag_panel, bg=theme["card_bg"])
|
|
||||||
drag_panel_content.pack(fill=tk.X, padx=10, pady=6)
|
|
||||||
|
|
||||||
dnd_section = tk.LabelFrame(
|
|
||||||
drag_panel_content, bg=theme["card_bg"], fg=theme["fg"],
|
|
||||||
font=("Microsoft YaHei UI", 10, "bold"), relief="flat", borderwidth=0
|
|
||||||
)
|
|
||||||
dnd_section.pack(fill=tk.X, pady=(0, 0))
|
|
||||||
dnd_frame = tk.Frame(dnd_section, bg=theme["card_bg"], highlightthickness=1, highlightbackground=theme["border"])
|
|
||||||
dnd_frame.configure(height=60)
|
|
||||||
dnd_frame.pack(fill=tk.X, padx=8, pady=6)
|
|
||||||
try:
|
|
||||||
dnd_frame.pack_propagate(False)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _set_highlight(active: bool):
|
|
||||||
try:
|
|
||||||
dnd_frame.configure(highlightbackground=theme["info"] if active else theme["border"])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
dnd_frame.bind('<Enter>', lambda e: _set_highlight(True))
|
|
||||||
dnd_frame.bind('<Leave>', lambda e: _set_highlight(False))
|
|
||||||
|
|
||||||
msg_row = tk.Frame(dnd_frame, bg=theme["card_bg"])
|
|
||||||
msg_row.pack(fill=tk.X)
|
|
||||||
if dnd_supported:
|
|
||||||
tk.Label(
|
|
||||||
msg_row, text="拖拽已启用:拖拽或点击此区域选择文件",
|
|
||||||
bg=theme["card_bg"], fg="#999999", justify="center"
|
|
||||||
).pack(fill=tk.X)
|
|
||||||
else:
|
|
||||||
tk.Label(
|
|
||||||
msg_row, text="点击此区域选择文件;可安装拖拽支持",
|
|
||||||
bg=theme["card_bg"], fg="#999999", justify="center"
|
|
||||||
).pack(fill=tk.X)
|
|
||||||
|
|
||||||
if not dnd_supported:
|
|
||||||
btn_row = tk.Frame(dnd_frame, bg=theme["card_bg"])
|
|
||||||
btn_row.pack(fill=tk.X)
|
|
||||||
|
|
||||||
is_frozen = getattr(sys, 'frozen', False)
|
|
||||||
|
|
||||||
def copy_install():
|
|
||||||
try:
|
|
||||||
mid_container.winfo_toplevel().clipboard_clear()
|
|
||||||
mid_container.winfo_toplevel().clipboard_append("pip install tkinterdnd2")
|
|
||||||
messagebox.showinfo("已复制", "已复制安装命令:pip install tkinterdnd2")
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showwarning("复制失败", str(e))
|
|
||||||
|
|
||||||
if is_frozen:
|
|
||||||
tk.Label(
|
|
||||||
btn_row, text="EXE版不支持运行时安装,请用源码版安装后重新打包",
|
|
||||||
bg=theme["card_bg"], fg="#999999", font=("Microsoft YaHei UI", 8)
|
|
||||||
).pack(side=tk.RIGHT, padx=4)
|
|
||||||
else:
|
|
||||||
def install_and_restart():
|
|
||||||
try:
|
|
||||||
add_to_log(log_text, "开始安装拖拽支持库 tkinterdnd2...\n", "info")
|
|
||||||
cmd = [sys.executable, "-m", "pip", "install", "tkinterdnd2"]
|
|
||||||
result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
||||||
add_to_log(log_text, result.stdout + "\n", "info")
|
|
||||||
add_to_log(log_text, "安装成功,准备重启程序以启用拖拽...\n", "success")
|
|
||||||
if messagebox.askyesno("安装完成", "已安装拖拽支持,是否立即重启应用?"):
|
|
||||||
os.execl(sys.executable, sys.executable, *sys.argv)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
add_to_log(log_text, f"安装失败: {e.stderr}\n", "error")
|
|
||||||
messagebox.showerror("安装失败", f"安装输出:\n{e.stderr}")
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_text, f"安装失败: {str(e)}\n", "error")
|
|
||||||
messagebox.showerror("安装失败", str(e))
|
|
||||||
|
|
||||||
create_modern_button(btn_row, "一键安装拖拽", install_and_restart, "primary", px_width=132, px_height=28).pack(side=tk.RIGHT, padx=(3, 0))
|
|
||||||
|
|
||||||
create_modern_button(btn_row, "复制安装命令", copy_install, "primary", px_width=132, px_height=28).pack(side=tk.RIGHT)
|
|
||||||
|
|
||||||
# 点击拖拽框选择文件
|
|
||||||
def _click_select(evt=None):
|
|
||||||
try:
|
|
||||||
files = filedialog.askopenfilenames(
|
|
||||||
title="选择图片或Excel文件",
|
|
||||||
filetypes=[
|
|
||||||
("支持文件", "*.xlsx *.xls *.jpg *.jpeg *.png *.bmp"),
|
|
||||||
("Excel", "*.xlsx *.xls"),
|
|
||||||
("图片", "*.jpg *.jpeg *.png *.bmp"),
|
|
||||||
("所有文件", "*.*"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if not files:
|
|
||||||
return
|
|
||||||
for p in files:
|
|
||||||
process_dropped_file(log_text, status_bar, p)
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("选择失败", str(e))
|
|
||||||
|
|
||||||
dnd_frame.bind('<Button-1>', _click_select)
|
|
||||||
msg_row.bind('<Button-1>', _click_select)
|
|
||||||
|
|
||||||
if dnd_supported:
|
|
||||||
def _on_drop(event):
|
|
||||||
try:
|
|
||||||
data = event.data
|
|
||||||
paths = []
|
|
||||||
buf = ""
|
|
||||||
in_brace = False
|
|
||||||
for ch in data:
|
|
||||||
if ch == '{':
|
|
||||||
in_brace = True
|
|
||||||
buf = ""
|
|
||||||
elif ch == '}':
|
|
||||||
in_brace = False
|
|
||||||
paths.append(buf)
|
|
||||||
buf = ""
|
|
||||||
elif ch == ' ' and not in_brace:
|
|
||||||
if buf:
|
|
||||||
paths.append(buf)
|
|
||||||
buf = ""
|
|
||||||
else:
|
|
||||||
buf += ch
|
|
||||||
if buf:
|
|
||||||
paths.append(buf)
|
|
||||||
for p in paths:
|
|
||||||
process_dropped_file(log_text, status_bar, p)
|
|
||||||
except Exception as e:
|
|
||||||
add_to_log(log_text, f"拖拽处理失败: {str(e)}\n", "error")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from tkinterdnd2 import DND_FILES
|
|
||||||
dnd_frame.drop_target_register(DND_FILES)
|
|
||||||
dnd_frame.dnd_bind('<<Drop>>', _on_drop)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _create_log_panel(mid_container, theme):
|
|
||||||
"""创建中间日志面板,返回 log_text widget"""
|
|
||||||
log_panel = create_card_frame(mid_container, "处理日志")
|
|
||||||
log_panel.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=(5, 5), pady=5)
|
|
||||||
|
|
||||||
log_text = scrolledtext.ScrolledText(
|
|
||||||
log_panel, wrap=tk.WORD, width=68, height=26,
|
|
||||||
bg=theme["log_bg"], fg=theme["log_fg"],
|
|
||||||
font=("Consolas", 9), state=tk.DISABLED,
|
|
||||||
relief="flat", borderwidth=0
|
|
||||||
)
|
|
||||||
log_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
|
|
||||||
|
|
||||||
log_text.tag_configure("command", foreground=theme["info"], font=("Consolas", 9, "bold"))
|
|
||||||
log_text.tag_configure("time", foreground=theme["secondary_bg"], font=("Consolas", 8))
|
|
||||||
log_text.tag_configure("separator", foreground=theme["border"])
|
|
||||||
log_text.tag_configure("success", foreground=theme["success"], font=("Consolas", 9, "bold"))
|
|
||||||
log_text.tag_configure("error", foreground=theme["error"], font=("Consolas", 9, "bold"))
|
|
||||||
log_text.tag_configure("warning", foreground=theme["warning"], font=("Consolas", 9, "bold"))
|
|
||||||
log_text.tag_configure("info", foreground=theme["info"], font=("Consolas", 9))
|
|
||||||
|
|
||||||
poll_log_queue(log_text)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_ver = ConfigManager().get('App', 'version', fallback='')
|
|
||||||
_ver_str = f" v{_ver}" if _ver else ""
|
|
||||||
except Exception:
|
|
||||||
_ver_str = ""
|
|
||||||
add_to_log(log_text, f"欢迎使用 益选-OCR订单处理系统{_ver_str}\n", "success")
|
|
||||||
add_to_log(log_text, "系统已就绪,请选择相应功能进行操作。\n\n", "info")
|
|
||||||
add_to_log(log_text, "功能说明:\n", "command")
|
|
||||||
add_to_log(log_text, "• 完整处理流程:一键完成OCR识别和Excel处理\n", "info")
|
|
||||||
add_to_log(log_text, "• 批量处理订单:批量处理多个订单文件\n", "info")
|
|
||||||
add_to_log(log_text, "• 处理烟草订单:专门处理烟草类订单\n", "info")
|
|
||||||
add_to_log(log_text, "• 合并订单:将多个订单合并为一个文件\n\n", "info")
|
|
||||||
cfg = ConfigManager()
|
|
||||||
add_to_log(log_text, f"请将需要处理的图片文件放入 {cfg.get_path('Paths', 'input_folder', fallback='data/input')} 目录中。\n", "warning")
|
|
||||||
add_to_log(log_text, f"OCR识别结果保存在 {cfg.get_path('Paths', 'output_folder', fallback='data/output')} 目录,处理完成的订单保存在 {cfg.get_path('Paths', 'result_folder', fallback='data/result')} 目录中。\n\n", "warning")
|
|
||||||
add_to_log(log_text, "=" * 50 + "\n\n", "separator")
|
|
||||||
|
|
||||||
return log_text
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""主函数"""
|
|
||||||
try:
|
|
||||||
root, theme, settings, dnd_supported = _init_window()
|
|
||||||
config = ConfigManager()
|
|
||||||
|
|
||||||
# 主容器
|
|
||||||
main_container = tk.Frame(root, bg=theme["bg"])
|
|
||||||
main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
||||||
content_frame = tk.Frame(main_container, bg=theme["bg"])
|
|
||||||
content_frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 中间容器(拖拽区 + 日志区)
|
|
||||||
mid_container = tk.Frame(content_frame, bg=theme["bg"])
|
|
||||||
mid_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 5), pady=5)
|
|
||||||
|
|
||||||
log_text = _create_log_panel(mid_container, theme)
|
|
||||||
|
|
||||||
# 状态栏
|
|
||||||
status_bar = StatusBar(root)
|
|
||||||
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
|
|
||||||
|
|
||||||
# 左侧面板
|
|
||||||
_create_left_panel(content_frame, theme, log_text, status_bar)
|
|
||||||
|
|
||||||
# 右侧面板
|
|
||||||
_create_right_panel(content_frame, theme, log_text, root, status_bar, config)
|
|
||||||
|
|
||||||
# 拖拽区域
|
|
||||||
_setup_drag_area(mid_container, theme, dnd_supported, log_text, status_bar)
|
|
||||||
|
|
||||||
# 快捷键 + 关闭事件
|
|
||||||
def on_close():
|
|
||||||
try:
|
|
||||||
w = root.winfo_width()
|
|
||||||
h = root.winfo_height()
|
|
||||||
settings['window_size'] = f"{w}x{h}"
|
|
||||||
settings['theme_mode'] = get_theme_mode()
|
|
||||||
save_user_settings(settings)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
root.protocol("WM_DELETE_WINDOW", on_close)
|
|
||||||
bind_keyboard_shortcuts(root, log_text, status_bar)
|
|
||||||
|
|
||||||
root.mainloop()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
error_msg = f"程序启动失败: {str(e)}\n详细错误信息:\n{traceback.format_exc()}"
|
|
||||||
print(error_msg)
|
|
||||||
try:
|
|
||||||
import tkinter.messagebox as mb
|
|
||||||
mb.showerror("启动错误", f"程序启动失败:\n{str(e)}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""商品记忆库查看/编辑对话框"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, messagebox, simpledialog
|
|
||||||
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
from app.core.db.product_db import ProductDatabase
|
|
||||||
from app.ui.ui_widgets import center_window
|
|
||||||
|
|
||||||
|
|
||||||
def _get_product_db():
|
|
||||||
cfg = ConfigManager()
|
|
||||||
db_path = cfg.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(cfg, 'get_path') else 'data/product_cache.db'
|
|
||||||
tpl_folder = cfg.get('Paths', 'template_folder', fallback='templates')
|
|
||||||
item_data = cfg.get('Templates', 'item_data', fallback='商品资料.xlsx')
|
|
||||||
tpl_path = os.path.join(tpl_folder, item_data)
|
|
||||||
return ProductDatabase(db_path, tpl_path)
|
|
||||||
|
|
||||||
|
|
||||||
def show_memory_editor(root):
|
|
||||||
"""显示商品记忆库编辑器"""
|
|
||||||
db = _get_product_db()
|
|
||||||
|
|
||||||
dlg = tk.Toplevel(root)
|
|
||||||
dlg.title("商品记忆库")
|
|
||||||
dlg.geometry("950x520")
|
|
||||||
center_window(dlg)
|
|
||||||
|
|
||||||
# ── 顶部搜索栏 ──
|
|
||||||
top = ttk.Frame(dlg)
|
|
||||||
top.pack(fill=tk.X, padx=8, pady=(8, 4))
|
|
||||||
|
|
||||||
ttk.Label(top, text="搜索:").pack(side=tk.LEFT)
|
|
||||||
search_var = tk.StringVar()
|
|
||||||
search_entry = ttk.Entry(top, textvariable=search_var, width=30)
|
|
||||||
search_entry.pack(side=tk.LEFT, padx=4)
|
|
||||||
|
|
||||||
# ── 统计标签 ──
|
|
||||||
stats_label = ttk.Label(top, text="")
|
|
||||||
stats_label.pack(side=tk.RIGHT)
|
|
||||||
|
|
||||||
# ── Treeview ──
|
|
||||||
columns = ("barcode", "name", "specification", "unit", "price", "source", "confidence", "usage_count", "last_seen")
|
|
||||||
tree = ttk.Treeview(dlg, columns=columns, show="headings", height=18)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"barcode": ("条码", 120),
|
|
||||||
"name": ("名称", 180),
|
|
||||||
"specification": ("规格", 80),
|
|
||||||
"unit": ("单位", 50),
|
|
||||||
"price": ("单价", 70),
|
|
||||||
"source": ("来源", 80),
|
|
||||||
"confidence": ("置信度", 60),
|
|
||||||
"usage_count": ("使用次数", 70),
|
|
||||||
"last_seen": ("最后使用", 140),
|
|
||||||
}
|
|
||||||
for col, (text, width) in headers.items():
|
|
||||||
tree.heading(col, text=text)
|
|
||||||
tree.column(col, width=width, anchor="center")
|
|
||||||
|
|
||||||
# 置信度颜色标签
|
|
||||||
tree.tag_configure("high", foreground="#28a745") # >= 80 绿
|
|
||||||
tree.tag_configure("medium", foreground="#ffc107") # 50-79 黄
|
|
||||||
tree.tag_configure("low", foreground="#dc3545") # < 50 红
|
|
||||||
|
|
||||||
scrollbar = ttk.Scrollbar(dlg, orient=tk.VERTICAL, command=tree.yview)
|
|
||||||
tree.configure(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0), pady=4)
|
|
||||||
scrollbar.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 8), pady=4)
|
|
||||||
|
|
||||||
# ── 数据加载 ──
|
|
||||||
all_records = []
|
|
||||||
|
|
||||||
def load_data(filter_text=""):
|
|
||||||
nonlocal all_records
|
|
||||||
all_records = db.get_all_memories()
|
|
||||||
|
|
||||||
tree.delete(*tree.get_children())
|
|
||||||
|
|
||||||
filtered = all_records
|
|
||||||
if filter_text:
|
|
||||||
ft = filter_text.lower()
|
|
||||||
filtered = [r for r in all_records
|
|
||||||
if ft in str(r.get('barcode', '')).lower()
|
|
||||||
or ft in str(r.get('name', '')).lower()]
|
|
||||||
|
|
||||||
for r in filtered:
|
|
||||||
conf = r.get('confidence', 0) or 0
|
|
||||||
tag = "high" if conf >= 80 else ("medium" if conf >= 50 else "low")
|
|
||||||
|
|
||||||
last_seen = r.get('last_seen', '') or ''
|
|
||||||
if last_seen and len(last_seen) > 16:
|
|
||||||
last_seen = last_seen[:16]
|
|
||||||
|
|
||||||
source_display = {
|
|
||||||
'template': '模板',
|
|
||||||
'ocr': 'OCR',
|
|
||||||
'user_confirmed': '手动',
|
|
||||||
}.get(r.get('source', ''), r.get('source', ''))
|
|
||||||
|
|
||||||
tree.insert("", tk.END, values=(
|
|
||||||
r.get('barcode', ''),
|
|
||||||
r.get('name', ''),
|
|
||||||
r.get('specification', ''),
|
|
||||||
r.get('unit', ''),
|
|
||||||
f"{r.get('price', 0):.2f}" if r.get('price') else '',
|
|
||||||
source_display,
|
|
||||||
conf,
|
|
||||||
r.get('usage_count', 0) or 0,
|
|
||||||
last_seen,
|
|
||||||
), tags=(tag,))
|
|
||||||
|
|
||||||
stats_label.config(text=f"共 {len(filtered)} / {len(all_records)} 条")
|
|
||||||
|
|
||||||
def on_search(*_):
|
|
||||||
load_data(search_var.get())
|
|
||||||
|
|
||||||
search_var.trace_add("write", on_search)
|
|
||||||
|
|
||||||
# ── 按钮区 ──
|
|
||||||
btn_frame = ttk.Frame(dlg)
|
|
||||||
btn_frame.pack(fill=tk.X, padx=8, pady=(0, 8))
|
|
||||||
|
|
||||||
def edit_selected():
|
|
||||||
sel = tree.selection()
|
|
||||||
if not sel:
|
|
||||||
messagebox.showwarning("提示", "请先选择一条记录")
|
|
||||||
return
|
|
||||||
item = tree.item(sel[0])
|
|
||||||
vals = item['values']
|
|
||||||
barcode = vals[0]
|
|
||||||
|
|
||||||
# 弹出编辑对话框
|
|
||||||
edit_dlg = tk.Toplevel(dlg)
|
|
||||||
edit_dlg.title(f"编辑: {barcode}")
|
|
||||||
edit_dlg.geometry("380x260")
|
|
||||||
center_window(edit_dlg)
|
|
||||||
|
|
||||||
fields = [
|
|
||||||
("名称", "name", vals[1]),
|
|
||||||
("规格", "specification", vals[2]),
|
|
||||||
("单位", "unit", vals[3]),
|
|
||||||
("单价", "price", vals[4]),
|
|
||||||
]
|
|
||||||
entries = {}
|
|
||||||
for i, (label, key, val) in enumerate(fields):
|
|
||||||
ttk.Label(edit_dlg, text=label).grid(row=i, column=0, sticky='w', padx=8, pady=4)
|
|
||||||
var = tk.StringVar(value=str(val) if val else '')
|
|
||||||
ttk.Entry(edit_dlg, textvariable=var, width=30).grid(row=i, column=1, padx=8, pady=4)
|
|
||||||
entries[key] = var
|
|
||||||
|
|
||||||
def save_edit():
|
|
||||||
updates = {}
|
|
||||||
for key, var in entries.items():
|
|
||||||
v = var.get().strip()
|
|
||||||
if key == 'price':
|
|
||||||
try:
|
|
||||||
updates[key] = float(v) if v else 0
|
|
||||||
except ValueError:
|
|
||||||
updates[key] = 0
|
|
||||||
else:
|
|
||||||
updates[key] = v
|
|
||||||
db.update_memory(barcode, updates)
|
|
||||||
edit_dlg.destroy()
|
|
||||||
load_data(search_var.get())
|
|
||||||
|
|
||||||
ttk.Button(edit_dlg, text="保存", command=save_edit).grid(row=len(fields), column=0, columnspan=2, pady=12)
|
|
||||||
|
|
||||||
def delete_selected():
|
|
||||||
sel = tree.selection()
|
|
||||||
if not sel:
|
|
||||||
messagebox.showwarning("提示", "请先选择一条记录")
|
|
||||||
return
|
|
||||||
item = tree.item(sel[0])
|
|
||||||
barcode = item['values'][0]
|
|
||||||
if messagebox.askyesno("确认删除", f"确定要删除条码 {barcode} 的记忆记录吗?"):
|
|
||||||
db.delete_memory(barcode)
|
|
||||||
load_data(search_var.get())
|
|
||||||
|
|
||||||
def reimport_template():
|
|
||||||
if messagebox.askyesno("确认", "重新从商品资料导入将重置所有模板商品的置信度为100,确定继续吗?"):
|
|
||||||
count = db.reimport()
|
|
||||||
messagebox.showinfo("完成", f"已重新导入 {count} 条记录")
|
|
||||||
load_data(search_var.get())
|
|
||||||
|
|
||||||
ttk.Button(btn_frame, text="编辑", command=edit_selected).pack(side=tk.LEFT, padx=4)
|
|
||||||
ttk.Button(btn_frame, text="删除", command=delete_selected).pack(side=tk.LEFT, padx=4)
|
|
||||||
ttk.Button(btn_frame, text="重新导入模板", command=reimport_template).pack(side=tk.LEFT, padx=4)
|
|
||||||
ttk.Button(btn_frame, text="刷新", command=lambda: load_data(search_var.get())).pack(side=tk.LEFT, padx=4)
|
|
||||||
ttk.Button(btn_frame, text="关闭", command=dlg.destroy).pack(side=tk.RIGHT, padx=4)
|
|
||||||
|
|
||||||
# 双击编辑
|
|
||||||
tree.bind("<Double-1>", lambda e: edit_selected())
|
|
||||||
|
|
||||||
# 初始加载
|
|
||||||
load_data()
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""处理结果预览对话框模块"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import datetime
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox, scrolledtext
|
|
||||||
|
|
||||||
from app.ui.theme import THEMES, get_theme_mode, apply_theme
|
|
||||||
from app.ui.ui_widgets import center_window
|
|
||||||
from app.core.utils.file_utils import format_file_size
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
|
|
||||||
TOBACCO_PREVIEW_WINDOW = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_output_dir():
|
|
||||||
"""获取输出目录的绝对路径"""
|
|
||||||
return ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True)
|
|
||||||
|
|
||||||
|
|
||||||
def show_result_preview(command, output):
|
|
||||||
"""显示处理结果预览"""
|
|
||||||
if "ocr" in command:
|
|
||||||
show_ocr_result_preview(output)
|
|
||||||
elif "excel" in command:
|
|
||||||
show_excel_result_preview(output)
|
|
||||||
elif "merge" in command:
|
|
||||||
show_merge_result_preview(output)
|
|
||||||
elif "pipeline" in command:
|
|
||||||
show_pipeline_result_preview(output)
|
|
||||||
else:
|
|
||||||
messagebox.showinfo("处理完成", f"操作已成功完成!\n请在{_get_output_dir()}目录查看结果。")
|
|
||||||
|
|
||||||
|
|
||||||
def show_ocr_result_preview(output):
|
|
||||||
"""显示OCR处理结果预览"""
|
|
||||||
files_match = re.search(r'找到 (\d+) 个图片文件,其中 (\d+) 个未处理', output)
|
|
||||||
processed_match = re.search(r'所有图片处理完成, 总计: (\d+), 成功: (\d+)', output)
|
|
||||||
|
|
||||||
if processed_match:
|
|
||||||
total = int(processed_match.group(1))
|
|
||||||
success = int(processed_match.group(2))
|
|
||||||
|
|
||||||
preview = tk.Toplevel()
|
|
||||||
preview.title("OCR处理结果")
|
|
||||||
preview.geometry("400x300")
|
|
||||||
preview.resizable(False, False)
|
|
||||||
center_window(preview)
|
|
||||||
|
|
||||||
tk.Label(preview, text="OCR处理完成", font=("Arial", 16, "bold")).pack(pady=10)
|
|
||||||
|
|
||||||
result_frame = tk.Frame(preview)
|
|
||||||
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
tk.Label(result_frame, text=f"总共处理: {total} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text=f"成功处理: {success} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text=f"失败数量: {total - success} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
|
|
||||||
if success == total:
|
|
||||||
result_text = "全部处理成功!"
|
|
||||||
result_color = "#28a745"
|
|
||||||
elif success > total * 0.8:
|
|
||||||
result_text = "大部分处理成功。"
|
|
||||||
result_color = "#ffc107"
|
|
||||||
else:
|
|
||||||
result_text = "处理失败较多,请检查日志。"
|
|
||||||
result_color = "#dc3545"
|
|
||||||
|
|
||||||
tk.Label(result_frame, text=result_text, font=("Arial", 12, "bold"), fg=result_color).pack(pady=10)
|
|
||||||
|
|
||||||
button_frame = tk.Frame(preview)
|
|
||||||
button_frame.pack(pady=10)
|
|
||||||
|
|
||||||
tk.Button(button_frame, text="查看输出文件", command=lambda: os.startfile(_get_output_dir())).pack(side=tk.LEFT, padx=10)
|
|
||||||
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10)
|
|
||||||
else:
|
|
||||||
messagebox.showinfo("OCR处理完成", f"OCR处理已完成,请在{_get_output_dir()}目录查看结果。")
|
|
||||||
|
|
||||||
|
|
||||||
def show_excel_result_preview(output):
|
|
||||||
"""显示Excel处理结果预览"""
|
|
||||||
extract_match = re.search(r'提取到 (\d+) 个商品信息', output)
|
|
||||||
file_match = re.search(r'采购单已保存到: (.+?)(?:\n|$)', output)
|
|
||||||
|
|
||||||
if extract_match and file_match:
|
|
||||||
products_count = int(extract_match.group(1))
|
|
||||||
output_file = file_match.group(1)
|
|
||||||
|
|
||||||
preview = tk.Toplevel()
|
|
||||||
preview.title("Excel处理结果")
|
|
||||||
preview.geometry("450x320")
|
|
||||||
preview.resizable(False, False)
|
|
||||||
center_window(preview)
|
|
||||||
|
|
||||||
tk.Label(preview, text="Excel处理完成", font=("Arial", 16, "bold")).pack(pady=10)
|
|
||||||
|
|
||||||
result_frame = tk.Frame(preview)
|
|
||||||
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
tk.Label(result_frame, text=f"提取商品数量: {products_count} 个", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text=f"输出文件: {os.path.basename(output_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
|
|
||||||
tk.Label(result_frame, text="采购单已成功生成!", font=("Arial", 12, "bold"), fg="#28a745").pack(pady=10)
|
|
||||||
|
|
||||||
file_frame = tk.Frame(result_frame, relief=tk.GROOVE, borderwidth=1)
|
|
||||||
file_frame.pack(fill=tk.X, padx=15, pady=5)
|
|
||||||
|
|
||||||
tk.Label(file_frame, text="文件信息", font=("Arial", 10, "bold")).pack(anchor=tk.W, padx=10, pady=5)
|
|
||||||
|
|
||||||
try:
|
|
||||||
file_size = os.path.getsize(output_file)
|
|
||||||
file_time = datetime.datetime.fromtimestamp(os.path.getmtime(output_file))
|
|
||||||
size_text = format_file_size(file_size)
|
|
||||||
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
|
||||||
tk.Label(file_frame, text=f"创建时间: {file_time.strftime('%Y-%m-%d %H:%M:%S')}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
|
||||||
except Exception:
|
|
||||||
tk.Label(file_frame, text="无法获取文件信息", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
|
||||||
|
|
||||||
button_frame = tk.Frame(preview)
|
|
||||||
button_frame.pack(pady=10)
|
|
||||||
|
|
||||||
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=5)
|
|
||||||
tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(output_file))).pack(side=tk.LEFT, padx=5)
|
|
||||||
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=5)
|
|
||||||
else:
|
|
||||||
messagebox.showinfo("Excel处理完成", f"Excel处理已完成,请在{_get_output_dir()}目录查看结果。")
|
|
||||||
|
|
||||||
|
|
||||||
def show_merge_result_preview(output):
|
|
||||||
"""显示合并结果预览"""
|
|
||||||
merged_match = re.search(r'合并了 (\d+) 个采购单', output)
|
|
||||||
product_match = re.search(r'共处理 (\d+) 个商品', output)
|
|
||||||
output_match = re.search(r'已保存到: (.+?)(?:\n|$)', output)
|
|
||||||
|
|
||||||
if merged_match and output_match:
|
|
||||||
merged_count = int(merged_match.group(1))
|
|
||||||
product_count = int(product_match.group(1)) if product_match else 0
|
|
||||||
output_file = output_match.group(1)
|
|
||||||
|
|
||||||
preview = tk.Toplevel()
|
|
||||||
preview.title("采购单合并结果")
|
|
||||||
preview.geometry("450x300")
|
|
||||||
preview.resizable(False, False)
|
|
||||||
apply_theme(preview)
|
|
||||||
|
|
||||||
tk.Label(preview, text="采购单合并完成", font=("Arial", 16, "bold")).pack(pady=10)
|
|
||||||
|
|
||||||
result_frame = tk.Frame(preview)
|
|
||||||
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
tk.Label(result_frame, text=f"合并采购单数量: {merged_count} 个", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text=f"处理商品数量: {product_count} 个", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text=f"输出文件: {os.path.basename(output_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
|
|
||||||
theme = THEMES[get_theme_mode()]
|
|
||||||
tk.Label(result_frame, text="采购单已成功合并!", font=("Arial", 12, "bold"), fg=theme["success"]).pack(pady=10)
|
|
||||||
|
|
||||||
button_frame = tk.Frame(preview)
|
|
||||||
button_frame.pack(pady=10)
|
|
||||||
|
|
||||||
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=10)
|
|
||||||
tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(output_file))).pack(side=tk.LEFT, padx=10)
|
|
||||||
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10)
|
|
||||||
else:
|
|
||||||
messagebox.showinfo("采购单合并完成", f"采购单合并已完成,请在{_get_output_dir()}目录查看结果。")
|
|
||||||
|
|
||||||
|
|
||||||
def show_pipeline_result_preview(output):
|
|
||||||
"""显示完整流程结果预览"""
|
|
||||||
ocr_match = re.search(r'所有图片处理完成, 总计: (\d+), 成功: (\d+)', output)
|
|
||||||
excel_match = re.search(r'提取到 (\d+) 个商品信息', output)
|
|
||||||
output_file_match = re.search(r'采购单已保存到: (.+?)(?:\n|$)', output)
|
|
||||||
|
|
||||||
preview = tk.Toplevel()
|
|
||||||
preview.title("完整流程处理结果")
|
|
||||||
preview.geometry("500x400")
|
|
||||||
preview.resizable(False, False)
|
|
||||||
center_window(preview)
|
|
||||||
|
|
||||||
tk.Label(preview, text="完整处理流程已完成", font=("Arial", 16, "bold")).pack(pady=10)
|
|
||||||
|
|
||||||
no_files_match = re.search(r'未找到可合并的文件', output)
|
|
||||||
if no_files_match:
|
|
||||||
tk.Label(preview, text="未找到可合并的文件,但其他步骤已成功执行", font=("Arial", 12)).pack(pady=0)
|
|
||||||
|
|
||||||
result_frame = tk.Frame(preview)
|
|
||||||
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
result_text = scrolledtext.ScrolledText(result_frame, wrap=tk.WORD, height=15, width=60)
|
|
||||||
result_text.pack(fill=tk.BOTH, expand=True, padx=15, pady=5)
|
|
||||||
result_text.configure(state=tk.NORMAL)
|
|
||||||
|
|
||||||
result_text.insert(tk.END, "===== 流程执行结果 =====\n\n", "title")
|
|
||||||
|
|
||||||
result_text.insert(tk.END, "步骤1: OCR识别\n", "step")
|
|
||||||
if ocr_match:
|
|
||||||
total = int(ocr_match.group(1))
|
|
||||||
success = int(ocr_match.group(2))
|
|
||||||
result_text.insert(tk.END, f" 处理图片: {total} 个\n", "info")
|
|
||||||
result_text.insert(tk.END, f" 成功识别: {success} 个\n", "info")
|
|
||||||
if success == total:
|
|
||||||
result_text.insert(tk.END, " 结果: 全部识别成功\n", "success")
|
|
||||||
else:
|
|
||||||
result_text.insert(tk.END, f" 结果: 部分识别成功 ({success}/{total})\n", "warning")
|
|
||||||
else:
|
|
||||||
result_text.insert(tk.END, " 结果: 无OCR处理或处理信息不完整\n", "warning")
|
|
||||||
|
|
||||||
result_text.insert(tk.END, "\n步骤2: Excel处理\n", "step")
|
|
||||||
if excel_match:
|
|
||||||
products = int(excel_match.group(1))
|
|
||||||
result_text.insert(tk.END, f" 提取商品: {products} 个\n", "info")
|
|
||||||
result_text.insert(tk.END, " 结果: 成功生成采购单\n", "success")
|
|
||||||
if output_file_match:
|
|
||||||
output_file = output_file_match.group(1)
|
|
||||||
result_text.insert(tk.END, f" 输出文件: {os.path.basename(output_file)}\n", "info")
|
|
||||||
else:
|
|
||||||
result_text.insert(tk.END, " 结果: 无Excel处理或处理信息不完整\n", "warning")
|
|
||||||
|
|
||||||
result_text.insert(tk.END, "\n===== 整体评估 =====\n", "title")
|
|
||||||
|
|
||||||
has_errors = "错误" in output or "失败" in output
|
|
||||||
|
|
||||||
no_files_match2 = re.search(r'未找到采购单文件', output)
|
|
||||||
single_file_match = re.search(r'只有1个采购单文件', output)
|
|
||||||
|
|
||||||
if no_files_match2:
|
|
||||||
result_text.insert(tk.END, "没有找到可合并的文件,但处理流程已成功完成。\n", "warning")
|
|
||||||
result_text.insert(tk.END, "可以选择打开Excel文件或查看输出文件夹。\n", "info")
|
|
||||||
elif single_file_match:
|
|
||||||
result_text.insert(tk.END, "只有一个采购单文件,无需合并,处理流程已成功完成。\n", "warning")
|
|
||||||
result_text.insert(tk.END, "可以选择打开生成的Excel文件。\n", "info")
|
|
||||||
elif ocr_match and excel_match and not has_errors:
|
|
||||||
result_text.insert(tk.END, "流程完整执行成功!\n", "success")
|
|
||||||
elif ocr_match or excel_match:
|
|
||||||
result_text.insert(tk.END, "流程部分执行成功,请检查日志获取详情。\n", "warning")
|
|
||||||
else:
|
|
||||||
result_text.insert(tk.END, "流程执行可能存在问题,请查看详细日志。\n", "error")
|
|
||||||
|
|
||||||
result_text.tag_configure("title", font=("Arial", 12, "bold"))
|
|
||||||
result_text.tag_configure("step", font=("Arial", 11, "bold"))
|
|
||||||
result_text.tag_configure("info", font=("Arial", 10))
|
|
||||||
result_text.tag_configure("success", font=("Arial", 10, "bold"), foreground="#28a745")
|
|
||||||
result_text.tag_configure("warning", font=("Arial", 10, "bold"), foreground="#ffc107")
|
|
||||||
result_text.tag_configure("error", font=("Arial", 10, "bold"), foreground="#dc3545")
|
|
||||||
|
|
||||||
result_text.configure(state=tk.DISABLED)
|
|
||||||
|
|
||||||
button_frame = tk.Frame(preview)
|
|
||||||
button_frame.pack(pady=10)
|
|
||||||
|
|
||||||
if output_file_match:
|
|
||||||
output_file = output_file_match.group(1)
|
|
||||||
tk.Button(button_frame, text="打开Excel文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=10)
|
|
||||||
else:
|
|
||||||
if excel_match or no_files_match or single_file_match:
|
|
||||||
output_dir = _get_output_dir()
|
|
||||||
excel_files = [f for f in os.listdir(output_dir) if f.startswith('采购单_') and (f.endswith('.xls') or f.endswith('.xlsx'))]
|
|
||||||
if excel_files:
|
|
||||||
excel_files.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
|
|
||||||
latest_file = os.path.join(output_dir, excel_files[0])
|
|
||||||
tk.Button(button_frame, text="打开最新Excel文件",
|
|
||||||
command=lambda: os.startfile(latest_file)).pack(side=tk.LEFT, padx=10)
|
|
||||||
|
|
||||||
tk.Button(button_frame, text="查看输出文件夹", command=lambda: os.startfile(_get_output_dir())).pack(side=tk.LEFT, padx=10)
|
|
||||||
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10)
|
|
||||||
|
|
||||||
|
|
||||||
def show_tobacco_result_preview(returncode, output):
|
|
||||||
"""显示烟草订单处理结果预览"""
|
|
||||||
global TOBACCO_PREVIEW_WINDOW
|
|
||||||
if returncode != 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
if TOBACCO_PREVIEW_WINDOW and TOBACCO_PREVIEW_WINDOW.winfo_exists():
|
|
||||||
TOBACCO_PREVIEW_WINDOW.lift()
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
TOBACCO_PREVIEW_WINDOW = None
|
|
||||||
|
|
||||||
result_file = None
|
|
||||||
order_time = "(未知)"
|
|
||||||
total_amount = "(未知)"
|
|
||||||
items_count = 0
|
|
||||||
|
|
||||||
abs_path_match = re.search(r'烟草订单处理完成,绝对路径: (.+)(?:\n|$)', output)
|
|
||||||
if abs_path_match:
|
|
||||||
result_file = abs_path_match.group(1).strip()
|
|
||||||
|
|
||||||
for line in output.split('\n'):
|
|
||||||
if "烟草公司订单处理成功" in line and "订单时间" in line:
|
|
||||||
time_match = re.search(r'订单时间: ([^,]+)', line)
|
|
||||||
amount_match = re.search(r'总金额: ([^,]+)', line)
|
|
||||||
items_match = re.search(r'处理条目: (\d+)', line)
|
|
||||||
|
|
||||||
if time_match:
|
|
||||||
order_time = time_match.group(1).strip()
|
|
||||||
if amount_match:
|
|
||||||
total_amount = amount_match.group(1).strip()
|
|
||||||
if items_match:
|
|
||||||
items_count = int(items_match.group(1).strip())
|
|
||||||
|
|
||||||
if not result_file or not os.path.exists(result_file):
|
|
||||||
default_path = os.path.join(_get_output_dir(), "银豹采购单_烟草公司.xls")
|
|
||||||
if os.path.exists(default_path):
|
|
||||||
result_file = default_path
|
|
||||||
|
|
||||||
preview = tk.Toplevel()
|
|
||||||
preview.title("烟草订单处理结果")
|
|
||||||
preview.geometry("450x320")
|
|
||||||
preview.resizable(False, False)
|
|
||||||
TOBACCO_PREVIEW_WINDOW = preview
|
|
||||||
|
|
||||||
def _close_preview():
|
|
||||||
global TOBACCO_PREVIEW_WINDOW
|
|
||||||
TOBACCO_PREVIEW_WINDOW = None
|
|
||||||
try:
|
|
||||||
preview.destroy()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
preview.protocol("WM_DELETE_WINDOW", _close_preview)
|
|
||||||
center_window(preview)
|
|
||||||
|
|
||||||
tk.Label(preview, text="烟草订单处理完成", font=("Arial", 16, "bold")).pack(pady=10)
|
|
||||||
|
|
||||||
result_frame = tk.Frame(preview)
|
|
||||||
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
tk.Label(result_frame, text=f"订单时间: {order_time}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text=f"订单总金额: {total_amount}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text=f"处理商品数量: {items_count} 个", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
|
|
||||||
if result_file and os.path.exists(result_file):
|
|
||||||
tk.Label(result_frame, text=f"输出文件: {os.path.basename(result_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text="银豹采购单已成功生成!", font=("Arial", 12, "bold"), fg="#28a745").pack(pady=10)
|
|
||||||
|
|
||||||
file_frame = tk.Frame(result_frame, relief=tk.GROOVE, borderwidth=1)
|
|
||||||
file_frame.pack(fill=tk.X, padx=15, pady=5)
|
|
||||||
tk.Label(file_frame, text="文件信息", font=("Arial", 10, "bold")).pack(anchor=tk.W, padx=10, pady=5)
|
|
||||||
|
|
||||||
try:
|
|
||||||
file_size = os.path.getsize(result_file)
|
|
||||||
file_time = datetime.datetime.fromtimestamp(os.path.getmtime(result_file))
|
|
||||||
size_text = format_file_size(file_size)
|
|
||||||
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
|
||||||
tk.Label(file_frame, text=f"创建时间: {file_time.strftime('%Y-%m-%d %H:%M:%S')}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
|
||||||
except Exception:
|
|
||||||
tk.Label(file_frame, text="无法获取文件信息", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
|
|
||||||
|
|
||||||
button_frame = tk.Frame(preview)
|
|
||||||
button_frame.pack(pady=10)
|
|
||||||
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(result_file)).pack(side=tk.LEFT, padx=5)
|
|
||||||
tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(result_file))).pack(side=tk.LEFT, padx=5)
|
|
||||||
tk.Button(button_frame, text="关闭", command=_close_preview).pack(side=tk.LEFT, padx=5)
|
|
||||||
else:
|
|
||||||
tk.Label(result_frame, text="未找到输出文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
|
|
||||||
tk.Label(result_frame, text=f"请检查{_get_output_dir()}目录", font=("Arial", 12, "bold"), fg="#dc3545").pack(pady=10)
|
|
||||||
|
|
||||||
button_frame = tk.Frame(preview)
|
|
||||||
button_frame.pack(pady=10)
|
|
||||||
tk.Button(button_frame, text="打开输出目录", command=lambda: os.startfile(_get_output_dir())).pack(side=tk.LEFT, padx=5)
|
|
||||||
tk.Button(button_frame, text="关闭", command=_close_preview).pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
preview.lift()
|
|
||||||
preview.attributes('-topmost', True)
|
|
||||||
preview.after_idle(lambda: preview.attributes('-topmost', False))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror(
|
|
||||||
"处理异常",
|
|
||||||
f"显示预览时发生错误: {e}\n请检查日志了解详细信息。"
|
|
||||||
)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""键盘快捷键模块"""
|
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox
|
|
||||||
|
|
||||||
from app.ui.ui_widgets import center_window
|
|
||||||
from app.ui.action_handlers import (
|
|
||||||
process_single_image_with_status,
|
|
||||||
process_excel_file_with_status,
|
|
||||||
batch_ocr_with_status,
|
|
||||||
run_pipeline_directly,
|
|
||||||
merge_orders_with_status,
|
|
||||||
)
|
|
||||||
from app.ui.file_operations import clean_cache
|
|
||||||
|
|
||||||
|
|
||||||
def bind_keyboard_shortcuts(root, log_widget, status_bar):
|
|
||||||
"""绑定键盘快捷键"""
|
|
||||||
root.bind('<Control-o>', lambda e: process_single_image_with_status(log_widget, status_bar))
|
|
||||||
root.bind('<Control-e>', lambda e: process_excel_file_with_status(log_widget, status_bar))
|
|
||||||
root.bind('<Control-b>', lambda e: batch_ocr_with_status(log_widget, status_bar))
|
|
||||||
root.bind('<Control-p>', lambda e: run_pipeline_directly(log_widget, status_bar))
|
|
||||||
root.bind('<Control-m>', lambda e: merge_orders_with_status(log_widget, status_bar))
|
|
||||||
root.bind('<F5>', lambda e: clean_cache(log_widget))
|
|
||||||
root.bind('<Escape>', lambda e: root.quit() if messagebox.askyesno("确认退出", "确定要退出程序吗?") else None)
|
|
||||||
root.bind('<F1>', lambda e: show_shortcuts_help())
|
|
||||||
|
|
||||||
|
|
||||||
def show_shortcuts_help():
|
|
||||||
"""显示快捷键帮助对话框"""
|
|
||||||
help_dialog = tk.Toplevel()
|
|
||||||
help_dialog.title("快捷键帮助")
|
|
||||||
help_dialog.geometry("400x450")
|
|
||||||
center_window(help_dialog)
|
|
||||||
|
|
||||||
tk.Label(help_dialog, text="键盘快捷键", font=("Arial", 16, "bold")).pack(pady=10)
|
|
||||||
|
|
||||||
help_text = tk.Text(help_dialog, wrap=tk.WORD, width=50, height=20)
|
|
||||||
help_text.pack(padx=20, pady=10, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
shortcuts = """
|
|
||||||
Ctrl+O: 处理单个图片
|
|
||||||
Ctrl+E: 处理Excel文件
|
|
||||||
Ctrl+B: OCR批量识别
|
|
||||||
Ctrl+P: 完整处理流程
|
|
||||||
Ctrl+M: 合并采购单
|
|
||||||
F5: 清除处理缓存
|
|
||||||
Esc: 退出程序
|
|
||||||
"""
|
|
||||||
|
|
||||||
help_text.insert(tk.END, shortcuts)
|
|
||||||
help_text.configure(state=tk.DISABLED)
|
|
||||||
|
|
||||||
tk.Button(help_dialog, text="确定", command=help_dialog.destroy).pack(pady=10)
|
|
||||||
|
|
||||||
help_dialog.lift()
|
|
||||||
help_dialog.attributes('-topmost', True)
|
|
||||||
help_dialog.after_idle(lambda: help_dialog.attributes('-topmost', False))
|
|
||||||
-193
@@ -1,193 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""主题管理模块"""
|
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import scrolledtext, ttk
|
|
||||||
|
|
||||||
# 私有主题模式变量
|
|
||||||
_theme_mode = "light"
|
|
||||||
|
|
||||||
# 浅色和深色主题颜色
|
|
||||||
THEMES = {
|
|
||||||
"light": {
|
|
||||||
"bg": "#f8f9fa",
|
|
||||||
"fg": "#212529",
|
|
||||||
"button_bg": "#ffffff",
|
|
||||||
"button_fg": "#495057",
|
|
||||||
"button_hover": "#e9ecef",
|
|
||||||
"primary_bg": "#007bff",
|
|
||||||
"primary_fg": "#ffffff",
|
|
||||||
"secondary_bg": "#6c757d",
|
|
||||||
"secondary_fg": "#ffffff",
|
|
||||||
"log_bg": "#ffffff",
|
|
||||||
"log_fg": "#212529",
|
|
||||||
"highlight_bg": "#007bff",
|
|
||||||
"highlight_fg": "#ffffff",
|
|
||||||
"border": "#dee2e6",
|
|
||||||
"success": "#28a745",
|
|
||||||
"error": "#dc3545",
|
|
||||||
"warning": "#ffc107",
|
|
||||||
"info": "#17a2b8",
|
|
||||||
"card_bg": "#ffffff",
|
|
||||||
"shadow": "#00000010"
|
|
||||||
},
|
|
||||||
"dark": {
|
|
||||||
"bg": "#1a1a1a",
|
|
||||||
"fg": "#e9ecef",
|
|
||||||
"button_bg": "#343a40",
|
|
||||||
"button_fg": "#e9ecef",
|
|
||||||
"button_hover": "#495057",
|
|
||||||
"primary_bg": "#0d6efd",
|
|
||||||
"primary_fg": "#ffffff",
|
|
||||||
"secondary_bg": "#6c757d",
|
|
||||||
"secondary_fg": "#ffffff",
|
|
||||||
"log_bg": "#212529",
|
|
||||||
"log_fg": "#e9ecef",
|
|
||||||
"highlight_bg": "#0d6efd",
|
|
||||||
"highlight_fg": "#ffffff",
|
|
||||||
"border": "#495057",
|
|
||||||
"success": "#198754",
|
|
||||||
"error": "#dc3545",
|
|
||||||
"warning": "#ffc107",
|
|
||||||
"info": "#0dcaf0",
|
|
||||||
"card_bg": "#2d3748",
|
|
||||||
"shadow": "#00000030"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_theme_mode() -> str:
|
|
||||||
return _theme_mode
|
|
||||||
|
|
||||||
|
|
||||||
def set_theme_mode(mode: str):
|
|
||||||
global _theme_mode
|
|
||||||
_theme_mode = mode
|
|
||||||
|
|
||||||
|
|
||||||
def create_modern_button(parent, text, command, style="primary", width=None, height=None, px_width=None, px_height=None):
|
|
||||||
"""创建现代化样式的按钮"""
|
|
||||||
theme = THEMES[_theme_mode]
|
|
||||||
|
|
||||||
if style == "primary":
|
|
||||||
bg_color = "white"
|
|
||||||
fg_color = theme["primary_bg"]
|
|
||||||
hover_color = "#f0f8ff"
|
|
||||||
border_color = theme["primary_bg"]
|
|
||||||
elif style == "secondary":
|
|
||||||
bg_color = theme["secondary_bg"]
|
|
||||||
fg_color = theme["secondary_fg"]
|
|
||||||
hover_color = theme["button_hover"]
|
|
||||||
border_color = theme["secondary_bg"]
|
|
||||||
else:
|
|
||||||
bg_color = "white"
|
|
||||||
fg_color = theme["primary_bg"]
|
|
||||||
hover_color = "#f0f8ff"
|
|
||||||
border_color = theme["primary_bg"]
|
|
||||||
|
|
||||||
button_frame = tk.Frame(parent, bg=border_color, highlightthickness=0)
|
|
||||||
button_frame.configure(relief="flat", bd=0)
|
|
||||||
if px_width or px_height:
|
|
||||||
try:
|
|
||||||
w = px_width if px_width else button_frame.winfo_reqwidth()
|
|
||||||
h = px_height if px_height else 32
|
|
||||||
button_frame.configure(width=w, height=h)
|
|
||||||
button_frame.pack_propagate(False)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
button = tk.Button(
|
|
||||||
button_frame,
|
|
||||||
text=text,
|
|
||||||
command=command,
|
|
||||||
bg=bg_color,
|
|
||||||
fg=fg_color,
|
|
||||||
font=("Microsoft YaHei UI", 8),
|
|
||||||
relief="flat",
|
|
||||||
bd=0,
|
|
||||||
padx=14,
|
|
||||||
pady=4,
|
|
||||||
anchor="center",
|
|
||||||
cursor="hand2",
|
|
||||||
activebackground=hover_color,
|
|
||||||
activeforeground=fg_color
|
|
||||||
)
|
|
||||||
|
|
||||||
if width:
|
|
||||||
button.configure(width=width)
|
|
||||||
else:
|
|
||||||
button.configure(width=12)
|
|
||||||
if height is not None:
|
|
||||||
button.configure(height=height)
|
|
||||||
else:
|
|
||||||
button.configure(height=1)
|
|
||||||
if height:
|
|
||||||
button.configure(height=height)
|
|
||||||
|
|
||||||
# 悬停效果
|
|
||||||
def on_enter(e):
|
|
||||||
button.configure(bg=hover_color)
|
|
||||||
|
|
||||||
def on_leave(e):
|
|
||||||
button.configure(bg=bg_color)
|
|
||||||
|
|
||||||
button.bind("<Enter>", on_enter)
|
|
||||||
button.bind("<Leave>", on_leave)
|
|
||||||
button_frame.bind("<Enter>", on_enter)
|
|
||||||
button_frame.bind("<Leave>", on_leave)
|
|
||||||
|
|
||||||
button.pack(fill=tk.BOTH, expand=True, padx=1, pady=1)
|
|
||||||
return button_frame
|
|
||||||
|
|
||||||
|
|
||||||
def create_card_frame(parent, title=None):
|
|
||||||
"""创建卡片样式的框架"""
|
|
||||||
theme = THEMES[_theme_mode]
|
|
||||||
|
|
||||||
card = tk.Frame(
|
|
||||||
parent,
|
|
||||||
bg=theme["card_bg"],
|
|
||||||
relief="flat",
|
|
||||||
borderwidth=1,
|
|
||||||
highlightbackground=theme["border"],
|
|
||||||
highlightthickness=1
|
|
||||||
)
|
|
||||||
|
|
||||||
if title:
|
|
||||||
title_label = tk.Label(
|
|
||||||
card,
|
|
||||||
text=title,
|
|
||||||
bg=theme["card_bg"],
|
|
||||||
fg=theme["fg"],
|
|
||||||
font=("Microsoft YaHei UI", 10, "bold")
|
|
||||||
)
|
|
||||||
title_label.pack(pady=(6, 3))
|
|
||||||
|
|
||||||
return card
|
|
||||||
|
|
||||||
|
|
||||||
def apply_theme(widget, theme_mode=None):
|
|
||||||
"""应用主题到小部件"""
|
|
||||||
if theme_mode is None:
|
|
||||||
theme_mode = _theme_mode
|
|
||||||
|
|
||||||
theme = THEMES[theme_mode]
|
|
||||||
|
|
||||||
try:
|
|
||||||
widget.configure(bg=theme["bg"], fg=theme["fg"])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for child in widget.winfo_children():
|
|
||||||
if isinstance(child, tk.Button) and not isinstance(child, ttk.Button):
|
|
||||||
child.configure(bg=theme["button_bg"], fg=theme["button_fg"])
|
|
||||||
elif isinstance(child, scrolledtext.ScrolledText):
|
|
||||||
child.configure(bg=theme["log_bg"], fg=theme["log_fg"])
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
child.configure(bg=theme["bg"], fg=theme["fg"])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
apply_theme(child, theme_mode)
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""UI控件模块 - StatusBar、ProgressReporter、可折叠框架等"""
|
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk
|
|
||||||
|
|
||||||
from app.ui.theme import THEMES, get_theme_mode
|
|
||||||
|
|
||||||
|
|
||||||
class StatusBar(tk.Frame):
|
|
||||||
"""状态栏,显示当前系统状态和进度"""
|
|
||||||
|
|
||||||
def __init__(self, master, **kwargs):
|
|
||||||
super().__init__(master, **kwargs)
|
|
||||||
self.configure(height=25, relief=tk.SUNKEN, borderwidth=1)
|
|
||||||
|
|
||||||
self.status_label = tk.Label(self, text="就绪", anchor=tk.W, padx=5)
|
|
||||||
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
||||||
|
|
||||||
self.progress = ttk.Progressbar(self, orient=tk.HORIZONTAL, length=200, mode='determinate')
|
|
||||||
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
|
|
||||||
|
|
||||||
self.progress.pack_forget()
|
|
||||||
|
|
||||||
def set_status(self, text, progress=None):
|
|
||||||
"""设置状态栏文本和进度"""
|
|
||||||
self.status_label.config(text=text)
|
|
||||||
|
|
||||||
if progress is not None and 0 <= progress <= 100:
|
|
||||||
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
|
|
||||||
self.progress.config(value=progress)
|
|
||||||
else:
|
|
||||||
self.progress.pack_forget()
|
|
||||||
|
|
||||||
def set_running(self, is_running=True):
|
|
||||||
"""设置运行状态"""
|
|
||||||
theme = THEMES[get_theme_mode()]
|
|
||||||
if is_running:
|
|
||||||
self.status_label.config(text="处理中...", foreground=theme["info"])
|
|
||||||
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
|
|
||||||
self.progress.config(mode='indeterminate')
|
|
||||||
self.progress.start()
|
|
||||||
else:
|
|
||||||
self.status_label.config(text="就绪", foreground=theme["fg"])
|
|
||||||
self.progress.stop()
|
|
||||||
self.progress.pack_forget()
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressReporter:
|
|
||||||
def __init__(self, status_bar: StatusBar):
|
|
||||||
self.status_bar = status_bar
|
|
||||||
|
|
||||||
def set(self, text: str, percent: int = None):
|
|
||||||
try:
|
|
||||||
if percent is not None:
|
|
||||||
self.status_bar.set_status(text, percent)
|
|
||||||
else:
|
|
||||||
self.status_bar.set_status(text)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def running(self):
|
|
||||||
try:
|
|
||||||
self.status_bar.set_running(True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def done(self):
|
|
||||||
try:
|
|
||||||
self.status_bar.set_running(False)
|
|
||||||
self.status_bar.set_status("就绪")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTip:
|
|
||||||
"""给组件添加悬停提示"""
|
|
||||||
def __init__(self, widget, text):
|
|
||||||
self.widget = widget
|
|
||||||
self.text = text
|
|
||||||
self.tip_window = None
|
|
||||||
self.widget.bind("<Enter>", self.show_tip)
|
|
||||||
self.widget.bind("<Leave>", self.hide_tip)
|
|
||||||
|
|
||||||
def show_tip(self, event=None):
|
|
||||||
if self.tip_window or not self.text:
|
|
||||||
return
|
|
||||||
x, y, _cx, cy = self.widget.bbox("insert")
|
|
||||||
x = x + self.widget.winfo_rootx() + 27
|
|
||||||
y = y + cy + self.widget.winfo_rooty() + 27
|
|
||||||
self.tip_window = tw = tk.Toplevel(self.widget)
|
|
||||||
tw.wm_overrideredirect(1)
|
|
||||||
tw.wm_geometry("+%d+%d" % (x, y))
|
|
||||||
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
|
|
||||||
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
|
|
||||||
font=("tahoma", "8", "normal"), padx=4, pady=2)
|
|
||||||
label.pack(ipadx=1)
|
|
||||||
|
|
||||||
def hide_tip(self, event=None):
|
|
||||||
tw = self.tip_window
|
|
||||||
self.tip_window = None
|
|
||||||
if tw:
|
|
||||||
tw.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
def create_collapsible_frame(parent, title, initial_state=True):
|
|
||||||
"""创建可折叠的面板"""
|
|
||||||
frame = tk.Frame(parent)
|
|
||||||
frame.pack(fill=tk.X, pady=5)
|
|
||||||
|
|
||||||
title_frame = tk.Frame(frame)
|
|
||||||
title_frame.pack(fill=tk.X)
|
|
||||||
|
|
||||||
state_var = tk.BooleanVar(value=initial_state)
|
|
||||||
indicator = "▼" if initial_state else "►"
|
|
||||||
state_label = tk.Label(title_frame, text=indicator, font=("Arial", 10, "bold"))
|
|
||||||
state_label.pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
title_label = tk.Label(title_frame, text=title, font=("Arial", 11, "bold"))
|
|
||||||
title_label.pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
content_frame = tk.Frame(frame)
|
|
||||||
if initial_state:
|
|
||||||
content_frame.pack(fill=tk.X, padx=20, pady=5)
|
|
||||||
|
|
||||||
def toggle_collapse(event=None):
|
|
||||||
current_state = state_var.get()
|
|
||||||
new_state = not current_state
|
|
||||||
state_var.set(new_state)
|
|
||||||
state_label.config(text="▼" if new_state else "►")
|
|
||||||
if new_state:
|
|
||||||
content_frame.pack(fill=tk.X, padx=20, pady=5)
|
|
||||||
else:
|
|
||||||
content_frame.pack_forget()
|
|
||||||
|
|
||||||
title_frame.bind("<Button-1>", toggle_collapse)
|
|
||||||
state_label.bind("<Button-1>", toggle_collapse)
|
|
||||||
title_label.bind("<Button-1>", toggle_collapse)
|
|
||||||
|
|
||||||
return content_frame, state_var
|
|
||||||
|
|
||||||
|
|
||||||
def center_window(window):
|
|
||||||
"""使窗口居中显示"""
|
|
||||||
window.update_idletasks()
|
|
||||||
width = window.winfo_width()
|
|
||||||
height = window.winfo_height()
|
|
||||||
x = (window.winfo_screenwidth() // 2) - (width // 2)
|
|
||||||
y = (window.winfo_screenheight() // 2) - (height // 2)
|
|
||||||
window.geometry('{}x{}+{}+{}'.format(width, height, x, y))
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""用户设置与最近文件管理模块"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import Dict, List, Any
|
|
||||||
|
|
||||||
from app.core.utils.log_utils import get_logger
|
|
||||||
from app.config.settings import ConfigManager
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
RECENT_LIST_WIDGET = None
|
|
||||||
|
|
||||||
|
|
||||||
def load_user_settings():
|
|
||||||
try:
|
|
||||||
path = os.path.abspath(os.path.join('data', 'user_settings.json'))
|
|
||||||
if os.path.exists(path):
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"加载用户设置失败: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_user_settings(settings: Dict[str, Any]):
|
|
||||||
try:
|
|
||||||
os.makedirs('data', exist_ok=True)
|
|
||||||
path = os.path.abspath(os.path.join('data', 'user_settings.json'))
|
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(settings, f, ensure_ascii=False, indent=2)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"保存用户设置失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_recent_files() -> List[str]:
|
|
||||||
s = load_user_settings()
|
|
||||||
items = s.get('recent_files', [])
|
|
||||||
if not isinstance(items, list):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _allowed(p: str) -> bool:
|
|
||||||
try:
|
|
||||||
if not isinstance(p, str) or not os.path.isfile(p):
|
|
||||||
return False
|
|
||||||
ext = os.path.splitext(p)[1].lower()
|
|
||||||
return ext in {'.xlsx', '.xls', '.jpg', '.jpeg', '.png', '.bmp'}
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
kept = [p for p in items if _allowed(p)]
|
|
||||||
if not kept:
|
|
||||||
candidates = []
|
|
||||||
cfg = ConfigManager()
|
|
||||||
for d in [cfg.get_path('Paths', 'output_folder', fallback='data/output'), cfg.get_path('Paths', 'result_folder', fallback='data/result')]:
|
|
||||||
try:
|
|
||||||
if os.path.exists(d):
|
|
||||||
for name in os.listdir(d):
|
|
||||||
p = os.path.join(d, name)
|
|
||||||
if _allowed(p):
|
|
||||||
candidates.append(p)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if candidates:
|
|
||||||
kept = candidates
|
|
||||||
try:
|
|
||||||
kept_sorted = sorted(kept, key=lambda p: os.path.getmtime(p), reverse=True)
|
|
||||||
except Exception:
|
|
||||||
kept_sorted = kept
|
|
||||||
if kept_sorted != items or len(kept_sorted) != len(items):
|
|
||||||
s['recent_files'] = kept_sorted[:20]
|
|
||||||
save_user_settings(s)
|
|
||||||
return kept_sorted[:10]
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_recent_list_widget():
|
|
||||||
try:
|
|
||||||
global RECENT_LIST_WIDGET
|
|
||||||
if RECENT_LIST_WIDGET is None:
|
|
||||||
return
|
|
||||||
RECENT_LIST_WIDGET.delete(0, tk.END)
|
|
||||||
for i, p in enumerate(get_recent_files(), start=1):
|
|
||||||
RECENT_LIST_WIDGET.insert(tk.END, f"{i}. {p}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"刷新最近文件列表失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_path_from_recent_item(s: str) -> str:
|
|
||||||
try:
|
|
||||||
m = re.match(r'^(\d+)\.\s+(.*)$', s)
|
|
||||||
p = m.group(2) if m else s
|
|
||||||
return p.strip().strip('"')
|
|
||||||
except Exception:
|
|
||||||
return s.strip().strip('"')
|
|
||||||
|
|
||||||
|
|
||||||
def add_recent_file(path: str) -> None:
|
|
||||||
try:
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
return
|
|
||||||
ext = os.path.splitext(path)[1].lower()
|
|
||||||
if ext not in {'.xlsx', '.xls', '.jpg', '.jpeg', '.png', '.bmp'}:
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
s = load_user_settings()
|
|
||||||
items = s.get('recent_files', [])
|
|
||||||
items = [p for p in items if p != path]
|
|
||||||
items.insert(0, path)
|
|
||||||
s['recent_files'] = items[:20]
|
|
||||||
save_user_settings(s)
|
|
||||||
refresh_recent_list_widget()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"添加最近文件失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def clear_recent_files():
|
|
||||||
try:
|
|
||||||
s = load_user_settings()
|
|
||||||
s['recent_files'] = []
|
|
||||||
save_user_settings(s)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"清空最近文件失败: {e}")
|
|
||||||
+79
-78
@@ -40,6 +40,7 @@ added_files = [
|
|||||||
('config/barcode_mappings.json', 'config/'),
|
('config/barcode_mappings.json', 'config/'),
|
||||||
('config/config.ini', 'config/'),
|
('config/config.ini', 'config/'),
|
||||||
('templates/银豹-采购单模板.xls', 'templates/'),
|
('templates/银豹-采购单模板.xls', 'templates/'),
|
||||||
|
('app', 'app'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# 需要隐式导入的模块
|
# 需要隐式导入的模块
|
||||||
@@ -55,10 +56,7 @@ hidden_imports = [
|
|||||||
'xlrd',
|
'xlrd',
|
||||||
'xlwt',
|
'xlwt',
|
||||||
'xlutils',
|
'xlutils',
|
||||||
'xlutils.copy',
|
|
||||||
'requests',
|
'requests',
|
||||||
'dotenv',
|
|
||||||
'tkinterdnd2',
|
|
||||||
'configparser',
|
'configparser',
|
||||||
'threading',
|
'threading',
|
||||||
'datetime',
|
'datetime',
|
||||||
@@ -66,17 +64,17 @@ hidden_imports = [
|
|||||||
're',
|
're',
|
||||||
'subprocess',
|
'subprocess',
|
||||||
'shutil',
|
'shutil',
|
||||||
'sqlite3',
|
'app.config.settings',
|
||||||
'logging',
|
'app.services.ocr_service',
|
||||||
'base64',
|
'app.services.order_service',
|
||||||
'concurrent.futures',
|
'app.services.tobacco_service',
|
||||||
'pathlib',
|
'app.core.utils.dialog_utils',
|
||||||
'typing',
|
'app.core.excel.converter',
|
||||||
]
|
]
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['启动器.py'],
|
['启动器.py'],
|
||||||
pathex=['.'],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=added_files,
|
datas=added_files,
|
||||||
hiddenimports=hidden_imports,
|
hiddenimports=hidden_imports,
|
||||||
@@ -197,104 +195,107 @@ def build_exe():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def create_portable_package():
|
def create_portable_package():
|
||||||
"""创建并更新便携版打包,并同步到桌面"""
|
"""创建便携版打包"""
|
||||||
print("更新便携版打包...")
|
print("创建便携版打包...")
|
||||||
|
|
||||||
# 1. 准备本地 release 目录
|
# 创建发布目录
|
||||||
release_dir = Path('release')
|
release_dir = Path('release')
|
||||||
|
if release_dir.exists():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(release_dir)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"警告: 无法完全清理发布目录 (可能文件被占用): {e}")
|
||||||
|
# 如果目录还在,尝试清理能清理的部分
|
||||||
|
for item in release_dir.iterdir():
|
||||||
|
try:
|
||||||
|
if item.is_dir(): shutil.rmtree(item)
|
||||||
|
else: item.unlink()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
# 不再删除整个目录,以保留 data/input 等用户数据
|
release_dir.mkdir(exist_ok=True)
|
||||||
if not release_dir.exists():
|
|
||||||
release_dir.mkdir(parents=True)
|
|
||||||
print(f"已创建本地发布目录: {release_dir}")
|
|
||||||
else:
|
|
||||||
print(f"本地发布目录已存在,将进行增量更新: {release_dir}")
|
|
||||||
|
|
||||||
# 2. 更新核心文件到本地 release
|
|
||||||
# 复制exe文件
|
# 复制exe文件
|
||||||
exe_file = Path('dist/OCR订单处理系统.exe')
|
exe_file = Path('dist/OCR订单处理系统.exe')
|
||||||
if exe_file.exists():
|
if exe_file.exists():
|
||||||
shutil.copy2(exe_file, release_dir)
|
shutil.copy2(exe_file, release_dir)
|
||||||
print(f"已更新 EXE: {exe_file} -> {release_dir}")
|
print(f"已复制: {exe_file} -> {release_dir}")
|
||||||
|
|
||||||
# 确保必要的子目录存在
|
# 创建必要的目录结构
|
||||||
dirs_to_ensure = ['data/input', 'data/output', 'data/result', 'logs', 'templates', 'config']
|
dirs_to_create = ['data/input', 'data/output', 'logs', 'templates', 'config']
|
||||||
for dir_path in dirs_to_ensure:
|
for dir_path in dirs_to_create:
|
||||||
(release_dir / dir_path).mkdir(parents=True, exist_ok=True)
|
(release_dir / dir_path).mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"已创建目录: {dir_path}")
|
||||||
|
|
||||||
# 复制配置文件
|
# 复制配置文件(包含API密钥)
|
||||||
files_to_copy = [
|
config_file = Path('config/config.ini')
|
||||||
(Path('config/config.ini'), release_dir / 'config'),
|
if config_file.exists():
|
||||||
(Path('config/barcode_mappings.json'), release_dir / 'config'),
|
shutil.copy2(config_file, release_dir / 'config')
|
||||||
(Path('config.ini'), release_dir),
|
print(f"已复制配置文件: {config_file} -> {release_dir / 'config'}")
|
||||||
(Path('templates/银豹-采购单模板.xls'), release_dir / 'templates'),
|
|
||||||
(Path('templates/商品资料.xlsx'), release_dir / 'templates'),
|
|
||||||
]
|
|
||||||
|
|
||||||
for src, dst_dir in files_to_copy:
|
|
||||||
if src.exists():
|
|
||||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.copy2(src, dst_dir)
|
|
||||||
print(f"已更新文件: {src} -> {dst_dir}")
|
|
||||||
else:
|
else:
|
||||||
print(f"警告: 源文件不存在,跳过更新: {src}")
|
print(f"警告: 配置文件不存在: {config_file}")
|
||||||
|
|
||||||
# 3. 创建/更新 README
|
# 复制完整的条码映射文件
|
||||||
|
barcode_mapping_file = Path('config/barcode_mappings.json')
|
||||||
|
if barcode_mapping_file.exists():
|
||||||
|
shutil.copy2(barcode_mapping_file, release_dir / 'config')
|
||||||
|
print(f"已复制条码映射文件: {barcode_mapping_file} -> {release_dir / 'config'}")
|
||||||
|
else:
|
||||||
|
print(f"警告: 条码映射文件不存在: {barcode_mapping_file}")
|
||||||
|
|
||||||
|
# 复制根目录的config.ini文件
|
||||||
|
root_config_file = Path('config.ini')
|
||||||
|
if root_config_file.exists():
|
||||||
|
shutil.copy2(root_config_file, release_dir)
|
||||||
|
print(f"已复制根配置文件: {root_config_file} -> {release_dir}")
|
||||||
|
else:
|
||||||
|
print(f"警告: 根配置文件不存在: {root_config_file}")
|
||||||
|
|
||||||
|
# 复制模板文件
|
||||||
|
template_file = Path('templates/银豹-采购单模板.xls')
|
||||||
|
if template_file.exists():
|
||||||
|
shutil.copy2(template_file, release_dir / 'templates')
|
||||||
|
print(f"已复制模板文件: {template_file} -> {release_dir / 'templates'}")
|
||||||
|
else:
|
||||||
|
print(f"警告: 模板文件不存在: {template_file}")
|
||||||
|
item_file = Path('templates/商品资料.xlsx')
|
||||||
|
if item_file.exists():
|
||||||
|
try:
|
||||||
|
(Path('dist') / 'templates').mkdir(exist_ok=True)
|
||||||
|
shutil.copy2(item_file, Path('dist') / 'templates')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
shutil.copy2(item_file, release_dir / 'templates')
|
||||||
|
print(f"已复制商品资料: {item_file} -> {release_dir / 'templates'}")
|
||||||
|
else:
|
||||||
|
print(f"警告: 商品资料文件不存在: {item_file}")
|
||||||
|
|
||||||
|
# 创建README文件
|
||||||
readme_content = '''
|
readme_content = '''
|
||||||
# OCR订单处理系统 - 便携版
|
# OCR订单处理系统 - 便携版
|
||||||
|
|
||||||
## 使用说明
|
## 使用说明
|
||||||
1. 双击 "OCR订单处理系统.exe" 启动程序
|
1. 双击 "OCR订单处理系统.exe" 启动程序
|
||||||
2. 将需要处理的图片文件放入 data/input 目录
|
2. 将需要处理的图片文件放入 data/input 目录
|
||||||
3. 处理结果将保存在 data/result 目录 (采购单 Excel)
|
3. 处理结果将保存在 data/output 目录
|
||||||
4. 中间过程文件在 data/output 目录
|
4. 日志文件保存在 logs 目录
|
||||||
5. 日志文件保存在 logs 目录
|
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
- 首次运行时需要配置百度OCR API密钥
|
||||||
- 支持的图片格式:jpg, jpeg, png, bmp
|
- 支持的图片格式:jpg, jpeg, png, bmp
|
||||||
- 单个文件大小建议不超过 4MB
|
- 单个文件大小不超过4MB
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
- OCR订单处理系统.exe - 主程序
|
- OCR订单处理系统.exe - 主程序
|
||||||
- data/input/ - 输入图片目录
|
- data/input/ - 输入图片目录
|
||||||
- data/result/ - 最终采购单目录
|
- data/output/ - 输出结果目录
|
||||||
- logs/ - 日志目录
|
- logs/ - 日志目录
|
||||||
'''
|
'''
|
||||||
|
|
||||||
with open(release_dir / 'README.txt', 'w', encoding='utf-8') as f:
|
with open(release_dir / 'README.txt', 'w', encoding='utf-8') as f:
|
||||||
f.write(readme_content)
|
f.write(readme_content)
|
||||||
|
print("已创建README.txt")
|
||||||
|
|
||||||
# 4. 同步到桌面
|
print(f"便携版打包完成,位置: {release_dir.absolute()}")
|
||||||
try:
|
|
||||||
# 用户指定的特殊桌面路径
|
|
||||||
desktop_path = Path(r"F:\Administrator\桌面")
|
|
||||||
if not desktop_path.exists():
|
|
||||||
# 兜底:如果 F 盘路径不存在,尝试系统默认路径
|
|
||||||
desktop_path = Path(os.path.join(os.path.expanduser("~"), "Desktop"))
|
|
||||||
|
|
||||||
desktop_release = desktop_path / "OCR系统_Release"
|
|
||||||
|
|
||||||
print(f"正在同步到桌面: {desktop_release}")
|
|
||||||
|
|
||||||
# 使用自定义的同步逻辑,避免删除目标目录中的其他文件
|
|
||||||
def sync_dir(src_root, dst_root):
|
|
||||||
if not dst_root.exists():
|
|
||||||
dst_root.mkdir(parents=True)
|
|
||||||
|
|
||||||
for item in src_root.iterdir():
|
|
||||||
dst_item = dst_root / item.name
|
|
||||||
if item.is_dir():
|
|
||||||
sync_dir(item, dst_item)
|
|
||||||
else:
|
|
||||||
# 如果是文件,直接覆盖更新
|
|
||||||
shutil.copy2(item, dst_item)
|
|
||||||
|
|
||||||
sync_dir(release_dir, desktop_release)
|
|
||||||
print(f"同步成功!桌面位置: {desktop_release.absolute()}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"同步到桌面失败: {e}")
|
|
||||||
|
|
||||||
print(f"本地便携版更新完成,位置: {release_dir.absolute()}")
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""主函数"""
|
"""主函数"""
|
||||||
|
|||||||
-73
@@ -1,73 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
import argparse
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def check_db(table_name=None, limit=20):
|
|
||||||
db_path = r'e:\2025Code\orc-order-v3\orc-order-v3\release\data\product_cache.db'
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
# 尝试开发环境路径
|
|
||||||
db_path = r'e:\2025Code\orc-order-v3\orc-order-v3\data\product_cache.db'
|
|
||||||
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
print(f"错误: 找不到数据库文件 {db_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"正在读取数据库: {db_path}")
|
|
||||||
print("-" * 60)
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# 获取所有表名
|
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
|
||||||
tables = [row[0] for row in cursor.fetchall() if row[0] != 'sqlite_sequence']
|
|
||||||
|
|
||||||
if not table_name:
|
|
||||||
print(f"数据库中的表: {', '.join(tables)}")
|
|
||||||
print("\n使用 'python check_db.py [表名]' 查看具体内容")
|
|
||||||
|
|
||||||
# 默认显示一些汇总信息
|
|
||||||
for table in tables:
|
|
||||||
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
|
||||||
count = cursor.fetchone()[0]
|
|
||||||
print(f" - {table}: {count} 条记录")
|
|
||||||
|
|
||||||
target_tables = [table_name] if table_name else tables
|
|
||||||
|
|
||||||
for table in target_tables:
|
|
||||||
if table not in tables:
|
|
||||||
print(f"\n警告: 表 '{table}' 不存在")
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f"\n=== 表: {table} (最近 {limit} 条) ===")
|
|
||||||
cursor.execute(f"SELECT * FROM {table} LIMIT {limit}")
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
print(" (空)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 打印表头
|
|
||||||
keys = rows[0].keys()
|
|
||||||
header = " | ".join(f"{str(k):<15}" for k in keys)
|
|
||||||
print(header)
|
|
||||||
print("-" * len(header))
|
|
||||||
|
|
||||||
# 打印行
|
|
||||||
for row in rows:
|
|
||||||
print(" | ".join(f"{str(row[k])[:15]:<15}" for k in keys))
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"读取数据库出错: {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="查看 OCR 系统数据库内容")
|
|
||||||
parser.add_argument("table", nargs="?", help="要查看的表名 (products, order_metadata, missing_barcodes)")
|
|
||||||
parser.add_argument("--limit", type=int, default=20, help="显示记录条数 (默认 20)")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
check_db(args.table, args.limit)
|
|
||||||
+7
-25
@@ -1,24 +1,17 @@
|
|||||||
[API]
|
[API]
|
||||||
api_key = yBU35EDIZ2ITLRk1MnFBN1tv
|
api_key = O0Fgk3o69RWJ86eAX8BTHRaB
|
||||||
secret_key = 7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr
|
secret_key = VyZD5lzcIMgsup1uuD6Cw0pfzS20IGPZ
|
||||||
timeout = 30
|
timeout = 30
|
||||||
max_retries = 5
|
max_retries = 3
|
||||||
retry_delay = 1
|
retry_delay = 2
|
||||||
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
|
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
|
||||||
token_url = https://aip.baidubce.com/oauth/2.0/token
|
|
||||||
form_ocr_url = https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result
|
|
||||||
general_ocr_url = https://aip.baidubce.com/rest/2.0/ocr/v1/accurate
|
|
||||||
|
|
||||||
[Paths]
|
[Paths]
|
||||||
input_folder = data/input
|
input_folder = data/input
|
||||||
output_folder = data/output
|
output_folder = data/output
|
||||||
temp_folder = data/temp
|
temp_folder = data/temp
|
||||||
template_folder = templates
|
template_folder = E:\2025Code\python\orc-order-v2\templates
|
||||||
template_file = templates\银豹-采购单模板.xls
|
|
||||||
processed_record = data/processed_files.json
|
processed_record = data/processed_files.json
|
||||||
data_dir = data
|
|
||||||
product_db = data/product_cache.db
|
|
||||||
result_folder = data/result
|
|
||||||
|
|
||||||
[Performance]
|
[Performance]
|
||||||
max_workers = 4
|
max_workers = 4
|
||||||
@@ -28,22 +21,11 @@ skip_existing = true
|
|||||||
[File]
|
[File]
|
||||||
allowed_extensions = .jpg,.jpeg,.png,.bmp
|
allowed_extensions = .jpg,.jpeg,.png,.bmp
|
||||||
excel_extension = .xlsx
|
excel_extension = .xlsx
|
||||||
max_file_size_mb = 5
|
max_file_size_mb = 4
|
||||||
|
|
||||||
[Templates]
|
[Templates]
|
||||||
purchase_order = 银豹-采购单模板.xls
|
purchase_order = 银豹-采购单模板.xls
|
||||||
item_data = 商品资料.xlsx
|
|
||||||
|
|
||||||
[App]
|
[App]
|
||||||
version = 2026.07.20.2105
|
version = 2026.03.30.1036
|
||||||
|
|
||||||
[Gitea]
|
|
||||||
base_url = https://gitea.94kan.cn
|
|
||||||
owner = houhuan
|
|
||||||
repo = yixuan-sync-data
|
|
||||||
token = 149f3f64edad2a3a25d94a054ea699f3d28f7294
|
|
||||||
|
|
||||||
[WebAuth]
|
|
||||||
username = admin
|
|
||||||
password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC
|
|
||||||
|
|
||||||
|
|||||||
@@ -231,10 +231,6 @@
|
|||||||
"map_to": "6977826050028",
|
"map_to": "6977826050028",
|
||||||
"description": "条码映射:6972556000022 -> 6977826050028"
|
"description": "条码映射:6972556000022 -> 6977826050028"
|
||||||
},
|
},
|
||||||
"6949352266280": {
|
|
||||||
"map_to": "6949352266273",
|
|
||||||
"description": "条码映射:6949352266280 -> 6949352266273"
|
|
||||||
},
|
|
||||||
"6925019900087": {
|
"6925019900087": {
|
||||||
"multiplier": 10,
|
"multiplier": 10,
|
||||||
"target_unit": "瓶",
|
"target_unit": "瓶",
|
||||||
@@ -257,17 +253,5 @@
|
|||||||
"target_unit": "个",
|
"target_unit": "个",
|
||||||
"specification": "1*14",
|
"specification": "1*14",
|
||||||
"description": "友臣肉松,1盒14个"
|
"description": "友臣肉松,1盒14个"
|
||||||
},
|
|
||||||
"6921734933485": {
|
|
||||||
"multiplier": 12,
|
|
||||||
"target_unit": "支",
|
|
||||||
"specification": "1*12",
|
|
||||||
"description": "得力铅笔"
|
|
||||||
},
|
|
||||||
"6901826888244": {
|
|
||||||
"multiplier": 30,
|
|
||||||
"target_unit": "对",
|
|
||||||
"specification": "1*30",
|
|
||||||
"description": "南孚电池"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-18
@@ -1,24 +1,17 @@
|
|||||||
[API]
|
[API]
|
||||||
api_key = yBU35EDIZ2ITLRk1MnFBN1tv
|
api_key = O0Fgk3o69RWJ86eAX8BTHRaB
|
||||||
secret_key = 7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr
|
secret_key = VyZD5lzcIMgsup1uuD6Cw0pfzS20IGPZ
|
||||||
timeout = 30
|
timeout = 30
|
||||||
max_retries = 5
|
max_retries = 3
|
||||||
retry_delay = 1
|
retry_delay = 2
|
||||||
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
|
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
|
||||||
token_url = https://aip.baidubce.com/oauth/2.0/token
|
|
||||||
form_ocr_url = https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result
|
|
||||||
general_ocr_url = https://aip.baidubce.com/rest/2.0/ocr/v1/accurate
|
|
||||||
|
|
||||||
[Paths]
|
[Paths]
|
||||||
input_folder = data/input
|
input_folder = data/input
|
||||||
output_folder = data/output
|
output_folder = data/output
|
||||||
result_folder = data/result
|
|
||||||
temp_folder = data/temp
|
temp_folder = data/temp
|
||||||
template_folder = templates
|
template_folder = templates
|
||||||
template_file = templates/银豹-采购单模板.xls
|
|
||||||
processed_record = data/processed_files.json
|
processed_record = data/processed_files.json
|
||||||
data_dir = data
|
|
||||||
product_db = data/product_cache.db
|
|
||||||
|
|
||||||
[Performance]
|
[Performance]
|
||||||
max_workers = 4
|
max_workers = 4
|
||||||
@@ -32,11 +25,4 @@ max_file_size_mb = 4
|
|||||||
|
|
||||||
[Templates]
|
[Templates]
|
||||||
purchase_order = 银豹-采购单模板.xls
|
purchase_order = 银豹-采购单模板.xls
|
||||||
item_data = 商品资料.xlsx
|
|
||||||
|
|
||||||
[Gitea]
|
|
||||||
base_url = https://gitea.94kan.cn
|
|
||||||
owner = houhuan
|
|
||||||
repo = yixuan-sync-data
|
|
||||||
token = 149f3f64edad2a3a25d94a054ea699f3d28f7294
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"window_size": "900x600",
|
||||||
|
"theme_mode": "light",
|
||||||
|
"recent_files": [
|
||||||
|
"data/result\\采购单_预处理之后_原始数据.xls",
|
||||||
|
"E:/2025Code/python/orc-order-v2/data/output/原始数据.xlsx",
|
||||||
|
"data/result\\采购单_预处理之后_订单1774849009841.xls",
|
||||||
|
"E:/2025Code/python/orc-order-v2/data/output/订单明细20260330133908.xlsx",
|
||||||
|
"data/output\\订单明细20260330133908.xlsx",
|
||||||
|
"E:/2025Code/python/orc-order-v2/data/output/订单1774849009841.xlsx",
|
||||||
|
"data/output\\订单1774849009841.xlsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
|
|
||||||
import os
|
|
||||||
import base64
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def get_access_token(api_key, secret_key):
|
|
||||||
url = f"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={api_key}&client_secret={secret_key}"
|
|
||||||
payload = ""
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
response = requests.request("POST", url, headers=headers, data=payload)
|
|
||||||
return response.json().get("access_token")
|
|
||||||
|
|
||||||
def test_general_ocr(image_path, api_key, secret_key):
|
|
||||||
# 用户要求的接口: 通用卡证票据识别
|
|
||||||
request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/general_ocr"
|
|
||||||
|
|
||||||
with open(image_path, "rb") as f:
|
|
||||||
img = base64.b64encode(f.read())
|
|
||||||
|
|
||||||
params = {"image": img}
|
|
||||||
access_token = get_access_token(api_key, secret_key)
|
|
||||||
request_url = request_url + "?access_token=" + access_token
|
|
||||||
headers = {'content-type': 'application/x-www-form-urlencoded'}
|
|
||||||
print(f"正在请求接口: {request_url}")
|
|
||||||
response = requests.post(request_url, data=params, headers=headers)
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 使用 config.ini 中的密钥
|
|
||||||
api_key = "yBU35EDIZ2ITLRk1MnFBN1tv"
|
|
||||||
secret_key = "7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr"
|
|
||||||
|
|
||||||
# 测试图片路径
|
|
||||||
test_image = r"F:\Administrator\桌面\OCR系统_Release\data\input\采购单_20260717_优链快批销售单.jpg"
|
|
||||||
|
|
||||||
if not os.path.exists(test_image):
|
|
||||||
print(f"错误: 找不到测试图片 {test_image}")
|
|
||||||
else:
|
|
||||||
print(f"正在测试接口【/v1/general_ocr】图片: {os.path.basename(test_image)}")
|
|
||||||
result = test_general_ocr(test_image, api_key, secret_key)
|
|
||||||
|
|
||||||
print("\n--- API 返回结果 ---")
|
|
||||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
if "error_code" in result:
|
|
||||||
if result["error_code"] == 6:
|
|
||||||
print("\n【重要提示】: 权限错误 (6)。请前往百度云控制台开启【通用票据识别】(General OCR) 服务。")
|
|
||||||
else:
|
|
||||||
print(f"\nAPI 返回错误: {result.get('error_msg')}")
|
|
||||||
else:
|
|
||||||
print("\n识别成功!请检查返回结果中的 words_result 是否包含供应商和日期。")
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
|
|
||||||
import os
|
|
||||||
import base64
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def get_access_token(api_key, secret_key):
|
|
||||||
url = f"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={api_key}&client_secret={secret_key}"
|
|
||||||
response = requests.request("POST", url)
|
|
||||||
return response.json().get("access_token")
|
|
||||||
|
|
||||||
def test_table_v2(image_path, api_key, secret_key):
|
|
||||||
# 表格识别V2接口
|
|
||||||
request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/table"
|
|
||||||
|
|
||||||
with open(image_path, "rb") as f:
|
|
||||||
img = base64.b64encode(f.read())
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"image": img,
|
|
||||||
"is_sync": "true",
|
|
||||||
"request_type": "excel"
|
|
||||||
}
|
|
||||||
access_token = get_access_token(api_key, secret_key)
|
|
||||||
request_url = request_url + "?access_token=" + access_token
|
|
||||||
headers = {'content-type': 'application/x-www-form-urlencoded'}
|
|
||||||
response = requests.post(request_url, data=params, headers=headers)
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
api_key = "kIehdWbbVD85K18qZYz6SeUf"
|
|
||||||
secret_key = "RCXTgmVjJJkNNMhfY5ASab0xY3mvF6d7"
|
|
||||||
|
|
||||||
input_dir = Path(r"F:\Administrator\桌面\OCR系统_Release\data\input")
|
|
||||||
images = list(input_dir.glob("*.jpg")) + list(input_dir.glob("*.png"))
|
|
||||||
|
|
||||||
if not images:
|
|
||||||
print("错误: 桌面 Release 目录下没有找到图片文件")
|
|
||||||
else:
|
|
||||||
latest_image = max(images, key=os.path.getmtime)
|
|
||||||
print(f"正在使用【表格识别V2】接口测试图片: {latest_image.name}")
|
|
||||||
|
|
||||||
result = test_table_v2(str(latest_image), api_key, secret_key)
|
|
||||||
|
|
||||||
# 打印表格外的文字(header/footer)
|
|
||||||
if "tables_result" in result:
|
|
||||||
print("\n--- 表格识别结果 ---")
|
|
||||||
for table in result["tables_result"]:
|
|
||||||
print("\n[Header 区域]:")
|
|
||||||
header = table.get("header", [])
|
|
||||||
for cell in header:
|
|
||||||
print(f"- {cell.get('words')}")
|
|
||||||
|
|
||||||
print("\n[Footer 区域]:")
|
|
||||||
footer = table.get("footer", [])
|
|
||||||
for cell in footer:
|
|
||||||
print(f"- {cell.get('words')}")
|
|
||||||
elif "result" in result and "tables_result" in result["result"]:
|
|
||||||
print("\n--- 表格识别结果 (嵌套结构) ---")
|
|
||||||
for table in result["result"]["tables_result"]:
|
|
||||||
print("\n[Header 区域]:")
|
|
||||||
header = table.get("header", [])
|
|
||||||
for cell in header:
|
|
||||||
print(f"- {cell.get('words')}")
|
|
||||||
|
|
||||||
print("\n[Footer 区域]:")
|
|
||||||
footer = table.get("footer", [])
|
|
||||||
for cell in footer:
|
|
||||||
print(f"- {cell.get('words')}")
|
|
||||||
else:
|
|
||||||
print(f"识别失败或未找到表格: {json.dumps(result, indent=2, ensure_ascii=False)[:500]}")
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.backend
|
|
||||||
container_name: yixuan-backend
|
|
||||||
ports:
|
|
||||||
- "18889:18889"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./config.ini:/app/config.ini:ro
|
|
||||||
- ./config:/app/config:ro
|
|
||||||
- ./templates:/app/templates:ro
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.frontend
|
|
||||||
container_name: yixuan-frontend
|
|
||||||
ports:
|
|
||||||
- "18888:18888"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# OCR 订单处理系统 - 系统架构文档 (v2.2)
|
||||||
|
|
||||||
|
本文件详述了“OCR 订单处理系统”的技术架构、业务流向、数据模型及部署方案。
|
||||||
|
|
||||||
|
## 1. 系统整体架构图 (System Overall Architecture)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph 用户交互层
|
||||||
|
UI[启动器.py / Tkinter GUI]
|
||||||
|
CLI[headless_api.py / CLI]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 核心业务逻辑层
|
||||||
|
OS[OrderService / 订单调度]
|
||||||
|
OCR[OCRService / 图片识别]
|
||||||
|
SSS[SpecialSuppliersService / 特殊供应商处理]
|
||||||
|
TS[TobaccoService / 烟草处理]
|
||||||
|
EP[ExcelProcessor / 标准化转换]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 基础设施与存储
|
||||||
|
CONFIG[ConfigManager / JSON 配置]
|
||||||
|
FS[FileSystem / Excel 数据存储]
|
||||||
|
LOG[QueueLogger / 异步日志队列]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 第三方集成
|
||||||
|
OPENCLAW[OpenClaw 自动化平台]
|
||||||
|
POSPAL[银豹 POS 系统 (导出模板)]
|
||||||
|
end
|
||||||
|
|
||||||
|
UI --> OS
|
||||||
|
CLI --> OS
|
||||||
|
OPENCLAW -- 调用 --> CLI
|
||||||
|
OS --> OCR
|
||||||
|
OS --> SSS
|
||||||
|
OS --> TS
|
||||||
|
OS --> EP
|
||||||
|
EP --> FS
|
||||||
|
EP --> CONFIG
|
||||||
|
SSS --> EP
|
||||||
|
TS --> EP
|
||||||
|
OS -- 验证 --> FS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图例说明
|
||||||
|
- **用户交互层**:支持桌面 GUI 操作及专为 OpenClaw 设计的无界面 API 接入。
|
||||||
|
- **核心业务层**:各服务高度解耦,通过 `OrderService` 进行智能路由分发。
|
||||||
|
- **存储层**:系统采用“文件即数据库”的设计,利用 Excel 存储模板和商品资料,JSON 存储映射关系。
|
||||||
|
- **第三方集成**:与 OpenClaw 平台通过 CLI 接口对接,最终生成符合银豹 POS 要求的采购单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心业务逻辑流程图 (Core Business Logic)
|
||||||
|
|
||||||
|
以“智能订单识别与预处理”为例:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 用户/OpenClaw
|
||||||
|
participant OS as OrderService
|
||||||
|
participant SSS as SpecialSuppliersService
|
||||||
|
participant TS as TobaccoService
|
||||||
|
participant EP as ExcelProcessor
|
||||||
|
|
||||||
|
User->>OS: 提交 Excel 文件
|
||||||
|
OS->>OS: 扫描前50行内容特征
|
||||||
|
|
||||||
|
alt 包含 "RCDH"
|
||||||
|
OS->>SSS: 路由至蓉城易购预处理
|
||||||
|
SSS->>SSS: 按 E, N, Q, S 列强制清洗
|
||||||
|
SSS-->>OS: 返回清洗后的临时文件
|
||||||
|
else 包含 "专卖证号"
|
||||||
|
OS->>TS: 路由至烟草专用预处理
|
||||||
|
TS->>TS: 数量*10 / 单价/10 / B,E,G,H列映射
|
||||||
|
TS-->>OS: 返回清洗后的临时文件
|
||||||
|
else 包含 "杨碧月"
|
||||||
|
OS->>SSS: 路由至杨碧月列对齐流程
|
||||||
|
SSS-->>OS: 返回标准列临时文件
|
||||||
|
else 通用格式
|
||||||
|
OS->>OS: 直接跳过预处理
|
||||||
|
end
|
||||||
|
|
||||||
|
OS->>EP: 执行标准条码映射与模板填充
|
||||||
|
EP->>EP: 校验单价 (与商品资料比对)
|
||||||
|
EP-->>User: 输出最终银豹采购单 (data/result)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 技术注解
|
||||||
|
- **智能指纹识别**:通过 `header=None` 读取前 50 行,避免了因标题行位置不固定导致的识别失败。
|
||||||
|
- **原子化预处理**:每个供应商逻辑独立,预处理结果均为统一格式的中间文件,确立了系统的可扩展性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术架构分层图 (Layered Architecture)
|
||||||
|
|
||||||
|
| 分层 | 技术栈 / 组件 | 功能描述 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **表现层 (Presentation)** | Tkinter, headless_api.py | 桌面 GUI 交互与 OpenClaw 命令行接口 |
|
||||||
|
| **业务逻辑层 (Business)** | Python 3.x, Pandas, OCRService | 核心数据清洗、条码分裂、供应商特征识别 |
|
||||||
|
| **数据访问层 (Data)** | Pandas (Excel Engine), Json | 对 Excel 模板、映射表、用户设置的读写 |
|
||||||
|
| **基础设施层 (Infrastructure)** | Queue, Logging, PyInstaller | 异步日志分发、全局错误处理、EXE 打包工具 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据架构设计 (Data Architecture)
|
||||||
|
|
||||||
|
系统未采用传统关系型数据库,而是基于 **JSON + Excel** 的混合存储架构。
|
||||||
|
|
||||||
|
### 4.1 表间关系示意 (JSON Mapping)
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
CONFIG_JSON ||--o{ BARCODE_MAPPING_JSON : "存储映射"
|
||||||
|
BARCODE_MAPPING_JSON {
|
||||||
|
string original_barcode "OCR识别出的原始条码"
|
||||||
|
string target_barcode "系统目标条码"
|
||||||
|
}
|
||||||
|
ITEM_DATA_EXCEL ||--o{ PURCHASE_ORDER_EXCEL : "验证单价"
|
||||||
|
ITEM_DATA_EXCEL {
|
||||||
|
string barcode "条码 (主键)"
|
||||||
|
float cost_price "进货价"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 存储方案
|
||||||
|
- **映射关系**:`barcode_mappings.json`。支持运行时动态更新,通过 `headless_api.py --update-mapping` 修改。
|
||||||
|
- **业务数据**:`templates/商品资料.xlsx`。作为单价校验的权威数据源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 微服务与模块化设计 (Microservices & Modularity)
|
||||||
|
|
||||||
|
虽然系统目前采用单体架构(Monolithic Architecture)以适配桌面部署环境,但在逻辑上采用了**微服务式的模块化设计**:
|
||||||
|
|
||||||
|
- **服务拆分**:每个供应商逻辑(Rongcheng, Tobacco, YangBiyue)都是独立的类,具备高度自治性。
|
||||||
|
- **解耦机制**:通过统一的 `preprocess` 契约(输入:原始文件,输出:清洗后文件)进行交互,未来可轻松迁移至独立服务。
|
||||||
|
- **进程隔离**:GUI 主进程与业务处理线程通过 `queue.Queue` 进行解耦,确保处理逻辑不阻塞用户界面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 部署架构图 (Deployment)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph 生产服务器 (Windows)
|
||||||
|
APP[orc-order-v2.exe]
|
||||||
|
DATA[data/ 目录]
|
||||||
|
LOGS[logs/ 目录]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 自动化平台
|
||||||
|
OC[OpenClaw]
|
||||||
|
end
|
||||||
|
|
||||||
|
OC -- 命令行调用 --> APP
|
||||||
|
APP -- 读写 --> DATA
|
||||||
|
APP -- 记录 --> LOGS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署要点
|
||||||
|
- **便携化**:通过 PyInstaller 将 Python 运行环境与依赖打包,实现单文件/单目录部署。
|
||||||
|
- **路径无关性**:系统内部通过 `os.path.abspath` 动态计算路径,支持安装在任意盘符。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安全架构图 (Security)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[外部输入] --> B{文件类型校验}
|
||||||
|
B -- 非图片/Excel --> C[拒绝处理]
|
||||||
|
B -- 图片/Excel --> D[清洗逻辑]
|
||||||
|
D --> E{单价偏差校验}
|
||||||
|
E -- 差值 > 1.0 --> F[生成警告日志/弹窗]
|
||||||
|
E -- 正常 --> G[生成采购单]
|
||||||
|
G --> H[日志埋点与审计]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安全策略
|
||||||
|
- **数据隔离**:所有处理后的文件存放在 `data/output` 和 `data/result`,不修改原始输入文件。
|
||||||
|
- **权限控制**:系统运行于用户权限下,利用 Windows 文件系统权限保护配置文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 技术债务与优化建议 (Tech Debt & Optimization)
|
||||||
|
|
||||||
|
### 7.1 当前技术债务
|
||||||
|
1. **并发限制**:目前为单进程串行处理,面对超大规模订单(万行级)可能存在阻塞。
|
||||||
|
2. **持久化局限**:使用 JSON 存储映射关系在条码量达到万级时,查询性能会下降。
|
||||||
|
3. **环境依赖**:OCR 引擎高度依赖 Tesseract/PaddleOCR 等本地二进制库,部署复杂。
|
||||||
|
|
||||||
|
### 7.2 单点故障风险 (SPOF Analysis)
|
||||||
|
1. **本地环境强依赖**:所有 OCR 与 Excel 处理均在单一 Windows 节点,若该节点故障,OpenClaw 对接将完全中断。
|
||||||
|
2. **核心模板丢失**:`templates/` 下的商品资料或采购单模板缺失会导致全流程崩溃。
|
||||||
|
3. **OCR 精度波动**:OCR 结果受图片质量影响,若 OCR 识别条码错误且无映射表,则该行数据将丢失。
|
||||||
|
|
||||||
|
### 7.3 架构优化建议方案
|
||||||
|
- **容灾备份**:建议将 `templates/` 和 `barcode_mappings.json` 定期备份至远程 Git 仓库(如 Gitea)。
|
||||||
|
- **分布式识别**:引入 PaddleOCR 服务端,支持多节点并发 OCR 识别,减少本地算力依赖。
|
||||||
|
- **配置热更新**:支持从远程 URL 加载 `barcode_mappings.json`,实现多机条码库同步。
|
||||||
|
- **数据回退机制**:增加中间文件持久化策略,允许在处理失败时手动干预已清洗的 Excel。
|
||||||
|
|
||||||
|
---
|
||||||
|
*附注:本文档图表均采用 Mermaid 标准编写,可直接在 VS Code (需安装 Mermaid 插件) 或 [Mermaid Live Editor](https://mermaid.live/) 中实时渲染并导出为高清 PNG/SVG 格式。*
|
||||||
|
|
||||||
|
---
|
||||||
|
*文档版本:2.2.0 | 生成日期:2026-03-31*
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# ALIGNMENT - 数据库增强与缺失条码提示
|
|
||||||
|
|
||||||
## 1. 原始需求描述
|
|
||||||
- **DB内容查看**: 用户需要查看数据库(SQLite)中的内容。
|
|
||||||
- **缺失条码提示与记录**: 在处理订单时,如果发现条码不在最新的 `商品资料.xlsx` 中,需要:
|
|
||||||
- 弹出 GUI 弹窗提示用户。
|
|
||||||
- 将缺失条码记录到数据库中。
|
|
||||||
|
|
||||||
## 2. 需求理解与边界确认
|
|
||||||
### 2.1 数据库查看
|
|
||||||
- **方案**: 提供一个增强版的 `check_db.py` 脚本,可以一键列出 `order_metadata`(单据信息)、`products`(商品资料)以及新增的 `missing_barcodes`(缺失条码)。
|
|
||||||
- **GUI集成**: 在主窗口菜单中尝试增加一个“查看处理记录”或“查看缺失条码”的快捷入口。
|
|
||||||
|
|
||||||
### 2.2 缺失条码处理
|
|
||||||
- **检查点**: 在 `ExcelProcessor` 提取商品信息时,或在 `OrderService` 生成结果前进行比对。
|
|
||||||
- **数据库记录**: 在 `product_cache.db` 中新建表 `missing_barcodes`。
|
|
||||||
- 表结构: `barcode (TEXT, PK), name (TEXT), last_seen (TEXT), source_file (TEXT), count (INTEGER)`。
|
|
||||||
- **弹窗提示**:
|
|
||||||
- 使用 `tkinter.messagebox` 或项目已有的 `dialog_utils` 进行弹窗。
|
|
||||||
- **策略确认**: 由于批量处理时频繁弹窗会打断用户,我们将采用“每个文件处理完后,如果有缺失条码,汇总弹窗一次”的策略,平衡实时性与操作体验。
|
|
||||||
|
|
||||||
## 3. 技术对齐
|
|
||||||
- **数据库**: 继续使用 `product_cache.db`,由 `ProductDatabase` 类管理新增表。
|
|
||||||
- **UI**: 现有的 `app/ui/main_window.py` 是基于 `tkinter` 的,弹窗逻辑将与之对齐。
|
|
||||||
- **配置**: 商品资料路径已在 `config.ini` 中定义,需确保指向 `release/templates/商品资料.xlsx`。
|
|
||||||
|
|
||||||
## 4. 验收标准
|
|
||||||
- [ ] 运行 `check_db.py` 能看到所有表的结构化数据。
|
|
||||||
- [ ] 故意处理一张包含未知条码的图片,程序能弹出警告窗口提示具体条码。
|
|
||||||
- [ ] 弹窗后,检查 `missing_barcodes` 表,确认该条码已被正确记录。
|
|
||||||
- [ ] 再次处理相同条码,记录中的 `count` 应该递增,且 `last_seen` 更新。
|
|
||||||
|
|
||||||
## 5. 待澄清问题
|
|
||||||
- **Q**: 弹窗是否需要阻塞处理流程?
|
|
||||||
- **A**: 考虑到用户追求“一键处理”的自动化,建议在单个文件处理完成后弹窗,用户点击确认后再继续下一个文件,或者在批量任务结束后汇总显示所有缺失条码。**初步决定:按文件汇总提示。**
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# DESIGN - 数据库增强与缺失条码提示
|
|
||||||
|
|
||||||
## 1. 架构设计
|
|
||||||
|
|
||||||
### 1.1 数据库层扩展
|
|
||||||
在 `app/core/db/product_db.py` 中增加对 `missing_barcodes` 表的管理。
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS missing_barcodes (
|
|
||||||
barcode TEXT PRIMARY KEY,
|
|
||||||
name TEXT DEFAULT '',
|
|
||||||
last_seen TEXT,
|
|
||||||
source_file TEXT,
|
|
||||||
count INTEGER DEFAULT 1
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 逻辑层流程
|
|
||||||
在 `ExcelProcessor` 处理商品数据时,增加比对逻辑:
|
|
||||||
1. 提取条码。
|
|
||||||
2. 调用 `ProductDatabase.is_barcode_exists(barcode)`。
|
|
||||||
3. 若不存在:
|
|
||||||
- 记录到 `missing_barcodes` 表。
|
|
||||||
- 将该条码加入“本次文件缺失列表”。
|
|
||||||
4. 文件处理结束前,若“本次文件缺失列表”不为空,调用 UI 层的弹窗通知。
|
|
||||||
|
|
||||||
## 2. 核心组件交互
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant P as ExcelProcessor
|
|
||||||
participant DB as ProductDatabase
|
|
||||||
participant UI as MainWindow/DialogUtils
|
|
||||||
|
|
||||||
P->>DB: get_memory(barcode)
|
|
||||||
alt 条码不存在
|
|
||||||
DB-->>P: None
|
|
||||||
P->>DB: record_missing_barcode(barcode, name, file)
|
|
||||||
P->>P: Add to missing_list
|
|
||||||
end
|
|
||||||
|
|
||||||
Note over P: 文件处理即将完成
|
|
||||||
|
|
||||||
rect rgb(200, 220, 255)
|
|
||||||
P->>UI: show_warning("以下条码缺失: ...")
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 接口定义
|
|
||||||
|
|
||||||
### ProductDatabase 类新增方法:
|
|
||||||
- `record_missing_barcode(barcode, name, source_file)`: 插入或更新缺失条码记录。
|
|
||||||
- `get_missing_barcodes(limit=100)`: 获取最近的缺失条码记录。
|
|
||||||
|
|
||||||
### UI 提示逻辑:
|
|
||||||
- 修改 `app/services/order_service.py` 中的 `process_order` 或相关方法,在处理流程中捕获缺失条码并触发 UI 回调。
|
|
||||||
|
|
||||||
## 4. DB 查看器实现
|
|
||||||
更新 `check_db.py`,使用 `tabulate` (如果安装了) 或简单的格式化输出打印所有表内容。
|
|
||||||
增加参数支持:`python check_db.py --table missing_barcodes`。
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# TASK - 数据库增强与缺失条码提示
|
|
||||||
|
|
||||||
## 1. 数据库层任务
|
|
||||||
| 原子任务 | 输入契约 | 输出契约 | 验收标准 |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **MB-DB-01** | 修改 `product_db.py` | 增加 `missing_barcodes` 表 | 运行后 DB 中出现该表 |
|
|
||||||
| **MB-DB-02** | 实现 `record_missing_barcode` | 方法可调用并写入数据 | 重复写入时 `count` 增加 |
|
|
||||||
|
|
||||||
## 2. 逻辑与UI任务
|
|
||||||
| 原子任务 | 输入契约 | 输出契约 | 验收标准 |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **MB-UI-01** | 修改 `ExcelProcessor` | 在识别条码时检查 DB | 日志显示“发现缺失条码” |
|
|
||||||
| **MB-UI-02** | 集成弹窗提示 | 处理完成前触发 `messagebox` | 界面弹出包含条码的警告框 |
|
|
||||||
|
|
||||||
## 3. 工具任务
|
|
||||||
| 原子任务 | 输入契约 | 输出契约 | 验收标准 |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **MB-TL-01** | 更新 `check_db.py` | 支持查看所有表 | 命令行输出清晰的表格数据 |
|
|
||||||
|
|
||||||
## 任务依赖图
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
MB-DB-01 --> MB-DB-02
|
|
||||||
MB-DB-02 --> MB-UI-01
|
|
||||||
MB-UI-01 --> MB-UI-02
|
|
||||||
MB-DB-01 --> MB-TL-01
|
|
||||||
```
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# ACCEPTANCE — 订单批量识别实施完成
|
|
||||||
|
|
||||||
## 任务完成状态
|
|
||||||
|
|
||||||
| 任务 | 状态 | 验收 |
|
|
||||||
|---|---|---|
|
|
||||||
| T1: metadata_extractor.py | ✓ | 单元测试覆盖(6 个日期/4 个供应商/4 个金额/2 个综合/3 个清理)|
|
|
||||||
| T2: order_metadata 表 + CRUD | ✓ | 单元测试覆盖(save/get/upsert/list/count/delete/empty 跳过)|
|
|
||||||
| T3: OCRService 写 meta.json | ✓ | 与现有 OCR 流程兼容 |
|
|
||||||
| T4: order_service 集成元信息 + 落库 | ✓ | 端到端集成测试通过 |
|
|
||||||
| T5: result + 原图重命名 | ✓ | 真实文件验证 result 命名为 采购单_YYYYMMDD_供应商_hash.xls |
|
|
||||||
| T6: processor_service 批量入口 | ✓ | BatchService.process_all_inputs 完整 |
|
|
||||||
| T7: UI 一键处理按钮 | ✓ | "一键处理全部图片" 按钮已添加 |
|
|
||||||
| T8: 单元测试 | ✓ | 217/217 通过(新增 26 个测试)|
|
|
||||||
|
|
||||||
## 测试结果
|
|
||||||
|
|
||||||
- `pytest tests/`:**217/217 通过**
|
|
||||||
- 端到端:mock meta.json → process_excel → 元信息落库 + result 重命名 ✓
|
|
||||||
- 日志输出含 supplier/bill_date/total_amount ✓
|
|
||||||
|
|
||||||
## 交付文件
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
- `app/core/ocr/metadata_extractor.py` — 元信息识别器
|
|
||||||
- `app/core/db/order_metadata_db.py` — SQLite 持久化
|
|
||||||
- `app/services/batch_service.py` — 批量入口
|
|
||||||
- `tests/test_metadata_extractor.py` — 22 个测试
|
|
||||||
- `tests/test_order_metadata_db.py` — 7 个测试
|
|
||||||
|
|
||||||
### 修改
|
|
||||||
- `app/services/ocr_service.py` — `_write_meta_json` 钩入 OCR 流程
|
|
||||||
- `app/services/order_service.py` — 集成 extractor/metadata_db + 文件重命名
|
|
||||||
- `app/ui/action_handlers.py` — `batch_process_all_inputs`
|
|
||||||
- `app/ui/main_window.py` — 一键处理全部图片按钮
|
|
||||||
|
|
||||||
## 桌面部署
|
|
||||||
|
|
||||||
待办:build_exe.py + 部署到 `F:\Administrator\桌面\益选OCR订单处理系统\`
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# ALIGNMENT — 订单批量识别(OCR 元信息提取 + 批量一键处理)
|
|
||||||
|
|
||||||
## 一、目标
|
|
||||||
|
|
||||||
完善 OCR → result 完整链路,新增 3 个能力:
|
|
||||||
1. **OCR 元信息识别与持久化**:从图片 OCR 原文识别供应商、单据日期、应付金额,存 SQLite
|
|
||||||
2. **基于元信息的文件命名**:result 文件 + 原图片按"采购单_YYYYMMDD_供应商_<hash>.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_供应商_<hash>.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(任务拆分)
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
# 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 已记录) |
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# 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`:待办清单
|
|
||||||
+14
-10
@@ -10,6 +10,7 @@ OCR订单处理系统 - 无界面自动化接口
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -24,9 +25,15 @@ from app.services.ocr_service import OCRService
|
|||||||
from app.services.order_service import OrderService
|
from app.services.order_service import OrderService
|
||||||
from app.services.tobacco_service import TobaccoService
|
from app.services.tobacco_service import TobaccoService
|
||||||
from app.services.special_suppliers_service import SpecialSuppliersService
|
from app.services.special_suppliers_service import SpecialSuppliersService
|
||||||
from app.core.utils.log_utils import get_logger, set_log_level
|
from app.core.utils.log_utils import set_log_level
|
||||||
|
|
||||||
logger = get_logger("HeadlessAPI")
|
# 配置日志输出到 stderr
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
stream=sys.stderr
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("HeadlessAPI")
|
||||||
|
|
||||||
def get_latest_file(directory: str, extensions: List[str]) -> Optional[str]:
|
def get_latest_file(directory: str, extensions: List[str]) -> Optional[str]:
|
||||||
"""获取目录中最新的指定后缀文件"""
|
"""获取目录中最新的指定后缀文件"""
|
||||||
@@ -96,9 +103,6 @@ def run_pipeline(args):
|
|||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
final_excel = None
|
final_excel = None
|
||||||
|
|
||||||
input_folder = config_manager.get('Paths', 'input_folder', fallback='data/input')
|
|
||||||
output_folder = config_manager.get('Paths', 'output_folder', fallback='data/output')
|
|
||||||
|
|
||||||
# 1. 处理条码映射更新
|
# 1. 处理条码映射更新
|
||||||
if args.update_mapping:
|
if args.update_mapping:
|
||||||
if not args.barcode:
|
if not args.barcode:
|
||||||
@@ -117,7 +121,7 @@ def run_pipeline(args):
|
|||||||
|
|
||||||
# 2. 烟草公司处理 (显式指定)
|
# 2. 烟草公司处理 (显式指定)
|
||||||
if args.tobacco:
|
if args.tobacco:
|
||||||
input_path = args.input or get_latest_file(output_folder, [".xlsx", ".xls"])
|
input_path = args.input or get_latest_file("data/output", [".xlsx", ".xls"])
|
||||||
if not input_path:
|
if not input_path:
|
||||||
print("ERROR: No tobacco order file found.", file=sys.stderr)
|
print("ERROR: No tobacco order file found.", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
@@ -128,7 +132,7 @@ def run_pipeline(args):
|
|||||||
|
|
||||||
# 3. 蓉城易购处理 (显式指定)
|
# 3. 蓉城易购处理 (显式指定)
|
||||||
elif args.rongcheng:
|
elif args.rongcheng:
|
||||||
input_path = args.input or get_latest_file(output_folder, [".xlsx", ".xls"])
|
input_path = args.input or get_latest_file("data/output", [".xlsx", ".xls"])
|
||||||
if not input_path:
|
if not input_path:
|
||||||
print("ERROR: No Rongcheng Yigou order file found.", file=sys.stderr)
|
print("ERROR: No Rongcheng Yigou order file found.", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
@@ -138,7 +142,7 @@ def run_pipeline(args):
|
|||||||
|
|
||||||
# 4. 普通 Excel 处理 (支持自动识别烟草/蓉城/杨碧月)
|
# 4. 普通 Excel 处理 (支持自动识别烟草/蓉城/杨碧月)
|
||||||
elif args.excel:
|
elif args.excel:
|
||||||
input_path = args.input or get_latest_file(input_folder, [".xlsx", ".xls"])
|
input_path = args.input or get_latest_file("data/input", [".xlsx", ".xls"])
|
||||||
if not input_path:
|
if not input_path:
|
||||||
print("ERROR: No Excel file found in input.", file=sys.stderr)
|
print("ERROR: No Excel file found in input.", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
@@ -148,9 +152,9 @@ def run_pipeline(args):
|
|||||||
|
|
||||||
# 5. 智能处理 (默认逻辑:自动判断图片还是 Excel)
|
# 5. 智能处理 (默认逻辑:自动判断图片还是 Excel)
|
||||||
else:
|
else:
|
||||||
input_path = args.input or get_latest_file(input_folder, [".jpg", ".jpeg", ".png", ".bmp", ".xlsx", ".xls"])
|
input_path = args.input or get_latest_file("data/input", [".jpg", ".jpeg", ".png", ".bmp", ".xlsx", ".xls"])
|
||||||
if not input_path:
|
if not input_path:
|
||||||
print(f"ERROR: No input file found in {input_folder}.", file=sys.stderr)
|
print("ERROR: No input file found in data/input.", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ext = os.path.splitext(input_path)[1].lower()
|
ext = os.path.splitext(input_path)[1].lower()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
2025-08-16 00:52:17,210 - app.core.excel.handlers.barcode_mapper - INFO - 条码映射: 6937003706322 -> 6937003703833
|
||||||
|
2025-11-15 16:34:22,181 - app.core.excel.handlers.barcode_mapper - INFO - 条码映射: 6923450653012 -> 69021343
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
|||||||
|
2025-08-16 00:52:16,853 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-08-16 00:52:16,861 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 20:52:59,975 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:52:59,975 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 20:52:59,980 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:52:59,980 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 20:52:59,985 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:52:59,985 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 20:52:59,999 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:52:59,999 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 20:53:00,004 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:53:00,004 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 21:55:05,648 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 21:55:05,656 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 21:55:14,957 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 21:55:14,960 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 21:56:00,538 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 21:56:00,562 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 22:00:56,344 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 22:00:56,344 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 22:00:56,357 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 22:00:56,357 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 22:00:56,364 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 22:00:56,365 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 23:22:38,475 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 23:22:38,475 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 23:53:32,028 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 23:53:32,028 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-14 23:56:57,447 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 23:56:57,447 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 00:18:49,537 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 00:18:49,537 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 00:44:36,719 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 00:44:36,720 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 01:58:02,054 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 01:58:02,054 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 09:48:24,108 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 09:48:24,126 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:06:56,596 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:06:56,620 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:10:31,639 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:10:31,653 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:39:24,612 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:39:24,612 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:39:48,326 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:39:48,337 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:39:52,727 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 10:39:52,728 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 10:49:13,775 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:49:13,775 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:51:44,473 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:51:44,475 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:51:49,960 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:51:49,960 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:54:02,883 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:54:02,886 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:54:06,684 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 10:54:06,705 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 10:58:26,607 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:58:26,621 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 10:58:36,067 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:58:36,082 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 14:17:23,600 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 14:17:23,608 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 14:17:23,670 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 14:17:23,682 - app.core.excel.merger - WARNING - 未在 data/result 目录下找到采购单Excel文件
|
||||||
|
2025-11-15 14:17:23,718 - app.core.excel.merger - WARNING - 没有找到可合并的采购单文件
|
||||||
|
2025-11-15 14:22:16,926 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 14:22:16,935 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:11:57,981 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:11:57,984 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:11:59,649 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 15:11:59,650 - app.core.excel.merger - INFO - 找到 2 个采购单Excel文件
|
||||||
|
2025-11-15 15:12:52,674 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:12:52,692 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:12:54,195 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 15:12:54,206 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 15:21:05,667 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:21:05,673 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:22:01,186 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:22:01,202 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:25:41,372 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:25:41,379 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:25:42,598 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 15:25:42,601 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 15:34:06,486 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:34:06,497 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:34:08,030 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 15:34:08,041 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 15:37:42,584 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:37:42,592 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:39:11,314 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:39:11,314 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:43:33,538 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:43:33,548 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:43:37,053 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 15:43:37,054 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 15:54:35,546 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:54:35,559 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 15:54:38,702 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 15:54:38,713 - app.core.excel.merger - INFO - 找到 2 个采购单Excel文件
|
||||||
|
2025-11-15 16:19:42,388 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:19:42,388 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:34:22,131 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:34:22,132 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:46:22,429 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:46:22,440 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:46:28,876 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:46:28,896 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:46:37,767 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:46:37,782 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:46:39,219 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 16:46:39,230 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 16:48:30,476 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:48:30,488 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:48:42,047 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:48:42,063 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:48:43,633 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 16:48:43,644 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 16:52:35,959 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:52:35,969 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:52:37,531 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 16:52:37,541 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 16:57:42,442 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:57:42,451 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:59:07,007 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:59:07,016 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:59:14,595 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:59:14,604 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 16:59:16,171 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 16:59:16,180 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 17:01:30,240 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:01:30,245 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 17:01:31,385 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 17:01:31,395 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 17:04:33,243 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:04:33,255 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 17:04:45,041 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:04:45,048 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 17:04:46,493 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 17:04:46,505 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 17:09:39,846 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:09:39,851 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 17:12:37,471 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:12:37,476 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 17:12:39,165 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 17:12:39,173 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-15 17:28:46,834 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:28:46,847 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 17:28:49,875 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 17:28:49,876 - app.core.excel.merger - INFO - 找到 2 个采购单Excel文件
|
||||||
|
2025-11-15 17:59:22,818 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:59:22,823 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 17:59:33,491 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:59:33,506 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 17:59:39,835 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:59:39,849 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 18:00:04,153 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 18:00:04,165 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-15 18:00:07,581 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-15 18:00:07,582 - app.core.excel.merger - INFO - 找到 3 个采购单Excel文件
|
||||||
|
2025-11-15 18:01:51,698 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 18:01:51,710 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 10:48:45,534 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 10:48:45,552 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 10:56:22,516 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 10:56:22,516 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 11:23:59,373 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 11:23:59,373 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 11:26:06,795 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 11:26:06,795 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 12:51:06,910 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 12:51:06,910 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 13:03:10,562 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:03:10,563 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 13:03:10,583 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:03:10,584 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 13:18:18,247 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:18:18,248 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 13:18:18,270 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:18:18,271 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 13:51:09,017 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:51:09,018 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 14:25:50,027 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 14:25:50,031 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 14:25:55,596 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 14:25:55,612 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 14:25:56,992 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-16 14:25:57,001 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-16 14:39:42,980 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 14:39:42,980 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 14:39:42,995 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 14:39:42,996 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 14:59:35,437 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 14:59:35,437 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 15:03:21,893 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 15:03:21,893 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 15:08:33,545 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 15:08:33,546 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 15:11:11,240 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 15:11:11,254 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 15:11:12,200 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-16 15:11:12,212 - app.core.excel.merger - INFO - 找到 2 个采购单Excel文件
|
||||||
|
2025-11-16 15:13:47,399 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 15:13:47,401 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 15:13:50,815 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-16 15:13:50,816 - app.core.excel.merger - INFO - 找到 3 个采购单Excel文件
|
||||||
|
2025-11-16 15:15:36,200 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 15:15:36,203 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-16 15:15:39,032 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-16 15:15:39,033 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-20 18:36:13,285 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:36:13,295 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-20 18:36:14,159 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-20 18:36:14,170 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-20 18:36:42,097 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:36:42,104 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-20 18:36:43,288 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-20 18:36:43,297 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-20 18:42:25,918 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:42:25,932 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-20 18:42:26,808 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-20 18:42:26,819 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-20 18:44:11,119 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:44:11,128 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-20 18:46:17,345 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-20 18:46:17,346 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-20 18:47:03,916 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:47:03,919 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-20 18:50:11,046 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-20 18:50:11,047 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-11-20 18:56:30,337 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:56:30,348 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-11-20 18:56:34,058 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-11-20 18:56:34,061 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-12-01 22:21:09,101 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-01 22:21:09,105 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-01 22:21:20,388 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2025-12-01 22:21:20,390 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2025-12-12 11:00:14,089 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 11:00:14,095 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-12 11:13:15,287 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 11:13:15,288 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-12 11:22:25,990 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 11:22:25,990 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-12 11:32:26,694 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 11:32:26,694 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-12 11:34:40,238 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 11:34:40,238 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-12 11:38:53,256 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 11:38:53,260 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-12 11:49:38,203 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 11:49:38,203 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-12 12:30:07,698 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 12:30:07,700 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2025-12-12 12:32:20,579 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-12 12:32:20,579 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 10:20:20,144 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 10:20:20,145 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 10:21:25,355 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 10:21:25,356 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 10:21:52,469 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 10:21:52,470 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:28:54,451 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:28:54,451 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:29:24,575 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:29:24,576 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:29:24,580 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:29:24,580 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:29:58,483 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:29:58,484 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:29:58,488 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:29:58,488 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:30:29,317 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:30:29,317 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:30:29,321 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:30:29,322 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:31:51,946 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:31:51,947 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:31:51,951 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:31:51,952 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:46:00,210 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:46:00,211 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:46:00,229 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:46:00,230 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:46:02,785 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:46:02,785 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:47:05,233 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:47:05,233 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:47:05,255 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:47:05,256 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:47:06,654 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:47:06,654 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:48:28,519 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:48:28,519 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:48:28,525 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:48:28,525 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:48:30,802 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:48:30,802 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:51:44,156 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:51:44,156 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:51:44,170 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:51:44,171 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 13:51:46,072 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 13:51:46,072 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:00:52,374 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:00:52,374 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:00:52,392 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:00:52,392 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:01:20,295 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:01:20,295 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:01:20,307 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:01:20,308 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:01:24,376 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:01:24,376 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:06:58,242 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:06:58,243 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:07:00,055 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:07:00,055 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:14:46,832 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:14:46,832 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:14:47,891 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:14:47,891 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:23:18,310 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:23:18,310 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:23:20,043 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:23:20,043 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:24:02,882 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:24:02,883 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:24:04,318 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:24:04,318 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:28:39,039 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:28:39,040 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:28:39,737 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:28:39,738 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:41:11,949 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:41:11,949 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:41:33,388 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:41:33,388 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:41:47,796 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:41:47,796 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-30 14:41:49,673 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-30 14:41:49,673 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 08:50:15,740 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 08:50:15,754 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 08:50:17,775 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 08:50:17,775 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 08:52:46,014 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 08:52:46,014 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 08:52:47,191 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 08:52:47,192 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 09:00:01,693 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 09:00:01,693 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 09:03:11,828 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 09:03:11,829 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 09:03:13,872 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 09:03:13,872 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 09:05:33,093 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 09:05:33,094 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 09:05:33,868 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 09:05:33,868 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 09:05:36,165 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2026-03-31 09:05:36,165 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2026-03-31 09:07:58,235 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 09:07:58,235 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 09:07:58,352 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 09:07:58,353 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 09:08:00,645 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2026-03-31 09:08:00,646 - app.core.excel.merger - INFO - 找到 2 个采购单Excel文件
|
||||||
|
2026-03-31 10:59:54,581 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 10:59:54,581 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 10:59:56,658 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 10:59:56,659 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 11:01:45,697 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 11:01:45,697 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 11:01:47,940 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 11:01:47,940 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 11:01:50,780 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2026-03-31 11:01:50,781 - app.core.excel.merger - INFO - 找到 3 个采购单Excel文件
|
||||||
|
2026-03-31 11:27:58,582 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 11:27:58,583 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 11:28:01,610 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 11:28:01,610 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 11:28:04,092 - app.core.excel.merger - INFO - 搜索目录 data/result 中的采购单Excel文件
|
||||||
|
2026-03-31 11:28:04,093 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
|
||||||
|
2026-03-31 11:28:11,907 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 11:28:11,908 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
|
2026-03-31 11:28:13,708 - app.core.excel.merger - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 11:28:13,708 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger完成,模板文件: E:\2025Code\python\orc-order-v2\templates\银豹-采购单模板.xls
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
2025-11-14 21:55:30,948 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-14 23:53:34,369 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 00:20:01,697 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 09:53:49,682 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 10:06:38,107 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 10:39:49,255 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 10:54:03,264 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 14:16:12,391 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 14:41:42,507 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 15:12:40,831 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 15:25:05,333 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 15:33:48,514 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 15:43:34,042 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 16:39:06,073 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 17:28:47,324 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-15 18:00:04,623 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-16 12:50:38,921 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-16 15:11:07,710 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-16 15:13:47,756 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-11-16 15:15:36,568 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2025-12-01 22:21:09,536 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2026-03-31 11:01:46,107 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2026-03-31 11:27:58,846 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
|
2026-03-31 11:27:58,858 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
2025-11-14 20:52:59,972 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 20:52:59,973 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:52:59,973 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 20:52:59,973 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 20:52:59,974 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 20:52:59,976 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 20:52:59,978 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:52:59,978 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 20:52:59,978 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 20:52:59,978 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 20:52:59,982 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 20:52:59,982 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:52:59,982 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 20:52:59,984 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 20:52:59,984 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 20:52:59,996 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 20:52:59,996 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:52:59,996 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 20:52:59,997 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 20:52:59,997 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 20:53:00,001 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 20:53:00,001 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 20:53:00,001 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 20:53:00,001 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 20:53:00,002 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 21:55:14,932 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 21:55:14,933 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 21:55:14,935 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 21:55:14,936 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 21:55:14,937 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 21:55:15,025 - app.core.ocr.table_ocr - INFO - 找到 0 个图片文件,其中 0 个未处理
|
||||||
|
2025-11-14 21:55:15,031 - app.core.ocr.table_ocr - WARNING - 没有需要处理的图片
|
||||||
|
2025-11-14 21:55:30,512 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 21:55:30,522 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 21:55:30,535 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 21:55:30,547 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 21:55:30,563 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 21:55:30,612 - app.core.ocr.table_ocr - INFO - 开始处理图片: E:/2025Code/python/orc-order-v2/data/input/微信图片_20250909184135_44_108.jpg
|
||||||
|
2025-11-14 21:55:32,256 - app.core.ocr.table_ocr - INFO - 图片处理成功: E:/2025Code/python/orc-order-v2/data/input/微信图片_20250909184135_44_108.jpg, 输出文件: data/output\微信图片_20250909184135_44_108.xlsx
|
||||||
|
2025-11-14 22:00:56,334 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 22:00:56,335 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 22:00:56,336 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 22:00:56,336 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 22:00:56,338 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 22:00:56,354 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 22:00:56,354 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 22:00:56,354 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 22:00:56,355 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 22:00:56,355 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 22:00:56,361 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 22:00:56,361 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 22:00:56,361 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 22:00:56,361 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 22:00:56,362 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 23:22:38,471 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 23:22:38,471 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 23:22:38,472 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 23:22:38,472 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 23:22:38,472 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 23:53:32,026 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 23:53:32,026 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 23:53:32,026 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 23:53:32,026 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 23:53:32,026 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-14 23:53:34,048 - app.core.ocr.table_ocr - INFO - 开始处理图片: data\input\微信图片_20250909184135_44_108.jpg
|
||||||
|
2025-11-14 23:53:35,690 - app.core.ocr.table_ocr - INFO - 图片处理成功: data\input\微信图片_20250909184135_44_108.jpg, 输出文件: data/output\微信图片_20250909184135_44_108.xlsx
|
||||||
|
2025-11-14 23:54:00,127 - app.core.ocr.table_ocr - INFO - 开始处理图片: data\input\微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-14 23:54:01,441 - app.core.ocr.table_ocr - INFO - 图片处理成功: data\input\微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-14 23:54:50,647 - app.core.ocr.table_ocr - INFO - 开始处理图片: data\input\微信图片_20251019141843_92_108.jpg
|
||||||
|
2025-11-14 23:54:51,981 - app.core.ocr.table_ocr - INFO - 图片处理成功: data\input\微信图片_20251019141843_92_108.jpg, 输出文件: data/output\微信图片_20251019141843_92_108.xlsx
|
||||||
|
2025-11-14 23:56:57,443 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-14 23:56:57,443 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-14 23:56:57,443 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-14 23:56:57,443 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-14 23:56:57,443 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 00:18:49,534 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 00:18:49,534 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 00:18:49,534 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 00:18:49,534 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 00:18:49,535 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 00:20:01,395 - app.core.ocr.table_ocr - INFO - 开始处理图片: data\input\微信图片_20251019141843_92_108.jpg
|
||||||
|
2025-11-15 00:20:03,253 - app.core.ocr.table_ocr - INFO - 图片处理成功: data\input\微信图片_20251019141843_92_108.jpg, 输出文件: data/output\微信图片_20251019141843_92_108.xlsx
|
||||||
|
2025-11-15 00:44:36,717 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 00:44:36,717 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 00:44:36,717 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 00:44:36,717 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 00:44:36,718 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 01:58:02,050 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 01:58:02,051 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 01:58:02,051 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 01:58:02,051 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 01:58:02,051 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 09:48:24,086 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 09:48:24,087 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 09:48:24,087 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 09:48:24,087 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 09:48:24,088 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 09:49:24,553 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 09:49:24,553 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 09:49:24,557 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 09:49:24,560 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 09:49:24,563 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 09:53:49,094 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 09:53:49,107 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 09:53:49,118 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 09:53:49,132 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 09:53:49,147 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 09:53:49,215 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-15 09:53:49,231 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-15 09:53:49,248 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 09:53:51,046 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 09:53:51,047 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-15 10:06:37,789 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 10:06:37,795 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:06:37,803 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 10:06:37,811 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 10:06:37,818 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 10:06:37,843 - app.core.ocr.table_ocr - INFO - 开始处理图片: E:/2025Code/python/orc-order-v2/data/input/微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 10:06:39,473 - app.core.ocr.table_ocr - INFO - 图片处理成功: E:/2025Code/python/orc-order-v2/data/input/微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 10:39:24,607 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 10:39:24,607 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:39:24,608 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 10:39:24,608 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 10:39:24,609 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 10:39:48,299 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 10:39:48,301 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:39:48,303 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 10:39:48,305 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 10:39:48,308 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 10:39:48,365 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-15 10:39:48,368 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-15 10:39:48,392 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 10:39:50,666 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 10:39:50,667 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-15 10:49:13,768 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 10:49:13,769 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:49:13,769 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 10:49:13,769 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 10:49:13,770 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 10:51:44,469 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 10:51:44,469 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:51:44,469 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 10:51:44,470 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 10:51:44,470 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 10:51:49,953 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 10:51:49,954 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:51:49,954 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 10:51:49,955 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 10:51:49,955 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 10:54:02,833 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 10:54:02,834 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 10:54:02,837 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 10:54:02,841 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 10:54:02,846 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 10:54:02,940 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-15 10:54:02,943 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-15 10:54:02,973 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 10:54:04,733 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 10:54:04,738 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-15 14:16:11,980 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 14:16:11,982 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 14:16:11,985 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 14:16:11,997 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 14:16:11,999 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 14:16:12,028 - app.core.ocr.table_ocr - INFO - 开始处理图片: E:/2025Code/python/orc-order-v2/data/input/微信图片_20251113183218_594_278.jpg
|
||||||
|
2025-11-15 14:16:14,042 - app.core.ocr.table_ocr - INFO - 图片处理成功: E:/2025Code/python/orc-order-v2/data/input/微信图片_20251113183218_594_278.jpg, 输出文件: data/output\微信图片_20251113183218_594_278.xlsx
|
||||||
|
2025-11-15 14:41:42,172 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 14:41:42,173 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 14:41:42,175 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 14:41:42,176 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 14:41:42,179 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 14:41:42,198 - app.core.ocr.table_ocr - INFO - 开始处理图片: F:/下载/微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 14:41:44,478 - app.core.ocr.table_ocr - INFO - 图片处理成功: F:/下载/微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 15:11:57,934 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 15:11:57,937 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:11:57,939 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 15:11:57,945 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 15:11:57,946 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 15:11:58,040 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-15 15:11:58,053 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-15 15:11:58,081 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251113183218_594_278.jpg
|
||||||
|
2025-11-15 15:11:58,091 - app.core.ocr.table_ocr - INFO - 已存在对应的Excel文件,跳过处理: 微信图片_20251113183218_594_278.jpg -> 微信图片_20251113183218_594_278.xlsx
|
||||||
|
2025-11-15 15:11:58,132 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-15 15:12:40,318 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 15:12:40,333 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:12:40,345 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 15:12:40,358 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 15:12:40,370 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 15:12:40,403 - app.core.ocr.table_ocr - INFO - 开始处理图片: F:/下载/微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 15:12:42,172 - app.core.ocr.table_ocr - INFO - 图片处理成功: F:/下载/微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 15:25:05,003 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 15:25:05,004 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:25:05,006 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 15:25:05,009 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 15:25:05,019 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 15:25:05,052 - app.core.ocr.table_ocr - INFO - 开始处理图片: F:/下载/微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 15:25:06,786 - app.core.ocr.table_ocr - INFO - 图片处理成功: F:/下载/微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 15:33:47,219 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 15:33:47,225 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:33:47,228 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 15:33:47,229 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 15:33:47,234 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 15:33:47,269 - app.core.ocr.table_ocr - INFO - 开始处理图片: F:/下载/微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 15:33:50,403 - app.core.ocr.table_ocr - INFO - 图片处理成功: F:/下载/微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 15:39:11,309 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 15:39:11,310 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:39:11,310 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 15:39:11,310 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 15:39:11,311 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 15:43:33,473 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 15:43:33,474 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 15:43:33,478 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 15:43:33,481 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 15:43:33,485 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 15:43:33,617 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-15 15:43:33,637 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-15 15:43:33,670 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251113183218_594_278.jpg
|
||||||
|
2025-11-15 15:43:35,235 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251113183218_594_278.jpg, 输出文件: data/output\微信图片_20251113183218_594_278.xlsx
|
||||||
|
2025-11-15 15:43:35,236 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-15 16:39:05,575 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 16:39:05,581 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:39:05,587 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 16:39:05,594 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 16:39:05,597 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 16:39:05,641 - app.core.ocr.table_ocr - INFO - 开始处理图片: F:/下载/微信图片_20251027125604_98_108.jpg
|
||||||
|
2025-11-15 16:39:07,420 - app.core.ocr.table_ocr - INFO - 图片处理成功: F:/下载/微信图片_20251027125604_98_108.jpg, 输出文件: data/output\微信图片_20251027125604_98_108.xlsx
|
||||||
|
2025-11-15 16:46:22,388 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 16:46:22,388 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 16:46:22,390 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 16:46:22,390 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 16:46:22,392 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 16:46:22,501 - app.core.ocr.table_ocr - INFO - 找到 0 个图片文件,其中 0 个未处理
|
||||||
|
2025-11-15 16:46:22,522 - app.core.ocr.table_ocr - WARNING - 没有需要处理的图片
|
||||||
|
2025-11-15 17:28:46,777 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 17:28:46,778 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:28:46,781 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 17:28:46,784 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 17:28:46,789 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 17:28:46,913 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-15 17:28:46,930 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-15 17:28:46,975 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251115145536_614_278.jpg
|
||||||
|
2025-11-15 17:28:48,564 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251115145536_614_278.jpg, 输出文件: data/output\微信图片_20251115145536_614_278.xlsx
|
||||||
|
2025-11-15 17:28:48,566 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-15 17:29:22,057 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 17:29:22,070 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:29:22,076 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 17:29:22,084 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 17:29:22,098 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 17:29:22,147 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-15 17:29:22,159 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-15 17:29:22,180 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251115145536_614_278.jpg
|
||||||
|
2025-11-15 17:29:22,198 - app.core.ocr.table_ocr - INFO - 已存在对应的Excel文件,跳过处理: 微信图片_20251115145536_614_278.jpg -> 微信图片_20251115145536_614_278.xlsx
|
||||||
|
2025-11-15 17:29:22,217 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-15 17:59:39,798 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 17:59:39,799 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 17:59:39,800 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 17:59:39,800 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 17:59:39,801 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 17:59:39,913 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 0 个未处理
|
||||||
|
2025-11-15 17:59:39,930 - app.core.ocr.table_ocr - WARNING - 没有需要处理的图片
|
||||||
|
2025-11-15 18:00:04,112 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-15 18:00:04,114 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-15 18:00:04,116 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-15 18:00:04,117 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-15 18:00:04,119 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-15 18:00:04,224 - app.core.ocr.table_ocr - INFO - 找到 2 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-15 18:00:04,247 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-15 18:00:04,278 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251114131924_600_278.jpg
|
||||||
|
2025-11-15 18:00:05,870 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251114131924_600_278.jpg, 输出文件: data/output\微信图片_20251114131924_600_278.xlsx
|
||||||
|
2025-11-15 18:00:05,872 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-16 11:23:59,369 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 11:23:59,369 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 11:23:59,369 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 11:23:59,369 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 11:23:59,369 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 11:26:06,790 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 11:26:06,791 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 11:26:06,791 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 11:26:06,791 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 11:26:06,792 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 12:50:38,502 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 12:50:38,503 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 12:50:38,504 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 12:50:38,506 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 12:50:38,508 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 12:50:38,541 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-16 12:50:38,550 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-16 12:50:38,560 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251115212128_148_108.jpg
|
||||||
|
2025-11-16 12:50:40,143 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251115212128_148_108.jpg, 输出文件: data/output\微信图片_20251115212128_148_108.xlsx
|
||||||
|
2025-11-16 12:50:40,149 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-16 12:51:06,904 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 12:51:06,906 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 12:51:06,906 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 12:51:06,906 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 12:51:06,907 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 13:03:10,557 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 13:03:10,557 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:03:10,557 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 13:03:10,558 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 13:03:10,558 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 13:03:10,578 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 13:03:10,578 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:03:10,579 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 13:03:10,579 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 13:03:10,580 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 13:18:18,243 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 13:18:18,243 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:18:18,244 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 13:18:18,244 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 13:18:18,244 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 13:18:18,265 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 13:18:18,265 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:18:18,266 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 13:18:18,266 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 13:18:18,267 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 13:51:09,012 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 13:51:09,013 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 13:51:09,013 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 13:51:09,013 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 13:51:09,014 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 14:39:42,975 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 14:39:42,976 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 14:39:42,976 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 14:39:42,976 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 14:39:42,977 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 14:39:42,990 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 14:39:42,991 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 14:39:42,991 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 14:39:42,991 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 14:39:42,992 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 15:11:05,478 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 15:11:05,485 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 15:11:05,487 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 15:11:05,489 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 15:11:05,498 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 15:11:05,536 - app.core.ocr.table_ocr - INFO - 找到 2 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-16 15:11:05,549 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-16 15:11:05,571 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251116151001_152_108.jpg
|
||||||
|
2025-11-16 15:11:08,994 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251116151001_152_108.jpg, 输出文件: data/output\微信图片_20251116151001_152_108.xlsx
|
||||||
|
2025-11-16 15:11:08,995 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-16 15:13:47,370 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 15:13:47,372 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 15:13:47,375 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 15:13:47,381 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 15:13:47,383 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 15:13:47,457 - app.core.ocr.table_ocr - INFO - 找到 3 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-16 15:13:47,470 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-16 15:13:47,510 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251116151334_153_108.jpg
|
||||||
|
2025-11-16 15:13:49,392 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251116151334_153_108.jpg, 输出文件: data/output\微信图片_20251116151334_153_108.xlsx
|
||||||
|
2025-11-16 15:13:49,397 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-16 15:15:36,173 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-16 15:15:36,173 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-16 15:15:36,174 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-16 15:15:36,175 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-16 15:15:36,177 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-16 15:15:36,243 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-16 15:15:36,257 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-16 15:15:36,299 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251116151514_154_108.jpg
|
||||||
|
2025-11-16 15:15:37,808 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251116151514_154_108.jpg, 输出文件: data/output\微信图片_20251116151514_154_108.xlsx
|
||||||
|
2025-11-16 15:15:37,809 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-20 18:44:11,080 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-20 18:44:11,083 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:44:11,087 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-20 18:44:11,089 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-20 18:44:11,094 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-20 18:44:11,194 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-20 18:44:11,216 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-20 18:44:11,261 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251116151514_154_108.jpg
|
||||||
|
2025-11-20 18:44:11,261 - app.core.ocr.table_ocr - INFO - 已存在对应的Excel文件,跳过处理: 微信图片_20251116151514_154_108.jpg -> 微信图片_20251116151514_154_108.xlsx
|
||||||
|
2025-11-20 18:44:11,295 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-20 18:47:03,864 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-20 18:47:03,867 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:47:03,867 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-20 18:47:03,869 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-20 18:47:03,871 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-20 18:47:03,990 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-20 18:47:04,011 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-20 18:47:04,048 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251116151514_154_108.jpg
|
||||||
|
2025-11-20 18:47:04,058 - app.core.ocr.table_ocr - INFO - 已存在对应的Excel文件,跳过处理: 微信图片_20251116151514_154_108.jpg -> 微信图片_20251116151514_154_108.xlsx
|
||||||
|
2025-11-20 18:47:04,080 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-11-20 18:56:30,283 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-11-20 18:56:30,284 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-11-20 18:56:30,287 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-11-20 18:56:30,289 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-11-20 18:56:30,291 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-11-20 18:56:30,417 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-11-20 18:56:30,439 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-11-20 18:56:30,483 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251116151514_154_108.jpg
|
||||||
|
2025-11-20 18:56:30,483 - app.core.ocr.table_ocr - INFO - 已存在对应的Excel文件,跳过处理: 微信图片_20251116151514_154_108.jpg -> 微信图片_20251116151514_154_108.xlsx
|
||||||
|
2025-11-20 18:56:30,495 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2025-12-01 22:21:09,028 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2025-12-01 22:21:09,043 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2025-12-01 22:21:09,046 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2025-12-01 22:21:09,048 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2025-12-01 22:21:09,052 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2025-12-01 22:21:09,173 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2025-12-01 22:21:09,198 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2025-12-01 22:21:09,246 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\微信图片_20251201221738_176_108.jpg
|
||||||
|
2025-12-01 22:21:10,536 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\微信图片_20251201221738_176_108.jpg, 输出文件: data/output\微信图片_20251201221738_176_108.xlsx
|
||||||
|
2025-12-01 22:21:10,538 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2026-03-31 11:01:45,677 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2026-03-31 11:01:45,691 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 11:01:45,691 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2026-03-31 11:01:45,692 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2026-03-31 11:01:45,692 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2026-03-31 11:01:45,700 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
|
||||||
|
2026-03-31 11:01:45,701 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 1 个文件
|
||||||
|
2026-03-31 11:01:45,712 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\20260331-110124.jpg
|
||||||
|
2026-03-31 11:01:47,745 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\20260331-110124.jpg, 输出文件: data/output\20260331-110124.xlsx
|
||||||
|
2026-03-31 11:01:47,747 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
|
||||||
|
2026-03-31 11:27:58,576 - app.core.ocr.table_ocr - INFO - 使用输入目录: E:\2025Code\python\orc-order-v2\data\input
|
||||||
|
2026-03-31 11:27:58,576 - app.core.ocr.table_ocr - INFO - 使用输出目录: E:\2025Code\python\orc-order-v2\data\output
|
||||||
|
2026-03-31 11:27:58,577 - app.core.ocr.table_ocr - INFO - 使用临时目录: E:\2025Code\python\orc-order-v2\data\temp
|
||||||
|
2026-03-31 11:27:58,577 - app.core.ocr.table_ocr - INFO - 允许的文件类型: ['.jpg', '.jpeg', '.png', '.bmp']
|
||||||
|
2026-03-31 11:27:58,577 - app.core.ocr.table_ocr - INFO - 初始化OCRProcessor完成:输入目录=data/input, 输出目录=data/output
|
||||||
|
2026-03-31 11:27:58,604 - app.core.ocr.table_ocr - INFO - 找到 2 个图片文件,其中 2 个未处理
|
||||||
|
2026-03-31 11:27:58,605 - app.core.ocr.table_ocr - INFO - 处理批次 1/1: 2 个文件
|
||||||
|
2026-03-31 11:27:58,614 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\20260331-112736.jpg
|
||||||
|
2026-03-31 11:27:58,615 - app.core.ocr.table_ocr - INFO - 开始处理图片: data/input\20260331-112747.jpg
|
||||||
|
2026-03-31 11:28:00,366 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\20260331-112747.jpg, 输出文件: data/output\20260331-112747.xlsx
|
||||||
|
2026-03-31 11:28:00,713 - app.core.ocr.table_ocr - INFO - 图片处理成功: data/input\20260331-112736.jpg, 输出文件: data/output\20260331-112736.xlsx
|
||||||
|
2026-03-31 11:28:00,715 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 2, 成功: 2
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user