diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 0000000..ff323e3 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,19 @@ +--- +last_reviewed: 2026-06-11 +--- + +# .agents — 项目内部维护目录 + +本目录存放维护人员与自动化代理相关资料,不属于对外公开的用户文档。 + +## 目录结构 + +| 子目录 | 说明 | +|--------|------| +| `docs/` | 维护文档:规范、经验总结、实施方案、操作指南 | +| `skills/` | Cursor Agent 技能:定义自动化工作流和代码检查流程 | + +## 文档边界 + +- 面向开源用户、外部贡献者的公开文档统一放置在项目根目录 `docs/` 下。 +- 维护规范、实施方案、经验总结、拉取请求佐证材料与各类内部记录资料,均统一放置在本目录下。 \ No newline at end of file diff --git a/.agents/docs/good-experience/2026-06-11-CSV-BOM-列名校验失败.md b/.agents/docs/good-experience/2026-06-11-CSV-BOM-列名校验失败.md new file mode 100644 index 0000000..6c849d3 --- /dev/null +++ b/.agents/docs/good-experience/2026-06-11-CSV-BOM-列名校验失败.md @@ -0,0 +1,36 @@ +--- +last_reviewed: 2026-06-11 +--- + +# 出现问题好的排查流程 + +## 出现问题后的排查流程 + +``` +日志报错 + │ + ├─ 1. 定位错误源码 + │ 根据日志标签 + 错误信息 → 找到报错函数和校验条件 + │ + ├─ 2. 分析日志时间线 + │ 对比错误时间与前后事件 → 判断是主流程还是试探性调用 + │ + ├─ 3. 编写诊断脚本(隔离测试) + │ │ + │ ├─ 正常 save/load 循环 → 确认基础路径是否健康 + │ ├─ 编码异常检测 → BOM、GBK 混入等 + │ ├─ 数据流变更检测 → 前端编辑/外部写入后列结构变化 + │ └─ 回退链检测 → glob 扫描到非预期文件 + │ + ├─ 4. 最小化复现 + │ 针对失败的测试用例,提取最简输入证明根因 + │ + └─ 5. 修复 + 验证 + 最小改动修复 → 确认不影响正常输入 +``` + +## 关键判断点 + +- 错误不影响主流程 → 优先排查试探性调用和回退链 +- 列名校验失败但文件肉眼正常 → 优先检查 BOM 和不可见字符 +- 错误出现在外部数据入口 → 优先检查编码兼容性和数据清洗 \ No newline at end of file diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 0000000..b00ef2b --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,17 @@ +--- +last_reviewed: 2026-06-11 +--- + +# .agents/skills — Cursor Agent 技能目录 + +存放 Cursor Agent 可调用的自动化技能定义。 + +## 技能清单 + +| 技能 | 说明 | +|------|------| +| `pre-commit-check/` | 提交前代码质量检查:运行 ruff lint/format、mypy 类型检查、deptry 依赖审计,自动修复可修复问题 | + +## 使用方式 + +Agent 在用户请求提交代码或检查代码质量时自动触发对应技能,无需手动调用。 \ No newline at end of file diff --git a/.agents/skills/pre-commit-check/SKILL.md b/.agents/skills/pre-commit-check/SKILL.md new file mode 100644 index 0000000..14774ab --- /dev/null +++ b/.agents/skills/pre-commit-check/SKILL.md @@ -0,0 +1,68 @@ +--- +name: pre-commit-check +description: >- + Run pre-commit code quality checks (ruff lint/format, mypy type check, deptry dependency audit) + and fix any issues before committing. Use when the user wants to commit code, asks to check code + quality, or mentions pre-commit validation. Also use when preparing code for submission or + when the user says "check before commit" or "run checks". +--- + +# Pre-Commit Code Quality Check + +## Workflow + +Run all checks in parallel first, then fix any issues iteratively: + +``` +Step 1: Run checks (parallel) + - uv run ruff check . + - uv run ruff format --check . + - uv run mypy src/ + - uv run deptry . + +Step 2: If any check fails, fix issues + - ruff check --fix . (auto-fix lint issues) + - ruff format . (auto-format) + - Fix type annotation errors manually + +Step 3: Re-run all checks to confirm +Step 4: Report results with table +``` + +## Common Fixes + +| Error | Fix | +|-------|-----| +| `UP038` | Replace `isinstance(e, (X, Y))` with `isinstance(e, X \| Y)` | +| `no-untyped-def` | Add return type annotation (`-> None` for void functions) | +| `I001` | Run `uv run ruff check --fix .` | +| `no-any-return` | Use `cast(Type, expression)` from `typing` | +| `type-arg` missing | Add type arguments: `dict[str, Any]` instead of `dict` | +| `unused-ignore` | Remove stale `# type: ignore` comments | + +## Type Annotation Rules + +- Functions that modify data in-place and return nothing: `-> None` +- Functions that call untyped external APIs: add `-> Any` return type +- Dicts that hold mixed types (str + float): use `dict[str, Any]` +- Import `Any` from `typing` when needed +- Import `cast` from `typing` when `no-any-return` triggers + +## Verification Report + +After all checks pass, report: + +| 检查项 | 状态 | +|--------|------| +| `ruff check .` | ✅ | +| `ruff format --check .` | ✅ | +| `mypy src/` | ✅ | +| `deptry .` | ✅ | + +## Notes + +- All commands use `uv run` prefix (project virtual environment) +- `mypy` has `strict = true` in `pyproject.toml` - expect strict type checking +- `ignore_missing_imports = true` is set, so missing third-party stubs are OK +- If `tests.*` override shows "unused section" note, it's normal (no tests dir yet) +- Never skip a check - all four must pass before committing \ No newline at end of file diff --git a/.coverage b/.coverage index e8cf8cf..fab3f7c 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e95652f --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# 系统 URL 配置(服务端专用,Web 用户无需配置) +# 复制此文件为 .env 并填入实际值 + +SSO_LOGIN_URL=https://your-sso-url/login +PORTAL_URL=https://your-portal-url/oshall +REIMBURSE_URL=http://your-reimburse-host:8081 +REIMBURSE_PAGE=/expen/common/common?v=4.0 +TRAVEL_PAGE=/expen/travel/travel?v=4.0 + +# LLM 配置 +LLM_MODEL=your-model-name +LLM_API_BASE=http://your-llm-host:port/v1 +LLM_API_KEY=your-api-key \ No newline at end of file diff --git a/.gitignore b/.gitignore index 042f8f7..91b2156 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ __pycache__/ logs/ .vscode/ uploads/ +.env +scripts/data/ \ No newline at end of file diff --git a/PowerShell注意事项.md b/PowerShell注意事项.md deleted file mode 100644 index e5803f2..0000000 --- a/PowerShell注意事项.md +++ /dev/null @@ -1,103 +0,0 @@ -# PowerShell 使用注意事项 - -> 适用于 Windows 环境下的 Shell 命令执行。以下坑点均来自实际踩坑记录。 - ---- - -## 1. 没有 `head` / `tail` / `sed` / `awk` - -PowerShell 不是 bash,这些 Unix 工具不存在。 - -| bash | PowerShell 替代 | -|------|-----------------| -| `cmd \| head -n 5` | `cmd \| Select-Object -First 5` | -| `cmd \| tail -n 3` | `cmd \| Select-Object -Last 3` | -| `grep pattern file` | `Select-String pattern file`(或直接调用 `rg`) | - -**优先用 ripgrep (`rg`)**:搜索文件内容时直接用 `rg`,不依赖 PowerShell 内置工具。 - ---- - -## 2. 引号嵌套规则严格 - -PowerShell 对外部命令的引号处理不同于 bash: - -- `"` 是双引号字符串,内部变量会展开 -- `'` 是单引号字符串,不展开变量 -- `-c "..."` 里再嵌套 Python 的 `"..."` 或 `r'...'` 时,层数超过两层极易出错 - -**示例:** - -```powershell -# 危险:$e 会被 PowerShell 当作变量展开 -python -c "print(f'error: {$e}')" - -# 安全:用单引号包裹整个 -c 参数(PowerShell 不展开单引号) -python -c 'print("hello")' -``` - -**最佳实践**:复杂命令写进临时 `.py` 脚本文件执行,而不是用 `-c` 一行塞完。 - ---- - -## 3. `$` 变量展开干扰 - -在双引号字符串中,PowerShell 会尝试解析 `$var`、`${expr}`。如果 Python 代码里包含 f-string 的 `{...}` 或正则里的 `$`,会被 PowerShell 提前处理。 - -**应对**:用单引号包裹整个命令参数,或将逻辑写进脚本文件。 - ---- - -## 4. Unicode / GBK 编码冲突 - -Windows PowerShell 默认控制台编码是 GBK。当 Python 输出包含非 GBK 字符(如全角符号、emoji、CJK 扩展字符)时: - -``` -UnicodeEncodeError: 'gbk' codec can't encode character '\uff08' in position X -``` - -**解决:** - -- Python 脚本开头加 `sys.stdout.reconfigure(encoding='utf-8')` -- 或设置环境变量 `$env:PYTHONIOENCODING = 'utf-8'` -- 或将输出写入文件而非直接打印到控制台 - ---- - -## 5. stderr 重定向行为不一致 - -bash 的 `2>&1` 在 PowerShell 中对外部程序(如 Python)有时能工作,但格式可能错乱。PowerShell 默认只捕获 stdout(流编号 6),stderr(流编号 2)需要显式合并。 - -| 写法 | 说明 | -|------|------| -| `cmd 2>&1` | stderr 合并到 stdout,对外部程序可用但不稳定 | -| `cmd *>&1` | PowerShell 特有语法,捕获所有输出流,更可靠 | - -**实际表现**:Python Traceback 走 stderr,用 `2>&1` 有时被 PowerShell 拦截后格式错乱。建议优先写脚本文件执行。 - ---- - -## 6. 中文路径在错误回显中乱码 - -命令失败时,PowerShell 的 stderr 回显中包含中文的路径会显示为 `�����`。不影响命令执行本身,但会让错误信息难以阅读。 - -**应对**:优先通过 Python 脚本自身打印结构化错误(写入文件或返回 JSON),而非依赖 PowerShell 的错误捕获来诊断问题。 - ---- - -## 7. 管道传递的是对象而非文本 - -PowerShell 的管道传递的是 .NET 对象而非文本字节流。当把 PowerShell 管道接到外部程序时,对象的 `ToString()` 可能不是预期的格式。 - ---- - -## 核心原则速查 - -1. **复杂命令写脚本**:超过一行的操作,写成 `.py` 或 `.ps1` 文件执行 -2. **优先用 ripgrep (`rg`)**:搜索文件内容时直接用 `rg` -3. **编码问题提前处理**:Python 入口设 `sys.stdout.reconfigure(encoding='utf-8')` -4. **避免 `-c` 嵌套引号超过两层** - ---- - -*创建于 2026-05-27 | Windows PowerShell + Python 环境* \ No newline at end of file diff --git a/README.md b/README.md index 42867c4..f34b558 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 财务报销自动化 -自动从 PDF 发票提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。 +自动从 PDF 发票或图片中提取信息,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。 **支持发票类型区分**:系统自动识别高铁票、酒店住宿等差旅发票与普通发票。差旅发票不生成易耗品出库单,走差旅报销流程;普通发票生成出库单,走普通报销流程。 @@ -11,8 +11,9 @@ ├── uv.lock # 依赖锁定文件 ├── Makefile # 任务脚本(跨平台) ├── tasks.py # 任务脚本(Windows 兼容) -├── config.json # 配置文件(登录凭据、默认值等) -├── config.example.json # 配置示例 +├── .pre-commit-config.yaml # pre-commit 钩子配置 +├── .env.example # 环境变量示例(SSO 地址、LLM 配置等) +├── config.example.json # 用户配置示例 ├── 易耗品、出库单.doc # 易耗品出库单 Word 模板 ├── src/ │ ├── __init__.py # 包初始化 / 日志器 @@ -22,8 +23,9 @@ │ ├── main.py # CLI 入口 │ ├── doc/ # 文档处理模块 │ │ ├── extractor.py # 编排入口:串联 PDF 读取 → LLM 提取 → 分类 -│ │ ├── pdf.py # PDF 文件发现与文本提取 +│ │ ├── pdf.py # PDF 图片渲染(PyMuPDF,供多模态 LLM 使用) │ │ ├── llm_extractor.py # LLM 信息提取 +│ │ ├── matcher.py # 数据匹配与校验 │ │ ├── invoice.py # 发票类型常量、分类逻辑、CSV 读写工具 │ │ ├── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM) │ │ ├── prompt.py # LLM 提示词模板 @@ -33,19 +35,24 @@ │ ├── templates/ │ │ ├── index.html # PC 端主页 │ │ └── mobile_upload.html # 移动端扫码上传 -│ ├── static/ # 前端 CSS / JS +│ ├── static/ +│ │ ├── css/ # 样式文件 +│ │ └── js/ # 前端脚本 │ └── uploads/ # 按会话隔离的上传与产物目录 +├── scripts/ # CLI 数据目录 +│ ├── data/ # 发票源文件、config.json 与 .invoice_cache 缓存 +│ └── test_*.py # 测试脚本 ├── tests/ # 测试目录 -├── *.pdf # 发票 PDF(CLI 模式,放在项目根目录) -├── invoice_summary.csv # 发票汇总表(CLI 产物) -└── images/ # 浏览器调试截图 +├── docs/ # 用户文档(API 说明、操作指南等) +├── images/ # 浏览器调试截图 +└── *.pdf / *.jpg / *.png # 发票 PDF 或图片(CLI 模式,放在 scripts/data/) ``` ## 数据流 ```mermaid flowchart TB - PDF[PDF 发票] --> Extract[extractor 提取] + PDF[PDF 发票 / 图片] --> Extract[extractor 提取] Extract --> Classify{发票类型分类} Classify -->|差旅发票| Travel[高铁票 / 酒店住宿] Classify -->|普通发票| General[普通发票] @@ -54,7 +61,7 @@ flowchart TB CSV -->|仅普通发票| Fill[fill_consumable_doc] Fill --> Doc[易耗品、出库单.doc] CSV --> Bot[bot 浏览器自动化] - Bot -->|差旅模式| Submit_T[差旅报销填报
TODO] + Bot -->|差旅模式| Submit_T[差旅报销填报] Bot -->|普通模式| Submit_G[普通报销填报] ``` @@ -79,18 +86,19 @@ python tasks.py install | 依赖 | 用途 | |------|------| -| pdfplumber | PDF 发票文本提取 | +| PyMuPDF | PDF 图片渲染(供多模态 LLM 使用) | +| llama-index | LLM 信息提取(发票识别、差旅信息提取) | | playwright | 财务系统浏览器自动化 | | flask | Web 服务 | | pywin32 | 填写 Word 出库单(`fill_consumable_doc`) | ### 2. 准备数据(CLI 模式) -将发票 PDF 放在项目根目录下。 +将发票 PDF 或图片(`.jpg`、`.png`、`.webp`、`.bmp`)放在 `scripts/data/` 目录下。 ### 3. 配置 -编辑 `config.json`: +编辑 `scripts/config.json`(参考 `config.example.json`): ```json { @@ -99,7 +107,7 @@ python tasks.py install "default_name": "默认报销人姓名", "default_card_no": "默认公务卡号", "default_person_id": "默认人员编号", - "consumable_storage": "躬行楼 C205" + "consumable_storage": "物料存储地" } ``` @@ -109,8 +117,20 @@ python tasks.py install | `default_name` | 默认报销人姓名 | | `default_card_no` | 默认公务卡号 | | `default_person_id` | 默认人员编号(工号) | -| `consumable_storage` | 出库单「存放地点」列默认值 | -| `sso_login_url` 等 | 系统 URL,一般无需修改 | +| `consumable_storage` | 出库单「存放地点」列默认值(默认: `躬行楼 C205`) | + +**服务端配置**(SSO 地址、报销系统 URL、LLM 参数)通过环境变量提供,有默认值,一般无需修改: + +| 环境变量 | 默认值 | 说明 | +|----------|--------|------| +| `SSO_LOGIN_URL` | `https://tyrz.fynu.edu.cn/sso/login` | SSO 登录地址 | +| `PORTAL_URL` | `https://tyrz.fynu.edu.cn/oshall` | 统一信息平台地址 | +| `REIMBURSE_URL` | `http://210.45.32.214:8081` | 报销系统地址 | +| `REIMBURSE_PAGE` | `/expen/common/common?v=4.0` | 普通报销页面路径 | +| `TRAVEL_PAGE` | `/expen/travel/travel?v=4.0` | 差旅报销页面路径 | +| `LLM_MODEL` | `qwen-vl-max` | LLM 模型名称 | +| `LLM_API_BASE` | `http://localhost:8080/v1` | LLM API 地址 | +| `LLM_API_KEY` | `lm-studio` | LLM API 密钥 | ### 4. 运行(CLI) @@ -135,39 +155,62 @@ uv run python src/main.py -u 工号 -p 密码 ```bash uv run python -m src.doc.fill_consumable_doc uv run python -m src.doc.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc" +uv run python -m src.doc.fill_consumable_doc --config scripts/config.json # 指定配置文件 uv run python -m src.doc.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份 ``` 填写规则概要: - 表头「日期」使用**填写当天**的日期(非发票开票日期) -- 从 `规格型号` 解析品名、规格、单位、数量、单价;`价税合计` 写入金额列 +- 从 `spec_model` 解析品名、规格、单位、数量、单价;`card_amount` 写入金额列 - 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)** -- 存放地点取自 `consumable_storage`;购货人/领用人签字、备注保持空白 +- 存放地点取自 `consumable_storage`(默认: `躬行楼 C205`);购货人/领用人签字、备注保持空白 ## 执行步骤说明 | 步骤 | 命令 | 说明 | |------|------|------| -| 发票提取 | `--step invoice` | 扫描根目录 PDF,生成 `invoice_summary.csv` | +| 发票提取 | `--step invoice` | 扫描 `scripts/data/` 目录的 PDF 和图片,生成 `invoice_summary.csv` | | 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 | -> 分步执行时,上一步的 CSV 会自动成为下一步的输入。 +> 全流程执行时数据在内存中流转,CSV 为参考产物。分步执行时,缓存数据会自动成为下一步的输入。 + +### 缓存机制 + +系统使用 JSON 缓存作为数据中转站,串联整个处理流程: + +``` +源文件 (PDF/图片) → LLM 多模态提取 → JSON 缓存 → 匹配/分类/填报 +``` + +| 缓存文件 | 说明 | +|----------|------| +| `<文件名>.json` | 每个源文件的 LLM 提取结果(发票信息、支付记录等) | +| `match_result.json` | 发票与支付记录的匹配结果 | +| `travel_info.json` | 差旅信息(事由、地点、时间等)提取结果 | + +缓存位置:CLI 模式为 `scripts/data/.invoice_cache/`,Web 模式为 `src/web/uploads//.invoice_cache/`。缓存文件与源文件同名(如 `发票1.pdf` 对应 `.invoice_cache/发票1.json`),后续步骤(金额匹配、发票分类、浏览器填报)均从缓存读取结构化数据。删除缓存后下次处理会重新提取。 ### CSV 字段说明 +发票级别 CSV(`invoice_summary.csv`)使用英文列名: + | 列名 | 说明 | |------|------| -| 序号 | 行号 | -| 发票类型 | 自动识别:`高铁票` / `酒店住宿` / `普通发票` | -| 发票号码 | 电子发票号码 | -| 开票日期 | 开票日期 | -| 项目名称 / 规格型号 | 货物或应税劳务信息(差旅发票为出发站→到达站) | -| 价税合计 | 发票含税金额 | -| 销售方名称 | 销方名称 | -| 出发站 / 到达站 / 车次 / 乘车日期 / 座位等级 | 高铁票专用字段 | -| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | 可手工或 Web 端编辑补充 | -| 备注 / 工号 | 可手工或 Web 端编辑补充 | +| `index` | 行号 | +| `invoice_type` | 发票类型:`train` / `hotel` / `general` | +| `invoice_number` | 电子发票号码 | +| `invoice_date` | 开票日期 | +| `item_name` | 货物或应税劳务名称 | +| `spec_model` | 规格型号(差旅发票为出发站→到达站) | +| `total_amount` | 发票含税金额 | +| `seller_name` | 销方名称 | +| `departure` / `arrival` | 出发站 / 到达站(高铁票专用) | +| `train_no` / `ride_date` / `seat_class` | 车次 / 乘车日期 / 座位等级(高铁票专用) | +| `person_name` | 人员姓名 | +| `card_date` / `card_no` / `card_amount` | 刷卡日期 / 公务卡号 / 刷卡金额 | +| `remark` | 备注 | +| `person_id` | 工号 | ## Web 服务 @@ -181,7 +224,7 @@ uv run python src/web/app.py ### 推荐使用流程 -1. 上传 PDF(或上传已有 CSV) +1. 上传 PDF 或图片(或上传已有 CSV) 2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充 3. 点击 **开始处理** — 完成发票提取、生成 CSV,系统自动识别发票类型并分类统计 4. **普通发票**:自动生成 **易耗品、出库单.doc**,可下载 @@ -189,30 +232,24 @@ uv run python src/web/app.py 6. 在表格中核对、修改发票数据(提交财务系统前会自动保存) 7. 确认无误后点击 **提交到财务系统** -### 处理模式 +### 处理流程 -| 模式 | 入口 | 说明 | -|------|------|------| -| **PDF 模式** | 上传 PDF | 提取发票 → 生成 CSV + 自动分类 → 普通发票生成出库单 | -| **CSV 快捷模式** | 仅上传 CSV | 跳过提取,读取 CSV 中的发票类型,按需生成出库单 | +上传 PDF 或图片 → LLM 识别文档类型 → 结构化提取 → 分类处理 -### 发票类型区分 +系统通过 LLM 多模态识别自动判断每张文档的类型,无需手动指定: -系统自动识别以下发票类型并按类型分流: - -| 发票类型 | 识别依据 | 出库单 | 填报模式 | -|----------|----------|--------|----------| -| **高铁票** | 含"电子客票"、"中国铁路"、"12306"等关键词 | 不生成 | 差旅报销(TODO) | -| **酒店住宿** | 含"住宿费"、"餐饮服务"、"租赁服务"等关键词 | 不生成 | 差旅报销(TODO) | -| **普通发票** | 其他所有发票 | 自动生成 | 普通报销(已实现) | - -当同一批次同时包含差旅发票和普通发票时,系统会为普通发票生成出库单,并在填报时分别处理。 +| 文档类型 | 处理方式 | 状态 | +|----------|----------|------| +| 发票(高铁票/酒店住宿/普通发票) | 提取发票信息 → 金额匹配 → 分类 | 已实现 | +| 支付记录(刷卡截图) | 提取刷卡信息 → 与发票匹配 | 已实现 | +| 出差事前申请单 | 提取出差事由、地点、时间 | 已实现 | +| 飞机票 | 同高铁票处理流程 | 计划中 | ### 功能一览 | 功能 | 说明 | |------|------| -| 发票提取 | 上传 PDF 后自动完成 | +| 发票提取 | 上传 PDF 或图片后自动完成 | | 发票类型自动分类 | 高铁票/酒店住宿/普通发票,自动分流处理 | | 易耗品出库单 | 仅普通发票自动生成 Word,差旅发票跳过 | | 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 | @@ -221,9 +258,26 @@ uv run python src/web/app.py | 配置上传 | 支持上传 `config.json` 填充表单 | | 手机扫码上传 | 二维码打开移动端页面,拍照上传,PC 端轮询同步 | -> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。 +> Web 端浏览器填报以无头模式运行。未上传 PDF 或图片时,填报阶段会跳过附件上传。 > 出库单生成需要 **Windows + Word + pywin32**;若失败,页面会显示具体原因,CSV 等其它产物仍可正常使用。 +### 会话产物 + +每次上传生成独立会话,产物存放在 `src/web/uploads//`: + +| 产物 | 说明 | +|------|------| +| `invoice_summary.csv` | 发票汇总数据(含 LLM 识别结果) | +| `payment_records.csv` | 支付记录级别数据(含匹配结果) | +| `travel_applications.json` | 出差事前申请单数据(JSON 格式) | +| `易耗品、出库单.doc` | 自动填写的出库单(仅普通发票) | +| `config.json` | 当次会话配置 | +| `session.log` | 处理日志 | +| `result.json` | 处理结果 | +| `.invoice_cache/` | LLM 提取结果缓存(JSON 格式,避免重复处理) | + +会话缓存机制与 CLI 模式相同,缓存目录中的 JSON 数据是后续匹配、分类和填报的唯一数据来源。 + 接口说明见 [API.md](./API.md)。 ## 开发任务 @@ -254,5 +308,5 @@ uv run python src/web/app.py - 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程 - 调试截图保存在 `images/` 目录 - 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板 -- `config.json` 含敏感信息,请勿提交到公开仓库 -- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,当前差旅报销填报流程仍在开发中(TODO) \ No newline at end of file +- `scripts/config.json` 含敏感信息,请勿提交到公开仓库 +- **发票类型区分**:差旅发票(高铁票/酒店住宿)不会生成易耗品出库单,差旅报销填报流程已完整实现(含差旅信息提取、明细录入、支付方式、补助清单、附件上传) \ No newline at end of file diff --git a/config.example.json b/config.example.json index 9a1f95f..9309afa 100644 --- a/config.example.json +++ b/config.example.json @@ -1,17 +1,8 @@ { "username": "你的工号", "password": "你的密码", - "sso_login_url": "https://tyrz.fynu.edu.cn/sso/login", - "portal_url": "https://tyrz.fynu.edu.cn/oshall", - "reimburse_url": "http://210.45.32.214:8081", - "reimburse_page": "/expen/common/common?v=4.0", "default_name": "默认报销人姓名", "default_card_no": "默认公务卡号", "default_person_id": "默认人员编号", - "consumable_storage": "躬行楼 C205", - "llm": { - "model": "你的模型名称", - "api_base": "你的API地址", - "api_key": "你的API密钥" - } + "consumable_storage": "躬行楼 C205" } diff --git a/config.json b/config.json deleted file mode 100644 index bcc2d01..0000000 --- a/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "username": "202407021", - "password": "wang!1624155937", - "sso_login_url": "https://tyrz.fynu.edu.cn/sso/login", - "portal_url": "https://tyrz.fynu.edu.cn/oshall", - "reimburse_url": "http://210.45.32.214:8081", - "reimburse_page": "/expen/common/common?v=4.0", - "default_name": "王建锋", - "default_card_no": "6282880139161682", - "default_person_id": "202407021", - "consumable_storage": "新工科 D605", - "llm": { - "model": "qwen/qwen3.5-9b", - "api_base": "http://100.123.83.115:1234/v1", - "api_key": "123456" - } -} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 12ce6a5..b7e50e8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -14,16 +14,15 @@ last_reviewed: 2026-06-09 | 1 | GET | `/` | PC 端主页 | | 2 | POST | `/api/session` | 创建会话 | | 3 | POST | `/api/upload/` | 上传文件(PDF/图片) | -| 4 | POST | `/api/upload-csv/` | 上传 CSV 发票数据 | -| 5 | GET | `/api/files/` | 列出会话目录中的文件 | -| 6 | POST | `/api/process/` | 启动处理(提取+LLM识别+出库单) | -| 7 | GET | `/api/logs/` | SSE 日志流 | -| 8 | GET | `/api/download//` | 下载生成的文件 | -| 9 | GET | `/api/data/` | 获取发票数据(JSON) | -| 10 | POST | `/api/save/` | 保存编辑后的发票数据 | -| 11 | POST | `/api/submit-financial/` | 提交到财务系统 | -| 12 | GET | `/mobile/` | 移动端上传页面 | -| 13 | POST | `/api/mobile-upload/` | 移动端上传图片 | +| 4 | GET | `/api/files/` | 列出会话目录中的文件 | +| 5 | POST | `/api/process/` | 启动处理(提取+LLM识别+出库单) | +| 6 | GET | `/api/logs/` | SSE 日志流 | +| 7 | GET | `/api/download//` | 下载生成的文件 | +| 8 | GET | `/api/data/` | 获取发票数据(JSON) | +| 9 | POST | `/api/save/` | 保存编辑后的发票数据 | +| 10 | POST | `/api/submit-financial/` | 提交到财务系统 | +| 11 | GET | `/mobile/` | 移动端上传页面 | +| 12 | POST | `/api/mobile-upload/` | 移动端上传图片 | --- @@ -78,28 +77,7 @@ HTTP `400` --- -### 3. 上传 CSV 发票数据 - -``` -POST /api/upload-csv/ -Content-Type: multipart/form-data -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| file | File | 发票汇总 CSV | - -**响应(成功):** - -```json -{ "ok": true, "filename": "invoice_summary.csv" } -``` - -> 上传 CSV 后可跳过 PDF 提取和 LLM 识别,直接进入处理/编辑流程。 - ---- - -### 4. 列出会话文件 +### 3. 列出会话文件 ``` GET /api/files/ @@ -129,7 +107,6 @@ Content-Type: application/json | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| mode | string | 否 | `"pdf"` / `"csv"` / `"auto"`(默认 `auto`) | | username | string | 否 | 财务系统工号 | | password | string | 否 | 登录密码 | | default_name | string | 否 | 默认报销人姓名 | @@ -137,15 +114,7 @@ Content-Type: application/json | default_person_id | string | 否 | 默认人员编号 | | consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 | -**mode 行为:** - -| mode | 行为 | -|------|------| -| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + LLM 识别 | -| `csv` | 使用已上传 CSV,跳过提取与 LLM 识别 | -| `pdf` | 执行 PDF 提取 + LLM 识别 | - -**处理内容(PDF 模式):** +**处理内容:** 1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv` 2. 对支付截图多模态 LLM 识别,回填刷卡字段 @@ -270,7 +239,6 @@ GET /api/download// |--------|------| | `invoice_summary.csv` | 发票汇总(含 LLM 识别结果) | | `易耗品、出库单.doc` | 自动填写的出库单 | -| 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 | **错误:** diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e2fad72 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +--- +last_reviewed: 2026-06-11 +--- + +# docs — 公开文档目录 + +本目录存放面向项目用户和外部贡献者的公开文档及说明文件。 + +## 文档清单 + +| 文件 | 说明 | +|------|------| +| `API.md` | Web API 完整接口文档:路由、请求/响应格式、SSE 日志流、错误码、端到端流程 | +| `报销操作指南.md` | 面向最终用户的操作步骤说明 | + +## 文档边界 + +维护规范、实施方案、经验总结等内部资料统一放置在 `.agents/` 目录下,不混入本目录。 \ No newline at end of file diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index dd9510a..9e9f087 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -65,37 +65,11 @@ uv run python src/main.py -u 202407021 -p "your_password" ### 3.1 发票数据 CSV -系统支持两种数据输入方式: - -**方式 A:PDF 发票 + 支付截图(自动提取)** - 上传 PDF 发票文件和支付截图,系统自动完成: 1. 从 PDF 提取发票信息 2. 多模态 LLM 识别支付截图中的刷卡信息 3. 生成 `invoice_summary.csv` -**方式 B:直接上传 CSV(快捷模式)** - -已有发票数据 CSV 可直接上传,跳过 PDF 提取和 LLM 识别步骤。 - -CSV 需包含以下列: - -| 列名 | 说明 | 示例 | -|------|------|------| -| 序号 | 发票序号 | 1, 2, 3... | -| 发票号码 | 发票编号 | 26442000005432652421 | -| 开票日期 | 发票开具日期 | 2026/5/18 | -| 项目名称 | 采购项目名称 | 电阻一批 | -| 规格型号 | 规格型号 | — | -| 价税合计 | 发票金额 | 2900.00 | -| 销售方名称 | 商户/销售方 | 佛山市泓宇芯科技有限公司 | -| 人员姓名 | 报销人 | 王建锋(默认值) | -| 刷卡日期 | 公务卡消费日期 | 2026/5/18 → 自动转为 2026-05-18 | -| 公务卡号 | 公务卡卡号 | 6282880139161682(默认值) | -| 刷卡金额 | 实际刷卡金额 | 2900.00 | -| 备注 | 备注信息 | — | -| 工号 | 人员工号 | 202407021(默认值) | - ### 3.2 发票类型分类 系统自动将发票分为两类,影响后续处理流程: @@ -125,7 +99,6 @@ uv run python src/web/app.py 1. **发票 PDF**:点击或拖拽上传 PDF 文件(支持多选) 2. **支付截图**:点击或拖拽上传图片文件(支持多选) 3. **手机扫码上传**:扫描页面二维码,通过手机拍照上传支付截图 -4. **CSV 快捷上传**:已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别 ### 4.3 配置信息 diff --git a/invoice_summary.csv b/invoice_summary.csv deleted file mode 100644 index 8e180a3..0000000 --- a/invoice_summary.csv +++ /dev/null @@ -1,4 +0,0 @@ -序号,发票类型,发票号码,开票日期,项目名称,规格型号,价税合计,销售方名称,出发站,到达站,车次,乘车日期,座位等级,人员姓名,刷卡日期,公务卡号,刷卡金额,备注,工号 -1,高铁票,26349119343000154520,2026/3/23,,,,,阜阳西,无锡东,G7221,2026/3/21,二等座,王建锋,,,,, -2,高铁票,26329166851000278168,2026/3/23,,,,,无锡东,阜阳西,G1826,2026/3/22,二等座,王建锋,,,,, -3,酒店住宿,26322000002199439186,2026/3/23,*住宿服务*住宿费 间 天 1 347.735849056604 347.74 6% 20.86,住宿费 间 天 1 347.735849056604 347.74 6% 20.86,368.6,,,,,,,,,,,, diff --git a/pipeline.log b/pipeline.log deleted file mode 100644 index 1099362..0000000 --- a/pipeline.log +++ /dev/null @@ -1,1536 +0,0 @@ -2026-06-08 13:31:01 [INFO ] pipeline: ============================================================ -2026-06-08 13:31:01 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:31:01 [INFO ] pipeline: ============================================================ -2026-06-08 13:31:01 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:31:02 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:32:29 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 319 字符 -2026-06-08 13:32:29 [INFO ] llm_extractor: LLM 发票提取成功: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:32:29 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:32:29 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:33:55 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 271 字符 -2026-06-08 13:33:55 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:33:55 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:33:55 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:35:21 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 311 字符 -2026-06-08 13:35:21 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋3.21住宿368.6.pdf -2026-06-08 13:35:21 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 13:35:21 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 13:35:21 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 13:35:21 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 13:38:29 [INFO ] pipeline: ============================================================ -2026-06-08 13:38:29 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:38:29 [INFO ] pipeline: ============================================================ -2026-06-08 13:38:29 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:38:30 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:39:15 [INFO ] pipeline: ============================================================ -2026-06-08 13:39:15 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:39:15 [INFO ] pipeline: ============================================================ -2026-06-08 13:39:15 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:39:16 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:39:26 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 354 字符 -2026-06-08 13:39:26 [INFO ] llm_extractor: LLM 发票提取成功: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:39:26 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:39:26 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:39:35 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 318 字符 -2026-06-08 13:39:35 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:39:35 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:39:35 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:39:46 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 309 字符 -2026-06-08 13:39:46 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋3.21住宿368.6.pdf -2026-06-08 13:39:46 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 13:39:46 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 13:39:46 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 13:39:46 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 13:40:04 [INFO ] pipeline: ============================================================ -2026-06-08 13:40:04 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:40:04 [INFO ] pipeline: ============================================================ -2026-06-08 13:40:04 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:40:05 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:40:13 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 317 字符 -2026-06-08 13:40:13 [INFO ] llm_extractor: LLM 发票提取成功: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:40:13 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:40:13 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:40:23 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 318 字符 -2026-06-08 13:40:23 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:40:23 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:40:23 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:40:34 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 309 字符 -2026-06-08 13:40:34 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋3.21住宿368.6.pdf -2026-06-08 13:40:34 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 13:40:34 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 13:40:34 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 13:40:34 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 13:45:56 [INFO ] pipeline: ============================================================ -2026-06-08 13:45:56 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:45:56 [INFO ] pipeline: ============================================================ -2026-06-08 13:45:56 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:45:57 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:46:05 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 317 字符 -2026-06-08 13:46:05 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000154520", - "开票日期": "2026/3/23", - "项目名称": "高铁票", - "规格型号": "", - "价税合计": "258.00", - "销售方名称": "中国铁路", - "出发站": "阜阳西", - "到达站": "无锡", - "车次": "G7221", - "乘车日期": "2026/3/21", - "座位等级": "二等座", - "人员姓名": "王建锋", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:46:05 [INFO ] llm_extractor: LLM 发票提取成功: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:46:05 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:46:05 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:46:15 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 318 字符 -2026-06-08 13:46:15 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26329166851000278168", - "开票日期": "2026/3/23", - "项目名称": "高铁票", - "规格型号": "", - "价税合计": "259.00", - "销售方名称": "中国铁路", - "出发站": "无锡东", - "到达站": "阜阳西", - "车次": "G1826", - "乘车日期": "2026/3/22", - "座位等级": "二等座", - "人员姓名": "王建锋", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:46:15 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:46:15 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:46:15 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:46:23 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 309 字符 -2026-06-08 13:46:23 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26322000002199439186", - "开票日期": "2026/3/23", - "项目名称": "*住宿服务*住宿费", - "规格型号": "间 天", - "价税合计": "368.60", - "销售方名称": "滨湖区雪浪山庄花园酒店", - "出发站": "", - "到达站": "", - "车次": "", - "乘车日期": "", - "座位等级": "", - "人员姓名": "", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:46:23 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋3.21住宿368.6.pdf -2026-06-08 13:46:23 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 13:46:23 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 13:46:23 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 13:46:23 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 13:47:51 [INFO ] pipeline: ============================================================ -2026-06-08 13:47:51 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:47:51 [INFO ] pipeline: ============================================================ -2026-06-08 13:47:51 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:47:52 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:48:01 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 316 字符 -2026-06-08 13:48:01 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000154520", - "开票日期": "2026/3/23", - "项目名称": "票价", - "规格型号": "", - "价税合计": "258.00", - "销售方名称": "中国铁路", - "出发站": "阜阳西", - "到达站": "无锡", - "车次": "G7221", - "乘车日期": "2026/3/21", - "座位等级": "二等座", - "人员姓名": "王建锋", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:48:01 [INFO ] llm_extractor: LLM 发票提取成功: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:48:01 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:48:01 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:48:09 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 324 字符 -2026-06-08 13:48:09 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26329166851000278168", - "开票日期": "2026/3/23", - "项目名称": "铁路旅客运输服务费", - "规格型号": "", - "价税合计": "259.00", - "销售方名称": "中国铁路", - "出发站": "无锡东", - "到达站": "阜阳西", - "车次": "G1826", - "乘车日期": "2026/3/22", - "座位等级": "二等座", - "人员姓名": "王建锋", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:48:09 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:48:09 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:48:09 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:48:17 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 309 字符 -2026-06-08 13:48:17 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26322000002199439186", - "开票日期": "2026/3/23", - "项目名称": "*住宿服务*住宿费", - "规格型号": "间 天", - "价税合计": "368.60", - "销售方名称": "滨湖区雪浪山庄花园酒店", - "出发站": "", - "到达站": "", - "车次": "", - "乘车日期": "", - "座位等级": "", - "人员姓名": "", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:48:17 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋3.21住宿368.6.pdf -2026-06-08 13:48:17 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 13:48:17 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 13:48:17 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 13:48:17 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 13:53:40 [INFO ] pipeline: ============================================================ -2026-06-08 13:53:40 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:53:40 [INFO ] pipeline: ============================================================ -2026-06-08 13:53:40 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:53:41 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:53:42 [ERROR] llm_extractor: LLM 请求失败: Error code: 400 - {'error': {'message': "'reasoning_effort' must be a string", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}} -2026-06-08 13:53:42 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 王建峰-阜阳西-无锡东258.pdf (Error code: 400 - {'error': {'message': "'reasoning_effort' must be a string", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}}) -2026-06-08 13:53:42 [INFO ] llm_extractor: 正则回退解析完成: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:53:42 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:53:42 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:53:42 [ERROR] llm_extractor: LLM 请求失败: Error code: 400 - {'error': {'message': "'reasoning_effort' must be a string", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}} -2026-06-08 13:53:42 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 王建锋-无锡东-阜阳西259.pdf (Error code: 400 - {'error': {'message': "'reasoning_effort' must be a string", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}}) -2026-06-08 13:53:42 [INFO ] llm_extractor: 正则回退解析完成: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:53:42 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:53:42 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:53:42 [ERROR] llm_extractor: LLM 请求失败: Error code: 400 - {'error': {'message': "'reasoning_effort' must be a string", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}} -2026-06-08 13:53:42 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 王建锋3.21住宿368.6.pdf (Error code: 400 - {'error': {'message': "'reasoning_effort' must be a string", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}}) -2026-06-08 13:53:42 [INFO ] llm_extractor: 正则回退解析完成: 王建锋3.21住宿368.6.pdf -2026-06-08 13:53:42 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 13:53:42 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 13:53:42 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 13:53:42 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 13:54:26 [INFO ] pipeline: ============================================================ -2026-06-08 13:54:26 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:54:26 [INFO ] pipeline: ============================================================ -2026-06-08 13:54:26 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:54:28 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:54:28 [ERROR] llm_extractor: LLM 请求失败: Error code: 400 - {'error': {'message': "Invalid 'reasoning_effort' value: 'on'. Supported values: none, minimal, low, medium, high, xhigh.", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}} -2026-06-08 13:54:28 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 王建峰-阜阳西-无锡东258.pdf (Error code: 400 - {'error': {'message': "Invalid 'reasoning_effort' value: 'on'. Supported values: none, minimal, low, medium, high, xhigh.", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}}) -2026-06-08 13:54:28 [INFO ] llm_extractor: 正则回退解析完成: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:54:28 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:54:28 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:54:28 [ERROR] llm_extractor: LLM 请求失败: Error code: 400 - {'error': {'message': "Invalid 'reasoning_effort' value: 'on'. Supported values: none, minimal, low, medium, high, xhigh.", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}} -2026-06-08 13:54:28 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 王建锋-无锡东-阜阳西259.pdf (Error code: 400 - {'error': {'message': "Invalid 'reasoning_effort' value: 'on'. Supported values: none, minimal, low, medium, high, xhigh.", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}}) -2026-06-08 13:54:28 [INFO ] llm_extractor: 正则回退解析完成: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:54:28 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:54:28 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:54:29 [ERROR] llm_extractor: LLM 请求失败: Error code: 400 - {'error': {'message': "Invalid 'reasoning_effort' value: 'on'. Supported values: none, minimal, low, medium, high, xhigh.", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}} -2026-06-08 13:54:29 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 王建锋3.21住宿368.6.pdf (Error code: 400 - {'error': {'message': "Invalid 'reasoning_effort' value: 'on'. Supported values: none, minimal, low, medium, high, xhigh.", 'type': 'invalid_request_error', 'param': 'reasoning_effort', 'code': 'invalid_value'}}) -2026-06-08 13:54:29 [INFO ] llm_extractor: 正则回退解析完成: 王建锋3.21住宿368.6.pdf -2026-06-08 13:54:29 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 13:54:29 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 13:54:29 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 13:54:29 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 13:54:42 [INFO ] pipeline: ============================================================ -2026-06-08 13:54:42 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:54:42 [INFO ] pipeline: ============================================================ -2026-06-08 13:54:42 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:54:43 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:54:57 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 317 字符 -2026-06-08 13:54:57 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000154520", - "开票日期": "2026/3/23", - "项目名称": "高铁票", - "规格型号": "", - "价税合计": "258.00", - "销售方名称": "中国铁路", - "出发站": "阜阳西", - "到达站": "无锡", - "车次": "G7221", - "乘车日期": "2026/3/21", - "座位等级": "二等座", - "人员姓名": "王建锋", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:54:57 [INFO ] llm_extractor: LLM 发票提取成功: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:54:57 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 13:54:57 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:55:08 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 318 字符 -2026-06-08 13:55:08 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26329166851000278168", - "开票日期": "2026/3/23", - "项目名称": "高铁票", - "规格型号": "", - "价税合计": "259.00", - "销售方名称": "中国铁路", - "出发站": "无锡东", - "到达站": "阜阳西", - "车次": "G1826", - "乘车日期": "2026/3/22", - "座位等级": "二等座", - "人员姓名": "王建锋", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:55:08 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:55:08 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 13:55:08 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 13:55:17 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 309 字符 -2026-06-08 13:55:17 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26322000002199439186", - "开票日期": "2026/3/23", - "项目名称": "*住宿服务*住宿费", - "规格型号": "间 天", - "价税合计": "368.60", - "销售方名称": "滨湖区雪浪山庄花园酒店", - "出发站": "", - "到达站": "", - "车次": "", - "乘车日期": "", - "座位等级": "", - "人员姓名": "", - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "备注": "", - "工号": "" -} -2026-06-08 13:55:17 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋3.21住宿368.6.pdf -2026-06-08 13:55:17 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 13:55:17 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 13:55:17 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 13:55:17 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 13:56:00 [INFO ] pipeline: ============================================================ -2026-06-08 13:56:00 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 13:56:00 [INFO ] pipeline: ============================================================ -2026-06-08 13:56:00 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 13:56:02 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:09:24 [INFO ] pipeline: ============================================================ -2026-06-08 14:09:24 [INFO ] pipeline: [1/3] 发票提取 -2026-06-08 14:09:24 [INFO ] pipeline: ============================================================ -2026-06-08 14:09:24 [INFO ] extractor: 发现 3 个 PDF 文件 -2026-06-08 14:09:25 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:09:31 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符 -2026-06-08 14:09:31 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000154520", - "开票日期": "2026/3/23", - "乘车日期": "2026/3/21", - "出发站": "阜阳西", - "到达站": "无锡东", - "座位等级": "二等座", - "车次": "G7221", - "人员姓名": "王建锋", - "票价": "258.00" -} -2026-06-08 14:09:31 [INFO ] llm_extractor: LLM 发票提取成功: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 14:09:31 [INFO ] extractor: [高铁票] 已解析: 王建峰-阜阳西-无锡东258.pdf -2026-06-08 14:09:31 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:09:35 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符 -2026-06-08 14:09:35 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26329166851000278168", - "开票日期": "2026/3/23", - "乘车日期": "2026/3/22", - "出发站": "无锡东", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1826", - "人员姓名": "王建锋", - "票价": "259.00" -} -2026-06-08 14:09:35 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 14:09:35 [INFO ] extractor: [高铁票] 已解析: 王建锋-无锡东-阜阳西259.pdf -2026-06-08 14:09:35 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:09:38 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 95 字符 -2026-06-08 14:09:38 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26322000002199439186", - "开票日期": "2026/3/23", - "价税合计": 368.60 -} -2026-06-08 14:09:38 [INFO ] llm_extractor: LLM 发票提取成功: 王建锋3.21住宿368.6.pdf -2026-06-08 14:09:38 [INFO ] extractor: [酒店住宿] 已解析: 王建锋3.21住宿368.6.pdf -2026-06-08 14:09:38 [INFO ] extractor: 共处理 3 张发票 -2026-06-08 14:09:38 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 14:09:38 [INFO ] pipeline: [1/3] 发票提取 完成 -2026-06-08 14:14:14 [INFO ] extractor: 发现 5 个 PDF 文件 -2026-06-08 14:14:16 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:14:22 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 14:14:22 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331138", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "张国庆", - "票价": "115.50" -} -2026-06-08 14:14:22 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331138-电子发票.pdf -2026-06-08 14:14:22 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-08 14:14:22 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:14:27 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 14:14:27 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000335414", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "王建锋", - "票价": "115.50" -} -2026-06-08 14:14:27 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000335414-电子发票.pdf -2026-06-08 14:14:27 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-08 14:14:27 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:14:32 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 14:14:32 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003550275", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "张国庆", - "票价": "117.50" -} -2026-06-08 14:14:32 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003550275-电子发票.pdf -2026-06-08 14:14:32 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-08 14:14:32 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:14:37 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 14:14:37 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003595208", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "王建锋", - "票价": "117.50" -} -2026-06-08 14:14:37 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003595208-电子发票.pdf -2026-06-08 14:14:37 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf -2026-06-08 14:14:37 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:14:40 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 94 字符 -2026-06-08 14:14:40 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26342000001715702281", - "开票日期": "2026/6/3", - "价税合计": 536.00 -} -2026-06-08 14:14:40 [INFO ] llm_extractor: LLM 发票提取成功: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 14:14:40 [INFO ] extractor: [酒店住宿] 已解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 14:14:40 [INFO ] extractor: 共处理 5 张发票 -2026-06-08 14:14:40 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 14:14:40 [WARNING] llm_extractor: 未找到 PDF-图片配对文件,跳过信息提取 -2026-06-08 14:14:40 [INFO ] fill_consumable_doc: 开始填写出库单: 易耗品、出库单.doc -2026-06-08 14:14:50 [INFO ] fill_consumable_doc: 出库单填写完成 -2026-06-08 14:50:57 [INFO ] extractor: 发现 5 个 PDF 文件 -2026-06-08 14:50:59 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:51:13 [ERROR] llm_extractor: LLM 请求失败: Error code: 502 -2026-06-08 14:51:13 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 26349119343000331138-电子发票.pdf (Error code: 502) -2026-06-08 14:51:13 [INFO ] llm_extractor: 正则回退解析完成: 26349119343000331138-电子发票.pdf -2026-06-08 14:51:13 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-08 14:51:13 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:51:26 [ERROR] llm_extractor: LLM 请求失败: Error code: 502 -2026-06-08 14:51:26 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 26349119343000335414-电子发票.pdf (Error code: 502) -2026-06-08 14:51:26 [INFO ] llm_extractor: 正则回退解析完成: 26349119343000335414-电子发票.pdf -2026-06-08 14:51:26 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-08 14:51:26 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:51:39 [ERROR] llm_extractor: LLM 请求失败: Error code: 502 -2026-06-08 14:51:39 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 26349119423003550275-电子发票.pdf (Error code: 502) -2026-06-08 14:51:39 [INFO ] llm_extractor: 正则回退解析完成: 26349119423003550275-电子发票.pdf -2026-06-08 14:51:39 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-08 14:51:39 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:53:56 [INFO ] extractor: 发现 5 个 PDF 文件 -2026-06-08 14:53:58 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:54:12 [ERROR] llm_extractor: LLM 请求失败: Error code: 502 -2026-06-08 14:54:12 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 26349119343000331138-电子发票.pdf (Error code: 502) -2026-06-08 14:54:12 [INFO ] llm_extractor: 正则回退解析完成: 26349119343000331138-电子发票.pdf -2026-06-08 14:54:12 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-08 14:54:12 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:54:25 [ERROR] llm_extractor: LLM 请求失败: Error code: 502 -2026-06-08 14:54:25 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 26349119343000335414-电子发票.pdf (Error code: 502) -2026-06-08 14:54:25 [INFO ] llm_extractor: 正则回退解析完成: 26349119343000335414-电子发票.pdf -2026-06-08 14:54:25 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-08 14:54:25 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:54:38 [ERROR] llm_extractor: LLM 请求失败: Error code: 502 -2026-06-08 14:54:38 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 26349119423003550275-电子发票.pdf (Error code: 502) -2026-06-08 14:54:38 [INFO ] llm_extractor: 正则回退解析完成: 26349119423003550275-电子发票.pdf -2026-06-08 14:54:38 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-08 14:54:38 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:54:52 [ERROR] llm_extractor: LLM 请求失败: Error code: 502 -2026-06-08 14:54:52 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: 26349119423003595208-电子发票.pdf (Error code: 502) -2026-06-08 14:54:52 [INFO ] llm_extractor: 正则回退解析完成: 26349119423003595208-电子发票.pdf -2026-06-08 14:54:52 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf -2026-06-08 14:54:52 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://127.0.0.1:1234/v1) -2026-06-08 14:55:39 [INFO ] extractor: 发现 5 个 PDF 文件 -2026-06-08 14:55:41 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 14:55:54 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 14:55:54 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331138", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "张国庆", - "票价": "115.50" -} -2026-06-08 14:55:54 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331138-电子发票.pdf -2026-06-08 14:55:54 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-08 14:55:54 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 14:56:02 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 14:56:02 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000335414", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "王建锋", - "票价": "115.50" -} -2026-06-08 14:56:02 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000335414-电子发票.pdf -2026-06-08 14:56:02 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-08 14:56:02 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 14:56:11 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 14:56:11 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003550275", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "张国庆", - "票价": "117.50" -} -2026-06-08 14:56:11 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003550275-电子发票.pdf -2026-06-08 14:56:11 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-08 14:56:11 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 14:56:25 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 14:56:25 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003595208", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "王建锋", - "票价": "117.50" -} -2026-06-08 14:56:25 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003595208-电子发票.pdf -2026-06-08 14:56:25 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf -2026-06-08 14:56:25 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 14:56:31 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 94 字符 -2026-06-08 14:56:31 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26342000001715702281", - "开票日期": "2026/6/3", - "价税合计": 536.00 -} -2026-06-08 14:56:31 [INFO ] llm_extractor: LLM 发票提取成功: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 14:56:31 [INFO ] extractor: [酒店住宿] 已解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 14:56:31 [INFO ] extractor: 共处理 5 张发票 -2026-06-08 14:56:31 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 14:56:31 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 5 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 14:56:31 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 14:56:31 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145318_279_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145319_280_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145320_281_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145321_282_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145323_283_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 14:56:49 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145318_279_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145319_280_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145320_281_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145321_282_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 14:56:49 [WARNING] llm_extractor: LLM 多模态提取失败,回退到 OCR+正则: 微信图片_20260608145323_283_42.jpg (cannot import name 'ImageDocument' from 'llama_index.core.llms' (D:\阜阳师范大学\财务报销\自动报销系统\.venv\Lib\site-packages\llama_index\core\llms\__init__.py)) -2026-06-08 14:56:49 [ERROR] llm_extractor: OCR 回退提取失败: Engine 'paddle_static' is unavailable because dependency 'paddlepaddle' is not installed. -2026-06-08 14:56:49 [INFO ] llm_extractor: LLM 提取完成: 匹配 5/5 行,更新 0 个字段 -2026-06-08 14:56:49 [INFO ] fill_consumable_doc: 开始填写出库单: 易耗品、出库单.doc -2026-06-08 14:56:57 [INFO ] fill_consumable_doc: 出库单填写完成 -2026-06-08 15:13:59 [INFO ] extractor: 发现 5 个 PDF 文件 -2026-06-08 15:14:01 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 15:14:11 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 15:14:11 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331138", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "张国庆", - "票价": "115.50" -} -2026-06-08 15:14:11 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331138-电子发票.pdf -2026-06-08 15:14:11 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-08 15:14:11 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 15:14:21 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 15:14:21 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000335414", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "王建锋", - "票价": "115.50" -} -2026-06-08 15:14:21 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000335414-电子发票.pdf -2026-06-08 15:14:21 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-08 15:14:21 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 15:14:30 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 15:14:30 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003550275", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "张国庆", - "票价": "117.50" -} -2026-06-08 15:14:30 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003550275-电子发票.pdf -2026-06-08 15:14:30 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-08 15:14:30 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 15:14:39 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 15:14:39 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003595208", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "王建锋", - "票价": "117.50" -} -2026-06-08 15:14:39 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003595208-电子发票.pdf -2026-06-08 15:14:39 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf -2026-06-08 15:14:39 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 15:14:45 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 96 字符 -2026-06-08 15:14:45 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26342000001715702281", - "开票日期": "2026/6/3", - "价税合计": "536.00" -} -2026-06-08 15:14:45 [INFO ] llm_extractor: LLM 发票提取成功: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 15:14:45 [INFO ] extractor: [酒店住宿] 已解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 15:14:45 [INFO ] extractor: 共处理 5 张发票 -2026-06-08 15:14:45 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 15:14:45 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 5 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 15:14:46 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 15:16:20 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145318_279_42.jpg -2026-06-08 15:16:20 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 15:17:35 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145319_280_42.jpg -2026-06-08 15:17:35 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 15:19:04 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145320_281_42.jpg -2026-06-08 15:19:04 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 15:20:14 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145321_282_42.jpg -2026-06-08 15:20:14 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 15:21:11 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145323_283_42.jpg -2026-06-08 15:21:11 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 15:21:11 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 15:21:11 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 15:22:26 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145318_279_42.jpg -2026-06-08 15:22:26 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 15:23:46 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145319_280_42.jpg -2026-06-08 15:23:46 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 15:24:24 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145320_281_42.jpg -2026-06-08 15:24:24 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 16:01:24 [INFO ] extractor: 发现 7 个 PDF 文件 -2026-06-08 16:01:26 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:01:37 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:01:37 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331138", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "张国庆", - "票价": "115.50" -} -2026-06-08 16:01:37 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331138-电子发票.pdf -2026-06-08 16:01:37 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-08 16:01:37 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:01:45 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:01:45 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331314", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "一等座", - "车次": "G1967", - "人员姓名": "陈曙光", - "票价": "167.00" -} -2026-06-08 16:01:45 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331314-电子发票.pdf -2026-06-08 16:01:45 [INFO ] extractor: [高铁票] 已解析: 26349119343000331314-电子发票.pdf -2026-06-08 16:01:45 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:01:54 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:01:54 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000335414", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "王建锋", - "票价": "115.50" -} -2026-06-08 16:01:54 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000335414-电子发票.pdf -2026-06-08 16:01:54 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-08 16:01:54 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:02:02 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:02:02 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003550275", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "张国庆", - "票价": "117.50" -} -2026-06-08 16:02:02 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003550275-电子发票.pdf -2026-06-08 16:02:02 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-08 16:02:03 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:02:10 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:02:10 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003552366", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "一等座", - "车次": "G1318", - "人员姓名": "陈曙光", - "票价": "189.50" -} -2026-06-08 16:02:10 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003552366-电子发票.pdf -2026-06-08 16:02:10 [INFO ] extractor: [高铁票] 已解析: 26349119423003552366-电子发票.pdf -2026-06-08 16:02:11 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:02:19 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:02:19 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003595208", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "王建锋", - "票价": "117.50" -} -2026-06-08 16:02:19 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003595208-电子发票.pdf -2026-06-08 16:02:19 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf -2026-06-08 16:02:19 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:02:23 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 94 字符 -2026-06-08 16:02:23 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26342000001715702281", - "开票日期": "2026/6/3", - "价税合计": 536.00 -} -2026-06-08 16:02:23 [INFO ] llm_extractor: LLM 发票提取成功: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 16:02:23 [INFO ] extractor: [酒店住宿] 已解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 16:02:23 [INFO ] extractor: 共处理 7 张发票 -2026-06-08 16:02:23 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 16:02:23 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 16:02:24 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:02:26 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145318_279_42.jpg -2026-06-08 16:02:26 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 16:02:28 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145319_280_42.jpg -2026-06-08 16:02:28 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 16:02:29 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145320_281_42.jpg -2026-06-08 16:02:29 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 16:02:31 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145321_282_42.jpg -2026-06-08 16:02:31 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 16:02:33 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145323_283_42.jpg -2026-06-08 16:02:33 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 16:02:33 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 16:02:34 [INFO ] llm_extractor: LLM 提取完成: 匹配 5/7 行,更新 0 个字段 -2026-06-08 16:02:34 [INFO ] fill_consumable_doc: 开始填写出库单: 易耗品、出库单.doc -2026-06-08 16:02:43 [INFO ] fill_consumable_doc: 出库单填写完成 -2026-06-08 16:22:03 [INFO ] extractor: 发现 7 个 PDF 文件 -2026-06-08 16:22:05 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:22:13 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:22:13 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331138", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "张国庆", - "票价": "115.50" -} -2026-06-08 16:22:13 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331138-电子发票.pdf -2026-06-08 16:22:13 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-08 16:22:13 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:22:19 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:22:19 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331314", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "一等座", - "车次": "G1967", - "人员姓名": "陈曙光", - "票价": "167.00" -} -2026-06-08 16:22:19 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331314-电子发票.pdf -2026-06-08 16:22:19 [INFO ] extractor: [高铁票] 已解析: 26349119343000331314-电子发票.pdf -2026-06-08 16:22:20 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:22:27 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:22:27 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000335414", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "王建锋", - "票价": "115.50" -} -2026-06-08 16:22:27 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000335414-电子发票.pdf -2026-06-08 16:22:27 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-08 16:22:27 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:22:34 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:22:34 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003550275", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "张国庆", - "票价": "117.50" -} -2026-06-08 16:22:34 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003550275-电子发票.pdf -2026-06-08 16:22:34 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-08 16:22:34 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:22:41 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:22:41 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003552366", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "一等座", - "车次": "G1318", - "人员姓名": "陈曙光", - "票价": "189.50" -} -2026-06-08 16:22:41 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003552366-电子发票.pdf -2026-06-08 16:22:41 [INFO ] extractor: [高铁票] 已解析: 26349119423003552366-电子发票.pdf -2026-06-08 16:22:42 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:22:49 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:22:49 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003595208", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "王建锋", - "票价": "117.50" -} -2026-06-08 16:22:49 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003595208-电子发票.pdf -2026-06-08 16:22:49 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf -2026-06-08 16:22:49 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:22:53 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 94 字符 -2026-06-08 16:22:53 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26342000001715702281", - "开票日期": "2026/6/3", - "价税合计": 536.00 -} -2026-06-08 16:22:53 [INFO ] llm_extractor: LLM 发票提取成功: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 16:22:53 [INFO ] extractor: [酒店住宿] 已解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 16:22:53 [INFO ] extractor: 共处理 7 张发票 -2026-06-08 16:22:53 [INFO ] extractor: 差旅发票: 7 张, 普通发票: 0 张 -2026-06-08 16:22:53 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 16:22:53 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 16:22:54 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:22:56 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145318_279_42.jpg -2026-06-08 16:22:56 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 16:22:58 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145319_280_42.jpg -2026-06-08 16:22:58 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 16:22:59 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145320_281_42.jpg -2026-06-08 16:22:59 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 16:23:01 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145321_282_42.jpg -2026-06-08 16:23:01 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 16:23:03 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145323_283_42.jpg -2026-06-08 16:23:03 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 16:23:03 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 16:23:03 [INFO ] llm_extractor: LLM 提取完成: 匹配 5/7 行,更新 0 个字段 -2026-06-08 16:23:03 [INFO ] fill_consumable_doc: 纯差旅发票,跳过易耗品出库单生成 -2026-06-08 16:37:34 [INFO ] extractor: 发现 7 个 PDF 文件 -2026-06-08 16:37:36 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:37:45 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:37:45 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331138", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "张国庆", - "票价": "115.50" -} -2026-06-08 16:37:45 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331138-电子发票.pdf -2026-06-08 16:37:45 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-08 16:37:45 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:37:51 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:37:51 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331314", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "一等座", - "车次": "G1967", - "人员姓名": "陈曙光", - "票价": "167.00" -} -2026-06-08 16:37:51 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331314-电子发票.pdf -2026-06-08 16:37:51 [INFO ] extractor: [高铁票] 已解析: 26349119343000331314-电子发票.pdf -2026-06-08 16:37:51 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:37:58 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:37:58 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000335414", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "王建锋", - "票价": "115.50" -} -2026-06-08 16:37:58 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000335414-电子发票.pdf -2026-06-08 16:37:58 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-08 16:37:58 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:38:04 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:38:04 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003550275", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "张国庆", - "票价": "117.50" -} -2026-06-08 16:38:04 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003550275-电子发票.pdf -2026-06-08 16:38:04 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-08 16:38:04 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:38:11 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:38:11 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003552366", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "一等座", - "车次": "G1318", - "人员姓名": "陈曙光", - "票价": "189.50" -} -2026-06-08 16:38:11 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003552366-电子发票.pdf -2026-06-08 16:38:11 [INFO ] extractor: [高铁票] 已解析: 26349119423003552366-电子发票.pdf -2026-06-08 16:38:11 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:38:19 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 198 字符 -2026-06-08 16:38:19 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003595208", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "王建锋", - "票价": "117.50" -} -2026-06-08 16:38:19 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003595208-电子发票.pdf -2026-06-08 16:38:19 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf -2026-06-08 16:38:19 [INFO ] llm_extractor: 开始请求 LLM (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1) -2026-06-08 16:38:23 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 96 字符 -2026-06-08 16:38:23 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "酒店住宿", - "发票号码": "26342000001715702281", - "开票日期": "2026/6/3", - "价税合计": "536.00" -} -2026-06-08 16:38:23 [INFO ] llm_extractor: LLM 发票提取成功: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 16:38:23 [INFO ] extractor: [酒店住宿] 已解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-08 16:38:23 [INFO ] extractor: 共处理 7 张发票 -2026-06-08 16:38:23 [INFO ] extractor: 差旅发票: 7 张, 普通发票: 0 张 -2026-06-08 16:38:23 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-08 16:38:23 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 16:38:24 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:38:25 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145318_279_42.jpg -2026-06-08 16:38:25 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 16:38:27 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145319_280_42.jpg -2026-06-08 16:38:27 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 16:38:30 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145320_281_42.jpg -2026-06-08 16:38:30 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 16:38:38 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145321_282_42.jpg -2026-06-08 16:38:38 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 16:38:39 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145323_283_42.jpg -2026-06-08 16:38:39 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 16:38:39 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 16:38:39 [INFO ] llm_extractor: PDF=26349119343000331138-电子发票.pdf, 发票号=26349119343000331138, 刷卡信息={'人员姓名': '', '刷卡日期': '', '公务卡号': '', '刷卡金额': ''} -2026-06-08 16:38:39 [INFO ] llm_extractor: PDF=26349119343000331314-电子发票.pdf, 发票号=26349119343000331314, 刷卡信息={'人员姓名': '', '刷卡日期': '', '公务卡号': '', '刷卡金额': ''} -2026-06-08 16:38:40 [INFO ] llm_extractor: PDF=26349119343000335414-电子发票.pdf, 发票号=26349119343000335414, 刷卡信息={'人员姓名': '', '刷卡日期': '', '公务卡号': '', '刷卡金额': ''} -2026-06-08 16:38:40 [INFO ] llm_extractor: PDF=26349119423003550275-电子发票.pdf, 发票号=26349119423003550275, 刷卡信息={'人员姓名': '', '刷卡日期': '', '公务卡号': '', '刷卡金额': ''} -2026-06-08 16:38:40 [INFO ] llm_extractor: PDF=26349119423003552366-电子发票.pdf, 发票号=26349119423003552366, 刷卡信息={'人员姓名': '', '刷卡日期': '', '公务卡号': '', '刷卡金额': ''} -2026-06-08 16:38:40 [INFO ] llm_extractor: LLM 提取完成: 匹配 5/7 行,更新 0 个字段 -2026-06-08 16:38:40 [INFO ] fill_consumable_doc: 纯差旅发票,跳过易耗品出库单生成 -2026-06-08 16:45:50 [INFO ] pipeline: ============================================================ -2026-06-08 16:45:50 [INFO ] pipeline: [2/3] LLM 信息提取 -2026-06-08 16:45:50 [INFO ] pipeline: ============================================================ -2026-06-08 16:45:50 [WARNING] llm_extractor: 未找到 PDF-图片配对文件,跳过信息提取 -2026-06-08 16:45:50 [INFO ] pipeline: [2/3] LLM 信息提取 完成 -2026-06-08 16:48:56 [INFO ] pipeline: ============================================================ -2026-06-08 16:48:56 [INFO ] pipeline: [2/3] LLM 信息提取 -2026-06-08 16:48:56 [INFO ] pipeline: ============================================================ -2026-06-08 16:48:56 [WARNING] llm_extractor: 未找到 PDF-图片配对文件,跳过信息提取 -2026-06-08 16:48:56 [INFO ] pipeline: [2/3] LLM 信息提取 完成 -2026-06-08 16:54:29 [INFO ] pipeline: ============================================================ -2026-06-08 16:54:29 [INFO ] pipeline: [2/3] LLM 信息提取 -2026-06-08 16:54:29 [INFO ] pipeline: ============================================================ -2026-06-08 16:54:29 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 16:54:30 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:54:31 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:54:31 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 16:54:31 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145319_280_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:54:31 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 16:54:31 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145320_281_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:54:31 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 16:54:31 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145321_282_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...//Z', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:54:31 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 16:54:31 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145323_283_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:54:31 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 16:54:31 [INFO ] llm_extractor: 在 uploads 会话目录找到配对: 179d55ea2a95 -2026-06-08 16:54:31 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 16:54:31 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:54:31 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:55:39 [INFO ] pipeline: ============================================================ -2026-06-08 16:55:39 [INFO ] pipeline: [2/3] LLM 信息提取 -2026-06-08 16:55:39 [INFO ] pipeline: ============================================================ -2026-06-08 16:55:39 [INFO ] pipeline: 使用 uploads 目录: 71dc1d795be5 -2026-06-08 16:55:39 [WARNING] llm_extractor: 未找到 PDF-图片配对文件,跳过信息提取 -2026-06-08 16:55:39 [INFO ] pipeline: [2/3] LLM 信息提取 完成 -2026-06-08 16:56:22 [INFO ] pipeline: ============================================================ -2026-06-08 16:56:22 [INFO ] pipeline: [2/3] LLM 信息提取 -2026-06-08 16:56:22 [INFO ] pipeline: ============================================================ -2026-06-08 16:56:22 [INFO ] pipeline: 使用 uploads 目录: 179d55ea2a95 -2026-06-08 16:56:22 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 16:56:23 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:56:24 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:56:24 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 16:56:24 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145319_280_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:56:24 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 16:56:24 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145320_281_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:56:24 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 16:56:24 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145321_282_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...//Z', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:56:24 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 16:56:24 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145323_283_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:56:24 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 16:56:24 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 16:56:24 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:56:24 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:59:20 [INFO ] pipeline: ============================================================ -2026-06-08 16:59:20 [INFO ] pipeline: [2/3] LLM 信息提取 -2026-06-08 16:59:20 [INFO ] pipeline: ============================================================ -2026-06-08 16:59:20 [INFO ] pipeline: 使用 uploads 目录: 179d55ea2a95 -2026-06-08 16:59:20 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 16:59:21 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:59:21 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:59:21 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 16:59:21 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145319_280_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:59:21 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 16:59:21 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145320_281_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:59:21 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 16:59:21 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145321_282_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...//Z', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:59:21 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 16:59:21 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145323_283_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 16:59:21 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 16:59:21 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 16:59:21 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 16:59:21 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 17:00:34 [INFO ] pipeline: ============================================================ -2026-06-08 17:00:34 [INFO ] pipeline: [2/3] LLM 信息提取 -2026-06-08 17:00:34 [INFO ] pipeline: ============================================================ -2026-06-08 17:00:34 [INFO ] pipeline: 使用 uploads 目录: 179d55ea2a95 -2026-06-08 17:00:34 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 17:00:35 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 17:00:35 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 17:00:35 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 17:00:35 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145319_280_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 17:00:35 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 17:00:35 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145320_281_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 17:00:35 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 17:00:35 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145321_282_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...//Z', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 17:00:35 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 17:00:35 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145323_283_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 17:00:35 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 17:00:35 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 17:00:35 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 17:00:35 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (2 validation errors for ChatMessage -blocks.0 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'text', 'text': ...提取刷卡信息。'}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found -blocks.1 - Unable to extract tag using discriminator 'block_type' [type=union_tag_not_found, input_value={'type': 'image_url', 'im...9k=', 'detail': 'high'}}, input_type=dict] - For further information visit https://errors.pydantic.dev/2.13/v/union_tag_not_found) -2026-06-08 17:03:44 [INFO ] pipeline: ============================================================ -2026-06-08 17:03:44 [INFO ] pipeline: [2/3] LLM 信息提取 -2026-06-08 17:03:44 [INFO ] pipeline: ============================================================ -2026-06-08 17:03:44 [INFO ] pipeline: 使用 uploads 目录: 179d55ea2a95 -2026-06-08 17:03:44 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-08 17:03:45 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-08 17:03:54 [INFO ] llm_extractor: LLM 多模态原始响应: { - "人员姓名": "", - "刷卡日期": "2026/6/1", - "公务卡号": "6282****1682", - "刷卡金额": "231.00" -} -2026-06-08 17:03:54 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145318_279_42.jpg, 结果: {'人员姓名': '', '刷卡日期': '2026/6/1', '公务卡号': '6282****1682', '刷卡金额': '231.00'} -2026-06-08 17:03:54 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-08 17:04:02 [INFO ] llm_extractor: LLM 多模态原始响应: { - "人员姓名": "", - "刷卡日期": "2026/6/1", - "公务卡号": "6282****1682", - "刷卡金额": "167.00" -} -2026-06-08 17:04:02 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145319_280_42.jpg, 结果: {'人员姓名': '', '刷卡日期': '2026/6/1', '公务卡号': '6282****1682', '刷卡金额': '167.00'} -2026-06-08 17:04:02 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-08 17:04:09 [INFO ] llm_extractor: LLM 多模态原始响应: { - "人员姓名": "", - "刷卡日期": "2026/6/2", - "公务卡号": "6282****1682", - "刷卡金额": "535.86" -} -2026-06-08 17:04:09 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145320_281_42.jpg, 结果: {'人员姓名': '', '刷卡日期': '2026/6/2', '公务卡号': '6282****1682', '刷卡金额': '535.86'} -2026-06-08 17:04:09 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-08 17:04:16 [INFO ] llm_extractor: LLM 多模态原始响应: { - "人员姓名": "", - "刷卡日期": "2026/6/3", - "公务卡号": "6282****1682", - "刷卡金额": "189.50" -} -2026-06-08 17:04:16 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145321_282_42.jpg, 结果: {'人员姓名': '', '刷卡日期': '2026/6/3', '公务卡号': '6282****1682', '刷卡金额': '189.50'} -2026-06-08 17:04:16 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen/qwen3.6-27b, base=http://100.123.83.113:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-08 17:04:26 [INFO ] llm_extractor: LLM 多模态原始响应: { - "人员姓名": "", - "刷卡日期": "2026/6/3", - "公务卡号": "6282****1682", - "刷卡金额": "235.00" -} -2026-06-08 17:04:26 [INFO ] llm_extractor: LLM 刷卡信息提取成功: 微信图片_20260608145323_283_42.jpg, 结果: {'人员姓名': '', '刷卡日期': '2026/6/3', '公务卡号': '6282****1682', '刷卡金额': '235.00'} -2026-06-08 17:04:26 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-08 17:04:26 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-08 17:04:26 [INFO ] llm_extractor: PDF=dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf, 发票号=26342000001715702281, 刷卡信息={'人员姓名': '', '刷卡日期': '2026/6/2', '公务卡号': '6282****1682', '刷卡金额': '535.86'} -2026-06-08 17:04:26 [INFO ] llm_extractor: PDF=26349119343000331138-电子发票.pdf, 发票号=26349119343000331138, 刷卡信息={'人员姓名': '', '刷卡日期': '2026/6/1', '公务卡号': '6282****1682', '刷卡金额': '231.00'} -2026-06-08 17:04:26 [INFO ] llm_extractor: PDF=26349119343000331314-电子发票.pdf, 发票号=26349119343000331314, 刷卡信息={'人员姓名': '', '刷卡日期': '2026/6/1', '公务卡号': '6282****1682', '刷卡金额': '167.00'} -2026-06-08 17:04:26 [INFO ] llm_extractor: PDF=26349119343000335414-电子发票.pdf, 发票号=26349119343000335414, 刷卡信息={'人员姓名': '', '刷卡日期': '2026/6/3', '公务卡号': '6282****1682', '刷卡金额': '189.50'} -2026-06-08 17:04:26 [INFO ] llm_extractor: PDF=26349119423003550275-电子发票.pdf, 发票号=26349119423003550275, 刷卡信息={'人员姓名': '', '刷卡日期': '2026/6/3', '公务卡号': '6282****1682', '刷卡金额': '235.00'} -2026-06-08 17:04:26 [INFO ] llm_extractor: LLM 提取完成: 匹配 5/7 行,更新 15 个字段 -2026-06-08 17:04:26 [INFO ] pipeline: [2/3] LLM 信息提取 完成 -2026-06-09 11:20:44 [INFO ] extractor: 发现 7 个 PDF 文件 -2026-06-09 11:20:52 [INFO ] llm_extractor: 开始请求 LLM (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1) -2026-06-09 11:21:28 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符 -2026-06-09 11:21:28 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331138", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "张国庆", - "票价": "115.50" -} - - -2026-06-09 11:21:28 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331138-电子发票.pdf -2026-06-09 11:21:28 [INFO ] extractor: [高铁票] 已解析: 26349119343000331138-电子发票.pdf -2026-06-09 11:21:28 [INFO ] llm_extractor: 开始请求 LLM (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1) -2026-06-09 11:22:02 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符 -2026-06-09 11:22:02 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000331314", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "一等座", - "车次": "G1967", - "人员姓名": "陈曙光", - "票价": "167.00" -} - - -2026-06-09 11:22:02 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000331314-电子发票.pdf -2026-06-09 11:22:02 [INFO ] extractor: [高铁票] 已解析: 26349119343000331314-电子发票.pdf -2026-06-09 11:22:02 [INFO ] llm_extractor: 开始请求 LLM (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1) -2026-06-09 11:22:36 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符 -2026-06-09 11:22:36 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119343000335414", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/2", - "出发站": "阜阳西", - "到达站": "合肥南", - "座位等级": "二等座", - "车次": "G1967", - "人员姓名": "王建锋", - "票价": "115.50" -} - - -2026-06-09 11:22:36 [INFO ] llm_extractor: LLM 发票提取成功: 26349119343000335414-电子发票.pdf -2026-06-09 11:22:36 [INFO ] extractor: [高铁票] 已解析: 26349119343000335414-电子发票.pdf -2026-06-09 11:22:36 [INFO ] llm_extractor: 开始请求 LLM (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1) -2026-06-09 11:23:09 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符 -2026-06-09 11:23:09 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003550275", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "张国庆", - "票价": "117.50" -} - - -2026-06-09 11:23:09 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003550275-电子发票.pdf -2026-06-09 11:23:09 [INFO ] extractor: [高铁票] 已解析: 26349119423003550275-电子发票.pdf -2026-06-09 11:23:09 [INFO ] llm_extractor: 开始请求 LLM (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1) -2026-06-09 11:23:42 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符 -2026-06-09 11:23:42 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003552366", - "开票日期": "2026/6/3", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "一等座", - "车次": "G1318", - "人员姓名": "陈曙光", - "票价": "189.50" -} - - -2026-06-09 11:23:42 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003552366-电子发票.pdf -2026-06-09 11:23:42 [INFO ] extractor: [高铁票] 已解析: 26349119423003552366-电子发票.pdf -2026-06-09 11:23:42 [INFO ] llm_extractor: 开始请求 LLM (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1) -2026-06-09 11:24:15 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 200 字符 -2026-06-09 11:24:15 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "高铁票", - "发票号码": "26349119423003595208", - "开票日期": "2026/6/5", - "乘车日期": "2026/6/3", - "出发站": "合肥南", - "到达站": "阜阳西", - "座位等级": "二等座", - "车次": "G1318", - "人员姓名": "王建锋", - "票价": "117.50" -} - - -2026-06-09 11:24:15 [INFO ] llm_extractor: LLM 发票提取成功: 26349119423003595208-电子发票.pdf -2026-06-09 11:24:15 [INFO ] extractor: [高铁票] 已解析: 26349119423003595208-电子发票.pdf -2026-06-09 11:24:15 [INFO ] llm_extractor: 开始请求 LLM (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1) -2026-06-09 11:24:45 [INFO ] llm_extractor: LLM 请求完成,响应总长度: 154 字符 -2026-06-09 11:24:45 [INFO ] llm_extractor: LLM 响应: { - "发票类型": "普通发票", - "发票号码": "26342000001715702281", - "开票日期": "2026/6/3", - "项目名称": "*生产生活服务*住宿服务", - "规格型号": "", - "价税合计": "536.00", - "销售方名称": "安徽栢景酒店 -2026-06-09 11:24:45 [WARNING] llm_extractor: LLM 发票提取失败,回退到正则解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf (Unterminated string starting at: line 8 column 12 (char 147)) -2026-06-09 11:24:45 [INFO ] llm_extractor: 正则回退解析完成: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-09 11:24:45 [INFO ] extractor: [普通发票] 已解析: dzfp_26342000001715702281_阜阳师范大学_20260603111046.pdf -2026-06-09 11:24:45 [INFO ] extractor: 共处理 7 张发票 -2026-06-09 11:24:45 [INFO ] extractor: 差旅发票: 6 张, 普通发票: 1 张 -2026-06-09 11:24:45 [INFO ] extractor: CSV 已保存: invoice_summary.csv -2026-06-09 11:24:45 [INFO ] llm_extractor: 文件名匹配 0 组,剩余 7 个 PDF、5 张图片,尝试金额匹配... -2026-06-09 11:24:45 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-09 11:24:47 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (request (2067 tokens) exceeds the available context size (768 tokens), try increasing it) -2026-06-09 11:24:47 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1, image=微信图片_20260608145319_280_42.jpg) -2026-06-09 11:24:48 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145319_280_42.jpg (request (2067 tokens) exceeds the available context size (768 tokens), try increasing it) -2026-06-09 11:24:48 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1, image=微信图片_20260608145320_281_42.jpg) -2026-06-09 11:24:50 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145320_281_42.jpg (request (2067 tokens) exceeds the available context size (768 tokens), try increasing it) -2026-06-09 11:24:50 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1, image=微信图片_20260608145321_282_42.jpg) -2026-06-09 11:24:51 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145321_282_42.jpg (request (2067 tokens) exceeds the available context size (768 tokens), try increasing it) -2026-06-09 11:24:51 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1, image=微信图片_20260608145323_283_42.jpg) -2026-06-09 11:24:52 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145323_283_42.jpg (request (2067 tokens) exceeds the available context size (768 tokens), try increasing it) -2026-06-09 11:24:52 [INFO ] llm_extractor: 金额匹配完成,共 5 组配对 -2026-06-09 11:24:52 [INFO ] llm_extractor: 找到 5 组 PDF-图片配对 -2026-06-09 11:24:52 [INFO ] llm_extractor: 开始多模态 LLM 请求 (model=qwen3.5-9b, base=http://100.123.83.115:1234/v1, image=微信图片_20260608145318_279_42.jpg) -2026-06-09 11:24:54 [ERROR] llm_extractor: LLM 多模态提取失败: 微信图片_20260608145318_279_42.jpg (request (2067 tokens) exceeds the available context size (768 tokens), try increasing it) diff --git a/pyproject.toml b/pyproject.toml index 40a0278..65b8250 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.12" dependencies = [ "flask>=3.0", "playwright>=1.40", - "pdfplumber>=0.10", + "PyMuPDF>=1.24", "pywin32>=306", "llama-index>=0.12.0", "llama-index-llms-openai-like==0.7.2", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..e783aff --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,21 @@ +--- +last_reviewed: 2026-06-11 +--- + +# scripts — 测试脚本目录 + +存放用于测试各模块功能的独立脚本,可直接运行。 + +## 脚本清单 + +| 文件 | 说明 | +|------|------| +| `test_multimodal.py` | 测试 PDF 多模态提取完整链路(PDF 渲染 + LLM 提取) | +| `test_travel_info.py` | 测试差旅信息提取函数(数据从 `data/.invoice_cache` 缓存加载) | + +## 运行方式 + +```bash +uv run python scripts/test_multimodal.py +uv run python scripts/test_travel_info.py +``` \ No newline at end of file diff --git a/scripts/test_multimodal.py b/scripts/test_multimodal.py new file mode 100644 index 0000000..a02bcea --- /dev/null +++ b/scripts/test_multimodal.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""测试 PDF 多模态提取完整链路 + +用法: + python scripts/test_multimodal.py +""" + +import io +import sys +from pathlib import Path + +# Windows 终端强制 UTF-8 +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) # noqa: E402 + +from src.doc.llm_extractor import extract_document # noqa: E402 +from src.doc.pdf import render_pdf_to_images # noqa: E402 + + +def test_render() -> None: + """测试 PDF 渲染""" + pdf_path = ROOT / "事前申请单.pdf" + if not pdf_path.exists(): + print(f"跳过: {pdf_path.name} 不存在") + return + + print("=" * 60) + print("测试 PDF 渲染") + print("=" * 60) + try: + images = render_pdf_to_images(pdf_path, dpi=150) + if images: + print(f"成功渲染 {len(images)} 页") + for i, img_b64 in enumerate(images): + print(f" 第 {i + 1} 页: base64 长度 {len(img_b64)} 字符") + else: + print("渲染返回空列表!") + except ImportError as e: + print(f"导入失败: {e}") + except Exception as e: + print(f"渲染异常: {e}") + + +def test_multimodal_extract() -> None: + """测试完整的多模态提取链路""" + pdf_path = ROOT / "事前申请单.pdf" + if not pdf_path.exists(): + print(f"跳过: {pdf_path.name} 不存在") + return + + print() + print("=" * 60) + print("测试多模态提取") + print("=" * 60) + try: + result = extract_document(pdf_path) + if result: + print("提取成功:") + import json + + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print("提取返回空字典!") + except Exception as e: + print(f"提取异常: {e}") + import traceback + + traceback.print_exc() + + +def main() -> None: + test_render() + test_multimodal_extract() + print() + print("测试完成!") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_travel_info.py b/scripts/test_travel_info.py new file mode 100644 index 0000000..7af2c5d --- /dev/null +++ b/scripts/test_travel_info.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""直接测试 extract_travel_info 函数 + +所有数据从 .invoice_cache 缓存中自动加载,无需手动构造样本数据。 + +用法: + python scripts/test_travel_info.py +""" + +import json +import sys +from pathlib import Path + +# 项目根目录 +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) # noqa: E402 + +from src.doc.llm_extractor import extract_travel_info # noqa: E402 + + +def main() -> None: + source_dir = ROOT / "scripts" / "data" + + if not source_dir.exists(): + print(f"源文件目录不存在: {source_dir}") + sys.exit(1) + + print("=" * 60) + print("测试 extract_travel_info(数据来自缓存)") + print("=" * 60) + print(f"源文件目录: {source_dir}") + print() + + try: + result = extract_travel_info( + source_dir=source_dir, + ) + print() + print("=" * 60) + print("提取结果:") + print("=" * 60) + print(json.dumps(result, ensure_ascii=False, indent=2)) + print() + print("测试通过!") + except Exception as e: + print(f"测试失败: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..e64f3dc --- /dev/null +++ b/src/README.md @@ -0,0 +1,92 @@ +--- +last_reviewed: 2026-06-11 +--- + +# src — 主源码目录 + +包含财务报销自动化系统的核心模块。 + +## 模块清单 + +| 文件/目录 | 说明 | +|-----------|------| +| `__init__.py` | 包初始化:提供 `get_logger()` 日志工厂(支持终端 + 文件双输出,按日期自动分文件) | +| `config.py` | 配置加载:从 `config.json` 读取用户凭据和默认值,从环境变量读取服务端配置(SSO 地址、LLM 参数) | +| `pipeline.py` | 流程编排:串联发票提取 → 分类 → CSV 保存 → 浏览器填报,支持分步执行 | +| `main.py` | CLI 入口:支持 `--step` 分步执行、`-u/-p` 覆盖凭据、`--cache-dir` 指定缓存目录 | +| `bot.py` | 浏览器自动化:Playwright 驱动的财务系统填报机器人(含差旅/普通两种模式,支持 headless) | +| `doc/` | 文档处理模块:PDF 渲染、LLM 提取、支付匹配、发票分类、出库单生成 | +| `web/` | Web 界面模块:Flask 应用、SSE 日志、可编辑表格、移动端上传、会话隔离 | + +## 数据流 + +``` +CLI/Web 入口 → pipeline.py (编排) + ↓ + doc/extractor.py (统一提取入口) + ↓ + ┌──── doc/pdf.py (PDF 渲染为图片) + │ + ├── doc/llm_extractor.py (多模态 LLM 识别) + │ ├── 发票 (invoice_type=train/hotel/general) + │ ├── 支付记录 (invoice_type=payment) + │ └── 出差事前申请单 (invoice_type=application) + │ + ├── doc/matcher.py (发票与支付记录按金额匹配) + │ ├── 一对一匹配 (发票数 == 刷卡数) + │ └── 一对多匹配 (贪心算法,相对容差 3%) + │ + └── doc/invoice.py (CSV/JSON 读写) + ├── payment_records.csv (支付记录级别) + ├── invoice_summary.csv (发票级别) + └── travel_applications.json (出差申请单) + ↓ + doc/fill_consumable_doc.py (普通发票 → Word 出库单) + ↓ + bot.py (浏览器填报) + ├── 差旅模式: 差旅信息提取 → 填报差旅单 → 上传差旅附件 + └── 普通模式: 基本信息 → 录入明细 → 支付信息 → 上传附件 +``` + +## 文档处理子模块 (`doc/`) + +详见 [`doc/README.md`](doc/README.md) + +核心能力: +- **统一文档提取**:LLM 自行判断文档类型(发票/支付记录/出差事前申请单),无需正则回退 +- **JSON 缓存**:提取结果缓存于 `.invoice_cache/`,避免重复处理 +- **金额匹配**:支持一对多匹配,相对容差 3%,未匹配发票单独列为记录 +- **差旅信息提取**:综合发票、支付记录和匹配结果,提取出差事由、地点、时间等 +- **出库单生成**:将 CSV 数据填入 Word 模板(pywin32 COM,仅 Windows) + +## Web 界面子模块 (`web/`) + +详见 [`web/README.md`](web/README.md) + +核心能力: +- **会话隔离**:每次上传生成独立 `session_id`,文件/日志/配置/结果各自隔离 +- **移动端同步**:PC 端生成二维码指向 `/mobile/`,跨设备协作上传 +- **可编辑表格**:前端加载 CSV 数据,支持在线编辑后保存 + +## 启动方式 + +```bash +# CLI 模式 +uv run python src/main.py --step all + +# Web 模式 +uv run python src/web/app.py +# 访问: http://localhost:5000 +``` + +## 发票类型与路由 + +系统根据 `invoice_type` 字段自动分流: + +| 发票类型 | 走什么流程 | 是否生成出库单 | +|----------|-----------|--------------| +| `train` / `hotel` | 差旅报销 | 否 | +| `general` | 普通报销 | 是(易耗品出库单) | +| `application` | 出差事前申请单 | 否(单独存储为 JSON) | + +**注意**:差旅发票和普通发票不支持混报,混合时会报错。 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py index 889b183..8893098 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -24,7 +24,10 @@ def get_logger(name: str) -> logging.Logger: 日志同时输出到终端和 logs/<日期>.log """ logger = logging.getLogger(name) - if not logger.handlers: + + # 使用专属标记判断是否已初始化标准 handler(stream + file), + # 避免被 _SSELogHandler 等外部 handler 干扰 + if not getattr(logger, "_standard_handlers_initialized", False): logger.setLevel(logging.INFO) formatter = logging.Formatter(_LOG_FMT, _LOG_DATE_FMT) @@ -39,4 +42,6 @@ def get_logger(name: str) -> logging.Logger: file_handler.setFormatter(formatter) logger.addHandler(file_handler) + logger._standard_handlers_initialized = True # type: ignore[attr-defined] + return logger diff --git a/src/bot.py b/src/bot.py index a3bc1eb..b151cdd 100644 --- a/src/bot.py +++ b/src/bot.py @@ -4,11 +4,10 @@ 使用 Playwright 操作财务报销系统,自动完成登录、填单、上传附件等操作。 对外接口: - load_invoice_data(csv_path, config) -> list[dict] 从 CSV 加载并补全默认值 run_bot(config, invoices) 启动浏览器并执行填报流程 """ -import csv +import json from pathlib import Path from typing import Any @@ -32,59 +31,22 @@ def _format_date(date_str: str) -> str: return date_str -def _safe_float(value: str | None, default: float = 0.0) -> float: - """安全转换为浮点数,空值或转换失败时返回默认值""" - if value is None or str(value).strip() == "": - return default - try: - return float(value) - except (ValueError, TypeError): - return default - - -# ------------------------------------------------------------------ -# CSV 数据加载 -# ------------------------------------------------------------------ - - -def load_invoice_data(csv_path: str, config: dict[str, str | Path]) -> list[dict[str, str | float | Any | Path]]: - """从 CSV 加载发票数据,自动补全空白字段的默认值 - - CSV 以支付记录为主键,每行包含 _invoices_json 字段(JSON 序列化的发票列表)。 - 本函数还原为发票级别的数据列表。 - """ - import json - - invoices = [] - with open(csv_path, encoding="utf-8") as f: - reader = csv.DictReader(f) - for row in reader: - invoices_json = row.get("_invoices_json", "") - if not invoices_json: - continue - try: - inv_list = json.loads(invoices_json) - for inv in inv_list: - invoices.append( - { - "seq": row.get("序号", ""), - "invoice_no": inv.get("发票号码", ""), - "invoice_date": inv.get("开票日期", ""), - "item_name": inv.get("项目名称", ""), - "spec_model": inv.get("规格型号", ""), - "total_amount": _safe_float(inv.get("价税合计")), - "seller_name": inv.get("销售方名称", ""), - "person_name": inv.get("人员姓名") or config.get("default_name", ""), - "card_date": _format_date(row.get("刷卡日期") or ""), - "card_no": row.get("公务卡号") or config.get("default_card_no", ""), - "card_amount": _safe_float(row.get("刷卡金额")), - "remark": row.get("备注") or "", - "person_id": row.get("工号") or config.get("default_person_id", ""), - } - ) - except json.JSONDecodeError: - log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...") - return invoices +def _classify_invoice_batch( + invoices: list[dict[str, str]], +) -> dict[str, list[dict[str, str]]]: + """按发票类型分组""" + travel: list[dict[str, str]] = [] + general: list[dict[str, str]] = [] + application: list[dict[str, str]] = [] + for inv in invoices: + inv_type = inv.get("invoice_type", "general") + if inv_type == "application": + application.append(inv) + elif inv_type in ("train", "hotel"): + travel.append(inv) + else: + general.append(inv) + return {"travel": travel, "general": general, "application": application} # ------------------------------------------------------------------ @@ -95,7 +57,7 @@ def load_invoice_data(csv_path: str, config: dict[str, str | Path]) -> list[dict class ReimburseBot: """财务报销自动化机器人""" - def __init__(self, config: dict[str, Any], headless: bool = False): + def __init__(self, config: dict[str, Any], headless: bool = False, work_dir: Path | None = None): self.config = config self.headless = headless self.work_dir: Path | None = None @@ -111,7 +73,7 @@ class ReimburseBot: def launch(self) -> None: """启动浏览器""" self.browser = self.pw.chromium.launch(headless=self.headless) - self.context = self.browser.new_context(viewport={"width": 1920, "height": 1080}) + self.context = self.browser.new_context(viewport={"width": 1360, "height": 768}) self.page = self.context.new_page() self.page.set_default_timeout(30000) @@ -156,7 +118,7 @@ class ReimburseBot: self._screenshot("portal_timeout") raise TimeoutError("登录超时,未跳转到信息门户") - def navigate_to_reimburse(self) -> None: + def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None: """从统一信息平台进入报销系统""" log.info("进入报销系统...") self._wait_for('text="快捷入口"', timeout=5000) @@ -197,17 +159,17 @@ class ReimburseBot: pass self._wait_for('text="报销录入"', timeout=5000) - common_url = self.config["reimburse_url"] + self.config["reimburse_page"] + common_url = self.config["reimburse_url"] + self.config[page_key] self.page.goto(common_url, wait_until="domcontentloaded", timeout=15000) self._wait_for('text="单据状态:"', timeout=5000) - def open_reimburse_menu(self) -> None: - """点击「新增」创建新报销单""" - log.info("创建新报销单...") + def create_new_form(self) -> None: + """点击「新增」创建新单据""" + log.info("创建新单据...") self.page.wait_for_timeout(2000) try: - self.page.click('button:has-text("新增")', timeout=5000) + self.page.click("#insert", timeout=5000) except Exception: try: self.page.click("text=新增", timeout=3000) @@ -218,6 +180,31 @@ class ReimburseBot: self.page.wait_for_timeout(3000) self._screenshot("after_add_click") + def fill_travel_info(self, basic_info: dict[str, Any]) -> None: + """填写差旅报销基本信息""" + log.info("填写差旅报销信息...") + + try: + self.page.fill("#CAUSE", basic_info.get("travel_purpose", "")) + self.page.fill("#SITE", basic_info.get("travel_location", "")) + + self.page.click("#PROJECTCODE", timeout=10000) + self.page.wait_for_timeout(1000) + self.page.wait_for_selector("#promodal .fixed-table-body tbody tr", timeout=10000) + first_row = self.page.query_selector("#promodal .fixed-table-body tbody tr") + if first_row: + first_row.click() + self.page.wait_for_timeout(1000) + + self.page.fill("#THEKSRQ", _format_date(basic_info.get("start_date", ""))) + self.page.fill("#THEJSRQ", _format_date(basic_info.get("end_date", ""))) + self.page.click("#saveAndNext", timeout=5000) + self.page.wait_for_timeout(2000) + self._screenshot("travel_basic_done") + except Exception: + log.error("填写基本信息失败") + self._screenshot("travel_basic_error") + def fill_basic_info(self, description: str = "元器件采购报销") -> None: """填写基本信息""" log.info("填写基本信息...") @@ -254,6 +241,96 @@ class ReimburseBot: self._screenshot("step3_done") + def add_travel_items(self, travel_items: dict[str, Any]) -> None: + """录入差旅报销明细""" + vehicle_map = { + "火车": "01", + "汽车": "02", + "轮船": "03", + "自带车": "04", + "公务车": "05", + "飞机": "06", + "租车": "07", + "自驾车": "08", + } + try: + traffic_info = travel_items.get("transport_fee") or [] + for item in traffic_info: + self.page.click("#insertDetail", timeout=5000) + self._wait_for('text="增加明细"', timeout=5000) + self.page.select_option("#cost", "1") + self.page.wait_for_timeout(500) + vehicle = item.get("vehicle_type", "") + if vehicle in vehicle_map: + self.page.select_option("#jtgj", vehicle_map[vehicle]) + + self.page.fill("#ksdd", item.get("departure_place", "")) + self.page.fill("#jsdd", item.get("arrival_place", "")) + self.page.fill( + '#t1 input[name="expenPwTraveldetail.MONEY"]', + str(item.get("amount", "")), + ) + self.page.fill( + '#t1 input[name="expenPwTraveldetail.HOWBILL"]', + str(item.get("bill_count", "")), + ) + self.page.fill( + '#t1 input[name="expenPwTraveldetail.SMARK"]', + str(item.get("remark", "")), + ) + self.page.click("#detailAdd", timeout=3000) + self.page.wait_for_timeout(1000) + + hotel_info = travel_items.get("hotel_fee") or [] + for item in hotel_info: + self.page.click("#insertDetail", timeout=5000) + self._wait_for('text="增加明细"', timeout=5000) + self.page.select_option("#cost", "2") + self.page.wait_for_timeout(500) + self.page.fill("#ksrq2", _format_date(str(item.get("checkin_date", "")))) + self.page.fill("#jsrq2", _format_date(str(item.get("checkout_date", "")))) + self.page.fill("#ts2", str(item.get("days", ""))) + self.page.fill("#rs2", str(item.get("person_count", ""))) + self.page.fill( + '#t2 input[name="expenPwTraveldetail.FPMONEY"]', + str(item.get("invoice_amount", "")), + ) + self.page.fill( + '#t2 input[name="expenPwTraveldetail.MONEY"]', + str(item.get("reimburse_amount", "")), + ) + self.page.fill( + '#t2 input[name="expenPwTraveldetail.SMARK"]', + str(item.get("remark", "")), + ) + self.page.click("#detailAdd", timeout=3000) + self.page.wait_for_timeout(1000) + + conference_info = travel_items.get("conference_fee") or [] + for item in conference_info: + self.page.click("#insertDetail", timeout=5000) + self._wait_for('text="增加明细"', timeout=5000) + self.page.select_option("#cost", "3") + self.page.wait_for_timeout(500) + self.page.fill( + '#t3 input[name="expenPwTraveldetail.HOWBILL"]', + str(item.get("bill_count", "")), + ) + self.page.fill( + '#t3 input[name="expenPwTraveldetail.MONEY"]', + str(item.get("amount", "")), + ) + self.page.fill( + '#t3 input[name="expenPwTraveldetail.SMARK"]', + str(item.get("remark", "")), + ) + self.page.click("#detailAdd", timeout=3000) + self.page.wait_for_timeout(1000) + except Exception as e: + log.error(f"录入总明细失败: {e}") + self._screenshot("item_total_error") + raise + def add_reimburse_items(self, invoices: list[dict[str, Any]]) -> None: """录入报销明细(一条总明细)""" card_amount = sum(inv["card_amount"] for inv in invoices) @@ -261,7 +338,6 @@ class ReimburseBot: try: self.page.click("#insertDetail", timeout=5000) - self.page.wait_for_timeout(1000) self._wait_for('text="经济事项名称"', timeout=5000) self.page.click("#economicscode2") self.page.wait_for_timeout(1000) @@ -286,6 +362,33 @@ class ReimburseBot: self._screenshot("item_total_error") raise + def fill_travel_payment(self, payment_info: list[dict[str, Any]]) -> None: + """录入差旅支付信息""" + log.info("录入差旅支付信息...") + try: + self.page.click('text="下一步(支付方式)"', timeout=5000) + self._wait_for('text="下一步(补助清单)"', timeout=5000) + + for info in payment_info: + self.page.click("#insertPay", timeout=5000) + self.page.wait_for_timeout(1000) + self.page.fill("#personid2", self.config["default_name"]) + self.page.fill("#accountname2", self.config["default_person_id"]) + self.page.fill("#receiptdate2", _format_date(info["card_date"])) + self.page.fill("#localaccount2", self.config["default_card_no"]) + self.page.fill("#receiptmoney2", str(info["card_amount"])) + self.page.fill("#money2", str(info["card_amount"])) + self.page.fill("#merchant2", info.get("merchant", "")) + self.page.fill("#smark2", info.get("remark", "")) + self.page.click("#payAdd", timeout=3000) + self.page.wait_for_timeout(1000) + except Exception as e: + log.error(f"支付方式录入失败: {e}") + self._screenshot("step5_error") + raise + + self._screenshot("step5_done") + def fill_payment(self, invoices: list[dict[str, Any]]) -> None: """录入支付信息""" log.info("录入支付信息...") @@ -314,11 +417,10 @@ class ReimburseBot: self._screenshot("step5_done") def upload_attachments(self, invoices: list[dict[str, Any]]) -> None: - """上传发票附件""" + """上传附件""" log.info("上传附件...") - try: - self.page.click("#next3", timeout=5000) + self.page.click("#next4", timeout=5000) self._wait_for("#submit2", timeout=5000) attachment_files = sorted((self.work_dir or Path(__file__).parent.parent).glob("*.pdf")) @@ -329,8 +431,8 @@ class ReimburseBot: for i, inv in enumerate(invoices): file_path = attachment_files[i] if i < len(attachment_files) else None try: - self._wait_for("#insertAcc", timeout=20000) # 等待增加按钮出现 - self.page.click("#insertAcc", timeout=5000) # 点击增加按钮出现 + self._wait_for("#insertAcc", timeout=20000) + self.page.click("#insertAcc", timeout=5000) self._wait_for("#fjlx", timeout=5000) self.page.select_option("#fjlx", "1") explanation = f"{inv['item_name']} - {inv['invoice_no']}" @@ -359,17 +461,33 @@ class ReimburseBot: self._screenshot("step6_done") - def submit(self) -> None: - """提交报销单""" - log.info("提交报销单...") + def upload_travel_attachments(self, attachment_info: list[dict[str, Any]]) -> None: + """上传差旅附件""" + log.info("上传差旅附件...") try: - self.page.click("#submit", timeout=5000) - self.page.wait_for_timeout(1000) - self._screenshot("submitted") + self.page.click("#next4", timeout=5000) + self._wait_for("#submit2", timeout=5000) + + for info in attachment_info: + attachment_file = self.work_dir / info["filename"] + self._wait_for("#insertAcc", timeout=20000) + self.page.click("#insertAcc", timeout=5000) + self._wait_for("#fjlx", timeout=5000) + if info["attachment_type"] == "invoice": + self.page.select_option("#fjlx", "1") + else: + self.page.select_option("#fjlx", "2") + self.page.fill("#fpsmxx", info["attachment_desc"]) + if attachment_file and attachment_file.exists(): + self.page.set_input_files("#file", str(attachment_file)) + self.page.wait_for_timeout(1000) + self.page.click("#cjtj", timeout=5000) + log.info("上传差旅附件完成") except Exception as e: - log.error(f"提交失败: {e}") - self._screenshot("submit_error") + log.error(f"差旅附件上传失败: {e}") + self._screenshot("travel_attachment_error") raise + self._screenshot("travel_attachment_done") def close(self) -> None: """关闭浏览器""" @@ -382,10 +500,6 @@ class ReimburseBot: except Exception: pass - # -------------------------------------------------------- - # 辅助方法 - # -------------------------------------------------------- - def _wait_for(self, selector: str, timeout: int | None = None) -> None: self.page.wait_for_selector(selector, timeout=timeout) @@ -394,31 +508,131 @@ class ReimburseBot: img_dir.mkdir(exist_ok=True) self.page.screenshot(path=str(img_dir / f"debug_{name}.png")) + def fill_travel_subsidy(self, subsidy_info: list[dict[str, Any]]) -> None: + """录入差旅补助清单""" + log.info("录入差旅补助清单...") + try: + self.page.click("#next3", timeout=5000) + self._wait_for("#next4", timeout=5000) + + for info in subsidy_info: + self.page.click("#insertSubsidy", timeout=5000) + self._wait_for('text="增加补助清单"', timeout=5000) + self.page.click("#jzg3", timeout=5000) + self.page.wait_for_timeout(500) + if info["person_name"] and info["person_name"] != "": + self.page.fill("#seacher", info["person_name"]) + elif info["person_id"] and info["person_id"] != "": + self.page.fill("#seacher", info["person_id"]) + else: + raise ValueError(f"人员编号和人员姓名不能同时为空: {info}") + self.page.click("#cx", timeout=5000) + self.page.wait_for_selector("div.fixed-table-loading", state="hidden", timeout=10000) + self.page.click("#tableEmp tbody tr", timeout=10000) + self.page.wait_for_timeout(1000) + + open_bank = self.page.input_value("#openbank1") + if not open_bank: + log.info("员工开户行未填写,默认填写中国工商银行") + self.page.fill("#openbank1", "中国工商银行") + + self.page.fill("#startdate1", _format_date(info["start_date"])) + self.page.fill("#enddate1", _format_date(info["end_date"])) + self.page.fill("#trafficdays1", str(info["days"])) + self.page.fill("#fooddays1", str(info["days"])) + self.page.fill("#trafficnorm1", str(80)) + self.page.fill("#foodnorm1", str(100)) + trafficmoney = int(info["days"]) * 80 + foodmoney = int(info["days"]) * 100 + subsidymoney = trafficmoney + foodmoney + self.page.fill("#trafficmoney1", str(trafficmoney)) + self.page.fill("#foodmoney1", str(foodmoney)) + self.page.fill("#subsidymoney1", str(subsidymoney)) + self.page.click("#add", timeout=3000) + self.page.wait_for_timeout(1000) + except Exception as e: + log.error(f"差旅补助清单录入失败: {e}") + self._screenshot("subsidy_error") + raise + + self._screenshot("subsidy_done") + # ------------------------------------------------------------------ # 对外入口 # ------------------------------------------------------------------ -def run_bot( - config: dict[str, Any], invoices: list[dict[str, Any]], headless: bool = False, work_dir: Path | None = None -) -> None: - """执行完整的浏览器填报流程""" +def run_bot(config: dict[str, Any], headless: bool = False, work_dir: Path | None = None) -> None: + """执行完整的浏览器填报流程,根据发票类型自动路由""" if not config["username"] or not config["password"]: raise ValueError("缺少用户名或密码") - bot = ReimburseBot(config, headless=headless) + if not work_dir: + raise ValueError("缺少工作目录") + + from .doc.llm_extractor import load_cache + + cache_map = load_cache(work_dir) + doc_values = [v for k, v in cache_map.items() if k != "travel_info"] + invoices = [data for data in doc_values if data.get("invoice_type") not in ("application", "payment")] + applications = [data for data in doc_values if data.get("invoice_type") == "application"] + + groups = _classify_invoice_batch(invoices) + travel_invoices = groups["travel"] + general_invoices = groups["general"] + + log.info( + f"发票分类: 差旅 {len(travel_invoices)} 张, 普通 {len(general_invoices)} 张, " + f"出差事前申请单 {len(applications)} 份" + ) + + if travel_invoices and general_invoices: + raise ValueError( + f"发票类型混合(差旅 {len(travel_invoices)} 张 + 普通 {len(general_invoices)} 张),不支持混报,请分开提交" + ) + + is_travel = bool(travel_invoices) + travel_info: dict[str, Any] = cache_map.get("travel_info", {}) + if is_travel and not travel_info: + from .doc.llm_extractor import CACHE_DIR_NAME, extract_travel_info + + travel_info = extract_travel_info(source_dir=work_dir) + cache_dir = work_dir / CACHE_DIR_NAME + cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_dir / "travel_info.json", "w", encoding="utf-8") as f: + json.dump(travel_info, f, ensure_ascii=False, indent=2) + log.info("差旅信息已保存到缓存") + + bot = ReimburseBot(config, headless=headless, work_dir=work_dir) bot.work_dir = work_dir try: bot.launch() bot.login_portal() - bot.navigate_to_reimburse() - bot.open_reimburse_menu() - bot.fill_basic_info() - bot.add_reimburse_items(invoices) - bot.fill_payment(invoices) - bot.upload_attachments(invoices) - # bot.submit() # 确认无误后再取消注释 + + if is_travel: + log.info("处理差旅发票...") + bot.navigate_to_reimburse(page_key="travel_page") + bot.create_new_form() + basic_info = travel_info["basic_info"] + bot.fill_travel_info(basic_info) + travel_items = travel_info["reimbursement_details"] + bot.add_travel_items(travel_items) + payment_info = travel_info["payment_methods"] + bot.fill_travel_payment(payment_info) + subsidy_info = travel_info["subsidy_list"] + bot.fill_travel_subsidy(subsidy_info) + attachment_info = travel_info["attachments"] + bot.upload_travel_attachments(attachment_info) + else: + log.info("处理普通发票...") + bot.navigate_to_reimburse(page_key="reimburse_page") + bot.create_new_form() + bot.fill_basic_info() + bot.add_reimburse_items(general_invoices) + bot.fill_payment(general_invoices) + bot.upload_attachments(general_invoices) + except Exception as e: log.error(f"操作失败: {e}") try: @@ -430,28 +644,6 @@ def run_bot( bot.close() -def run_bot_web(config: dict[str, Any], invoices: list[dict[str, Any]], work_dir: Path) -> None: +def run_bot_web(config: dict[str, Any], work_dir: Path) -> None: """Web 模式填报 — headless,附件从指定目录读取""" - if not config["username"] or not config["password"]: - raise ValueError("缺少用户名或密码") - - bot = ReimburseBot(config, headless=True) - bot.work_dir = work_dir - try: - bot.launch() - bot.login_portal() - bot.navigate_to_reimburse() - bot.open_reimburse_menu() - bot.fill_basic_info() - bot.add_reimburse_items(invoices) - bot.fill_payment(invoices) - bot.upload_attachments(invoices) - except Exception as e: - log.error(f"操作失败: {e}") - try: - bot._screenshot("error") - except Exception: - pass - raise - finally: - bot.close() + run_bot(config, headless=True, work_dir=work_dir) diff --git a/src/config.py b/src/config.py index 4e7875a..8e0cb44 100644 --- a/src/config.py +++ b/src/config.py @@ -1,13 +1,14 @@ """ 配置加载 -从项目根目录的 config.json 读取配置,返回结构化的配置字典。 +从 scripts/data/config.json 读取用户配置,从环境变量读取服务端配置(LLM + 系统 URL)。 """ import json +import os from pathlib import Path -_CONFIG_PATH = Path(__file__).parent.parent / "config.json" +_CONFIG_PATH = Path(__file__).parent.parent.parent / "scripts" / "data" / "config.json" def load_config() -> dict[str, str | Path]: @@ -20,10 +21,11 @@ def load_config() -> dict[str, str | Path]: project_root = _CONFIG_PATH.parent return { - "sso_login_url": raw.get("sso_login_url", "https://tyrz.fynu.edu.cn/sso/login"), - "portal_url": raw.get("portal_url", "https://tyrz.fynu.edu.cn/oshall"), - "reimburse_url": raw.get("reimburse_url", "http://210.45.32.214:8081"), - "reimburse_page": raw.get("reimburse_page", "/expen/common/common?v=4.0"), + "sso_login_url": os.environ.get("SSO_LOGIN_URL", "https://tyrz.fynu.edu.cn/sso/login"), + "portal_url": os.environ.get("PORTAL_URL", "https://tyrz.fynu.edu.cn/oshall"), + "reimburse_url": os.environ.get("REIMBURSE_URL", "http://210.45.32.214:8081"), + "reimburse_page": os.environ.get("REIMBURSE_PAGE", "/expen/common/common?v=4.0"), + "travel_page": os.environ.get("TRAVEL_PAGE", "/expen/travel/travel?v=4.0"), "username": raw.get("username", ""), "password": raw.get("password", ""), "default_name": raw.get("default_name", ""), @@ -35,15 +37,9 @@ def load_config() -> dict[str, str | Path]: def get_llm_config() -> dict[str, str]: - """加载 LLM 配置,缺失字段使用默认值""" - raw = {} - if _CONFIG_PATH.exists(): - with open(_CONFIG_PATH, encoding="utf-8") as f: - raw = json.load(f) - - llm_raw = raw.get("llm", {}) + """加载 LLM 配置,优先从环境变量读取,缺失字段使用默认值""" return { - "model": llm_raw.get("model", "qwen-vl-max"), - "api_base": llm_raw.get("api_base", "http://localhost:8080/v1"), - "api_key": llm_raw.get("api_key", "lm-studio"), + "model": os.environ.get("LLM_MODEL", "qwen-vl-max"), + "api_base": os.environ.get("LLM_API_BASE", "http://localhost:8080/v1"), + "api_key": os.environ.get("LLM_API_KEY", "lm-studio"), } diff --git a/src/doc/README.md b/src/doc/README.md index 335a5c6..0c05efe 100644 --- a/src/doc/README.md +++ b/src/doc/README.md @@ -1,6 +1,6 @@ --- - -## last_reviewed: 2026-06-09 +last_reviewed: 2026-06-09 +--- # src/doc — 文档处理模块 @@ -12,13 +12,13 @@ | 文件 | 作用 | | ------------------------ | -------------------------------------------------- | | `extractor.py` | 编排入口:串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类 | -| `pdf.py` | PDF 文件发现与文本提取(pdfplumber) | +| `pdf.py` | PDF 图片渲染(PyMuPDF) | | `llm_extractor.py` | 基于 LLM 的信息提取(发票文本 + 支付截图多模态) | | `matcher.py` | 发票与支付截图按金额匹配,回填刷卡信息至发票记录 | | `invoice.py` | 发票类型常量、分类逻辑、CSV 读写工具 | | `fill_consumable_doc.py` | 将 CSV 数据填入易耗品出库单 Word 模板(pywin32 COM) | | `prompt.py` | LLM 提示词模板加载 | -| `prompts/` | 提示词模板文件(`invoice_system.md`、`card_info_system.md`) | +| `prompts/` | 提示词模板文件(`invoice_system.md`、`travel_info_system.md`) | ## 数据流 @@ -27,7 +27,7 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表] 支付截图 → llm_extractor.py → [刷卡记录] ↓ - matcher.py(按金额贪心匹配,容差 10 元) + matcher.py(按金额贪心匹配,相对容差 3%) ↓ invoice.py 分类 → CSV(已回填刷卡日期/卡号/金额) ↓ @@ -36,7 +36,7 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表] ## 依赖说明 -- **pdfplumber** — PDF 文本提取 +- **PyMuPDF (pymupdf)** — PDF 图片渲染 - **pywin32** — Word COM 自动化(仅 Windows) - **llama-index** — LLM 信息提取 @@ -45,5 +45,4 @@ PDF 发票 → pdf.py → llm_extractor.py → [发票列表] - `fill_consumable_doc.py` 依赖 Microsoft Word + COM,仅 Windows 可用 - LLM 提取不会覆盖 CSV 中已有非空字段 - 提示词模板位于 `prompts/` 目录,由 `prompt.py` 加载 -- LLM 提取失败时直接报错,无正则回退 - +- LLM 提取失败时直接报错,无正则回退 \ No newline at end of file diff --git a/src/doc/extractor.py b/src/doc/extractor.py index 0074083..8c9b99b 100644 --- a/src/doc/extractor.py +++ b/src/doc/extractor.py @@ -1,65 +1,237 @@ """发票提取编排 -串联 PDF 读取 → LLM 提取 → 支付截图匹配 → 分类,生成支付记录列表。 +统一扫描目录下所有文件(PDF + 图片),通过 LLM 提取结构化数据, +根据 LLM 返回的「invoice_type」字段自动分类为发票/支付记录/出差事前申请单。 对外接口: - extract_invoices(directory) -> tuple[list[dict], dict] + extract_invoices(directory) -> tuple[list[dict], list[dict], dict] """ +import json +from pathlib import Path +from typing import Any + from .. import get_logger -from .invoice import classify_invoice_batch -from .llm_extractor import extract_invoice_from_text +from .llm_extractor import extract_document from .matcher import match_invoices_to_cards -from .pdf import extract_text_from_pdf, find_pdf_files log = get_logger("extractor") -def extract_invoices( - directory: str = ".", -) -> tuple[list[dict[str, str]], dict[str, list[dict[str, str]]]]: - """扫描目录下所有 PDF,提取发票信息并匹配支付记录 +def _classify_invoice_batch( + invoices: list[dict[str, str]], +) -> dict[str, list[dict[str, str]]]: + """按发票类型分组""" + travel: list[dict[str, str]] = [] + general: list[dict[str, str]] = [] + application: list[dict[str, str]] = [] + for inv in invoices: + inv_type = inv.get("invoice_type", "general") + if inv_type == "application": + application.append(inv) + elif inv_type in ("train", "hotel"): + travel.append(inv) + else: + general.append(inv) + return {"travel": travel, "general": general, "application": application} + + +# JSON 缓存目录(相对于源文件目录) +CACHE_DIR_NAME = ".invoice_cache" + +# 支持的文件扩展名 +SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".bmp"} + + +def _get_cache_dir(source_dir: Path) -> Path: + """获取缓存目录路径""" + cache_dir = source_dir / CACHE_DIR_NAME + cache_dir.mkdir(exist_ok=True) + return cache_dir + + +def _get_json_path(file_path: Path, cache_dir: Path) -> Path: + """根据文件路径生成对应的 JSON 缓存路径""" + return cache_dir / f"{file_path.stem}.json" + + +def _save_to_cache(file_path: Path, extracted_data: dict[str, Any], cache_dir: Path) -> Path: + """将提取结果保存到 JSON 缓存文件,并记录源文件路径""" + json_path = _get_json_path(file_path, cache_dir) + cache_data = { + "source_file": str(file_path), + "source_filename": file_path.name, + "extracted_data": extracted_data, + } + with open(json_path, "w", encoding="utf-8") as f: + json.dump(cache_data, f, ensure_ascii=False, indent=2) + log.info(f"提取结果已缓存: {json_path.name}") + return json_path + + +def _load_from_cache(json_path: Path) -> dict[str, Any] | None: + """从 JSON 缓存文件加载提取结果""" + if not json_path.exists(): + return None + try: + with open(json_path, encoding="utf-8") as f: + cache_data: dict[str, Any] = json.load(f) + result: dict[str, Any] | None = cache_data.get("extracted_data") + return result + except Exception as e: + log.warning(f"读取缓存失败 {json_path.name}: {e}") + return None + + +def _extract_document(file_path: Path, cache_dir: Path) -> dict[str, str] | None: + """提取单个文件的结构化信息,优先使用缓存。 + + 根据 LLM 返回的「invoice_type」字段自动分类: + - "payment" -> 支付记录 + - "application" -> 申请单 + - 有 "invoice_number" -> 发票 + - 其他 -> 无法识别 + + Args: + file_path: 文件路径(PDF 或图片)。 + cache_dir: JSON 缓存目录。 Returns: - (payment_records, groups): 支付记录列表和按发票类型分组的字典 - groups = {'travel': [差旅发票], 'general': [普通发票]} + 提取结果字典,失败时返回 None。 """ - pdf_files = find_pdf_files(directory) - if not pdf_files: - log.warning("未找到 PDF 文件") - return [], {"travel": [], "general": []} + json_path = _get_json_path(file_path, cache_dir) + cached = _load_from_cache(json_path) + if cached: + cached["_source_file"] = file_path.name + log.info(f"使用缓存: {file_path.name}") + return cached - log.info(f"发现 {len(pdf_files)} 个 PDF 文件") + log.info(f"使用多模态提取: {file_path.name}") + try: + result = extract_document(file_path) + if result: + result["_source_file"] = file_path.name + _save_to_cache(file_path, result, cache_dir) + return result + except Exception as e: + log.warning(f"多模态提取失败: {file_path.name} ({e})") + + return None + + +def _find_all_files(directory: str) -> list[Path]: + """扫描目录下所有支持的文件(PDF + 图片)""" + dir_path = Path(directory) + files = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS] + return sorted(files) + + +def _save_match_result(payment_records: list[dict[str, Any]], cache_dir: Path) -> None: + """将发票与支付记录的匹配结果保存到缓存。""" + match_data: dict[str, list[dict[str, Any]]] = {} + for record in payment_records: + card_source = record.get("_source_file", "") + matched = record.get("_matched_invoices", []) + card_amount = record.get("card_amount", "") + + if matched: + invoice_list = [ + { + "file": inv.get("_source_file", ""), + "type": inv.get("invoice_type", ""), + "amount": inv.get("total_amount", inv.get("total_amount", "")), + } + for inv in matched + ] + + if card_source: + match_data[f"{card_source} (¥{card_amount})"] = invoice_list + else: + key = "__unmatched__" + if key not in match_data: + match_data[key] = [] + match_data[key].extend(invoice_list) + + if not match_data: + return + + match_path = cache_dir / "match_result.json" + with open(match_path, "w", encoding="utf-8") as f: + json.dump(match_data, f, ensure_ascii=False, indent=2) + log.info(f"匹配结果已缓存: {match_path.name}") + + +def extract_invoices( + directory: str = ".", +) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[str, list[dict[str, str]]]]: + """扫描目录下所有文件,提取信息并匹配支付记录 + + 统一使用 LLM 提取,根据返回的「invoice_type」自动分类: + - 发票(有 invoice_number)-> 参与金额匹配 + - 支付记录(invoice_type="payment")-> 参与金额匹配 + - 出差事前申请单 -> 单独存储,不参与匹配 + + Returns: + (payment_records, applications, groups): + - payment_records: 支付记录列表(仅包含真实发票,不含申请单) + - applications: 出差事前申请单列表(单独存储,不参与支付匹配) + - groups: 按文档类型分组的字典 + {'travel': [差旅发票], 'general': [普通发票], 'application': [出差事前申请单]} + """ + source_dir = Path(directory) + cache_dir = _get_cache_dir(source_dir) + + all_files = _find_all_files(directory) + if not all_files: + log.warning("未找到支持的文件") + return [], [], {"travel": [], "general": [], "application": []} + + log.info(f"发现 {len(all_files)} 个文件") all_invoices = [] - for pdf_path in pdf_files: - text = extract_text_from_pdf(pdf_path) - if not text: - log.warning(f"未能提取文本: {pdf_path.name}") + all_cards = [] + applications = [] + + for file_path in all_files: + result = _extract_document(file_path, cache_dir) + + if not result: + log.warning(f"未能解析: {file_path.name}") continue - invoice = extract_invoice_from_text(text, pdf_path.name) + inv_type = result.get("invoice_type", "") - if invoice and invoice.get("发票号码"): - all_invoices.append(invoice) - log.info(f"[{invoice['发票类型']}] 已解析: {pdf_path.name}") + if inv_type == "application": + applications.append(result) + log.info(f"[{inv_type}] 已解析: {file_path.name}") + elif inv_type == "payment": + all_cards.append(result) + log.info(f"[{inv_type}] 已解析: {file_path.name}") + elif result.get("invoice_number"): + all_invoices.append(result) + log.info(f"[{inv_type}] 已解析: {file_path.name}") else: - log.warning(f"未能解析: {pdf_path.name}") + log.warning(f"无法分类: {file_path.name} (invoice_type={inv_type})") - if all_invoices: - log.info(f"共处理 {len(all_invoices)} 张发票") - else: + log.info(f"分类结果: 发票 {len(all_invoices)} 张, 支付记录 {len(all_cards)} 条, 申请单 {len(applications)} 份") + + if not all_invoices: log.warning("未成功解析任何发票") - # 将支付截图与发票进行金额匹配,返回以支付记录为主键的列表 - payment_records = match_invoices_to_cards(all_invoices, directory) + payment_records = match_invoices_to_cards(all_invoices, all_cards) + _save_match_result(payment_records, cache_dir) - # 从支付记录中还原所有发票用于分类 - all_invoices_restored: list[dict[str, str]] = [] + # 构建分类 + all_documents: list[dict[str, str]] = [] for record in payment_records: - all_invoices_restored.extend(record.get("_matched_invoices", [])) + all_documents.extend(record.get("_matched_invoices", [])) + all_documents.extend(applications) - groups = classify_invoice_batch(all_invoices_restored) - log.info(f"差旅发票: {len(groups['travel'])} 张, 普通发票: {len(groups['general'])} 张") + groups = _classify_invoice_batch(all_documents) + log.info( + f"文档分类: 差旅发票 {len(groups['travel'])} 张, " + f"普通发票 {len(groups['general'])} 张, " + f"出差事前申请单 {len(groups['application'])} 份" + ) - return payment_records, groups + return payment_records, applications, groups diff --git a/src/doc/fill_consumable_doc.py b/src/doc/fill_consumable_doc.py index db1d8d7..2f447d7 100644 --- a/src/doc/fill_consumable_doc.py +++ b/src/doc/fill_consumable_doc.py @@ -14,8 +14,8 @@ from pathlib import Path from typing import Any from .. import get_logger -from ..bot import load_invoice_data from ..config import load_config +from ..doc.invoice import load_invoice_csv log = get_logger("fill_consumable_doc") @@ -143,7 +143,8 @@ def fill_consumable_doc( doc_path = Path(doc_path) if config is None: config = load_config() - invoices = load_invoice_data(str(csv_path), config) + + invoices = load_invoice_csv(csv_path.parent / "invoice_summary.csv") or [] if backup: bak = doc_path.with_suffix(doc_path.suffix + ".bak") @@ -192,7 +193,7 @@ def fill_consumable_doc( qty = str(qty_val) if qty_val > 0 else "1" values = [ - str(inv.get("seq", i + 1)), + str(inv.get("index", i + 1)), parsed["product_name"], parsed["spec"], parsed["unit"], @@ -240,7 +241,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="将发票 CSV 填入易耗品出库单") parser.add_argument("--csv", default=str(root / "invoice_summary.csv")) parser.add_argument("--doc", default=str(root / "易耗品、出库单.doc")) - parser.add_argument("--config", default=str(root / "config.json")) + parser.add_argument("--config", default=str(root / "scripts" / "data" / "config.json")) parser.add_argument("--no-backup", action="store_true") args = parser.parse_args() diff --git a/src/doc/invoice.py b/src/doc/invoice.py index 877e04c..c3085ec 100644 --- a/src/doc/invoice.py +++ b/src/doc/invoice.py @@ -1,16 +1,12 @@ """发票数据模型与 CSV 工具 -定义发票类型常量、CSV 列结构,提供发票分类和 CSV 读写功能。 +定义 CSV 列结构,提供发票分类和 CSV 读写功能。 对外接口: - INVOICE_LEVEL_COLUMNS 发票级别 CSV 列定义 - PAYMENT_RECORD_COLUMNS 支付记录级别 CSV 列定义 - INVOICE_TYPE_* 发票类型常量 - is_travel_invoice(type) 判断是否为差旅发票 - classify_invoice_batch(invoices) 按类型分组 load_csv(path) 读取支付记录 CSV save_csv(payment_records, path) 保存支付记录 CSV save_invoice_csv(payment_records, path) 保存发票级别 CSV + save_application_json(applications, path) 保存出差申请单 JSON """ import csv @@ -21,76 +17,63 @@ from .. import get_logger log = get_logger("invoice") -# ------------------------------------------------------------------ -# CSV 列定义 -# ------------------------------------------------------------------ - -# 发票级别 CSV 列(用于 invoice_summary.csv,每行一张发票) +# CSV 列名 INVOICE_LEVEL_COLUMNS = [ - "序号", - "发票类型", - "发票号码", - "开票日期", - "项目名称", - "规格型号", - "价税合计", - "销售方名称", - "出发站", - "到达站", - "车次", - "乘车日期", - "座位等级", - "人员姓名", - "刷卡日期", - "公务卡号", - "刷卡金额", - "备注", - "工号", + "index", + "invoice_type", + "invoice_number", + "invoice_date", + "item_name", + "spec_model", + "total_amount", + "seller_name", + "departure", + "arrival", + "train_no", + "ride_date", + "seat_class", + "person_name", + "card_date", + "card_no", + "card_amount", + "remark", + "person_id", ] -# 支付记录级别 CSV 列(用于 payment_records.csv,每行一笔支付) PAYMENT_RECORD_COLUMNS = [ - "序号", - # 支付信息 - "刷卡日期", - "公务卡号", - "刷卡金额", - # 发票聚合信息 - "关联发票数", - "发票详情", # 格式: 类型[号码]¥金额 | 类型[号码]¥金额 - "备注", - # 内部字段(用于下游解析) - "_invoices_json", # JSON 序列化的发票列表,供 bot/fill_doc 使用 - "工号", + "index", + "card_date", + "card_no", + "card_amount", + "relative_invoice_count", + "invoice_detail", + "remark", + "_matched_invoices", + "person_id", ] -# ------------------------------------------------------------------ -# 发票类型常量 -# ------------------------------------------------------------------ -INVOICE_TYPE_TRAIN = "高铁票" -INVOICE_TYPE_HOTEL = "酒店住宿" -INVOICE_TYPE_GENERAL = "普通发票" - -INVOICE_TYPE_TRAVEL = frozenset([INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL]) +def _is_application_document(invoice_type: str) -> bool: + """判断是否为出差事前申请单""" + return invoice_type == "application" -def is_travel_invoice(invoice_type: str) -> bool: - """判断是否为差旅发票(高铁票/酒店住宿)""" - return invoice_type in INVOICE_TYPE_TRAVEL - - -def classify_invoice_batch(invoices: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]: - """将发票列表按类型分组:{'travel': [...], 'general': [...]}""" - travel = [] - general = [] +def _classify_invoice_batch( + invoices: list[dict[str, str]], +) -> dict[str, list[dict[str, str]]]: + """按发票类型分组""" + travel: list[dict[str, str]] = [] + general: list[dict[str, str]] = [] + application: list[dict[str, str]] = [] for inv in invoices: - inv_type = inv.get("发票类型", INVOICE_TYPE_GENERAL) - if is_travel_invoice(inv_type): + inv_type = inv.get("invoice_type", "general") + if _is_application_document(inv_type): + application.append(inv) + elif inv_type in ("train", "hotel"): travel.append(inv) else: general.append(inv) - return {"travel": travel, "general": general} + return {"travel": travel, "general": general, "application": application} # ------------------------------------------------------------------ @@ -108,69 +91,37 @@ def _clean_invoice_for_json(inv: dict[str, str]) -> dict[str, str]: return clean -def load_csv(csv_path: Path) -> list[dict[str, str]] | None: - """读取支付记录 CSV 为 dict 列表,失败返回 None""" +def _load_csv( + csv_path: Path, + required_columns: list[str], + label: str = "CSV", +) -> list[dict[str, str]] | None: + """通用 CSV 读取器:按 required_columns 校验列,失败返回 None""" try: - with open(csv_path, encoding="utf-8", newline="") as f: + with open(csv_path, encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) fieldnames = reader.fieldnames or [] - missing = [c for c in PAYMENT_RECORD_COLUMNS if c not in fieldnames] + missing = [c for c in required_columns if c not in fieldnames] if missing: - log.error(f"CSV 缺少必要列: {missing}") + log.error(f"{label} 缺少必要列: {missing}") return None return [row for row in reader] except FileNotFoundError: - log.error(f"CSV 文件不存在: {csv_path.name}") + log.error(f"{label} 文件不存在: {csv_path.name}") return None except Exception as e: - log.error(f"CSV 读取失败: {e}") + log.error(f"{label} 读取失败: {e}") return None +def load_csv(csv_path: Path) -> list[dict[str, str]] | None: + """读取支付记录 CSV 为 dict 列表,失败返回 None""" + return _load_csv(csv_path, PAYMENT_RECORD_COLUMNS, "CSV") + + def load_invoice_csv(csv_path: Path) -> list[dict[str, str]] | None: """读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None""" - try: - with open(csv_path, encoding="utf-8", newline="") as f: - reader = csv.DictReader(f) - fieldnames = reader.fieldnames or [] - missing = [c for c in INVOICE_LEVEL_COLUMNS if c not in fieldnames] - if missing: - log.error(f"发票 CSV 缺少必要列: {missing}") - return None - return [row for row in reader] - except FileNotFoundError: - log.error(f"发票 CSV 文件不存在: {csv_path.name}") - return None - except Exception as e: - log.error(f"发票 CSV 读取失败: {e}") - return None - - -def load_invoices_from_csv(csv_path: Path) -> list[dict[str, str]] | None: - """从支付记录 CSV 中还原发票级别的数据(供 bot/fill_doc 使用) - - 读取 _invoices_json 字段,反序列化后展平为发票列表。 - """ - rows = load_csv(csv_path) - if rows is None: - return None - - invoices = [] - for row in rows: - invoices_json = row.get("_invoices_json", "") - if not invoices_json: - continue - try: - inv_list = json.loads(invoices_json) - for inv in inv_list: - # 从支付记录回填刷卡信息 - inv["刷卡日期"] = row.get("刷卡日期", inv.get("刷卡日期", "")) - inv["公务卡号"] = row.get("公务卡号", inv.get("公务卡号", "")) - inv["刷卡金额"] = row.get("刷卡金额", inv.get("刷卡金额", "")) - invoices.append(inv) - except json.JSONDecodeError: - log.warning(f"无法解析发票 JSON: {invoices_json[:50]}...") - return invoices + return _load_csv(csv_path, INVOICE_LEVEL_COLUMNS, "发票 CSV") def save_csv( @@ -180,8 +131,8 @@ def save_csv( """将支付记录列表保存为 CSV(以支付记录为主键) 每条支付记录包含: - - 刷卡日期、公务卡号、刷卡金额(支付信息) - - 关联发票数、发票详情(发票聚合信息) + - card_date, card_no, card_amount(支付信息) + - relative_invoice_count, invoice_detail(发票聚合信息) - _matched_invoices(内部字段,序列化为 JSON 存储在 CSV 中) """ csv_path = Path(output_path) @@ -191,7 +142,6 @@ def save_csv( writer.writerow(PAYMENT_RECORD_COLUMNS) for idx, record in enumerate(payment_records, 1): - # 序列化关联发票为 JSON matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment] invoices_json = json.dumps( [_clean_invoice_for_json(inv) for inv in matched_invoices], @@ -201,14 +151,14 @@ def save_csv( writer.writerow( [ idx, - record.get("刷卡日期", ""), - record.get("公务卡号", ""), - record.get("刷卡金额", ""), - record.get("关联发票数", str(len(matched_invoices))), - record.get("发票详情", ""), - record.get("备注", ""), + record.get("card_date", ""), + record.get("card_no", ""), + record.get("card_amount", ""), + record.get("relative_invoice_count", str(len(matched_invoices))), + record.get("invoice_detail", ""), + record.get("remark", ""), invoices_json, - record.get("工号", ""), + record.get("person_id", ""), ] ) @@ -223,6 +173,7 @@ def save_invoice_csv( 从 _matched_invoices 中还原每张发票,回填刷卡信息, 生成以发票为主键的 CSV,用于人工填写报销单参考。 + 出差事前申请单不会被写入此文件(它们有独立的 CSV)。 """ csv_path = Path(output_path) @@ -235,27 +186,29 @@ def save_invoice_csv( matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment] for inv in matched_invoices: clean_inv = _clean_invoice_for_json(inv) + if _is_application_document(clean_inv.get("invoice_type", "")): + continue writer.writerow( [ idx, - clean_inv.get("发票类型", ""), - clean_inv.get("发票号码", ""), - clean_inv.get("开票日期", ""), - clean_inv.get("项目名称", ""), - clean_inv.get("规格型号", ""), - clean_inv.get("价税合计", ""), - clean_inv.get("销售方名称", ""), - clean_inv.get("出发站", ""), - clean_inv.get("到达站", ""), - clean_inv.get("车次", ""), - clean_inv.get("乘车日期", ""), - clean_inv.get("座位等级", ""), - clean_inv.get("人员姓名", ""), - record.get("刷卡日期", ""), - record.get("公务卡号", ""), - record.get("刷卡金额", ""), - record.get("备注", ""), - record.get("工号", ""), + clean_inv.get("invoice_type", ""), + clean_inv.get("invoice_number", ""), + clean_inv.get("invoice_date", ""), + clean_inv.get("item_name", ""), + clean_inv.get("spec_model", ""), + clean_inv.get("total_amount", ""), + clean_inv.get("seller_name", ""), + clean_inv.get("departure", ""), + clean_inv.get("arrival", ""), + clean_inv.get("train_no", ""), + clean_inv.get("ride_date", ""), + clean_inv.get("seat_class", ""), + clean_inv.get("person_name", ""), + record.get("card_date", ""), + record.get("card_no", ""), + record.get("card_amount", ""), + record.get("remark", ""), + record.get("person_id", ""), ] ) idx += 1 @@ -263,11 +216,18 @@ def save_invoice_csv( log.info(f"发票级别 CSV 已保存: {csv_path.name}") -def save_csv_rows(csv_path: Path, rows: list[dict[str, str]]) -> None: - """将 dict 列表保存为支付记录 CSV(用于更新已有 CSV)""" - with open(csv_path, "w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=PAYMENT_RECORD_COLUMNS) - writer.writeheader() - writer.writerows(rows) +def save_application_json( + applications: list[dict[str, str]], + output_path: str | Path = "travel_applications.json", +) -> None: + """将出差事前申请单列表保存为独立 JSON 文件 - log.info(f"CSV 已保存: {csv_path.name}") + 使用 JSON 保留完整嵌套结构(如出差人员信息的列表形式), + 避免 CSV 扁平化导致的字段丢失。 + """ + json_path = Path(output_path) + + with open(json_path, "w", encoding="utf-8") as f: + json.dump(applications, f, ensure_ascii=False, indent=2) + + log.info(f"出差申请单 JSON 已保存: {json_path.name}") diff --git a/src/doc/llm_extractor.py b/src/doc/llm_extractor.py index 5a203f6..4d871c4 100644 --- a/src/doc/llm_extractor.py +++ b/src/doc/llm_extractor.py @@ -1,12 +1,19 @@ -""" -LLM 信息提取 +"""LLM 信息提取 -使用 LLM 从 PDF 文本中提取结构化数据,以及从支付截图中提取刷卡信息。 -支持 JSON 格式输出,字段与 CSV_COLUMNS 对齐。 +使用 LLM 从 PDF 文本/图片、支付截图中提取结构化数据。 -对外接口: - extract_invoice_from_text(text, file_name) -> dict 从 PDF 文本提取发票信息 - extract_card_info_from_image(image_path) -> dict 从支付截图提取刷卡信息 +## 功能模块 + +- **统一文档提取**:使用一套提示词,LLM 自行判断文档类型(发票/支付记录/出差事前申请单等),支持 JSON 格式输出。 +- **差旅信息提取**:综合多张发票、支付记录和匹配结果,提取出差事由、地点、时间等差旅相关信息。 +- **缓存管理**:支持从 `.invoice_cache/` 目录加载已提取的结构化数据和匹配结果,避免重复处理。 + +## 对外接口 + +- `extract_document(file_path) -> dict` — 统一入口:从任意图片/PDF 提取信息 +- `extract_travel_info(source_dir) -> dict` — 综合发票和匹配结果提取差旅信息 +- `load_cache(source_dir) -> dict` — 加载缓存的结构化数据 +- `load_match_result(source_dir) -> dict` — 加载发票与支付记录的匹配结果 """ from __future__ import annotations @@ -17,7 +24,10 @@ from pathlib import Path from typing import Any, cast from .. import get_logger -from .prompt import build_card_info_system_prompt, build_invoice_system_prompt +from .prompt import ( + build_invoice_system_prompt, + build_travel_info_system_prompt, +) log = get_logger("llm_extractor") @@ -38,47 +48,12 @@ def _create_llm() -> Any: api_base=llm_config["api_base"], api_key=llm_config.get("api_key", "lm-studio"), temperature=0.1, - max_tokens=8192, + max_tokens=65535, request_timeout=600.0, is_chat_model=True, ) -def _llm_query(system_prompt: str, user_content: str, max_tokens: int = 4096) -> str: - """发送请求到 LLM 并返回完整响应文本。""" - from llama_index.core.llms import ChatMessage - - from ..config import get_llm_config - - messages = [ - ChatMessage(role="system", content=system_prompt), - ChatMessage(role="user", content=user_content), - ] - - llm_config = get_llm_config() - llm = _create_llm() - log.info("开始请求 LLM (model=%s, base=%s)", llm_config["model"], llm_config["api_base"]) - - try: - parts = [] - for resp in llm.stream_chat( - messages, - temperature=0.1, - max_tokens=max_tokens, - extra_body={"reasoning_effort": "none"}, - ): - delta = resp.delta - if delta: - parts.append(delta) - text = "".join(parts) - log.info("LLM 请求完成,响应总长度: %d 字符", len(text)) - log.info("LLM 响应: %s", text) - return text - except Exception as e: - log.error("LLM 请求失败: %s", e) - raise - - def _parse_json_response(text: str) -> dict[str, Any]: """从 LLM 响应中提取 JSON,处理可能的 Markdown 包裹。""" text = text.strip() @@ -98,31 +73,8 @@ def _parse_json_response(text: str) -> dict[str, Any]: return cast(dict[str, Any], json.loads(text)) -def extract_invoice_from_text(text: str, file_name: str = "") -> dict[str, Any]: - """从 PDF 发票文本中提取结构化数据。 - - Args: - text: PDF 提取的文本内容。 - file_name: 原始文件名(用于日志)。 - - Returns: - 包含所有 CSV_COLUMNS 字段的字典。 - """ - system_prompt = build_invoice_system_prompt() - user_content = f"请分析以下发票文本并提取信息:\n\n文件名: {file_name}\n\n---\n\n{text}\n\n---" - - try: - response = _llm_query(system_prompt, user_content, max_tokens=4096) - result = _parse_json_response(response) - log.info("LLM 发票提取成功: %s", file_name) - return result - except Exception as e: - log.error("LLM 发票提取失败: %s (%s)", file_name, e) - raise - - # ------------------------------------------------------------------ -# 支付截图信息提取(多模态) +# 统一文档提取(多模态,直接传图片给 LLM) # ------------------------------------------------------------------ @@ -134,36 +86,53 @@ def _image_to_base64(image_path: Path) -> str: def _llm_query_multimodal( system_prompt: str, - text: str, - image_b64: str, - max_tokens: int = 4096, + text: str | None = None, + image_b64s: list[str] | None = None, + blocks: list[Any] | None = None, + reasoning_effort: str = "none", ) -> str: - """发送多模态请求(文本 + 图片)到 LLM。""" + """发送多模态请求到 LLM。 + + Args: + system_prompt: 系统提示词。 + text: 用户文本(与 image_b64s 配合使用,文本在前、图片在后)。 + image_b64s: base64 编码的图片列表。 + blocks: 预构建的内容块列表(TextBlock/ImageBlock),传入时忽略 text 和 image_b64s。 + + Returns: + LLM 响应文本。 + """ from llama_index.core.base.llms.types import ImageBlock, TextBlock from llama_index.core.llms import ChatMessage from ..config import get_llm_config + if blocks is not None: + final_blocks = blocks + else: + text = text or "" + image_b64s = image_b64s or [] + final_blocks = [TextBlock(text=text)] + for img_b64 in image_b64s: + final_blocks.append( + ImageBlock( + url=f"data:image/jpeg;base64,{img_b64}", + detail="high", + ) + ) + messages = [ ChatMessage(role="system", content=system_prompt), - ChatMessage( - role="user", - blocks=[ - TextBlock(text=text), - ImageBlock( - url=f"data:image/jpeg;base64,{image_b64}", - detail="high", - ), - ], - ), + ChatMessage(role="user", blocks=final_blocks), ] llm_config = get_llm_config() llm = _create_llm() log.info( - "开始请求 LLM 多模态 (model=%s, base=%s)", + "开始请求 LLM 多模态 (model=%s, base=%s, blocks=%d)", llm_config["model"], llm_config["api_base"], + len(final_blocks), ) try: @@ -171,8 +140,7 @@ def _llm_query_multimodal( for resp in llm.stream_chat( messages, temperature=0.1, - max_tokens=max_tokens, - extra_body={"reasoning_effort": "none"}, + extra_body={"reasoning_effort": reasoning_effort}, ): delta = resp.delta if delta: @@ -186,25 +154,173 @@ def _llm_query_multimodal( raise -def extract_card_info_from_image(image_path: Path) -> dict[str, Any]: - """从支付截图中提取刷卡信息。 +def extract_document(file_path: Path) -> dict[str, Any]: + """统一文档提取入口:从任意图片/PDF 中提取结构化信息。 + + LLM 会根据统一提示词自行判断文档类型(发票/支付记录/出差事前申请单等)。 Args: - image_path: 支付截图图片路径。 + file_path: 文件路径(支持 PDF 和图片格式)。 Returns: - 包含刷卡日期、刷卡金额、公务卡号的字典。 + 包含提取字段的字典。 """ - system_prompt = build_card_info_system_prompt() - user_text = f"请分析以下支付截图并提取信息:\n\n文件名: {image_path.name}" + from .pdf import render_pdf_to_images - image_b64 = _image_to_base64(image_path) + system_prompt = build_invoice_system_prompt() + user_text = f"请分析以下财务文档并提取信息:\n\n文件名: {file_path.name}" + + # PDF 先渲染为图片 + suffix = file_path.suffix.lower() + if suffix == ".pdf": + image_b64s = render_pdf_to_images(file_path) + else: + image_b64s = [_image_to_base64(file_path)] + + if not image_b64s: + log.warning(f"文件渲染为空: {file_path.name}") + return {} try: - response = _llm_query_multimodal(system_prompt, user_text, image_b64, max_tokens=4096) + response = _llm_query_multimodal(system_prompt, user_text, image_b64s) result = _parse_json_response(response) - log.info("LLM 支付截图提取成功: %s", image_path.name) + log.info("LLM 文档提取成功: %s", file_path.name) return result except Exception as e: - log.error("LLM 支付截图提取失败: %s (%s)", image_path.name, e) + log.error("LLM 文档提取失败: %s (%s)", file_path.name, e) + raise + + +# ------------------------------------------------------------------ +# 差旅信息提取 +# ------------------------------------------------------------------ + +CACHE_DIR_NAME = ".invoice_cache" + + +def load_cache(source_dir: Path) -> dict[str, Any]: + """从 JSON 缓存目录加载结构化数据,构建 source filename -> 缓存数据的映射。 + + Args: + source_dir: 源文件目录(包含 .invoice_cache 子目录)。 + + Returns: + {source_filename: extracted_data} 字典。 + 额外包含 "travel_info" 键(如果 travel_info.json 存在)。 + """ + cache_map: dict[str, Any] = {} + cache_dir = source_dir / CACHE_DIR_NAME + if not cache_dir.exists(): + return cache_map + + for json_path in sorted(cache_dir.glob("*.json")): + try: + with open(json_path, encoding="utf-8") as f: + cache_data = json.load(f) + + # travel_info.json 结构不同,直接存储 + if json_path.name == "travel_info.json": + cache_map["travel_info"] = cache_data + continue + + extracted = cache_data.get("extracted_data", {}) + src_file = extracted.get("_source_file", "") + if src_file: + cache_map[src_file] = extracted + except Exception as e: + log.warning(f"读取缓存失败 {json_path.name}: {e}") + + return cache_map + + +def load_match_result(source_dir: Path) -> dict[str, list[dict[str, Any]]]: + """从 JSON 缓存目录加载发票与支付记录的匹配结果。 + + Args: + source_dir: 源文件目录(包含 .invoice_cache 子目录)。 + + Returns: + {支付记录源文件 (含金额): [发票信息列表]} 字典。 + 每个发票信息包含 file, type, amount 字段。 + """ + cache_dir = source_dir / CACHE_DIR_NAME + match_path = cache_dir / "match_result.json" + if not match_path.exists(): + return {} + + try: + with open(match_path, encoding="utf-8") as f: + result: dict[str, list[dict[str, Any]]] = json.load(f) + return result + except Exception as e: + log.warning(f"读取匹配结果缓存失败: {e}") + return {} + + +def extract_travel_info( + source_dir: Path | None = None, +) -> dict[str, Any]: + """根据差旅发票(bot 格式),让 LLM 提取出差相关信息。 + + 仅支持从 JSON 缓存加载数据。 + + bot 格式的发票包含以下字段: + - 发票类型, invoice_no, invoice_date, item_name, spec_model + - total_amount, seller_name, person_name, person_id + - card_date, card_no, card_amount, remark + + Args: + source_dir: 源文件目录(必填,包含 .invoice_cache 子目录)。 + + Returns: + 包含出差事由、地点、交通工具、时间、住宿信息等字段的字典。 + """ + # 仅从 JSON 缓存加载结构化数据 + if not source_dir: + log.warning("未提供 source_dir,无法加载缓存数据") + return {} + + system_prompt = build_travel_info_system_prompt() + + # 构建 source filename -> 缓存数据的映射 + cache_map = load_cache(source_dir) + + # 加载发票与支付记录的匹配结果 + match_result = load_match_result(source_dir) + + # 拼接纯文本消息 + parts = [ + "以下是本次报销的所有源文件及其提取出的结构化数据。" + "每个源文件的数据来自 OCR 识别和发票信息提取,已按文件名分组展示。" + ] + + # 如果有匹配结果,作为额外上下文提供 + if match_result: + parts.append( + "【发票与支付记录匹配结果】" + "以下数据已将发票信息与对应的支付记录进行关联匹配," + "用于判断每笔支付对应的发票和商户信息。\n" + json.dumps(match_result, ensure_ascii=False, indent=2) + ) + + # 按源文件名提供结构化数据 + for filename, extracted in cache_map.items(): + parts.append( + f"【源文件: {filename}】" + "以下为从该文件提取的结构化发票/支付/申请单数据。\n" + json.dumps(extracted, ensure_ascii=False, indent=2) + ) + + parts.append("\n=== 请返回 JSON 格式结果 ===") + user_message = "\n".join(parts) + log.info(f"user_message: {user_message}") + try: + response = _llm_query_multimodal( + system_prompt=system_prompt, + text=user_message, + reasoning_effort="low", + ) + result = _parse_json_response(response) + log.info("LLM 差旅信息提取成功") + return result + except Exception as e: + log.error("LLM 差旅信息提取失败: %s", e) raise diff --git a/src/doc/matcher.py b/src/doc/matcher.py index 32fb9c1..fbe1c25 100644 --- a/src/doc/matcher.py +++ b/src/doc/matcher.py @@ -1,8 +1,11 @@ -"""发票与支付截图匹配 +"""发票与支付记录匹配 -将提取到的发票数据与支付截图中的刷卡记录进行金额匹配, +将提取到的发票数据与支付记录进行金额匹配, 输出以支付记录为主键的结果列表。 +支付记录由统一提取模块(extractor)根据 LLM 返回的「invoice_type」字段分类而来, +不再按文件类型假设文档类型。 + ## 业务约束 - 发票数 >= 付款记录数(最少一张发票对应一张付款记录) @@ -11,7 +14,7 @@ ## 匹配流程 -1. 扫描目录下图片文件,调用 LLM 提取刷卡信息(日期/金额/卡号) +1. 接收分类好的发票和支付记录列表 2. 解析发票和刷卡记录的金额,进行总额校验 - 发票总额 < 刷卡总额时发出 warning 3. 按金额降序排序 @@ -39,27 +42,15 @@ ## 对外接口 - match_invoices_to_cards(invoices, directory, tolerance) -> list[dict] + match_invoices_to_cards(invoices, cards, tolerance) -> list[dict] """ -from pathlib import Path from typing import Any from .. import get_logger -from .llm_extractor import extract_card_info_from_image log = get_logger("matcher") -# 支持的图片扩展名 -IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"} - - -def _find_images(directory: str) -> list[Path]: - """在目录下查找支付截图图片文件""" - dir_path = Path(directory) - images = [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in IMAGE_EXTENSIONS] - return sorted(images) - def _safe_float(value: str | None, default: float = 0.0) -> float: """安全转换为浮点数""" @@ -71,50 +62,37 @@ def _safe_float(value: str | None, default: float = 0.0) -> float: return default -def _extract_all_cards(directory: str) -> list[dict[str, Any]]: - """提取目录下所有支付截图的刷卡信息""" - images = _find_images(directory) - if not images: - log.warning("未找到支付截图图片") - return [] - - log.info(f"发现 {len(images)} 张支付截图") - cards = [] - for img_path in images: - try: - card_info = extract_card_info_from_image(img_path) - card_info["_source_file"] = img_path.name - cards.append(card_info) - log.info(f"[支付截图] 已解析: {img_path.name}") - except Exception as e: - log.warning(f"支付截图解析失败 {img_path.name}: {e}") - - log.info(f"共提取 {len(cards)} 条刷卡记录") - return cards - - def _build_invoice_summary(invoices: list[dict[str, Any]]) -> str: """将多张发票信息汇总为备注字符串""" parts = [] for inv in invoices: - person_name = inv.get("人员姓名") or inv.get("发票号码", "未知") - inv_type = inv.get("发票类型", "未知") - amount = inv.get("价税合计", "未知") - parts.append(f"{inv_type}[{person_name}]¥{amount}") + inv_type = inv.get("invoice_type", "unknown") + amount = inv.get("total_amount", "unknown") + + if inv_type == "train": + label = inv.get("person_name") or inv.get("invoice_number", "unknown") + elif inv_type == "hotel": + label = "hotel" + else: + label = inv.get("item_name") or inv.get("invoice_number", "unknown") + + parts.append(f"{inv_type}[{label}]¥{amount}") return " | ".join(parts) -def _relative_tolerance(base: float, rate: float = 0.05) -> float: - """根据基准金额计算相对容差(默认 5%)""" +def _relative_tolerance(base: float, rate: float = 0.03) -> float: + """根据基准金额计算相对容差(默认 3%)""" return abs(base) * rate def match_invoices_to_cards( invoices: list[dict[str, Any]], - directory: str, + cards: list[dict[str, Any]] | None = None, tolerance: float = 0.03, ) -> list[dict[str, Any]]: - """将发票与支付截图按金额匹配,输出以支付记录为主键的结果列表 + """将发票与支付记录按金额匹配,输出以支付记录为主键的结果列表 + + 支付记录由统一提取模块(extractor)根据 LLM 返回的「invoice_type」字段分类而来。 业务约束: - 发票数 >= 付款记录数 @@ -122,28 +100,26 @@ def match_invoices_to_cards( - 若发票数 == 付款数,一对一匹配,无需一对多 Args: - invoices: 发票列表,需包含 "价税合计" 字段 - directory: 支付截图所在目录 - tolerance: 金额匹配容差比例(默认 0.05 = 5%) + invoices: 发票列表,需包含 "total_amount" 字段 + cards: 支付记录列表,需包含 "card_amount" 字段(由 extractor 分类提供) + tolerance: 金额匹配容差比例(默认 0.03 = 3%) Returns: 以支付记录为主键的结果列表,每条记录包含: - - 刷卡日期、公务卡号、刷卡金额(支付信息) + - card_date, card_no, card_amount(支付信息) - 关联发票列表(_matched_invoices) - 发票详情备注 - 未匹配发票单独作为一条无刷卡信息的记录 """ - cards = _extract_all_cards(directory) if not cards: - log.warning("无刷卡记录可供匹配,发票将保持原状") - # 无刷卡记录时,每张发票作为独立记录返回 + log.warning("无支付记录可供匹配,发票将保持原状") return _invoices_to_records(invoices) # 解析金额 for card in cards: - card["_amount"] = _safe_float(card.get("刷卡金额")) + card["_amount"] = _safe_float(card.get("card_amount")) for inv in invoices: - inv["_amount"] = _safe_float(inv.get("价税合计")) + inv["_amount"] = _safe_float(inv.get("total_amount")) # 数据校验 total_invoices = sum(inv["_amount"] for inv in invoices) @@ -156,8 +132,8 @@ def match_invoices_to_cards( total_tolerance = _relative_tolerance(max(total_invoices, total_cards), tolerance) if total_invoices < total_cards - total_tolerance: log.warning( - f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f})," - f"超出容差 {tolerance * 100:.0f}%,匹配结果可能有偏差" + f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f}), " + f"超出容差 {tolerance * 100:.0f}%, 匹配结果可能有偏差" ) # 按金额降序排序 @@ -193,7 +169,7 @@ def _match( ) -> dict[int, list[int]]: """执行匹配,返回 {card_index: [invoice_indices]} 的映射 - tolerance 为相对容差比例(如 0.05 表示 5%) + tolerance 为相对容差比例(如 0.03 表示 3%) """ result: dict[int, list[int]] = {} assigned: set[int] = set() @@ -213,10 +189,7 @@ def _match_one_to_one( assigned: set[int], result: dict[int, list[int]], ) -> None: - """一对一匹配:发票数等于刷卡数,按金额从大到小依次配对 - - tolerance 为相对容差比例,以刷卡金额为基准计算 - """ + """一对一匹配:发票数等于刷卡数,按金额从大到小依次配对""" for card_idx, card in enumerate(cards): if card_idx >= len(invoices): break @@ -227,13 +200,13 @@ def _match_one_to_one( assigned.add(card_idx) result[card_idx] = [card_idx] log.info( - f"[一对一] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} " - f"↔ {card.get('_source_file', '未知')} ¥{card['_amount']:.2f}" + f"[一对一] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} " + f"↔ {card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}" ) else: log.warning( f"[一对一] 金额偏差超出容差: " - f"{inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} " + f"{inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} " f"vs ¥{card['_amount']:.2f} (差 ¥{diff:.2f}, 容差 ¥{card_tol:.2f})" ) @@ -245,14 +218,7 @@ def _match_one_to_many( assigned: set[int], result: dict[int, list[int]], ) -> None: - """一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配 - - tolerance 为相对容差比例(如 0.05 表示 5%),以刷卡金额为基准计算 - - 匹配分两阶段: - 1. 精确匹配:先扫描金额完全相等(差值 <= 0.01 元)的发票-刷卡对,直接锁定 - 2. 贪心匹配:剩余未分配的发票和刷卡记录走贪心凑金额 - """ + """一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配""" # ---- 阶段 1:精确匹配(金额差 <= 0.01 元视为相等)---- exact_tolerance = 0.01 @@ -272,21 +238,20 @@ def _match_one_to_many( assigned.add(idx) result[card_idx] = [idx] log.info( - f"[一对多-精确] {inv.get('发票号码', '未知')} ¥{inv_amount:.2f} " - f"↔ {card.get('_source_file', '未知')} ¥{card_amount:.2f}" + f"[一对多-精确] {inv.get('invoice_number', 'unknown')} ¥{inv_amount:.2f} " + f"↔ {card.get('_source_file', 'unknown')} ¥{card_amount:.2f}" ) - break # 每张刷卡只精确匹配一张发票 + break # ---- 阶段 2:贪心匹配(仅处理未精确匹配的刷卡记录)---- for card_idx, card in enumerate(cards): - if card_idx in result: # 已在阶段 1 精确匹配 + if card_idx in result: continue card_amount = card["_amount"] if card_amount <= 0: continue - # 以刷卡金额为基准计算相对容差 card_tol = _relative_tolerance(card_amount, tolerance) remaining = card_amount @@ -302,8 +267,6 @@ def _match_one_to_many( if inv_amount <= 0: continue - # 最后一张发票:金额 + 容差 >= remaining 即可 - # 中间发票:金额不超过 remaining + 容差 if inv_amount + card_tol >= remaining: is_match = True else: @@ -328,8 +291,8 @@ def _match_one_to_many( for idx in matched_indices: inv = invoices[idx] log.info( - f"[一对多-贪心] {inv.get('发票号码', '未知')} ¥{inv['_amount']:.2f} " - f"→ {card.get('_source_file', '未知')} ¥{card['_amount']:.2f}" + f"[一对多-贪心] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} " + f"→ {card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}" ) @@ -341,18 +304,18 @@ def _build_payment_records( """构建以支付记录为主键的结果列表""" records: list[dict[str, Any]] = [] - # 已有匹配记录的支付 for card_idx, inv_indices in card_to_invoices.items(): card = cards[card_idx] matched_invs = [invoices[idx] for idx in inv_indices] record = { - "刷卡日期": card.get("刷卡日期", ""), - "公务卡号": card.get("公务卡号", ""), - "刷卡金额": str(card["_amount"]), - "关联发票数": str(len(matched_invs)), - "发票详情": _build_invoice_summary(matched_invs), - "备注": "", + "card_date": card.get("card_date", ""), + "card_no": card.get("card_no", ""), + "card_amount": str(card["_amount"]), + "relative_invoice_count": str(len(matched_invs)), + "invoice_detail": _build_invoice_summary(matched_invs), + "remark": "", + "_source_file": card.get("_source_file", ""), "_matched_invoices": matched_invs, } records.append(record) @@ -365,12 +328,12 @@ def _build_payment_records( unmatched = [inv for idx, inv in enumerate(invoices) if idx not in matched_indices] for inv in unmatched: record = { - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "关联发票数": "1", - "发票详情": _build_invoice_summary([inv]), - "备注": "未匹配到支付记录", + "card_date": "", + "card_no": "", + "card_amount": "", + "relative_invoice_count": "1", + "invoice_detail": _build_invoice_summary([inv]), + "remark": "unmatched", "_matched_invoices": [inv], } records.append(record) @@ -383,12 +346,12 @@ def _invoices_to_records(invoices: list[dict[str, Any]]) -> list[dict[str, Any]] records = [] for inv in invoices: record = { - "刷卡日期": "", - "公务卡号": "", - "刷卡金额": "", - "关联发票数": "1", - "发票详情": _build_invoice_summary([inv]), - "备注": "", + "card_date": "", + "card_no": "", + "card_amount": "", + "relative_invoice_count": "1", + "invoice_detail": _build_invoice_summary([inv]), + "remark": "", "_matched_invoices": [inv], } records.append(record) diff --git a/src/doc/pdf.py b/src/doc/pdf.py index 9388280..49519d5 100644 --- a/src/doc/pdf.py +++ b/src/doc/pdf.py @@ -1,42 +1,46 @@ -"""PDF 文件发现与文本提取 +"""PDF 图片渲染 -从 PDF 发票文件中提取原始文本内容。 +从 PDF 发票文件中渲染为图片供多模态 LLM 使用。 对外接口: - find_pdf_files(directory) -> list[Path] 查找目录下所有 PDF - extract_text_from_pdf(filepath) -> str 提取 PDF 文本 + render_pdf_to_images(filepath, dpi) -list[str] 渲染 PDF 为图片字节 """ +import base64 from pathlib import Path +import fitz + from .. import get_logger log = get_logger("pdf") -def find_pdf_files(directory: str = ".") -> list[Path]: - """查找目录下所有 PDF 文件(非递归)""" - pdf_dir = Path(directory) - if not pdf_dir.exists(): - return [] - return sorted(pdf_dir.glob("*.pdf")) +def render_pdf_to_images(filepath: Path, dpi: int = 300) -> list[str]: + """将 PDF 渲染为图片,返回 base64 编码的 JPEG 字符串列表。 + Args: + filepath: PDF 文件路径。 + dpi: 渲染分辨率(默认 150,平衡质量与速度)。 -def extract_text_from_pdf(filepath: Path) -> str: - """从单个 PDF 中提取全部文本""" + Returns: + base64 编码的 JPEG 图片字符串列表(每页一个)。 + """ + images = [] try: - import pdfplumber - except ImportError as err: - raise ImportError("缺少 pdfplumber,请执行: uv pip install pdfplumber") from err + doc = fitz.open(filepath) + zoom = dpi / 72.0 # 72 DPI 是 fitz 默认 + matrix = fitz.Matrix(zoom, zoom) - try: - parts = [] - with pdfplumber.open(filepath) as pdf: - for page in pdf.pages: - text = page.extract_text() - if text: - parts.append(text) - return "\n".join(parts) + for page in doc: + pix = page.get_pixmap(matrix=matrix) + jpg_bytes = pix.tobytes("jpg") + b64 = base64.b64encode(jpg_bytes).decode("utf-8") + images.append(b64) + + doc.close() + log.info(f"PDF 渲染成功: {filepath.name} ({len(images)} 页, {dpi} DPI)") except Exception as e: - log.error(f"无法读取 {filepath.name}: {e}") - return "" + log.error(f"PDF 渲染失败 {filepath.name}: {e}") + + return images diff --git a/src/doc/prompt.py b/src/doc/prompt.py index 8419306..21d3dc0 100644 --- a/src/doc/prompt.py +++ b/src/doc/prompt.py @@ -21,6 +21,6 @@ def build_invoice_system_prompt() -> str: return _load_prompt("invoice_system.md") -def build_card_info_system_prompt() -> str: - """构建支付截图信息提取系统提示词。""" - return _load_prompt("card_info_system.md") +def build_travel_info_system_prompt() -> str: + """构建差旅信息提取系统提示词。""" + return _load_prompt("travel_info_system.md") diff --git a/src/doc/prompts/README.md b/src/doc/prompts/README.md new file mode 100644 index 0000000..17cffe0 --- /dev/null +++ b/src/doc/prompts/README.md @@ -0,0 +1,20 @@ +--- +last_reviewed: 2026-06-11 +--- + +# src/doc/prompts — LLM 提示词模板 + +存放 LLM 信息提取使用的系统提示词模板文件,由 `src/doc/prompt.py` 动态加载。 + +## 模板清单 + +| 文件 | 用途 | +|------|------| +| `invoice_system.md` | 发票提取系统提示词:指导 LLM 从发票图片、支付截图、出差申请单等文档中提取结构化信息 | +| `travel_info_system.md` | 差旅信息提取系统提示词:指导 LLM 整合已结构化的发票信息、付款记录和出差申请单,生成差旅报销所需的结构化数据 | + +## 加载方式 + +```python +from src.doc.prompt import build_invoice_system_prompt, build_travel_info_system_prompt +``` \ No newline at end of file diff --git a/src/doc/prompts/card_info_system.md b/src/doc/prompts/card_info_system.md deleted file mode 100644 index e9c50d5..0000000 --- a/src/doc/prompts/card_info_system.md +++ /dev/null @@ -1,11 +0,0 @@ -# 支付截图信息提取系统提示词 - -你是财务支付截图信息提取助手。你的任务是从支付截图(银行转账记录、微信/支付宝付款凭证等)中提取结构化信息,并以 JSON 格式返回。 - -需要提取的字段(全部必填,无法识别时返回空字符串): - -1. 刷卡日期: 支付发生的日期,格式为 YYYY/M/D -2. 刷卡金额: 实际支付金额,只保留数字(如 123.45) -3. 公务卡号: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串 - -严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。 \ No newline at end of file diff --git a/src/doc/prompts/invoice_system.md b/src/doc/prompts/invoice_system.md index f5c4307..742161a 100644 --- a/src/doc/prompts/invoice_system.md +++ b/src/doc/prompts/invoice_system.md @@ -1,33 +1,88 @@ -# 发票提取系统提示词 +你是财务文档信息提取助手。你的任务是从图片中提取结构化信息,可能是支付截图、银行转账记录、微信/支付宝付款凭证等,也可能是发票文件,也可能是出差事前申请单,不管任何形式都要用统一的 JSON 格式返回信息。 -你是财务文档信息提取助手。你的任务是从发票文本中提取结构化信息,并以 JSON 格式返回。 +第一步要先判断是,支付记录、高铁票、酒店住宿,普通发票,然后不同类型输出的信息不同。 -需要提取的字段(全部必填,无法识别时返回空字符串): +## 输出示例 + +以下是火车票类型发票的完整示例: +```json +{ + "invoice_type": "train", //必填项 + "invoice_number": "26349119343000335414", + "invoice_date": "2026-06-05", //必填项 + "ride_date": "2026-06-02", //必填项 + "departure": "阜阳西", //必填项 + "arrival": "合肥南", //必填项 + "seat_class": "二等座", + "train_no": "G1967", + "person_name": "王建锋", //必填项 + "total_amount": "115.50" //必填项 +} +``` +以下是支付记录的完整示例: +```json +{ + "invoice_type": "payment",//必填项 + "card_date": "2026-06-01",//必填项 + "card_amount": "231.00",//必填项 + "card_no": "6282****1682" +} +``` +以下是酒店住宿发票的完整示例: +```json +{ + "invoice_type": "hotel",//必填项 + "invoice_number": "26342000001715702281", + "invoice_date": "2026-06-03", + "total_amount": "536.00"//必填项 +} +``` +## 重要规则 +- **必填项**:invoice_type、invoice_date、ride_date、departure、arrival、person_name、total_amount 字段为必填,必须填写。 +- **空值处理**:可选字段如没有对应信息,返回空字符串;金额字段找不到才填`0`,否则尽量填写实际金额。 + +**支付记录**:如果是支付截图、银行转账记录、微信/支付宝付款凭证大概率就是支付记录,请返回如下字段(全部必填,无法识别时返回空字符串): +1. invoice_type: "payment" +2. card_date: 支付发生的日期,格式为 YYYY-M-D +3. card_amount: 实际支付金额,只保留数字(如 123.45) +4. card_no: 付款银行卡号,如果截图中有显示则提取,没有则返回空字符串 + +**出差事前申请单**,如果是**出差事前申请单**请返回如下字段: +1. invoice_type: "application" +2. project_name:通常是(项目编号/项目名称) +2. purpose:一段文本描述 +3. start_date:格式为 YYYY-M-D +4. end_date:格式为 YYYY-M-D +5. person_info,包含: + 1. person_id:字母+数字 + 2. person_name:有编号就肯定由姓名 + +发票需要提取的字段(全部必填,无法识别时返回空字符串): 先判断发票类型,如果是高铁票/或者火车票,返回如下字段: -1. 发票类型: "高铁票" -2. 发票号码: 发票的唯一编号 -3. 开票日期: 格式为 YYYY/M/D -4. 乘车日期: 格式为 YYYY/M/D -5. 出发站: 没有留空 -6. 到达站: 没有留空 -7. 座位等级: 没有留空 -8. 车次: 没有留空 -9. 人员姓名: 没有留空 -10. 价税合计:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息 +1. invoice_type: "train" +2. invoice_number: 发票的唯一编号 +3. invoice_date: 格式为 YYYY-M-D +4. ride_date: 格式为 YYYY-M-D +5. departure: 没有留空 +6. arrival: 没有留空 +7. seat_class: 没有留空 +8. train_no: 没有留空 +9. person_name: 没有留空 +10. total_amount:就是票价,找不到票价信息才填`0`,能够找到尽量填写找到的信息 如果是酒店住宿(酒店住宿通产包含关键字:住宿服务,酒店,生产生活服务等,请仔细分析,这种发票和普通发票类似),返回如下字段: -1. 发票类型: "酒店住宿" -2. 发票号码: 发票的唯一编号 -3. 开票日期: 格式为 YYYY/M/D -4. 价税合计: 金额数字 +1. invoice_type: "hotel" +2. invoice_number: 发票的唯一编号 +3. invoice_date: 格式为 YYYY-M-D +4. total_amount: 金额数字 如果是普通发票,返回如下字段: -1. 发票类型: "普通发票" -2. 发票号码: 发票的唯一编号 -3. 开票日期: 格式为 YYYY/M/D -4. 项目名称: 商品或服务名称,总结的人能看懂 -5. 规格型号: 规格描述 -6. 价税合计: 金额数字 -7. 销售方名称: 卖方全称 +1. invoice_type: "general" +2. invoice_number: 发票的唯一编号 +3. invoice_date: 格式为 YYYY-M-D +4. item_name: 商品或服务名称,总结的人能看懂 +5. spec_model: 规格描述 +6. total_amount: 金额数字 +7. seller_name: 卖方全称 -严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。 \ No newline at end of file +**千万注意!千万注意!**:严格只输出 JSON,不要输出任何其他文字、Markdown 标记或解释。 \ No newline at end of file diff --git a/src/doc/prompts/travel_info_system.md b/src/doc/prompts/travel_info_system.md new file mode 100644 index 0000000..2fd40b0 --- /dev/null +++ b/src/doc/prompts/travel_info_system.md @@ -0,0 +1,123 @@ +# 差旅信息提取系统提示词 + +你是财务差旅信息提取助手。你的任务是根据发票信息、付款记录,提取出差相关的结构化信息,并以严格符合以下类型要求的 JSON 格式返回。所有类型约束为最高优先级规则,任何情况下不得违反。 + +🔴 最高优先级:强制性类型约束(优先级高于所有其他规则) +1. 根节点必须包含且仅包含以下 6 个字段,字段类型绝对不可变更: + + | 字段名 | 强制类型 | 空值处理规则 | + | ------ | --------- | ------------------ | + | `basic_info` | 对象 (dict) | 必填,所有子字段必须完整存在 | + | `reimbursement_details` | 对象 (dict) | 必填,必须且仅包含以下 3 个子字段 | + | `payment_methods` | 数组 (list) | 必填,无数据时赋值为`[]` | + | `subsidy_list` | 数组 (list) | 必填,无数据时赋值为`[]` | + | `attachments` | 数组 (list) | 必填,无数据时赋值为`[]` | +2. `reimbursement_details`对象必须包含且仅包含以下 3 个子字段,每个子字段必须是数组类型: + + |字段名|强制类型|空值处理规则| + |---|---|---| + |`transport_fee`|数组 (list)|无数据时赋值为`[]`| + |`hotel_fee`|数组 (list)|无数据时赋值为`[]`| + |`conference_fee`|数组 (list)|无数据时赋值为`[]`| +3. 绝对禁止以下行为: +* 省略上述任何一个根节点字段或报销明细的子字段 +* 将数组类型的字段赋值为null、字符串、数字或对象 +* 在报销明细中添加任何未定义的子字段 +* 合并不同模块的数组数据 +✅ 正确类型示例 +```json +{ + "basic_info": {...}, + "reimbursement_details": { + "transport_fee": [{"vehicle_type":"train", ...}], + "hotel_fee": [], + "conference_fee": [] + }, + "payment_methods": [], + "subsidy_list": [{"person_name":"张三", ...}], + "attachments": [{"filename":"发票.pdf", ...}] +} +``` +❌ 错误类型示例(绝对禁止) +```json +{ + "basic_info": {...}, + "reimbursement_details": { + "transport_fee": [{"vehicle_type":"train", ...}] + // 错误:省略了hotel_fee和conference_fee字段 + }, + "payment_methods": null, // 错误:数组类型不能为null + "subsidy_list": "" // 错误:数组类型不能为字符串 + // 错误:省略了attachments字段 +} +``` + +## 输入数据说明 +你会收到以下数据: +1. **发票信息**:包含高铁票(火车/飞机票)和酒店住宿发票的结构化提取数据 +2. **付款记录**:包含刷卡日期、刷卡金额、公务卡号等信息 +3. **出差事前申请单**(可选):包含项目名称、出差事由、出差时间、出差人员等信息 + +需要提取的信息: +1. `basic_info`:(必填,每一项都必须填,给出合理的猜测) + 1. `travel_purpose`:如果有出差事前申请单,优先使用申请单中的出差事由;否则根据所有发票信息总结一个合理的出差事由(如"参加XX学术会议"、"前往XX办理公务"等) + 2. `travel_location`:出差目的地,注意一定是从阜阳出发,根据交通工具出发点和目的地也可以推断得到出差地点,出差事前申请单也有说明 + 3. `start_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D + 4. `end_date`:由交通工具发票的乘车日期推断,没有的话从一切可以知道的信息推断,格式 YYYY-M-D +2. `reimbursement_details`:(至少有一项) + 1. `transport_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式) + 1. `vehicle_type`:从以下选项中选择最符合的一个:train、car、ship、personal_car、official_car、plane、rental_car、self_drive + 2. `start_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D + 3. `end_date`: 由交通工具发票的乘车日期填写,格式 YYYY-M-D + 4. `departure_place`:由交通工具发票的信息填写,通常是城市名称 + 5. `arrival_place`:由交通工具发票的信息填写,通常是城市名称 + 6. `amount`:由交通工具发票的信息填写,通常是城市名称 + 7. `bill_count`:由交通工具发票的信息填写,通常是城市名称 + 8. `remark`:填写基本信息,例如:王建锋和张国庆高铁票 + 2. `hotel_fee`:(如有,每一项都要必填,无直接信息时给出合理猜测;多项请采用上述通用 JSON 数组格式) + 1. `checkin_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具离开阜阳时间为最高优先级)综合推断,格式 YYYY-M-D,例如:2026-06-01 + 2. `checkout_date`:(酒店发票,通常不含)、(交通工具发票,优先级最高)、(出差事前申请单,时间有可能不对,实际不一定按照规划的进行,以交通工具回阜阳时间为最高优先级)综合推断,格式 YYYY-M-D,例如:2026-06-03 + 3. `days`:结束日期 - 开始日期,整数,例如 2026-06-03 - 2026-06-01,天数为 2 天 + 4. `person_count`:根据发票信息和车票信息综合判断住宿人数,有可能开成一张发票,人数一定是整数 + 5. `invoice_amount`:所有酒店住宿发票的价税合计总额,数字 + 6. `reimburse_amount`:所有酒店住宿付款记录的合计总额,数字 + 7. `remark`:根据所有信息综合判断住宿人员,然后就填写所有人姓名,例如:王建锋、张国庆住宿 + 3. `conference_fee`(如果有,每一项都要必填,给出合理的猜测) + 1. `bill_count`:根据发票信息判断,有几张关于会务费培训费的发票,一定是整数 + 2. `amount`:会务费培训发票的总金额 + 3. `remark`:会务培训的基本信息 + +4. `payment_methods`:(多少笔支付记录就有多少条;多项请采用上述通用 JSON 数组格式) + 1. `card_date`:根据付款记录,格式 YYYY-M-D + 2. `card_amount`:根据付款记录填写,单位为元,数字 + 3. `merchant`:根据发票信息推测商户信息(高铁票统一为中国铁路) + 4. `remark`:说明该笔付款关联的发票信息,例如:王建锋和张国庆从阜阳西 - 合肥南高铁票 +5. `subsidy_list`:(必填;多项请采用上述通用 JSON 数组格式) + 1. `person_id`:无直接信息时给出合理编号 + 2. `person_name`:根据车票、住宿等信息推断出差人员姓名 + 3. `start_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D,例如:2026-06-01 + 4. `end_date`:根据当前人员的来回的交通工具发票上的时间推断,如果没有依据基本信息中的日期信息,格式 YYYY-M-D,例如:2026-06-03 + 5. `days`:结束日期 - 开始日期 + 1,整数(例如:2026-06-03 - 2026-06-01 + 1,天数为 3 天) +6. `attachments`:(必填,用户已经告诉你所有文件了`【源文件: {filename}】`,"invoice_type": "payment"的不作为附件) + 1. `filename`: 严格使用用户提供的原始文件名,不得修改任何字符 + 2. `attachment_type`:从以下两个选项中选择:invoice、other + 3. `attachment_desc`:简要描述该文件的基本信息 + +**推理规则**: +- 补助清单由人员数量决定:例如`[{"person_id": "xxxxxxx", "person_name": "张三", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}, {"person_id": "2024xxxxx", "person_name": "李四", "start_date":"2026-06-01", "end_date": "2026-06-03", "days": 3}]` +- 支付方式示例:`[{"card_date": "2026-06-01","card_amount": 231.0,"merchant": "中国铁路网络有限公司","remark": "张国庆和王建锋从阜阳西-合肥南高铁票"},{"card_date": "2026-06-01","card_amount": 167.0,"merchant": "中国铁路网络有限公司","remark": "陈曙光从阜阳西-合肥南高铁票"}]` +- 交通费,去和回不能放在一起,最好放在两个交通费单里,去时放一个,回时放一个 +- 如果有出差事前申请单,优先使用申请单中的出差事由 +- 出差开始时间优先取最早的交通工具乘车日期,无交通工具发票时参考申请单时间 +- 出差结束时间优先取最晚的交通工具乘车日期,无交通工具发票时参考申请单时间 +- 若无交通工具发票,用开票日期和付款日期综合判断 +- 住宿天数 = checkout_date - checkin_date 结果要大于等于 0 +- 若只有单张酒店发票且无明确天数信息,住宿天数默认为 1 +- 若只有单张酒店发票且无明确人数信息,住宿人数默认为 1 +- 支付记录不放在附件中! + +## 最终输出要求 +* 严格只输出符合上述所有要求的 JSON 字符串 +* 不要输出任何思考过程、解释文字、Markdown 标记或其他内容 +* 输出的 JSON 必须语法正确,无多余逗号、引号等语法错误 +* 必须严格遵守所有强制性类型约束,任何违反类型要求的输出均视为无效 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 71cdb83..5518acc 100644 --- a/src/main.py +++ b/src/main.py @@ -37,13 +37,18 @@ def main() -> None: "-u", "--username", default=None, - help="信息门户登录账号(覆盖 config.json)", + help="信息门户登录账号(覆盖 scripts/config.json)", ) parser.add_argument( "-p", "--password", default=None, - help="信息门户登录密码(覆盖 config.json)", + help="信息门户登录密码(覆盖 scripts/config.json)", + ) + parser.add_argument( + "--cache-dir", + default=None, + help="发票缓存目录(包含 .invoice_cache 子目录,默认: scripts/data)", ) args = parser.parse_args() @@ -51,6 +56,7 @@ def main() -> None: step=args.step, username=args.username, password=args.password, + cache_dir=args.cache_dir, ) sys.exit(exit_code) diff --git a/src/pipeline.py b/src/pipeline.py index 021d218..b5269b0 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -5,8 +5,8 @@ 数据在内存中流转,同时生成 CSV 中间产物。 发票类型区分: - - 差旅发票(高铁票/酒店住宿):不生成易耗品出库单,走差旅报销流程 - - 普通发票:生成易耗品出库单,走普通报销流程 + - 差旅发票(train/hotel):不生成易耗品出库单,走差旅报销流程 + - 普通发票(general):生成易耗品出库单,走普通报销流程 """ from pathlib import Path @@ -16,52 +16,57 @@ from . import get_logger from .config import load_config from .doc.extractor import extract_invoices from .doc.invoice import ( - classify_invoice_batch, - load_invoices_from_csv, + save_application_json, save_invoice_csv, ) from .doc.invoice import ( save_csv as save_payment_csv, ) + +def _classify_invoice_batch( + invoices: list[dict[str, str]], +) -> dict[str, list[dict[str, str]]]: + """按发票类型分组""" + travel: list[dict[str, str]] = [] + general: list[dict[str, str]] = [] + application: list[dict[str, str]] = [] + for inv in invoices: + inv_type = inv.get("invoice_type", "general") + if inv_type == "application": + application.append(inv) + elif inv_type in ("train", "hotel"): + travel.append(inv) + else: + general.append(inv) + return {"travel": travel, "general": general, "application": application} + + log = get_logger("pipeline") -def _find_upload_directory(project_dir: Path) -> Path | None: - """自动发现 uploads 目录下最新且包含 PDF 的会话文件夹""" - uploads_base = project_dir / "src" / "web" / "uploads" - if not uploads_base.is_dir(): - return None +def _classify_from_cache(cache_path: Path) -> dict[str, list[dict[str, Any]]]: + """从缓存目录读取发票数据并按类型分组""" + from .doc.llm_extractor import load_cache - # 只选包含 PDF 的目录 - valid_dirs = [d for d in uploads_base.iterdir() if d.is_dir() and list(d.glob("*.pdf"))] - if not valid_dirs: - return None - - # 按修改时间排序,取最新 - session_dirs = sorted( - valid_dirs, - key=lambda d: d.stat().st_mtime, - reverse=True, - ) - return session_dirs[0] + cache_map = load_cache(cache_path) + invoices = [data for data in cache_map.values() if data.get("invoice_type") not in ("application", "payment")] + return _classify_invoice_batch(invoices) -def _classify_from_csv(csv_path: Path) -> dict[str, list[dict[str, Any]]]: - """从已生成的 CSV 中读取发票数据并按类型分组""" - rows = load_invoices_from_csv(csv_path) - if rows is None: - return {"travel": [], "general": []} - return classify_invoice_batch(rows) - - -def run_pipeline(step: str = "all", username: str | None = None, password: str | None = None) -> int: +def run_pipeline( + step: str = "all", + username: str | None = None, + password: str | None = None, + cache_dir: str | None = None, +) -> int: """执行报销流程 Args: step: all | invoice | submit username: 覆盖 config.json 中的用户名 password: 覆盖 config.json 中的密码 + cache_dir: 发票缓存目录(包含 .invoice_cache 子目录) """ config = load_config() if username: @@ -69,8 +74,8 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str | if password: config["password"] = password - # 工作目录(项目根目录) project_dir = Path(__file__).parent.parent + cache_path = Path(cache_dir) if cache_dir else project_dir / "scripts" / "data" # -------------------------------------------------- # Step 1: 发票提取 @@ -83,15 +88,17 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str | log.info("[1/2] 发票提取") log.info("=" * 60) - payment_records, groups = extract_invoices(str(project_dir)) + payment_records, applications, groups = extract_invoices(str(cache_path)) if not payment_records: log.error("未提取到任何发票数据") return 1 - save_payment_csv(payment_records, project_dir / "payment_records.csv") - save_invoice_csv(payment_records, project_dir / "invoice_summary.csv") + save_payment_csv(payment_records, cache_path / "payment_records.csv") + save_invoice_csv(payment_records, cache_path / "invoice_summary.csv") + + if applications: + save_application_json(applications, cache_path / "travel_applications.json") - # 打印分类结果 log.info(f"发票分类: 差旅 {len(groups['travel'])} 张, 普通 {len(groups['general'])} 张") if step == "invoice": @@ -106,30 +113,22 @@ def run_pipeline(step: str = "all", username: str | None = None, password: str | log.info("[2/2] 报销提交") log.info("=" * 60) - from .bot import load_invoice_data, run_bot + from .bot import run_bot - csv_path = project_dir / "payment_records.csv" - bot_invoices = load_invoice_data(str(csv_path), config) - - # 根据发票类型选择填报模式 if groups is None: - groups = _classify_from_csv(csv_path) + groups = _classify_from_cache(cache_path) if groups["travel"] and not groups["general"]: log.info("检测到纯差旅发票,使用差旅报销模式") - # TODO: 差旅报销填报流程 - run_bot(config, bot_invoices) + run_bot(config, work_dir=cache_path) else: log.info("检测到普通发票,使用普通报销模式") - run_bot(config, bot_invoices) + run_bot(config, work_dir=cache_path) if step == "submit": log.info("[2/2] 报销提交 完成") return 0 - # -------------------------------------------------- - # 全流程完成 - # -------------------------------------------------- log.info("=" * 60) log.info("全流程执行完毕") log.info("=" * 60) diff --git a/src/web/README.md b/src/web/README.md index e0c9336..afbf7bf 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -11,7 +11,6 @@ last_reviewed: 2026-06-09 - **会话隔离**:每次上传生成独立 `session_id`,文件、日志、配置、结果各自隔离在 `uploads//` 目录下,避免并发冲突。 - **异步处理**:耗时的 PDF 提取、LLM 调用在后台线程执行,前端通过 SSE 实时查看日志流,不阻塞 HTTP 连接。 - **前后端分离最小化**:前端使用原生 JS + Bootstrap 5,不引入构建工具,保持单页应用轻量可维护。 -- **双模式支持**:PDF 发票提取模式和 CSV 快捷上传模式,后者跳过 LLM 识别和 PDF 解析,直接处理已有发票数据。 ## 文件结构 @@ -51,7 +50,6 @@ src/web/ | GET | `/` | 主界面 | | POST | `/api/session` | 创建会话,返回 session_id | | POST | `/api/upload/` | 上传 PDF/图片 | -| POST | `/api/upload-csv/` | 上传 CSV 发票数据 | | GET | `/api/files/` | 列出会话文件 | | POST | `/api/process/` | 启动管道(后台线程) | | GET | `/api/logs/` | SSE 日志流 | diff --git a/src/web/app.py b/src/web/app.py index f87a331..803d941 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -38,13 +38,32 @@ from src.doc.fill_consumable_doc import ( # noqa: E402, I001 fill_consumable_from_template, ) from src.doc.invoice import ( # noqa: E402, I001 - classify_invoice_batch, load_csv, load_invoice_csv, save_csv as save_payment_csv, save_invoice_csv, + save_application_json, ) + +def _classify_invoice_batch( + invoices: list[dict[str, str]], +) -> dict[str, list[dict[str, str]]]: + """按发票类型分组""" + travel: list[dict[str, str]] = [] + general: list[dict[str, str]] = [] + application: list[dict[str, str]] = [] + for inv in invoices: + inv_type = inv.get("invoice_type", "general") + if inv_type == "application": + application.append(inv) + elif inv_type in ("train", "hotel"): + travel.append(inv) + else: + general.append(inv) + return {"travel": travel, "general": general, "application": application} + + fill_log = get_logger("fill_consumable_doc") CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME @@ -161,7 +180,7 @@ def _has_general_invoices(rows: list[dict[str, str]]) -> bool: invoices.append(row) if not invoices: return False - groups = classify_invoice_batch(invoices) + groups = _classify_invoice_batch(invoices) return len(groups["general"]) > 0 @@ -179,7 +198,7 @@ def _get_invoice_groups(rows: list[dict[str, str]]) -> dict[str, int]: # 发票级别格式:直接使用 elif "发票类型" in row: invoices.append(row) - groups = classify_invoice_batch(invoices) + groups = _classify_invoice_batch(invoices) return { "travel_count": len(groups["travel"]), "general_count": len(groups["general"]), @@ -250,14 +269,18 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any start = time.time() # ---- Step 1: 发票提取 ---- - invoices, groups = extract_invoices(str(session_dir)) + invoices, applications, groups = extract_invoices(str(session_dir)) if not invoices: return {"ok": False, "error": "未提取到任何发票数据"} - # 保存两个 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考) + # 保存 CSV:支付记录级别(供 bot/出库单使用)和发票级别(供人工参考) save_payment_csv(invoices, session_dir / "payment_records.csv") save_invoice_csv(invoices, session_dir / "invoice_summary.csv") + # 出差申请单单独保存 + if applications: + save_application_json(applications, session_dir / "travel_applications.json") + # 统计发票总数 invoice_count = sum(len(inv.get("_matched_invoices", [])) for inv in invoices) @@ -276,50 +299,6 @@ def run_pipeline_web(session_dir: Path, config: dict[str, Any]) -> dict[str, Any return result -def run_csv_pipeline_web(session_dir: Path, config: dict[str, Any], csv_filename: str) -> dict[str, Any]: - """直接使用上传的 CSV 文件,跳过 PDF 提取""" - start = time.time() - - csv_path = session_dir / csv_filename - if not csv_path.exists(): - return {"ok": False, "error": "CSV 文件不存在"} - - # 尝试读取支付记录格式 - rows = load_csv(csv_path) - if rows is None: - # 尝试读取发票级别格式 - invoice_rows = load_invoice_csv(csv_path) - if invoice_rows is None: - return {"ok": False, "error": "CSV 读取失败"} - # 发票级别格式:直接统计 - type_stats = _get_invoice_groups(invoice_rows) - elapsed = time.time() - start - return { - "ok": True, - "elapsed": f"{elapsed:.1f}s", - "invoice_count": len(invoice_rows), - "csv_url": f"/api/download/{session_dir.name}/{csv_filename}", - "travel_count": type_stats["travel_count"], - "general_count": type_stats["general_count"], - } - - # 支付记录格式:统计发票类型 - type_stats = _get_invoice_groups(rows) - - elapsed = time.time() - start - result = { - "ok": True, - "elapsed": f"{elapsed:.1f}s", - "invoice_count": type_stats["travel_count"] + type_stats["general_count"], - "csv_url": f"/api/download/{session_dir.name}/{csv_filename}", - "travel_count": type_stats["travel_count"], - "general_count": type_stats["general_count"], - } - doc_fill = _try_fill_consumable_doc(session_dir, config) - _append_doc_download(result, session_dir.name, doc_fill) - return result - - def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, Any]: """执行财务系统填报(从前端确认后调用) @@ -331,9 +310,10 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, if not csv_path.exists(): return {"ok": False, "error": "未找到发票数据,请先处理"} - from src.bot import load_invoice_data, run_bot_web + from src.bot import run_bot_web - bot_invoices = load_invoice_data(str(csv_path), config) + # TODO: 改为从缓存读取或直接请求 LLM,与差旅报销保持一致 + # bot_invoices = load_invoice_data(str(csv_path), config) # 判断发票类型 rows = load_csv(csv_path) @@ -345,7 +325,7 @@ def run_financial_submit(session_dir: Path, config: dict[str, Any]) -> dict[str, else: fill_log.info("检测到普通发票,使用普通报销模式") - run_bot_web(config, bot_invoices, session_dir) + run_bot_web(config, session_dir) return {"ok": True} @@ -384,22 +364,6 @@ def upload_file(session_id: str) -> Any: return jsonify({"ok": True, "filename": safe_name}) -@app.route("/api/upload-csv/", methods=["POST"]) -def upload_csv(session_id: str) -> Any: - """上传 CSV 发票数据文件(跳过 PDF 提取)""" - session_dir = _validate_session(session_id) - if isinstance(session_dir, tuple): - return session_dir - - f = request.files.get("file") - if not f or not f.filename: - return jsonify({"error": "未选择文件"}), 400 - - safe_name = Path(f.filename).name - f.save(str(session_dir / safe_name)) - return jsonify({"ok": True, "filename": safe_name}) - - @app.route("/api/files/", methods=["GET"]) def list_files(session_id: str) -> Any: """列出会话目录中的文件""" @@ -420,7 +384,6 @@ def start_process(session_id: str) -> Any: return session_dir body = request.get_json(silent=True) or {} - mode = body.get("mode", "auto") # "pdf", "csv", or "auto" # 读取配置 config = _build_web_config(body) @@ -435,19 +398,7 @@ def start_process(session_id: str) -> Any: def _run() -> None: result = {"ok": False, "error": "未知错误"} try: - if mode == "csv": - csv_files = list(session_dir.glob("*.csv")) - if not csv_files: - result = {"ok": False, "error": "未找到 CSV 文件"} - else: - result = run_csv_pipeline_web(session_dir, config, csv_files[0].name) - else: - csv_files = list(session_dir.glob("*.csv")) - pdf_files = list(session_dir.glob("*.pdf")) - if csv_files and not pdf_files: - result = run_csv_pipeline_web(session_dir, config, csv_files[0].name) - else: - result = run_pipeline_web(session_dir, config) + result = run_pipeline_web(session_dir, config) except BaseException as e: result = {"ok": False, "error": str(e)} if isinstance(e, KeyboardInterrupt | SystemExit): diff --git a/src/web/static/README.md b/src/web/static/README.md new file mode 100644 index 0000000..30452c4 --- /dev/null +++ b/src/web/static/README.md @@ -0,0 +1,18 @@ +--- +last_reviewed: 2026-06-11 +--- + +# src/web/static — 静态资源目录 + +存放 Web 界面的 CSS 样式表和 JavaScript 前端逻辑。 + +## 文件结构 + +| 路径 | 说明 | +|------|------| +| `css/index.css` | 全局样式:上传区域、日志面板、可编辑表格、状态徽章 | +| `js/index.js` | 前端逻辑:文件上传、SSE 日志监听、发票数据编辑、配置同步、移动端扫码上传、财务提交 | + +## 技术栈 + +原生 JavaScript + Bootstrap 5,无构建工具,保持单页应用轻量可维护。 \ No newline at end of file diff --git a/src/web/static/js/index.js b/src/web/static/js/index.js index 703c7ae..c169be5 100644 --- a/src/web/static/js/index.js +++ b/src/web/static/js/index.js @@ -1,6 +1,5 @@ let sessionId = null; const pdfFiles = [], imgFiles = []; -let csvFile = null; let invoiceData = []; // 当前编辑数据 [{__row, ...fields}] let csvFilename = ''; // 当前 CSV 文件名 let lastDownloadUrls = {}; // 最近一次可下载文件链接 @@ -50,23 +49,6 @@ function renderFileList(type) { ).join(''); } -// ---- CSV 上传 ---- -function handleCsvFile(input) { - const file = input.files[0]; - if (!file) return; - csvFile = file; - document.getElementById('csv-zone').classList.add('active'); - document.getElementById('csv-list').innerHTML = - `${file.name}×`; - input.value = ''; -} - -function removeCsvFile() { - csvFile = null; - document.getElementById('csv-zone').classList.remove('active'); - document.getElementById('csv-list').innerHTML = ''; -} - // ---- 配置上传 ---- function handleConfigUpload(input) { const file = input.files[0]; @@ -122,22 +104,10 @@ function handleConfigUpload(input) { }); }); -// CSV 拖拽 -const csvZone = document.getElementById('csv-zone'); -csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); }); -csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover')); -csvZone.addEventListener('drop', e => { - e.preventDefault(); - csvZone.classList.remove('dragover'); - const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv')); - if (file) handleCsvFile({ files: [file] }); -}); - // ---- 处理 ---- async function startProcess() { - const isCsvMode = !!csvFile; - if (!isCsvMode && !pdfFiles.length && !imgFiles.length) { - alert('请先上传文件或 CSV'); + if (!pdfFiles.length && !imgFiles.length) { + alert('请先上传文件或图片'); return; } @@ -152,17 +122,11 @@ async function startProcess() { try { await ensureSession(); - if (isCsvMode) { + const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))]; + for (const {f} of allFiles) { const fd = new FormData(); - fd.append('file', csvFile); - await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd }); - } else { - const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))]; - for (const {f} of allFiles) { - const fd = new FormData(); - fd.append('file', f); - await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd }); - } + fd.append('file', f); + await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd }); } document.getElementById('status').innerHTML = '处理中...'; @@ -174,7 +138,6 @@ async function startProcess() { default_card_no: document.getElementById('cfg-card').value, default_person_id: document.getElementById('cfg-person-id').value, consumable_storage: document.getElementById('cfg-storage').value, - mode: isCsvMode ? 'csv' : 'auto', }; await fetch(`/api/process/${sessionId}`, { @@ -248,8 +211,13 @@ function showDownloadLinks(result) { ).join(''); if (warn) { - if (result.doc_ok === false && result.doc_error) { + if (result.doc_skipped) { warn.style.display = 'block'; + warn.style.color = '#0d6efd'; + warn.textContent = result.doc_message || '差旅报销无需生成易耗品出库单'; + } else if (result.doc_ok === false && result.doc_error) { + warn.style.display = 'block'; + warn.style.color = ''; warn.textContent = '出库单未生成:' + result.doc_error; } else { warn.style.display = 'none'; @@ -257,7 +225,7 @@ function showDownloadLinks(result) { } } - section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none'; + section.style.display = items.length || (result.doc_ok === false) || result.doc_skipped ? 'block' : 'none'; } // ---- 发票数据编辑 ---- diff --git a/src/web/templates/README.md b/src/web/templates/README.md new file mode 100644 index 0000000..5c90a81 --- /dev/null +++ b/src/web/templates/README.md @@ -0,0 +1,14 @@ +--- +last_reviewed: 2026-06-11 +--- + +# src/web/templates — HTML 模板目录 + +存放 Flask 渲染的 HTML 模板文件。 + +## 模板清单 + +| 文件 | 说明 | +|------|------| +| `index.html` | PC 端主界面:包含文件上传区、配置表单、处理按钮、SSE 日志面板、可编辑发票表格、下载链接、财务提交按钮、移动端二维码 | +| `mobile_upload.html` | 移动端上传页面:支持拍照/相册选择,上传至当前会话 | \ No newline at end of file diff --git a/src/web/templates/index.html b/src/web/templates/index.html index 607bd46..6ef0cba 100644 --- a/src/web/templates/index.html +++ b/src/web/templates/index.html @@ -43,17 +43,6 @@ - -
-
📊 CSV 快捷上传 (已有发票数据 CSV 可直接上传,跳过提取和 LLM 识别)
-
-
📊
-
点击或拖拽上传 CSV 文件
-
-
- -
-
@@ -111,7 +100,7 @@