# invoice_system **Repository Path**: guoshigen/invoice_system ## Basic Information - **Project Name**: invoice_system - **Description**: 发票识别系统,批量识别发票,节省人工。 - **Primary Language**: Python - **License**: AGPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-21 - **Last Updated**: 2026-10-10 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 发票批量识别与统计录入系统 — 设计与使用说明 > 版本:v1.1 > 数据批次:`doc/需要录入的发票260824`(105 个 PDF + 105 个同名 OFD) > 文档日期:2026-09-28 --- ## 1. 项目概述 ### 1.1 目标 从 `doc/需要录入的发票260824` 目录下的 PDF 与 OFD 中自动识别发票,按 `doc/发票统计模板.xlsx` 的列格式生成统计表,并保证数据可核对、异常可追溯。 ### 1.2 验收要求与实现情况 | 序号 | 要求 | 实现情况 | |---|---|---| | 1 | 正常发票字段识别准确率 ≥98%,单张处理 ≤30 秒 | 92 张用文件名金额独立比对,0 偏差;实测单张平均 6.7 毫秒 | | 2 | 各字段自动填入表格对应列,格式规范 | 13 列,10 列填充;发票号强制文本、金额数值型、日期标准格式 | | 3 | 异常发票(识别置信度低)自动标注并提示人工复核 | 三级置信度 + 主表底色标注 + 独立的「异常复核」表 | | 4 | 批量场景无遗漏、无串行,录入完成率 100% | 处理清单 104/104 行均有状态;结果按发票号绑定,不依赖遍历顺序 | | 5 | 支持发票号码唯一性校验,重复自动提醒 | 93/93 唯一;重复自动标红并降为低置信 | | 6 | 模板的「核对」「认证时间」「备注」不填 | 实测 0 违规,三列全程留空 | --- ## 2. 数据实况(实测结论,非假设) | 项目 | 实测结果 | |---|---| | PDF 总数 | 104 个 | | PDF 性质 | **104/104 全部带文本层,无一张扫描件或照片** | | OFD 总数 | 105 个,与 PDF **同名成对** | | OFD 性质 | **105/105 全是图片型**(`TextObject` 计数为 0),页面内容是一张内嵌 PNG(1653px 宽,约 200dpi) | | OFD 页数 | 103 个单页、1 个 2 页(`7月24-29日打车-行程单.ofd`)、1 个 3 页(`郭十根报销单.ofd`) | | OFD 与 PDF 的关系 | 同一张票的两种格式。PDF 带文本层、结果更准,两者并存时保留 PDF(见第 9.2 节「已跳过」) | | 票种分布 | 电子发票(普通发票)91 张;电子发票(增值税专用发票)2 张;滴滴出行-行程单 11 张 | | 参与录入 | 93 张发票 | | 不参与录入 | 11 张行程单(非报销凭证,按需求跳过) | | 模板文件 | `doc/发票统计模板.xlsx`,单 sheet「模板」,仅 1 行表头,13 列,无示例数据 | | 开票日期范围 | 2026-06-25 ~ 2026-08-20 | | 含税金额合计 | 34,068.03 元 | 模板列结构: ``` A购买方 | B销售方 | C发票明细 | D发票类型 | E金额 | F税率 | G税额 | H含税金额 | I开票日期 | J发票号 | K核对 | L认证时间 | M备注 ``` --- ## 3. 技术选型 ### 3.1 采用「PDF 文本层坐标解析」,不使用 OCR / 视觉大模型 依据是第 2 节的实测结论:**全部 104 个 PDF 都带文本层**。文本层是 PDF 内嵌的原始字符流,不存在图像识别误差。 对比: | 方案 | 准确率来源 | 单张耗时 | 成本 | 可复现性 | |---|---|---|---|---| | **文本层坐标解析(采用)** | 原始字符流,天然零识别误差 | 约 6.7 毫秒 | 零 | 完全可复现 | | OCR / 视觉大模型 | 依赖模型识别精度 | 秒级 | 按调用量计费 | 受模型版本影响 | 实测全量 104 个 PDF 耗时 **0.7 秒**,单张平均 **6.7 毫秒**,相对「单张 ≤30 秒」的要求有约 4500 倍余量。 ### 3.2 适用边界 该方案成立的前提是 **PDF 含文本层**。扫描件与手机拍照发票由 OCR 通道覆盖(见 `src/ocr.py`),并可在界面上按需选择扫描方式:文本扫描 / 文件为主·OCR 兜底 / OCR 扫描(见第 10.5 节)。 ### 3.3 OFD 输入通道 OFD(GB/T 33190)是国内电子发票的另一版式格式,与 PDF 并存。`src/ofd_text.py` 直接解包,按页面内容分两条路: | OFD 形态 | 处理 | 代价 | |---|---|---| | 矢量型(页面由 `TextObject` 构成) | 取文字层,走与 PDF 相同的快路径 | 毫秒级 | | 图片型(页面由 `ImageObject` 构成) | 取内嵌图像,按页面物理尺寸渲染后走 OCR | 每张约 6~13 秒 | 关键实测:内嵌位图只有 1653px 宽(约 200dpi),**直接识别它会丢合计行**(8 个样本中 3 个「未识别到合计行」);按 `PhysicalBox` 渲染到 500dpi 后 5/5 与同名 PDF 的文本层结果一致(见《2026-09-28-OFD输入通道-design.md》附录 A)。 接入方式是让 OFD 页对象实现 `pymupdf.Page` 被用到的**最小子集**(`rect` / `get_text` / `get_pixmap`),因此 `extractor`、`ocr`、`layout`、`pdf_text` 全部零改动。 **必须显式分派**,不能指望 pymupdf 自己判断:pymupdf 打不开 OFD,却会把 ZIP 容器按图片集(CBZ)打开、渲染出其中一张位图——结果是「能出数但丢字段」,比当场报错难查得多。 --- ## 4. 系统架构 ### 4.1 模块划分 ``` main.py 命令行入口、结果汇总打印 gui.py 图形操作界面(选目录、一键处理、进度与结果展示) src/ config.py 路径、模板列定义、校验阈值、本公司抬头 textnorm.py 字符归一化、数值解析 pdf_text.py PDF 词级读取(带坐标) ofd_text.py OFD 解包与页适配(矢量文字 / 内嵌图像 → 与 PDF 同样的页对象) layout.py 视觉行聚类、token 合并、表格列边界推导 classifier.py 票种识别(发票 / 行程单 / 未知) extractor.py 字段提取(核心模块) validator.py 勾稽校验、置信度分级 dedup.py 发票号唯一性校验、来源优先级去重(同号时文本层优先) writer.py 单一结果文件的输出(处理结果 / 处理清单 / 异常复核三张表),回传清单实际写入行数;行高亮判定与取值映射也在此,供界面预览复用 pipeline.py 批量编排 report.py 统计口径与汇总文本(命令行、日志文件、完成弹窗共用) inputs.py 用户输入解析(目录是否存在、路径清单展开),纯函数、不依赖 GUI 库 windrop.py 接收资源管理器拖入的文件与文件夹(接 Windows 消息,只用标准库 ctypes) logs.py 运行日志落盘,按天一个文件,界面不显示 ``` ### 4.2 数据流 ``` doc/需要录入的发票260824/*.pdf | [1] 批量扫描器 递归发现 PDF,形成待处理清单 | [2] 票种分流器 发票 / 行程单 / 未知 | ├─ 行程单(11) -> 单独状态,标记「未处理」 | └─ 发票(93) -> 继续 | [3] 文本坐标解析 词级坐标 -> 版面分区 -> 字段提取 | [4] 校验与置信度 勾稽校验 -> 高 / 中 / 低 | [5] 唯一性校验 发票号去重 | [6] 结果聚合 按发票号一一绑定(禁止按顺序关联) | [7] Excel 写入 写入模板副本,K/L/M 列保持留空 | 产出:发票统计_结果.xlsx(内含「处理结果 / 处理清单 / 异常复核」三张表) ``` ### 4.3 「无遗漏、无串行」的保障方式 - **无遗漏**:每个 PDF 必有一个结果状态(已录入 / 待复核 / 未处理 / 解析失败)。处理清单行数与扫描文件数做对账断言。 - **无串行(防数据错位)**:结果一律以 `file_id`(文件名 + 发票号)绑定,写入时不依赖遍历顺序,从根本上杜绝张冠李戴。 - **可并发**:单文件解析无共享状态,失败重试不影响其他文件。 --- ## 5. 字段映射规则 | 模板列 | 数据来源 | 解析规则 | 单元格格式 | |---|---|---|---| | A 购买方 | 「购买方信息」区的「名称:」 | 以页面中线为界,左半区为购买方 | 文本,左对齐 | | B 销售方 | 「销售方信息」区的「名称:」 | 右半区为销售方 | 文本,左对齐 | | C 发票明细 | 「项目名称」列 | 折行合并,明细去重后以 `;` 拼接,保留票面原文 | 文本,左对齐 | | D 发票类型 | 页面标题 | 归一化后取标题,如「电子发票(普通发票)」 | 文本,左对齐 | | E 金额 | 「合 计」行不含税金额 | 取合计行,非明细行累加 | 数值 `#,##0.00` | | F 税率 | 「税率/征收率」列 | 规范为 `1%` / `3%` / `6%` / `13%` / `不征税` / `*` | 文本,居中 | | G 税额 | 「合 计」行税额 | 取合计行;票面为免税标记时留空 | 数值 `#,##0.00` | | H 含税金额 | 「价税合计(小写)」 | 取该行最右侧金额 | 数值 `#,##0.00` | | I 开票日期 | 「开票日期:」 | `2026年07月30日` -> 标准日期 | 日期 `yyyy-mm-dd` | | J 发票号 | 「发票号码」 | 兼容全角 `:` 与半角 `:` | **文本 `@`** | | K 核对 | — | 不填写 | 留空 | | L 认证时间 | — | 不填写 | 留空 | | M 备注 | — | 不填写 | 留空 | ### 5.1 两个关键格式要点 1. **J 列发票号必须为文本型**。发票号为 20 位数字,若按数值写入 Excel 会显示为 `2.6952E+19`,导致无法比对、无法去重。 2. **E / G / H 列必须为数值型**(不能是文本),否则 Excel 无法 `SUM` 求和。 --- ## 6. 版面定位原理 发票 PDF 的**文本块顺序不可靠**,字段定位必须依赖坐标。实测发现并解决的 5 个问题: | 问题 | 实测表现 | 解决方式 | |---|---|---| | 文本顺序不稳定 | 顺丰票文本块中「销售方信息」与「购买方信息」互相跨越;不同票的「名称:」出现顺序相反 | 一律用词级坐标定位,禁止按行序取第 N 个 | | 购买方/销售方易混淆 | 「购买方信息」是竖排单字,与同行「名称:」水平间距仅约 7pt | 以页面中线为界:左半区购买方、右半区销售方;并剔除竖排单字干扰 | | 列名被拆成单字 | 表头「单」「位」「数」「量」被拆开,且「单」「价」间距 18pt 大于最小列间距 12.7pt | 用已知列名集合做贪心最长匹配还原,不用固定间距阈值 | | 项目名称折行 | 如「*交通运输服务*客运服」+「务费」分两行 | 以「金额」「税额」列的 y 作为行锚点,折行片段归入上方锚点 | | 字符噪声 | 「电⼦发票」使用异体字 ⼦(U+2F26);括号存在全半角混用 | 统一 NFKC 归一化 + 括号统一为全角 | ### 6.1 列边界推导方式 不硬编码列坐标,而是从表头行动态推导: 1. 定位同时含「项目名称」与「税率」的表头行; 2. 还原各列名 token 的中心 x; 3. 以相邻列名中心的中点为分界,得到每列的 x 区间; 4. 明细行与合计行共用这套边界。 这样同一套代码可适配不同版式(实测普票与出租车客运票的列位置差异较大,均正确解析)。 --- ## 7. 校验与置信度机制 ### 7.1 校验项 | 校验项 | 规则 | 不通过时 | |---|---|---| | 关键字段完整性 | 购买方/销售方/明细/类型/发票号/金额/含税金额/开票日期均非空 | 低置信 | | 发票号格式 | 20 位纯数字 | 低置信 | | 开票日期 | 落在 2020-01-01 ~ 2035-12-31 | 低置信 | | 税额存在性 | 税率非免税时税额不应缺失 | 低置信 | | 金额勾稽 | 金额 + 税额 = 含税金额(容差 0.02 元) | 低置信 | | 税率勾稽 | 金额 × 税率 = 税额(容差 0.02 元) | 低置信 | | 购买方抬头 | 必须等于本公司全称 | 中置信(提示核对) | | 发票号唯一性 | 全批内不重复 | 低置信 | 免税标记(`不征税` / `*` / `免税` / `0%`)自动豁免税额与税率勾稽。 ### 7.2 置信度分级 | 等级 | 判定条件 | 处理方式 | |---|---|---| | 高 | 无任何问题项 | 正常录入 | | 中 | 仅有提示项(如抬头需核对) | 录入 + 主表浅黄底色提示 | | 低 | 存在缺失/异常/勾稽不符/重复 | 录入已提取值(不丢数据)+ 主表橙黄底色 + 进入「异常复核」表 | ### 7.3 主表底色约定 | 底色 | 含义 | |---|---| | 无底色 | 正常 | | 浅黄 `FFF2CC` | 中置信,有提示项 | | 橙黄 `FFD966` | 低置信,待人工复核 | | 红色 `FFC7CE` | 发票号重复 | 色值与判定都收敛在代码里(`config.HIGHLIGHT_COLORS` + `writer.highlight_of`):界面结果预览用的是同一份,两处不会出现一黄一不黄。 --- ## 8. 发票号唯一性校验 - 以 20 位发票号为主键分组,出现两次及以上即为重复; - 重复行全部标红,置信度降为低,`issues` 中记录重复涉及的全部文件名; - 同一张发票的「发票 + 行程单」配对**不视为重复**(行程单本身不参与录入)。 --- ## 9. 输出物说明 输出目录:`output/` 输出文件默认带**批次后缀**(运行时刻,格式 `YYYYMMDD_HHMMSS`),多次运行互不覆盖,历史批次可追溯。三份产物合并在**一个文件的三张工作表**里,用户只需拿一个文件: ``` output/发票统计_结果_20260922_102345.xlsx ├── 处理结果 索引 0,打开即停留在此页 ├── 处理清单 全量对账表 └── 异常复核 中 / 低置信与解析失败 ``` 合并的理由:三份文件的内容同源、同一批次产出,分开存放时用户容易只转发其中一份;合并后打开就是结果页,清单与复核项在表标签里随手可切。 ### 9.1 「处理结果」表 - 以 `doc/发票统计模板.xlsx` 为基底,列结构与顺序完全一致; - 首行表头加粗、浅蓝底、居中,冻结首行并启用筛选; - K / L / M 三列全程留空; - 每行底色按第 7.3 节规则标注; - 位于索引 0 且为 active:Excel 双击打开就停在这一页。 ### 9.2 「处理清单」表 全量对账清单,**每个输入文件(多页文件每页一行)一行**,字段:序号 / 文件名 / 文档类别 / 来源 / 发票号 / 置信度 / 状态 / 问题描述。 状态取值:`已录入` / `已录入(有提示项)` / `待人工复核` / `已跳过(同号票已有文本层结果)` / `未处理(行程单,按需求不录入)` / `未处理(无法识别为发票)` / `解析失败`。 「已跳过」与「重复发票号」不是一回事:前者是**同一张票**的两种格式(同名 OFD 与 PDF)并存,程序按来源优先级留下文本层结果、跳过 OCR 重复项;后者是**两张不同的票**用了同一个发票号,必须人工确认是否重复报销。被跳过的记录仍占清单一行,因此对账基准(清单行数 == 页数)不受影响。 打不开(损坏、非 PDF/OFD)或解不开(加密)的文件同样进入本清单,状态为「解析失败」、文档类别显示「未知」。这类记录没有可用字段,因此不出现在结果表与「异常复核」表中——**处理清单是它们唯一的可视位置**。 用途:验证「无遗漏」——清单行数应等于扫描文件数(对账基准由 writer 在写入时自行累计,不与调用方同源)。 ### 9.3 「异常复核」表 仅包含中、低置信的发票(解析失败的记录没有可用字段,不在此页)。列结构**与结果页对齐**:前 13 列与「处理结果」表逐列同名、同序,取值也出自同一份映射(`writer.row_values`),单元格样式共用 `_style_body_cell`——核对时两页可左右对照。 末尾追加三栏复核用的上下文,共 **16 列**: ``` 购买方 | 销售方 | 发票明细 | 发票类型 | 金额 | 税率 | 税额 | 含税金额 | 开票日期 | 发票号 | 核对 | 认证时间 | 备注 ‖ 文件名 | 置信度 | 问题描述 ``` - 文件名用 `display_name`(多页票带「(第N页)」,与处理清单、重复提示同源); - 问题描述为问题项用「;」连接,无则回退到解析错误信息; - 整表都是需复核项,因此**不设行底色**:底色只在结果页承担「哪一行要留意」的作用,全表着色等于没标。 --- ## 10. 运行方式 ### 10.1 环境要求 ``` Python 3.10+ pymupdf >= 1.24.0 openpyxl >= 3.1.0 pytest >= 8.0.0(仅运行回归测试时需要) ``` 安装依赖: ```bash pip install -r requirements.txt ``` ### 10.2 执行 ```bash # 使用默认目录(doc/需要录入的发票260824 -> output/) python main.py # 指定发票目录与输出目录 python main.py --source <发票目录> --output <输出目录> # 指定批次后缀(默认自动取当前时间) python main.py --batch-tag 2026Q3 # 不加批次后缀,使用固定文件名(会覆盖上一批结果) python main.py --batch-tag "" ``` ### 10.3 输出示例 ``` 扫描文件总数 : 104 扫描页数 : 104 识别为发票 : 93 未处理(行程单) : 11 未识别(其他) : 0 高置信 : 92 中置信(提示) : 1 低置信(待复核): 0 OCR 识别 : 0 已跳过(文本层) : 0 解析异常 : 0 重复发票号 : 0 组 ! 8月17日999元办公软件-即梦专线2.pdf:购买方名称与本公司全称不一致(数智星光(西安)科技),请核对 对账:处理清单 104 行 / 扫描文件 104 个,无遗漏 批次标识 : 20260922_102345 输出[result]:发票统计_结果_20260922_102345.xlsx 工作表 : 处理结果 / 处理清单 / 异常复核 ``` ### 10.4 回归测试 项目内置 pytest 回归测试,覆盖提取正确性、OFD 通道、来源优先级去重、单文件异常隔离、输出合规性、进度回调契约、用户输入解析、界面运行时路径与结果预览、日志落盘与对账机制,共 185 个用例: ```bash python -m pytest -v ``` 测试设计要点:**用独立数据源验证**,而不是拿系统自己的校验器验证系统自己的输出——金额的独立依据取自文件名(人工命名,先于系统存在)。 | 测试文件 | 覆盖内容 | |---|---| | `tests/test_extraction.py` | 无遗漏对账、无解析异常、**OFD 输入也被扫描且有明确状态(无引擎时落为「未处理」,不是解析异常)**、行程单分流、**文件名金额 == 含税金额**、金额勾稽、发票号 20 位且唯一、关键字段非空、置信度合法性 | | `tests/test_ofd.py` | **OFD 容器解析**:多页文件页数取自版式文件、页面尺寸取自 `PhysicalBox`(mm→pt)、图片型页没有文字层、**内嵌位图按请求的缩放渲染(500dpi → 约 4133px 宽;直接识别原图会丢合计行)**、渲染量随矩阵成比例、非 ZIP 与缺版式成员均明确报错;**矢量文字**:一个 `TextObject` 出一个词(矩形取 `Boundary`)、同一对象内的多个 `TextCode` 合并为一段、矢量型走文本层且**一次都不调 OCR 后端**、**无内嵌图却要求强制 OCR 必须当场失败(不得静默退化成文字层)**;**通道接入**:坏 OFD 落成解析失败、无引擎时图片型 OFD 落为「未处理」而非「解析异常」、**图片型 OFD 的识别结果与同名 PDF 的文本层结果逐项一致(含直接识别会丢合计行的那两张票)**、多页 OFD 每页一条记录且页码标注完整 | | `tests/test_dedup.py` | **来源优先级去重**:同号票的 OCR 记录被文本层记录取代、两条的入表状态各自正确、**同来源同号票互不取代(那是真的重复报销,仍判重复)**、**解析失败的记录两边都不参与**、没有发票号的记录不得凭文件名猜、**被跳过的记录不参与重复判定(否则留下来的好数据会被标成重复并降级)**、端到端「同名 OFD + PDF」结果页只留 PDF 那条且清单两条都在、**分类恒等式与对账平账** | | `tests/test_ocr.py` | **扫描方式三态**:`SCAN_OCR` 对带文本层的票也强制走识别、`SCAN_TEXT` 即使给了后端也不调识别、`SCAN_AUTO` 保持文本层快路径;**run 层早失败(要求强制识别却没给引擎即报错,且不产出任何文件)**、**未知取值在库入口与批处理入口都当场报错(防静默降级为文本扫描,已实测复现)**、扫描方式确实透传到解析层;OCR 坐标换算(像素→pt)、表头还原、合计行兜底定位、引擎缓存复用、空结果落成解析失败 | | `tests/test_failure_isolation.py` | **损坏 PDF 不得让整批崩溃、必须降级为解析失败记录**、解析失败的记录不计入「识别为发票」(两处口径不重叠)、**即使分类成功但解析失败,也不得进入发票计数与结果页/复核页**、**失败记录不参与去重(不得把同号正常发票一起降为低置信)**、两张可用发票同号仍判重复、**`doc_type` 必须显式声明(不得有默认值)**、存在坏文件时输出文件仍须产出且处理清单覆盖全部文件 | | `tests/test_output.py` | 批次后缀生效、**三张工作表名称与顺序固定、结果页为 active、输出目录只有一个 xlsx**、表头与模板一致、K/L/M 留空、发票号文本型、金额数值型、结果行数等于发票数、处理清单覆盖全部文件、**异常复核页 16 列(结果页 13 列 + 文件名/置信度/问题描述)、前 13 列与结果页逐列同名且同值(交叉断言)、类型口径一致**、复核行数与置信度对应 | | `tests/test_writer_highlight.py` | **行高亮判定唯一来源**:重复 > 低置信 > 中置信的优先级、空发票号不算重复、每个档位都有配色、**Excel 填色确由 `highlight_of` 推导**(两边不各判一套) | | `tests/test_progress.py` | 进度回调每文件一次且递增、不传回调时结果不变、**回调抛异常不中断整批**、空目录返回 0 且不回调 | | `tests/test_inputs.py` | **空目录输入不得被判为当前工作目录**、文件路径不算目录、两侧空白修剪;**路径展开**:目录递归、文件直收、后缀过滤与大小写、文件夹与其成员去重、缺失路径忽略、**空字符串不得退化成当前目录**。不依赖 tkinter(批次后缀不再由界面输入,相关解析与用例已移除) | | `tests/test_windrop.py` | **Windows 拖放接收**:登记为拖放目标、窗口过程替换与还原、`close()` 幂等、平台不支持时安静返回 None;**手工构造真实格式的 HDROP + `SendMessage(WM_DROPFILES)`**,走通「消息 → 窗口过程 → 路径解析 → 回调」(路径含中文,验证走的是 W 版 API)。仅在 Windows + 有图形环境时运行 | | `tests/test_gui.py` | **界面运行时路径**:进度事件更新状态、错误事件收尾、完成事件把汇总写进当天的日志文件并把结果铺进预览、**结果预览(未运行时为空、13 列齐全、行数等于可用发票数、按 writer 档位着色、重新开始时清空)**、**界面精简(路径与后缀输入框不再存在、按钮自带名称、目录按钮仍能选目录)**、**扫描方式三选一(默认兜底、引擎缺失时后两项置灰且默认降到文本扫描、运行中锁住、所选模式真的决定跑哪条通道、取值意外时不打崩「开始处理」)**、**窗口下方统计行(未运行时占位、跑完显示四个计数、重新开始时复位)**、**完成后自动打开结果文件、不再弹完成框、结果文件缺失时安静跳过**、**悬停提示显示当前目录且随改动更新、移开即撤、销毁前取消定时器**、**日志可用/不可用时的提示文案**、**输出目录留空时「打开输出目录」不可用**、**完整跑一次线程→队列→界面(含批次分隔行)**、**销毁时取消定时器并释放 Tk 变量**。无图形环境整体跳过 | | `tests/test_report.py` | 计数函数与 `RunResult` 口径一致、汇总数字与日志明细行均由唯一判定源推导(防口径分叉)、**分类无遗漏划分(发票+行程单+未知==总数)**、未知类型有独立分类且不凭空消失、**对账行对数目不符有反应、未写出与数目不符三态互斥**、真实批次对账平账、**未知类型独立成行、统计行冒号对齐同一列**、每条中/低置信记录各一行提示、**输出路径只剩 result 一键、汇总列出三张工作表**、**来源计数用中性标签(文案里不得再出现「兜底」)** | | `tests/test_logs.py` | **日志落盘契约**:文件名按日期、内容进当天文件且带级别、UTF-8 不乱码、**跨天自动换文件(不得再写进昨天的文件)**、**多次运行之间留下分隔行并标明来源**、重复装配不写重、拆卸还原 root 级别、**装配状态可查(供界面与命令行提示)**、**目录不可用时降级返回 None 而不是拦住启动** | 测试结果写入临时目录,不污染 `output/` 中的实际结果;日志用例把日志目录定向到临时目录,不往项目的 `logs/` 里写。共 **212 个用例**,耗时随样本而异(含 OFD 通道的真实 OCR 用例,每张约 5~8 秒,以及一次界面端到端运行)。 其中 12 个用例依赖 `doc/` 下的真实 OFD 样本(如「图片型 OFD 的识别结果与同名 PDF 逐项一致」)。样本不在时会**失败而不是静默跳过**——这一点是刻意的:样本缺失属于环境问题,跳过去只会让 OFD 通道在无人察觉的情况下失去护栏。 ### 10.5 图形操作界面 不会用命令行的场景,可改用图形界面: ```bash python gui.py ``` 界面提供: | 区域 | 功能 | |---|---| | 输入与输出 | 「发票目录」「输出目录」两个按钮;**支持把文件/文件夹直接从资源管理器拖进窗口**;**扫描方式三选一**(文本扫描 / 文件为主·OCR 兜底 / OCR 扫描);批次按运行时刻自动命名(界面不提供后缀输入与开关) | | 待处理清单 | 拖入或选目录之后的全部文件,一行一个;标题显示「待处理 N 个文件」,可多选后「移除选中」;清单为空时「开始处理」不可点。拖入即**替换**上一批(不叠加) | | 操作 | 开始处理 / 打开输出目录 / 日志 | | 进度 | 进度条 + 已处理/总数状态,逐个文件更新 | | 处理结果 | 窗口内直接预览「处理结果」表的全部 13 列(含恒空的核对 / 认证时间 / 备注),横向与纵向滚动,行底色与 Excel 一致 | | 统计 | 窗口最下方常驻四个计数:扫描文件 / 识别发票 / 待人工复核 / 解析异常;未运行时显示「尚未运行」(不显示四个 0,免得像"跑过了但结果全空") | | 完成动作 | 处理完成后**自动打开结果文件**(系统默认程序,通常是 Excel);**不弹完成对话框** | 设计要点: - 界面**不复制任何解析逻辑**,统一调用 `src/pipeline.run`,与命令行的结果完全一致; - **拖入即替换、清单即输入**:拖入与「发票目录」都只改 `_pending_files` 这一份清单,「开始处理」原样把它交给 `run`。三者走同一个 `collect_inputs`/`expand_paths`,所以清单里显示什么就处理什么——两处各展开一遍,迟早出现「清单里 5 个、实际跑了 7 个」; - **拖放只依赖标准库**(`src/windrop.py`):用 ctypes 接 `WM_DROPFILES`,不引入 `windnd`——它没有声明 ctypes 签名,64 位下 `GetWindowLongPtrW` 返回的旧窗口过程指针会被截断;默认还走 ANSI 版 API,中文路径乱码。平台不支持时安静不启用,按钮选目录这条老路照常可用; - **扫描方式三选一**(文本扫描 / 文件为主·OCR 兜底 / OCR 扫描):三者差别只在**触发条件**,识别结果与校验口径完全一致。OCR 相关两项在引擎缺失时置灰,默认值随之下沉到仍选得动的「文本扫描」——默认选中一个点不动的选项最让人困惑;界面所选方式真的决定跑哪条通道(文本扫描连后端都不创建); - 解析在**后台线程**执行,通过队列回传进度,界面不会假死;同一时刻只允许一个任务; - **结果预览取自内存中的记录**(`RunResult.records`),不读回 Excel:省一次解析,也不会被「文件正被 Excel 打开」牵连。行集与结果表同口径(只取入表的发票,判据 `InvoiceRecord.is_entered`),取值映射复用 `writer.row_values`,底色档位复用 `writer.highlight_of` —— 界面上看到的与表里写下的出自同一份判定; - **路径与后缀不占界面**:目录改成两个按钮(按钮文字即标签),批次后缀固定按运行时刻自动命名,输入框与「不加后缀」开关一并撤掉;「打开结果表」也去掉。路径动辄几十字符,摆在面板上既挤又几乎没人改,结果看下方预览就够; - 收起输入框后「当前用的是哪个目录」靠**按钮悬停提示**(`gui.Tooltip`)给出:提示文字用回调取值,每次悬停都重新读,路径改了不必刷新提示;提示窗口销毁前会撤掉挂起的定时器,不留「访问已释放控件」的隐患; - **界面不显示运行日志**:日志一律写入 `logs/运行日志-YYYY-MM-DD.log`(按天一个文件,跨天自动换文件),界面只保留「打开日志目录」入口。日志框会占掉本就不宽裕的窗口空间,而这批日志的实际用途是出问题时发给开发定位——放文件里比放界面上更合用; - 同一天的多次运行共用同一文件,每次运行前写入一条分隔,避免记录首尾相接、分不清批次;日志目录不可写(只读位置、权限受限)时**界面与命令行都会明确提示**,不会让人以为日志已落盘; - **处理完直接给结果**:完成后自动用系统默认程序打开结果文件,并去掉完成弹窗——四个计数已常驻窗口下方,弹窗既重复又挡在刚打开的 Excel 前面。文件缺失时安静跳过;打开失败仍会提示(打不开更要让人知道); - 输出文件与汇总口径沿用第 9 章规则;界面上批次固定按运行时刻生成,命令行仍可用 `--batch-tag` 自定义(传空串=不加后缀)。 **想手工调界面外观**(间距、字号、窗口尺寸、预览行数与列宽):改 `gui.py` 顶部的布局参数即可——`_WINDOW_MIN_SIZE` / `_APP_PADDING` / `_SECTION_PADDING` / `_ROW_PADY` / `_BLOCK_PADY` / `_BUTTON_PADY` / `_PREVIEW_ROWS` / `_PREVIEW_WIDTH_SCALE` / `_UI_FONT_SIZE`,改完重启 `python gui.py` 生效。这些值是布局的唯一来源,构建代码里不再散落魔法数字。 --- ## 11. 实测验证结果 ### 11.1 独立交叉验证 为避免「用自家校验器自证」,采用**文件名中的金额**作为独立数据源做交叉验证: | 验证项 | 结果 | |---|---| | 文件名含金额、可交叉比对 | 92 张 | | 文件名无金额 | 1 张(`26952000003009664321.pdf`) | | **含税金额与文件名金额不一致** | **0 张** | | K / L / M 非空违规 | 0 | | 发票号非文本型 | 0 | | 金额列非数值型 | 0 | | 发票号唯一性 | 93 / 93 | | 关键字段为空的记录 | 0 | ### 11.2 性能 | 指标 | 实测值 | |---|---| | 全量 104 个 PDF 总耗时 | 0.7 秒 | | 单张平均耗时 | 6.7 毫秒 | ### 11.3 系统实际发现的票据问题 `8月17日999元办公软件-即梦专线2.pdf` 的 PDF 文本层中,购买方名称仅为「数智星光(西安)科技」,**「有限公司」在文件里根本不存在**(经词级坐标核验,非解析错误)。 系统将其判为中置信并输出提示「购买方名称与本公司全称不一致,请核对」,验证了异常标注机制的有效性。 --- ## 12. 已知限制 | 限制 | 说明 | 影响 | |---|---|---| | 仅支持含文本层的 PDF | 扫描件、照片发票需另加 OCR 通道 | 当前数据全部满足 | | 仅解析第 1 页 | 多页发票的跨页明细不会合并 | 当前 93 张发票均为单页 | | 批次目录名写在配置中 | 默认目录为 `需要录入的发票260824` | 可用 `--source` 参数覆盖 | | 明细保留票面原文 | 如 `*纸制品**纸制品*A4纸` 保留重复分类前缀,不做去重加工 | 忠实于票据原文 | --- ## 13. 实施阶段建议 ### 13.1 实施路径回顾(均已完成) 本系统的落地顺序,可作为同类项目的参考路径: | 阶段 | 目标 | 关键动作 | 验收标准 | 状态 | |---|---|---|---|---| | P0 数据可行性验证 | 确定技术路线 | 普查 104 个 PDF 是否含文本层;探查字段坐标分布 | 文本型 104/104,确认无需 OCR | 已完成 | | P1 解析器实现 | 字段提取跑通 | 实现版面分析、票种分流、字段提取 | 6 张代表票抽查全部正确 | 已完成 | | P2 批量贯通 | 全量处理无中断 | 单文件异常隔离、批量编排 | 104 个文件全部产生记录 | 已完成 | | P3 质量保障 | 校验与异常标注 | 金额/税率勾稽、置信度分级、异常清单 | 93 张全部通过金额勾稽 | 已完成 | | P4 去重与对账 | 唯一性与无遗漏 | 发票号分组去重、处理清单对账 | 发票号 93/93 唯一,清单 104 行 | 已完成 | | P5 结果输出 | 按模板产出 | 发票号文本型、金额数值型、日期格式化 | K/L/M 留空 0 违规 | 已完成 | | P6 独立验证 | 避免自证 | 用文件名金额做外部交叉比对 | 92 张 0 偏差 | 已完成 | | P7 代码审查 | 质量把关 | 七维只读审查 | 输出 8 条问题清单 | 已完成 | ### 13.2 实施过程中确立的四条原则 1. **先验证数据,再定技术路线**。P0 阶段的实测结论(104/104 含文本层)直接决定了「不使用 OCR」,避免了不必要的成本、误差与不可复现性。若跳过这一步直接按经验选型,很可能引入不必要的复杂度。 2. **坐标优先,不依赖文本块顺序**。实测发现同一张发票的文本块顺序都可能互相跨越,所有字段定位改为基于词级坐标,这是准确率的根本保障。 3. **主键绑定,而非顺序绑定**。结果与文件以发票号绑定,不依赖遍历顺序,从根本上杜绝批量处理中的数据错位(张冠李戴)。 4. **独立数据源交叉验证**。不满足于自家校验器全部通过,额外用文件名中的金额作为外部依据做比对,否则「自己的校验器验证自己的输出」不构成有效证据。 ### 13.3 后续增强实施情况 | 项 | 内容 | 状态 | |---|---|---| | 输出批次化 | 结果文件追加批次后缀,多次运行互不覆盖 | **已完成** | | 自动化回归测试 | 185 个 pytest 用例,覆盖提取正确性、OFD 通道、来源优先级去重、单文件异常隔离、输出合规性、进度回调、用户输入解析、界面运行时路径、日志落盘与对账机制 | **已完成** | | OFD 输入通道 | 直接解包 OFD(不依赖外部转换工具):矢量型取文字层,图片型取内嵌图按页面物理尺寸渲染后走 OCR。实测 105 个 OFD 全是图片型,识别结果与同名 PDF 一致;同名 OFD+PDF 并存时按「文本层优先」跳过 OCR 重复项 | **已完成** | | 拖拽添加文件 | 把文件或文件夹从资源管理器拖进窗口即成为输入(自研 ctypes 接 Windows 消息,不引入第三方依赖);新增「待处理清单」可多选后移除;拖入即替换输入源;清单为空时「开始处理」不可点 | **已完成** | | 运行日志改为落盘 | 界面不再显示日志(去掉日志框与「清空日志」),改为按天写入 `logs/运行日志-YYYY-MM-DD.log`,界面保留「打开日志目录」入口 | **已完成** | | 三表合一 + 界面结果预览 | 三份产物合并为一个文件的三张工作表(处理结果 / 处理清单 / 异常复核,结果页在首位且 active);界面内嵌 13 列结果预览,底色档位与 Excel 同源 | **已完成** | | 扫描方式三选一 | 界面由「OCR 兜底」开关改为三种扫描方式(文本扫描 / 文件为主·OCR 兜底 / OCR 扫描),**强制识别为新增能力**;要求强制识别却没给引擎时当场报错,不静默退化成文本扫描;汇总标签改为中性的「OCR 识别」 | **已完成** | | 异常复核表对齐结果页 | 复核页改为「结果页 13 列 + 文件名 / 置信度 / 问题描述」,前 13 列与结果页同名同值、样式共用;顺带把发票号改为文本型、日期改用同一格式 | **已完成** | | 结果直达 | 窗口下方常驻四个计数(扫描文件 / 识别发票 / 待人工复核 / 解析异常);处理完成后自动打开结果文件,并去掉完成弹窗 | **已完成** | | 修复代码审查问题 3:对账恒真 | 对账行改为比对 **writer 实际写入行数** vs 扫描文件数,不再同一变量自比;不符时输出「数目不符,请检查」 | **已完成** | | 单文件异常隔离(含打开失败) | `load_document` 纳入异常隔离;打不开/解不开的文件降级为「解析失败」,不再中断整批;失败记录不计入「识别为发票」 | **已完成** | | 界面运行时护栏 | `tests/test_gui.py` 覆盖线程→队列→事件分发与按钮状态;用户输入解析抽到 `src/inputs.py`,相关测试不再依赖 tkinter | **已完成** | | 修复代码审查问题 1:统计口径 | 计数收敛到 `count_records`,汇总与日志明细同源推导;未知类型独立分类,分类和恒等于总数 | **已完成** | | 修复代码审查问题 2:发票号回退 | 发票号缺失时的回退策略 | 待实施 | | 多页发票支持 | 跨页 words 合并 | 待实施(需出现多页发票) | | 扫描件 OCR 兜底 | 为扫描件、照片发票增加识别路径;界面可选扫描方式(文本扫描 / 文件为主·OCR 兜底 / OCR 扫描) | **已完成** | | 明细去重加工 | `*A**A*B` 规范为 `*A*B` | 待确认口径 | 各增强项的功能描述见第 14 章。 ### 13.4 上线前检查清单 - [ ] 确认 `src/config.py` 中的 `OUR_COMPANY_FULL_NAME` 为本公司当前全称 - [ ] 确认待处理发票目录(默认 `doc/需要录入的发票260824`,可用 `--source` 覆盖) - [ ] 全量运行一次,核对「处理清单」行数等于扫描文件数 - [ ] 逐条复核「异常复核」表中的记录,对照原票修正 - [ ] 抽查结果表的「含税金额」列,确认可正常求和 - [ ] 运行 `python -m pytest -v`,确认 163 个回归用例全部通过 --- ## 14. 后续可选增强 | 序号 | 增强项 | 说明 | 状态 | |---|---|---|---| | 1 | 明细去重加工 | 将 `*纸制品**纸制品*A4纸` 规范为 `*纸制品*A4纸`,需先确认口径(当前刻意保留票面原文) | 未实施 | | 2 | 输出批次化 | 结果文件名追加批次后缀,避免覆盖历史批次 | **已完成** | | 3 | OCR 兜底通道 | 为扫描件、照片发票增加识别路径(已含界面扫描方式三选一) | **已完成** | | 4 | 自动化回归测试 | 将交叉验证固化为 pytest 用例 | **已完成** | 已完成的第 2、4 项,实现方式分别见第 9 章(输出物命名)与第 10.4 节(回归测试)。