diff --git a/.gitignore b/.gitignore index e2296db..b14367e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ build/ # Playwright MCP snapshots .playwright-mcp/ +# Local secrets & backups +config.json +*.doc.bak + # Uploads (user data) web/uploads/ diff --git a/API.md b/API.md new file mode 100644 index 0000000..75c9a06 --- /dev/null +++ b/API.md @@ -0,0 +1,463 @@ +# 财务报销自动化 — API 文档 + +> 基础地址: `http://localhost:5000` +> 启动: `python web/app.py` + +## 总览 + +| # | 方法 | 路径 | 说明 | +|---|------|------|------| +| 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/` | 启动处理(提取+OCR+出库单) | +| 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/` | 移动端上传图片 | + +--- + +## 会话与目录 + +- 调用 `POST /api/session` 获得 `session_id` +- 该会话下所有文件存放在 `web/uploads//` +- 典型产物:`invoice_summary.csv`、`invoice_summary.md`、`易耗品、出库单.doc`、`config.json`、`session.log`、`result.json` + +--- + +## 接口详情 + +### 1. 创建会话 + +``` +POST /api/session +``` + +**响应:** + +```json +{ "session_id": "a1b2c3d4e5f6" } +``` + +--- + +### 2. 上传文件(PDF/图片) + +``` +POST /api/upload/ +Content-Type: multipart/form-data +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| file | File | PDF 发票或支付截图 | + +**响应(成功):** + +```json +{ "ok": true, "filename": "1. 电容一批.pdf" } +``` + +**响应(失败):** + +```json +{ "error": "未选择文件" } +``` + +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 提取和 OCR,直接进入处理/编辑流程。 + +--- + +### 4. 列出会话文件 + +``` +GET /api/files/ +``` + +**响应:** + +```json +{ + "pdfs": ["1. 电容一批.pdf"], + "images": ["payment_01.jpg"] +} +``` + +`images` 包含扩展名:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。 + +--- + +### 5. 启动管道处理 + +``` +POST /api/process/ +Content-Type: application/json +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| mode | string | 否 | `"pdf"` / `"csv"` / `"auto"`(默认 `auto`) | +| username | string | 否 | 财务系统工号 | +| password | string | 否 | 登录密码 | +| default_name | string | 否 | 默认报销人姓名 | +| default_card_no | string | 否 | 默认公务卡号 | +| default_person_id | string | 否 | 默认人员编号 | +| consumable_storage | string | 否 | 出库单存放地点;未填则用 `config.json` 中的值 | + +**mode 行为:** + +| mode | 行为 | +|------|------| +| `auto` | 仅有 CSV、无 PDF → CSV 模式;否则 → PDF 提取 + OCR | +| `csv` | 使用已上传 CSV,跳过提取与 OCR | +| `pdf` | 执行 PDF 提取 + OCR | + +**处理内容(PDF 模式):** + +1. 从会话目录 PDF 提取发票信息 → `invoice_summary.csv` / `.md` +2. 对支付截图 OCR,回填刷卡字段 +3. 从项目根目录复制 `易耗品、出库单.doc` 模板到会话目录并自动填写(需 Windows + Word) + +配置会写入 `web/uploads//config.json`。 + +**响应(立即):** + +```json +{ "status": "started" } +``` + +处理在后台线程执行,进度与结果通过 `GET /api/logs/`(SSE)获取。 + +**SSE 完成时 `result` 示例(成功):** + +```json +{ + "ok": true, + "elapsed": "45.2s", + "invoice_count": 4, + "csv_url": "/api/download//invoice_summary.csv", + "md_url": "/api/download//invoice_summary.md", + "doc_url": "/api/download//%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc", + "doc_ok": true +} +``` + +**出库单生成失败时(CSV 等仍可能成功):** + +```json +{ + "ok": true, + "invoice_count": 4, + "csv_url": "/api/download//invoice_summary.csv", + "doc_ok": false, + "doc_error": "服务器未安装 pywin32,无法生成 Word 出库单" +} +``` + +--- + +### 6. SSE 日志流 + +``` +GET /api/logs/ +Accept: text/event-stream +``` + +**日志行格式:** + +``` +data: 2026-05-26 12:00:01 [INFO ] extractor: 正在提取发票... +``` + +**结束消息:** + +```json +{ + "type": "done", + "result": { } +} +``` + +`result` 结构取决于触发来源: + +| 来源 | 典型字段 | +|------|----------| +| `/api/process` | `ok`, `elapsed`, `invoice_count`, `csv_url`, `md_url`, `doc_url`, `doc_ok`, `doc_error` | +| `/api/submit-financial` | `ok`, `submit_ok`, `submit_error` | + +--- + +### 7. 下载文件 + +``` +GET /api/download// +``` + +**响应:** 文件二进制流,带 `Content-Disposition: attachment` 与 UTF-8 文件名。 + +| 扩展名 | Content-Type | +|--------|----------------| +| `.csv` | `text/csv; charset=utf-8` | +| `.md` | `text/markdown; charset=utf-8` | +| `.doc` | `application/msword` | +| 其它 | `application/octet-stream` | + +**常见文件名:** + +| 文件名 | 说明 | +|--------|------| +| `invoice_summary.csv` | 发票汇总(含 OCR 结果) | +| `invoice_summary.md` | Markdown 摘要 | +| `易耗品、出库单.doc` | 自动填写的出库单 | +| 用户上传的 CSV 名 | CSV 快捷模式下的原始文件 | + +**错误:** + +```json +{ "error": "文件不存在" } +``` + +HTTP `404`。`filename` 仅允许会话目录内的文件名(防止路径穿越)。 + +--- + +### 8. 获取发票数据 + +``` +GET /api/data/ +``` + +**响应:** + +```json +{ + "csv_filename": "invoice_summary.csv", + "fields": [ + "序号", "发票号码", "开票日期", "项目名称", "规格型号", + "价税合计", "销售方名称", "人员姓名", "刷卡日期", + "公务卡号", "刷卡金额", "备注", "工号" + ], + "data": [ + { + "__row": 0, + "序号": "1", + "发票号码": "26442000005432755951", + "价税合计": "2900.00" + } + ] +} +``` + +- `fields`:列顺序 +- `data[].__row`:内部行索引(保存时不需要提交,服务端按数组顺序写回) + +**错误:** `404` 未找到 CSV;`500` 读取失败。 + +--- + +### 9. 保存编辑后的发票数据 + +``` +POST /api/save/ +Content-Type: application/json +``` + +**请求体:** + +```json +{ + "csv_filename": "invoice_summary.csv", + "data": [ + { + "序号": "1", + "发票号码": "26442000005432755951", + "开票日期": "2026/05/18", + "项目名称": "...", + "规格型号": "...", + "价税合计": "2900.00", + "销售方名称": "...", + "人员姓名": "", + "刷卡日期": "2026/04/28", + "公务卡号": "", + "刷卡金额": "2850.00", + "备注": "", + "工号": "202407021" + } + ] +} +``` + +**响应(成功):** + +```json +{ + "ok": true, + "doc_ok": true, + "doc_url": "/api/download//%E6%98%93%E8%80%97%E5%93%81%E3%80%81%E5%87%BA%E5%BA%93%E5%8D%95.doc" +} +``` + +保存后会根据最新 CSV **重新生成** 出库单 Word(与会话 `config.json` 中的 `consumable_storage` 等配置一致)。 + +若出库单生成失败: + +```json +{ + "ok": true, + "doc_ok": false, + "doc_error": "出库单模板不存在,请将模板放在项目根目录" +} +``` + +--- + +### 10. 提交到财务系统 + +``` +POST /api/submit-financial/ +``` + +**前置条件:** + +- 会话目录存在 `config.json`(由 `/api/process` 写入) +- 存在可用的发票 CSV(通常为 `invoice_summary.csv`) + +**说明:** 前端一般在提交前调用 `/api/save` 保存表格修改。本接口**不会**自动执行发票提取或 OCR。 + +**响应(立即):** + +```json +{ "status": "started" } +``` + +**SSE 完成示例:** + +```json +{ + "type": "done", + "result": { + "ok": true, + "submit_ok": true + } +} +``` + +失败时 `submit_ok: false`,`submit_error` 为错误描述。 + +--- + +### 11. 移动端上传页面 + +``` +GET /mobile/ +``` + +返回移动端 HTML 页面。扫码上传的图片与 PC 端共用同一会话目录;PC 通过轮询 `GET /api/files/` 同步文件列表。 + +--- + +### 12. 移动端上传图片 + +``` +POST /api/mobile-upload/ +Content-Type: multipart/form-data +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| file | File | 图片文件 | + +逻辑与 `POST /api/upload/` 相同。 + +--- + +## 错误码汇总 + +| HTTP | 场景 | +|------|------| +| 400 | 参数缺失、未找到配置等 | +| 404 | `session_id` 不存在、文件不存在 | +| 500 | CSV 读取失败等内部错误 | + +统一错误体: + +```json +{ "error": "错误描述" } +``` + +--- + +## 端到端流程 + +```mermaid +sequenceDiagram + participant PC as PC 端 + participant Server as Server + participant Mobile as 移动端 + participant Word as Word COM + + PC->>Server: POST /api/session + Server-->>PC: session_id + + PC->>Server: POST /api/upload/{sid} + PC->>Server: POST /api/process/{sid} + Note over Server: PDF 提取 + OCR + 写 config.json + Server->>Word: 复制模板并填写出库单 + Server-->>PC: SSE done (csv_url, doc_url, ...) + + PC->>Server: GET /api/data/{sid} + Server-->>PC: fields + data + PC->>Server: POST /api/save/{sid} + Note over Server: 更新 CSV,重新生成出库单 + Server-->>PC: doc_url + + PC->>Server: GET /api/download/{sid}/易耗品、出库单.doc + + PC->>Server: POST /api/submit-financial/{sid} + Note over Server: Playwright 浏览器填报 + Server-->>PC: SSE done (submit_ok) + + Mobile->>Server: POST /api/mobile-upload/{sid} + PC->>Server: GET /api/files/{sid} (轮询) +``` + +--- + +## 相关 CLI + +不经过 Web、在本地直接填写出库单: + +```bash +python -m app.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc" +``` + +详见 [README.md](./README.md)。 diff --git a/README.md b/README.md index 27aa6f3..28b3972 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,85 @@ # 财务报销自动化 -自动从 PDF 发票提取信息,OCR 识别支付记录截图,然后在财务系统中自动填报报销单。 +自动从 PDF 发票提取信息,OCR 识别支付记录截图,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。 ## 项目结构 ``` -├── run.py # CLI 入口 -├── config.json # 配置文件(登录凭据、系统 URL 等) +├── run.py # CLI 入口(发票提取 → OCR → 浏览器填报) +├── config.json # 配置文件(登录凭据、默认值、存放地点等) +├── 易耗品、出库单.doc # 易耗品出库单 Word 模板(Web/CLI 填写用) ├── app/ -│ ├── config.py # 配置加载 -│ ├── extractor.py # PDF 发票信息提取 -│ ├── ocr.py # OCR 刷卡信息识别 -│ ├── bot.py # 浏览器自动填报 -│ └── pipeline.py # 流程编排(数据在内存中流转) +│ ├── config.py # 配置加载 +│ ├── extractor.py # PDF 发票信息提取 +│ ├── ocr.py # OCR 刷卡信息识别 +│ ├── bot.py # 浏览器自动填报 +│ ├── pipeline.py # CLI 流程编排 +│ └── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM) ├── web/ -│ ├── app.py # Web 服务入口 +│ ├── app.py # Web 服务入口 │ ├── templates/ -│ │ ├── index.html # Web 前端页面 -│ │ └── mobile_upload.html # 移动端扫码上传页面 -│ └── uploads/ # 用户上传文件目录 -├── *.pdf # 发票 PDF(按需放置) -├── *.png / *.jpg # 与 PDF 同名的支付截图 -├── invoice_summary.csv # 中间产物 — 发票汇总表 -└── images/ # 调试截图 +│ │ ├── index.html # PC 端主页 +│ │ └── mobile_upload.html # 移动端扫码上传 +│ ├── static/ # 前端 CSS / JS +│ └── uploads/ # 按会话隔离的上传与产物目录 +├── *.pdf # 发票 PDF(CLI 模式,放在项目根目录) +├── *.png / *.jpg # 与 PDF 配对的支付截图 +├── invoice_summary.csv # 发票汇总表(CLI 产物) +└── images/ # 浏览器调试截图 ``` ## 数据流 -``` -PDF 文件 ──► extractor 提取 ──► 发票列表 - │ -支付截图 ──► OCR 识别 ────────────► 回填刷卡信息 - │ - invoice_summary.csv - │ - bot 打开浏览器 ──► 自动填报 +```mermaid +flowchart TB + PDF[PDF 发票] --> Extract[extractor 提取] + Extract --> List[发票列表] + Img[支付截图] --> OCR[OCR 识别] + List --> OCR + OCR --> CSV[(invoice_summary.csv)] + CSV --> Fill[fill_consumable_doc] + Fill --> Doc[易耗品、出库单.doc] + CSV --> Bot[bot 浏览器自动化] + Bot --> Submit[财务系统填报
可选] ``` ## 环境要求 - Python 3.10+ -- 依赖见下方安装步骤 +- Windows(易耗品出库单填写依赖 Microsoft Word + COM,仅 Windows 可用) +- 主要依赖见下方安装步骤 ## 快速开始 ### 1. 安装依赖 -> 以下依赖需在 MinerU 虚拟环境中安装: -> ```bash -> conda activate MinerU -> ``` +> OCR 相关依赖建议在已有 PaddleOCR 的环境中安装(如 MinerU 虚拟环境)。 ```bash -pip install pdfplumber==0.11.9 paddleocr==2.8.1 playwright==1.60.0 flask==3.0.3 +pip install pdfplumber==0.11.9 paddleocr==2.8.1 playwright==1.60.0 flask==3.0.3 pywin32 playwright install chromium ``` -### 2. 准备数据 +| 依赖 | 用途 | +|------|------| +| pdfplumber | PDF 发票文本提取 | +| paddleocr | 支付截图 OCR | +| playwright | 财务系统浏览器自动化 | +| flask | Web 服务 | +| pywin32 | 填写 Word 出库单(`fill_consumable_doc`) | + +### 2. 准备数据(CLI 模式) 将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图: 1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf` ↔ `发票.png`) -2. **金额近邻匹配** — 文件名不同时,自动提取 PDF 的价税合计和截图的刷卡金额进行配对 +2. **金额近邻匹配** — 文件名不同时,按价税合计与刷卡金额就近配对 截图支持格式:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。 -``` ### 3. 配置 -编辑 `config.json`,填写登录凭据和默认值: +编辑 `config.json`: ```json { @@ -76,13 +87,21 @@ playwright install chromium "password": "你的密码", "default_name": "默认报销人姓名", "default_card_no": "默认公务卡号", - "default_person_id": "默认人员编号" + "default_person_id": "默认人员编号", + "consumable_storage": "躬行楼 C205" } ``` -未填写的字段将使用默认值,URL 类配置一般无需修改。 +| 字段 | 说明 | +|------|------| +| `username` / `password` | 信息门户登录凭据 | +| `default_name` | 默认报销人姓名 | +| `default_card_no` | 默认公务卡号 | +| `default_person_id` | 默认人员编号(工号) | +| `consumable_storage` | 出库单「存放地点」列默认值 | +| `sso_login_url` 等 | 系统 URL,一般无需修改 | -### 4. 运行 +### 4. 运行(CLI) ```bash # 全流程(发票提取 → OCR 识别 → 浏览器填报) @@ -97,52 +116,93 @@ python run.py --step submit # 仅浏览器填报 python run.py -u 工号 -p 密码 ``` +### 5. 填写易耗品出库单(CLI) + +需已生成 `invoice_summary.csv`,且本机已安装 **Microsoft Word**: + +```bash +python -m app.fill_consumable_doc +python -m app.fill_consumable_doc --csv invoice_summary.csv --doc "易耗品、出库单.doc" +python -m app.fill_consumable_doc --no-backup # 不生成 .doc.bak 备份 +``` + +填写规则概要: + +- 从 `规格型号` 解析品名、规格、单位、数量、单价;`价税合计` 写入金额列 +- 单价/金额保留两位小数;表格内统一为 **宋体五号(10.5 磅)** +- 存放地点取自 `consumable_storage`;购货人/领用人签字、备注保持空白 + ## 执行步骤说明 | 步骤 | 命令 | 说明 | |------|------|------| -| 发票提取 | `--step invoice` | 扫描根目录 PDF,提取发票号码、金额、销售方等信息,生成 `invoice_summary.csv` 和 `.md` | -| OCR 识别 | `--step ocr` | 对支付截图执行 OCR,识别刷卡日期、刷卡金额、持卡人姓名,回填到 CSV | -| 浏览器填报 | `--step submit` | 打开浏览器,登录信息门户 → 进入报销系统 → 自动填单、录入明细、上传附件 | +| 发票提取 | `--step invoice` | 扫描根目录 PDF,生成 `invoice_summary.csv` / `.md` | +| OCR 识别 | `--step ocr` | 识别支付截图,回填刷卡日期、金额、持卡人等到 CSV | +| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 | -> 分步执行时,上一步的 CSV 产物会自动成为下一步的输入。 +> 分步执行时,上一步的 CSV 会自动成为下一步的输入。 + +### CSV 字段说明 + +| 列名 | 说明 | +|------|------| +| 序号 | 行号 | +| 发票号码 | 电子发票号码 | +| 开票日期 | 开票日期 | +| 项目名称 / 规格型号 | 货物或应税劳务信息 | +| 价税合计 | 发票含税金额 | +| 销售方名称 | 销方名称 | +| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | OCR 自支付截图回填 | +| 备注 / 工号 | 可手工或 Web 端编辑补充 | ## Web 服务 -提供浏览器界面,上传文件即可自动处理: +提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。 ```bash python web/app.py ``` -访问 `http://localhost:5000`,上传文件并填写配置后点击「开始处理」。 +访问 `http://localhost:5000`。 -### 两种处理模式 +### 推荐使用流程 + +1. 上传 PDF + 支付截图(或上传已有 CSV) +2. 填写配置(账号、密码、姓名、公务卡号、存放地点等),可上传 `config.json` 一键填充 +3. 点击 **开始处理** — 完成发票提取、OCR、生成 CSV,并自动填写 **易耗品、出库单.doc** +4. 在 **下载文件** 区域下载 CSV / Markdown / 出库单 Word +5. 在表格中核对、修改发票数据(提交财务系统前会自动保存) +6. 确认无误后点击 **提交到财务系统** + +### 处理模式 | 模式 | 入口 | 说明 | |------|------|------| -| **PDF 模式** | 上传 PDF + 截图 | 自动提取发票信息 → OCR 识别刷卡记录 → 生成 CSV → 可选浏览器填报 | -| **CSV 快捷模式** | 上传已有 CSV 文件 | 跳过提取和 OCR,直接使用 CSV 数据进行浏览器填报 | +| **PDF 模式** | 上传 PDF + 截图 | 提取发票 → OCR → 生成 CSV + 出库单 | +| **CSV 快捷模式** | 仅上传 CSV | 跳过提取与 OCR,直接生成出库单并进入编辑/提交 | -### 功能说明 +### 功能一览 | 功能 | 说明 | |------|------| -| 发票提取 + OCR | 上传 PDF 后自动完成,无需手动操作 | -| CSV 快捷上传 | 已有发票数据 CSV 可直接上传,跳过前面的步骤 | -| 浏览器填报 | 勾选「同时提交到财务系统」后自动运行 | -| 实时日志 | 处理进度通过 SSE 实时推送 | -| 下载 CSV | 处理后下载发票汇总表 | -| 配置上传 | 可上传 `config.json` 自动填充表单 | -| 手机扫码上传 | 支付截图区域显示二维码,手机端扫码后可拍照/选图上传,PC 端实时同步接收 | +| 发票提取 + OCR | 上传 PDF 后自动完成 | +| 易耗品出库单 | 处理完成后自动生成 Word,可下载 | +| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 | +| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 | +| 实时日志 | SSE 推送处理进度 | +| 配置上传 | 支持上传 `config.json` 填充表单 | +| 手机扫码上传 | 二维码打开移动端页面,拍照上传支付截图,PC 端轮询同步 | -> Web 模式下浏览器以无头模式运行,不会弹出窗口。 -> 若未上传 PDF 附件,浏览器填报阶段将自动跳过附件上传步骤。 +> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。 +> 出库单生成需要 **Windows + Word + pywin32**;若失败,页面会显示具体原因,CSV 等其它产物仍可正常使用。 + +接口说明见 [API.md](./API.md)。 ## 注意事项 -- 第三步会打开浏览器窗口,请勿关闭或切换标签页 -- 首次运行可能需要手动处理 SSO 登录(如已保存会话则跳过) -- 调试截图保存在 `images/` 目录,出错时可查看 -- `invoice_summary.csv` 中空白的字段会在 OCR 步骤自动回填,不会覆盖已有数据 -- 提交按钮默认未启用,确认数据无误后可在 `app/bot.py` 中取消注释 `bot.submit()` \ No newline at end of file +- 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程 +- 首次运行可能需要处理 SSO 登录(已保存会话时可跳过) +- 调试截图保存在 `images/` 目录 +- OCR 不会覆盖 CSV 中已有非空字段 +- 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板 +- `config.json` 含敏感信息,请勿提交到公开仓库 diff --git a/app/bot.py b/app/bot.py index e71a861..4af1b12 100644 --- a/app/bot.py +++ b/app/bot.py @@ -297,23 +297,13 @@ class ReimburseBot: for i, inv in enumerate(invoices): file_path = attachment_files[i] if i < len(attachment_files) else None - - self.page.click("#insertAcc", timeout=5000) - self.page.wait_for_timeout(1000) - try: + self._wait_for("#insertAcc", timeout=20000) # 等待增加按钮出现 + self.page.click("#insertAcc", timeout=5000) #点击增加按钮出现 self._wait_for("#fjlx", timeout=5000) - except Exception: - pass - - try: self.page.select_option("#fjlx", "1") - except Exception: - pass - - try: explanation = f"{inv['item_name']} - {inv['invoice_no']}" - self.page.fill("#fpsmxx", explanation) + self.page.fill("#fpsmxx", explanation) except Exception: pass @@ -323,16 +313,14 @@ class ReimburseBot: self.page.wait_for_timeout(1000) except Exception as e: log.error(f"文件上传失败: {e}") - try: self.page.click("#cjtj", timeout=5000) - self.page.wait_for_timeout(1500) except Exception: try: self.page.press("body", "Escape") except Exception: pass - + log.info("上传附件完成") except Exception as e: log.error(f"附件上传失败: {e}") self._screenshot("step6_error") diff --git a/app/config.py b/app/config.py index a24f0ad..77f5016 100644 --- a/app/config.py +++ b/app/config.py @@ -29,5 +29,6 @@ def load_config() -> dict: "default_name": raw.get("default_name", ""), "default_card_no": raw.get("default_card_no", ""), "default_person_id": raw.get("default_person_id", ""), + "consumable_storage": raw.get("consumable_storage", "躬行楼 C205"), "attachment_dir": project_root / "attachments", } \ No newline at end of file diff --git a/app/fill_consumable_doc.py b/app/fill_consumable_doc.py new file mode 100644 index 0000000..38c2a0b --- /dev/null +++ b/app/fill_consumable_doc.py @@ -0,0 +1,243 @@ +""" +将 invoice_summary.csv 填入「易耗品、出库单.doc」表格。 + +仅写入表格数据单元格,保留原模板字体、边框与版式。 +""" + +from __future__ import annotations + +import argparse +import re +import shutil +from pathlib import Path + +from . import get_logger +from .bot import load_invoice_data +from .config import load_config + +log = get_logger("fill_consumable_doc") + +CONSUMABLE_DOC_FILENAME = "易耗品、出库单.doc" + +# Word COM 常量 +WD_CHARACTER = 1 + +# 表格统一字体:宋体、五号(10.5 磅) +TABLE_FONT_NAME = "宋体" +TABLE_FONT_SIZE = 10.5 + + +def _split_name_spec(left: str) -> tuple[str, str]: + m = re.search(r"(\S+一批)\s*$", left) + if m: + return m.group(1), left[: m.start()].strip() + parts = left.split(" ", 1) + if len(parts) == 2: + return parts[0], parts[1] + return left, "" + + +def parse_spec_model(spec: str) -> dict[str, str]: + spec = (spec or "").strip() + if " 个 " not in spec: + return { + "product_name": spec, + "spec": "", + "unit": "", + "qty": "", + "unit_price": "", + } + + left, right = spec.split(" 个 ", 1) + product_name, model_spec = _split_name_spec(left.strip()) + tokens = right.split() + + qty = "" + unit_price = "" + if len(tokens) >= 4 and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]): + qty, unit_price = tokens[0], tokens[1] + elif tokens and re.fullmatch(r"\d+(?:\.\d+)?", tokens[0]): + qty, unit_price = "1", tokens[0] + + return { + "product_name": product_name, + "spec": model_spec, + "unit": "个", + "qty": qty, + "unit_price": unit_price, + } + + +def _format_money(value: str | float) -> str: + """单价、金额:固定保留两位小数。""" + if value is None or value == "": + return "" + try: + num = float(value) + except (TypeError, ValueError): + return str(value) + return f"{num:.2f}" + + +def _format_cn_date(date_str: str) -> str: + if not date_str: + return "" + parts = date_str.replace("-", "/").split("/") + if len(parts) != 3: + return date_str + y, m, d = parts[0], str(int(parts[1])), str(int(parts[2])) + return f"{y}年{m}月{d}日" + + +def _apply_font(rng) -> None: + """将范围字体设为宋体五号(含数字与英文)。""" + font = rng.Font + font.Name = TABLE_FONT_NAME + font.NameFarEast = TABLE_FONT_NAME + font.NameAscii = TABLE_FONT_NAME + font.NameOther = TABLE_FONT_NAME + font.NameBi = TABLE_FONT_NAME + font.Size = TABLE_FONT_SIZE + + +def _set_cell_value(cell, text: str) -> None: + """写入单元格正文(不含末尾单元格标记)。""" + rng = cell.Range + rng.MoveEnd(WD_CHARACTER, -1) + rng.Text = "" if text is None else str(text) + _apply_font(rng) + + +def _normalize_table_font(tbl) -> None: + """填写完成后统一整张表的字体。""" + for row in tbl.Rows: + for cell in row.Cells: + rng = cell.Range + rng.MoveEnd(WD_CHARACTER, -1) + _apply_font(rng) + + +def _replace_date_in_doc(doc, new_date: str) -> None: + """仅替换表头段落中的日期文字,不改动段落其余部分。""" + if not new_date: + return + try: + para = doc.Paragraphs(3) + except Exception: + return + rng = para.Range + text = rng.Text.replace("\r", "").replace("\x07", "") + m = re.search(r"\d{4}年\d{1,2}月\d{1,2}日", text) + if not m: + return + start = rng.Start + m.start() + end = rng.Start + m.end() + doc.Range(Start=start, End=end).Text = new_date + + +def fill_consumable_doc( + csv_path: str | Path, + doc_path: str | Path, + config: dict | None = None, + backup: bool = True, +) -> Path: + csv_path = Path(csv_path) + doc_path = Path(doc_path) + if config is None: + config = load_config() + invoices = load_invoice_data(str(csv_path), config) + + if backup: + bak = doc_path.with_suffix(doc_path.suffix + ".bak") + shutil.copy2(doc_path, bak) + + import win32com.client + + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + word.DisplayAlerts = 0 + doc = word.Documents.Open(str(doc_path.resolve())) + + try: + if invoices: + cn_date = _format_cn_date(invoices[0].get("invoice_date", "")) + _replace_date_in_doc(doc, cn_date) + + tbl = doc.Tables(1) + storage = config.get("consumable_storage", "躬行楼 C205") + + for i, inv in enumerate(invoices): + row_idx = i + 2 + if row_idx > tbl.Rows.Count: + break + + parsed = parse_spec_model(inv.get("spec_model", "")) + amount = _format_money(inv.get("total_amount", "")) + unit_price = _format_money(parsed["unit_price"]) + qty = parsed["qty"] + if qty and qty.isdigit(): + qty = str(int(qty)) + + values = [ + str(inv.get("seq", i + 1)), + parsed["product_name"], + parsed["spec"], + parsed["unit"], + qty, + unit_price, + amount, + "", # 购货人签字 — 保持空白 + storage, + "", # 领用人签字 — 保持空白 + "", # 备注 — 保持空白,避免撑破版式 + ] + + for col_idx, val in enumerate(values, start=1): + _set_cell_value(tbl.Cell(row_idx, col_idx), val) + + _normalize_table_font(tbl) + + doc.Save() + finally: + doc.Close() + word.Quit() + + return doc_path + + +def fill_consumable_from_template( + csv_path: str | Path, + template_path: str | Path, + output_path: str | Path, + config: dict | None = None, +) -> Path: + """从模板复制并填写出库单(Web 会话每次从模板重新生成)。""" + template_path = Path(template_path) + output_path = Path(output_path) + if not template_path.exists(): + raise FileNotFoundError(f"出库单模板不存在: {template_path}") + shutil.copy2(template_path, output_path) + return fill_consumable_doc(csv_path, output_path, config=config, backup=False) + + +def main() -> None: + root = Path(__file__).resolve().parents[1] + 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("--no-backup", action="store_true") + args = parser.parse_args() + + cfg = None + if Path(args.config).exists(): + import json + + with open(args.config, encoding="utf-8") as f: + cfg = {**load_config(), **json.load(f)} + out = fill_consumable_doc(args.csv, args.doc, config=cfg, backup=not args.no_backup) + print(f"已填写并保存: {out}") + + +if __name__ == "__main__": + main() diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..b33d40b --- /dev/null +++ b/config.example.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/web/app.py b/web/app.py index eedaec2..39c447a 100644 --- a/web/app.py +++ b/web/app.py @@ -16,6 +16,7 @@ import threading import time import uuid from pathlib import Path +from urllib.parse import quote from flask import Flask, Response, jsonify, render_template, request, stream_with_context @@ -25,8 +26,16 @@ sys.path.insert(0, str(PROJECT_ROOT)) from app.config import load_config as load_project_config from app.extractor import extract_invoices, save_csv, save_markdown +from app.fill_consumable_doc import ( + CONSUMABLE_DOC_FILENAME, + fill_consumable_from_template, +) +from app import get_logger from app.ocr import enrich_with_ocr, _save_csv as save_ocr_csv, save_markdown_from_csv, _load_csv as load_ocr_csv +fill_log = get_logger("fill_consumable_doc") +CONSUMABLE_TEMPLATE = PROJECT_ROOT / CONSUMABLE_DOC_FILENAME + app = Flask(__name__, template_folder="templates") UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads" @@ -73,7 +82,7 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler: handler.setFormatter(fmt) handler.setLevel(logging.INFO) - for name in ["extractor", "ocr", "pipeline", "bot"]: + for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]: logger = logging.getLogger(name) logger.setLevel(logging.INFO) logger.addHandler(handler) @@ -82,21 +91,79 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler: def _remove_log_collector(handler: _SSELogHandler): - for name in ["extractor", "ocr", "pipeline", "bot"]: + for name in ["extractor", "ocr", "pipeline", "bot", "fill_consumable_doc"]: logging.getLogger(name).removeHandler(handler) handler.close_file() # ================================================================ -# 管道入口 +# 出库单填写 # ================================================================ + +def _load_session_config(session_dir: Path) -> dict: + config = load_project_config() + cfg_path = session_dir / "config.json" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as f: + config.update(json.load(f)) + return config + + +def _resolve_invoice_csv(session_dir: Path) -> Path | None: + csv_path = session_dir / "invoice_summary.csv" + if csv_path.exists(): + return csv_path + for f in session_dir.glob("*.csv"): + return f + return None + + +def _try_fill_consumable_doc(session_dir: Path, config: dict) -> dict: + """根据 CSV 填写易耗品出库单,供会话目录下载。""" + if not CONSUMABLE_TEMPLATE.exists(): + fill_log.warning("出库单模板不存在: %s", CONSUMABLE_TEMPLATE) + return {"ok": False, "error": "出库单模板不存在,请将模板放在项目根目录"} + + csv_path = _resolve_invoice_csv(session_dir) + if csv_path is None: + return {"ok": False, "error": "未找到发票 CSV"} + + out_doc = session_dir / CONSUMABLE_DOC_FILENAME + try: + fill_log.info("开始填写出库单: %s", out_doc.name) + fill_consumable_from_template( + csv_path, CONSUMABLE_TEMPLATE, out_doc, config=config + ) + fill_log.info("出库单填写完成") + return {"ok": True, "doc_filename": CONSUMABLE_DOC_FILENAME} + except ImportError: + fill_log.error("填写出库单需要 pywin32,请执行: pip install pywin32") + return {"ok": False, "error": "服务器未安装 pywin32,无法生成 Word 出库单"} + except Exception as e: + fill_log.exception("填写出库单失败: %s", e) + return {"ok": False, "error": str(e)} + + +def _append_doc_download(result: dict, session_id: str, doc_fill: dict) -> None: + if doc_fill.get("ok"): + fn = doc_fill["doc_filename"] + result["doc_url"] = f"/api/download/{session_id}/{quote(fn)}" + result["doc_ok"] = True + else: + result["doc_ok"] = False + result["doc_error"] = doc_fill.get("error", "未知错误") + + # ================================================================ # 管道入口 # ================================================================ -def run_pipeline_web(session_dir: Path, config: dict, run_bot: bool = False): - """在 Web 会话目录中执行管道,结果写入 session 目录下的文件""" +def run_pipeline_web(session_dir: Path, config: dict): + """在 Web 会话目录中执行提取+OCR,结果写入 session 目录下的文件 + + 注意:不再自动提交财务系统。提交通由 /api/submit-financial/ 触发。 + """ start = time.time() # ---- Step 1: 发票提取 ---- @@ -117,25 +184,21 @@ def run_pipeline_web(session_dir: Path, config: dict, run_bot: bool = False): save_ocr_csv(csv_path, rows) save_markdown_from_csv(csv_path, rows) - # ---- Step 3: 浏览器填报(可选)---- - if run_bot: - from app.bot import load_invoice_data, run_bot_web - - bot_invoices = load_invoice_data(str(csv_path), config) - run_bot_web(config, bot_invoices, session_dir) - elapsed = time.time() - start - return { + result = { "ok": True, "elapsed": f"{elapsed:.1f}s", "invoice_count": len(rows), "csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv", "md_url": f"/api/download/{session_dir.name}/invoice_summary.md", } + doc_fill = _try_fill_consumable_doc(session_dir, config) + _append_doc_download(result, session_dir.name, doc_fill) + return result -def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str, run_bot: bool = False): - """直接使用上传的 CSV 文件进行填报,跳过 PDF 提取和 OCR""" +def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str): + """直接使用上传的 CSV 文件,跳过 PDF 提取和 OCR""" start = time.time() csv_path = session_dir / csv_filename @@ -147,20 +210,29 @@ def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str, run if rows is None: return {"ok": False, "error": "CSV 读取失败"} - # 浏览器填报 - if run_bot: - from app.bot import load_invoice_data, run_bot_web - - bot_invoices = load_invoice_data(str(csv_path), config) - run_bot_web(config, bot_invoices, session_dir) - elapsed = time.time() - start - return { + result = { "ok": True, "elapsed": f"{elapsed:.1f}s", "invoice_count": len(rows), "csv_url": f"/api/download/{session_dir.name}/{csv_filename}", } + 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) -> dict: + """执行财务系统填报(从前端确认后调用)""" + csv_path = session_dir / "invoice_summary.csv" + if not csv_path.exists(): + return {"ok": False, "error": "未找到发票数据,请先处理"} + + from app.bot import load_invoice_data, run_bot_web + + bot_invoices = load_invoice_data(str(csv_path), config) + run_bot_web(config, bot_invoices, session_dir) + return {"ok": True} # ================================================================ @@ -230,13 +302,12 @@ def list_files(session_id: str): @app.route("/api/process/", methods=["POST"]) def start_process(session_id: str): - """启动管道处理""" + """启动管道处理(仅提取+OCR,不自动提交财务系统)""" session_dir = _validate_session(session_id) if isinstance(session_dir, tuple): return session_dir body = request.get_json(silent=True) or {} - run_bot_flag = body.get("submit", False) mode = body.get("mode", "auto") # "pdf", "csv", or "auto" # 读取配置 @@ -253,27 +324,24 @@ def start_process(session_id: str): result = {"ok": False, "error": "未知错误"} try: if mode == "csv": - # CSV 模式:直接使用上传的 CSV,跳过 PDF 提取和 OCR 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, run_bot_flag) + result = run_csv_pipeline_web(session_dir, config, csv_files[0].name) else: - # 自动检测:如果有 CSV 则走 csv 管道,否则走 pdf 管道 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, run_bot_flag) + result = run_csv_pipeline_web(session_dir, config, csv_files[0].name) else: - result = run_pipeline_web(session_dir, config, run_bot_flag) + result = run_pipeline_web(session_dir, config) except BaseException as e: result = {"ok": False, "error": str(e)} if isinstance(e, (KeyboardInterrupt, SystemExit)): raise finally: try: - # 原子写入:先写临时文件,再重命名,避免 SSE 读到截断的空文件 tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp") with open(tmp_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False) @@ -337,11 +405,156 @@ def download_file(session_id: str, filename: str): if isinstance(session_dir, tuple): return session_dir - filepath = session_dir / filename + # 防止路径穿越 + safe_name = Path(filename).name + filepath = session_dir / safe_name if not filepath.exists(): return jsonify({"error": "文件不存在"}), 404 - return Response(filepath.read_bytes(), mimetype="application/octet-stream") + if safe_name.endswith(".doc"): + mimetype = "application/msword" + elif safe_name.endswith(".csv"): + mimetype = "text/csv; charset=utf-8" + elif safe_name.endswith(".md"): + mimetype = "text/markdown; charset=utf-8" + else: + mimetype = "application/octet-stream" + + disposition = f"attachment; filename*=UTF-8''{quote(safe_name)}" + return Response( + filepath.read_bytes(), + mimetype=mimetype, + headers={"Content-Disposition": disposition}, + ) + + +@app.route("/api/data/", methods=["GET"]) +def get_invoice_data(session_id: str): + """读取发票数据并返回 JSON(供前端表格编辑)""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + csv_path = session_dir / "invoice_summary.csv" + if not csv_path.exists(): + # CSV 模式下可能是其他文件名 + csv_files = list(session_dir.glob("*.csv")) + csv_files = [f for f in csv_files if f.name != SESSION_RESULT_FILE] + if csv_files: + csv_path = csv_files[0] + else: + return jsonify({"error": "未找到发票数据,请先处理"}), 404 + + rows = load_ocr_csv(csv_path) + if rows is None: + return jsonify({"error": "CSV 读取失败"}), 500 + + # 添加行号用于编辑追踪 + data = [] + for i, row in enumerate(rows): + entry = dict(row) + entry["__row"] = i + data.append(entry) + + # 返回原始字段顺序(去掉内部字段) + fields = [k for k in rows[0].keys() if not k.startswith('__')] if rows else [] + + return jsonify({"csv_filename": csv_path.name, "fields": fields, "data": data}) + + +@app.route("/api/save/", methods=["POST"]) +def save_invoice_data(session_id: str): + """保存前端编辑后的发票数据到 CSV""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + body = request.get_json(silent=True) or {} + data = body.get("data", []) + csv_filename = body.get("csv_filename", "invoice_summary.csv") + + csv_path = session_dir / csv_filename + if not csv_path.exists(): + return jsonify({"error": "CSV 文件不存在"}), 404 + + # 读取原 CSV 获取字段顺序(使用第一个数据的 keys) + original_rows = load_ocr_csv(csv_path) + if original_rows is None or len(original_rows) == 0: + return jsonify({"error": "无法读取原始 CSV 结构"}), 500 + + # 从原数据中获取字段顺序(去掉内部字段) + fieldnames = list(original_rows[0].keys()) + + import csv as csv_module + + with open(csv_path, "w", newline="", encoding="utf-8-sig") as f: + writer = csv_module.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for entry in data: + row = {k: entry.get(k, "") for k in fieldnames} + writer.writerow(row) + + resp = {"ok": True} + config = _load_session_config(session_dir) + doc_fill = _try_fill_consumable_doc(session_dir, config) + if doc_fill.get("ok"): + fn = doc_fill["doc_filename"] + resp["doc_url"] = f"/api/download/{session_id}/{quote(fn)}" + resp["doc_ok"] = True + else: + resp["doc_ok"] = False + resp["doc_error"] = doc_fill.get("error") + return jsonify(resp) + + +@app.route("/api/submit-financial/", methods=["POST"]) +def submit_financial(session_id: str): + """手动触发财务系统填报""" + session_dir = _validate_session(session_id) + if isinstance(session_dir, tuple): + return session_dir + + # 读取配置 + config_path = session_dir / "config.json" + if not config_path.exists(): + return jsonify({"error": "未找到配置,请先配置后处理"}), 400 + + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + # 清除上次处理留下的结果文件,避免 SSE 误判为已完成 + result_file = session_dir / SESSION_RESULT_FILE + if result_file.exists(): + result_file.unlink() + + # 在后台线程执行提交 + handler = _install_log_collector(session_dir) + + def _run(): + result = {"ok": False, "error": "未知错误"} + try: + submit_result = run_financial_submit(session_dir, config) + if submit_result.get("ok"): + result = {"ok": True} + else: + result = submit_result + except BaseException as e: + result = {"ok": False, "error": str(e)} + if isinstance(e, (KeyboardInterrupt, SystemExit)): + raise + finally: + try: + tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump({"ok": True, "submit_ok": result.get("ok"), "submit_error": result.get("error")}, f, ensure_ascii=False) + tmp_path.replace(session_dir / SESSION_RESULT_FILE) + except Exception: + pass + _remove_log_collector(handler) + + threading.Thread(target=_run, daemon=True).start() + + return jsonify({"status": "started"}) # ================================================================ @@ -358,7 +571,14 @@ def _validate_session(session_id: str): def _build_web_config(body: dict) -> dict: """从请求体构建配置""" config = load_project_config() - for key in ("username", "password", "default_name", "default_card_no", "default_person_id"): + for key in ( + "username", + "password", + "default_name", + "default_card_no", + "default_person_id", + "consumable_storage", + ): if body.get(key): config[key] = body[key] return config diff --git a/web/static/css/index.css b/web/static/css/index.css new file mode 100644 index 0000000..d67f19c --- /dev/null +++ b/web/static/css/index.css @@ -0,0 +1,24 @@ +body { background: #f5f7fa; } +.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; } +.upload-zone { + border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center; + cursor: pointer; transition: all .2s; background: #fff; min-height: 100px; +} +.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; } +.upload-zone.active { border-color: #28a745; background: #f0fff4; } +.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; } +.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; } +.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; } +.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; } +.log-container .empty { color: #666; font-style: italic; } +.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; } +.btn-process { font-size: 17px; padding: 10px 40px; } +.status-badge { font-size: 13px; } + +/* 可编辑表格 */ +.table-editable { font-size: 13px; } +.table-editable th { position: sticky; top: 0; background: #f8f9fa; z-index: 1; font-weight: 600; white-space: nowrap; } +.table-editable td { vertical-align: middle; } +.table-editable input.form-control { font-size: 13px; padding: 4px 8px; min-width: 80px; } +.table-wrapper { max-height: 500px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 8px; } +.edit-note { font-size: 12px; color: #888; margin-bottom: 8px; } \ No newline at end of file diff --git a/web/static/js/index.js b/web/static/js/index.js new file mode 100644 index 0000000..cf338e8 --- /dev/null +++ b/web/static/js/index.js @@ -0,0 +1,517 @@ +let sessionId = null; +const pdfFiles = [], imgFiles = []; +let csvFile = null; +let invoiceData = []; // 当前编辑数据 [{__row, ...fields}] +let csvFilename = ''; // 当前 CSV 文件名 +let lastDownloadUrls = {}; // 最近一次可下载文件链接 + +// ---- Session ---- +async function ensureSession() { + if (sessionId) return sessionId; + const r = await fetch('/api/session', { method: 'POST' }); + const d = await r.json(); + sessionId = d.session_id; + return sessionId; +} + +// ---- 上传 ---- +function handleFiles(input, type) { + const files = Array.from(input.files); + const list = type === 'pdf' ? pdfFiles : imgFiles; + const listId = type === 'pdf' ? 'pdf-list' : 'img-list'; + const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone'; + + files.forEach(f => { + if (!list.find(x => x.name === f.name)) { + f.__source = 'local'; // 标记为本地手动选择 + list.push(f); + } + }); + + renderFileList(type); + document.getElementById(zoneId).classList.add('active'); + input.value = ''; +} + +function removeFile(type, index) { + const list = type === 'pdf' ? pdfFiles : imgFiles; + list.splice(index, 1); + renderFileList(type); + if (list.length === 0) { + document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active'); + } +} + +function renderFileList(type) { + const list = type === 'pdf' ? pdfFiles : imgFiles; + const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list'); + box.innerHTML = list.map((f, i) => + `${f.name}×` + ).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]; + if (!file) return; + const reader = new FileReader(); + reader.onload = function(e) { + try { + const cfg = JSON.parse(e.target.result); + const map = { + 'cfg-username': cfg.username, + 'cfg-password': cfg.password, + 'cfg-name': cfg.default_name, + 'cfg-card': cfg.default_card_no, + 'cfg-person-id': cfg.default_person_id, + 'cfg-storage': cfg.consumable_storage, + }; + for (const [id, val] of Object.entries(map)) { + if (val) document.getElementById(id).value = val; + } + // 同步 config.json 中的工号和公务卡号到表格 + if (cfg.username) syncConfigToTable('工号'); + if (cfg.default_card_no) syncConfigToTable('公务卡号'); + alert('配置已加载'); + } catch (err) { + alert('config.json 解析失败: ' + err.message); + } + }; + reader.readAsText(file); + input.value = ''; +} + +// ---- 拖拽 ---- +['pdf','img'].forEach(type => { + const zone = document.getElementById(type + '-zone'); + zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); }); + zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); + zone.addEventListener('drop', e => { + e.preventDefault(); + zone.classList.remove('dragover'); + const files = Array.from(e.dataTransfer.files).filter(f => { + if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf'); + /\.(png|jpe?g|bmp|webp)$/i.test(f.name); + }); + if (files.length) { + const list = type === 'pdf' ? pdfFiles : imgFiles; + files.forEach(f => { + f.__source = 'local'; // 标记为本地拖拽 + if (!list.find(x => x.name === f.name)) list.push(f); + }); + renderFileList(type); + zone.classList.add('active'); + } + }); +}); + +// 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'); + return; + } + + const btn = document.getElementById('btn-start'); + btn.disabled = true; + btn.textContent = '处理中...'; + document.getElementById('status').innerHTML = '上传中...'; + document.getElementById('log-box').innerHTML = ''; + document.getElementById('edit-section').style.display = 'none'; + document.getElementById('download-section').style.display = 'none'; + + try { + await ensureSession(); + + if (isCsvMode) { + 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 }); + } + } + + document.getElementById('status').innerHTML = '处理中...'; + + const cfg = { + username: document.getElementById('cfg-username').value, + password: document.getElementById('cfg-password').value, + default_name: document.getElementById('cfg-name').value, + 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}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cfg), + }); + + // 监听 SSE 日志 + const es = new EventSource(`/api/logs/${sessionId}`); + const logBox = document.getElementById('log-box'); + let firstLine = true; + + es.addEventListener('message', e => { + if (firstLine) { logBox.innerHTML = ''; firstLine = false; } + try { + const msg = JSON.parse(e.data); + if (msg.type === 'done') { + es.close(); + const result = msg.result; + document.getElementById('status').innerHTML = result.ok + ? '完成' + : '失败'; + btn.disabled = false; + btn.textContent = '开始处理'; + + if (result.ok) { + showDownloadLinks(result); + loadInvoiceData(); // 加载可编辑数据 + } else { + alert('处理失败: ' + (result.error || '未知错误')); + } + return; + } + } catch (err) {} + logBox.innerHTML += e.data; + logBox.scrollTop = logBox.scrollHeight; + }); + + es.onerror = () => { + es.close(); + document.getElementById('status').innerHTML = '连接中断'; + btn.disabled = false; + btn.textContent = '开始处理'; + }; + + } catch (e) { + alert('请求失败: ' + (e.message || '未知错误')); + btn.disabled = false; + btn.textContent = '开始处理'; + document.getElementById('status').innerHTML = '失败'; + } +} + +// ---- 下载链接 ---- +function showDownloadLinks(result) { + const section = document.getElementById('download-section'); + const box = document.getElementById('download-links'); + const warn = document.getElementById('doc-fill-warning'); + if (!section || !box) return; + + const items = []; + if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url }); + if (result.md_url) items.push({ label: 'invoice_summary.md', url: result.md_url }); + if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url }); + + lastDownloadUrls = {}; + items.forEach(it => { lastDownloadUrls[it.label] = it.url; }); + + box.innerHTML = items.map(it => + `${it.label}` + ).join(''); + + if (warn) { + if (result.doc_ok === false && result.doc_error) { + warn.style.display = 'block'; + warn.textContent = '出库单未生成:' + result.doc_error; + } else { + warn.style.display = 'none'; + warn.textContent = ''; + } + } + + section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none'; +} + +// ---- 发票数据编辑 ---- +async function loadInvoiceData() { + try { + const r = await fetch(`/api/data/${sessionId}`); + const d = await r.json(); + if (d.error) return; + + csvFilename = d.csv_filename || 'invoice_summary.csv'; + invoiceData = d.data || []; + const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__')); + renderTable(invoiceData, fields); + + // 渲染后自动将配置区的工号/公务卡号同步到表格 + syncConfigToTable('工号'); + syncConfigToTable('公务卡号'); + } catch (e) { /* ignore */ } +} + +// 配置区 → 表格的同步映射:工号 ↔ cfg-username,公务卡号 ↔ cfg-card +const CONFIG_SYNC = { + '工号': 'cfg-username', + '公务卡号': 'cfg-card', +}; + +// 从配置区输入框的值更新到所有表格行 +function syncConfigToTable(field) { + const inputId = CONFIG_SYNC[field]; + if (!inputId) return; + const val = document.getElementById(inputId).value || ''; + invoiceData.forEach((row, i) => { + row[field] = val; + }); + // 更新表格中对应单元格的显示 + const tbody = document.getElementById('invoice-tbody'); + if (!tbody) return; + const rows = tbody.querySelectorAll('tr'); + rows.forEach((tr, i) => { + const inputs = tr.querySelectorAll('input'); + fieldsCache.forEach((f, colIdx) => { + if (f === field && inputs[colIdx]) { + inputs[colIdx].value = val; + } + }); + }); +} + +let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引 + +function renderTable(data, fields) { + const section = document.getElementById('edit-section'); + if (!data.length) { section.style.display = 'none'; return; } + section.style.display = 'block'; + + // 使用后端返回的字段顺序, fallback 到 Object.keys + if (!fields || !fields.length) { + fields = Object.keys(data[0]).filter(k => !k.startsWith('__')); + } + fieldsCache = fields; + + // 表头 + document.getElementById('invoice-thead').innerHTML = ` + + # + ${fields.map(f => `${f}`).join('')} + + `; + + // 表体:每行首列为序号,其后为各字段 input + const rows = data.map((row, i) => { + const cells = fields.map(f => { + let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`; + // 如果该字段参与配置区同步,额外调用 syncTableToConfig + if (CONFIG_SYNC[f]) { + onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`; + } + return ``; + }).join(''); + return `${i + 1}${cells}`; + }).join(''); + + document.getElementById('invoice-tbody').innerHTML = rows; +} + +// 从表格修改同步回配置区 +function syncTableToConfig(field, value) { + const inputId = CONFIG_SYNC[field]; + if (inputId) { + document.getElementById(inputId).value = value; + } +} + +function escapeHtml(s) { + return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +// 将当前表格编辑内容写回服务器(内部调用) +async function saveInvoiceData() { + try { + const r = await fetch(`/api/save/${sessionId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }), + }); + const d = await r.json(); + if (d.doc_url || d.doc_ok === false) { + showDownloadLinks({ + csv_url: lastDownloadUrls['invoice_summary.csv'], + md_url: lastDownloadUrls['invoice_summary.md'], + doc_url: d.doc_url, + doc_ok: d.doc_ok, + doc_error: d.doc_error, + }); + } + } catch (e) { + console.warn('自动保存失败,继续提交:', e); + } +} + +// ---- 提交到财务系统 ---- +async function submitFinancial() { + const btn = document.getElementById('btn-submit'); + btn.disabled = true; + btn.textContent = '提交中...'; + document.getElementById('log-box').innerHTML = ''; + + try { + // 提交前先自动保存当前表格编辑内容 + await saveInvoiceData(); + + await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' }); + + // 监听日志流(复用 SSE) + const es = new EventSource(`/api/logs/${sessionId}`); + const logBox = document.getElementById('log-box'); + let firstLine = true; + + es.addEventListener('message', e => { + if (firstLine) { logBox.innerHTML = ''; firstLine = false; } + try { + const msg = JSON.parse(e.data); + if (msg.type === 'done') { + es.close(); + btn.disabled = false; + const result = msg.result; + if (result && result.submit_ok) { + btn.textContent = '✅ 提交完成'; + setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000); + } else { + const errorMsg = result?.submit_error || result?.error || '未知错误'; + btn.textContent = '❌ 提交失败'; + logBox.innerHTML += `
❌ 提交失败:${errorMsg}
`; + logBox.scrollTop = logBox.scrollHeight; + console.warn('提交失败:', errorMsg); + setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000); + } + return; + } + } catch (err) {} + logBox.innerHTML += e.data; + logBox.scrollTop = logBox.scrollHeight; + }); + + es.onerror = () => { + es.close(); + btn.disabled = false; + btn.textContent = '🚀 提交到财务系统'; + }; + } catch (e) { + alert('提交失败: ' + e.message); + btn.disabled = false; + btn.textContent = '🚀 提交到财务系统'; + } +} + +// ---- 二维码 / 手机扫码上传 ---- +let qrGenerated = false; +let syncTimer = null; + +async function generateQr() { + await ensureSession(); + const mobileUrl = window.location.origin + '/mobile/' + sessionId; + const box = document.getElementById('qrcode'); + box.innerHTML = ''; + new QRCode(box, { + text: mobileUrl, + width: 120, + height: 120, + colorDark: '#333', + colorLight: '#fff', + }); + qrGenerated = true; +} + +// ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ---- +async function startSync() { + if (syncTimer) return; + await syncFiles(); + syncTimer = setInterval(syncFiles, 3000); +} + +function stopSync() { + if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } +} + +async function syncFiles() { + if (!sessionId) return; + try { + const r = await fetch(`/api/files/${sessionId}`); + const d = await r.json(); + const serverNames = new Set(d.images || []); + const localNames = new Set(imgFiles.map(f => f.name)); + + for (const name of serverNames) { + if (!localNames.has(name)) { + const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`); + const blob = await resp.blob(); + const file = new File([blob], name, { type: blob.type }); + file.__source = 'server'; // 标记为扫码上传 + imgFiles.push(file); + } + } + + // 只清理来自服务器但已不存在的文件,保留本地手动选择的文件 + for (const f of [...imgFiles]) { + if (!serverNames.has(f.name) && f.__source !== 'local') { + const idx = imgFiles.indexOf(f); + if (idx > -1) imgFiles.splice(idx, 1); + } + } + + renderFileList('img'); + if (imgFiles.length) { + document.getElementById('img-zone').classList.add('active'); + } else { + document.getElementById('img-zone').classList.remove('active'); + } + } catch (e) { /* ignore */ } +} + +generateQr(); +startSync(); + +// ---- 配置区 → 表格的双向同步绑定 ---- +Object.values(CONFIG_SYNC).forEach(inputId => { + const el = document.getElementById(inputId); + if (el) { + el.addEventListener('input', () => { + // 根据 inputId 反查对应的字段名 + const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0]; + if (field) syncConfigToTable(field); + }); + } +}); \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html index affac5b..4c516fc 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -5,26 +5,7 @@ 财务报销自动化 - + @@ -103,11 +84,9 @@ body { background: #f5f7fa; } -
-
- - -
+
+ +
@@ -119,10 +98,32 @@ body { background: #f5f7fa; } - -
-
📊 处理结果
-
+ + + + + @@ -132,307 +133,6 @@ body { background: #f5f7fa; }
- + \ No newline at end of file diff --git a/易耗品、出库单.doc b/易耗品、出库单.doc new file mode 100644 index 0000000..d683b75 Binary files /dev/null and b/易耗品、出库单.doc differ