feat: Web 表格编辑、易耗品出库单自动生成与文档完善
- 新增 fill_consumable_doc,根据 CSV 填写 Word 出库单(宋体五号) - Web 处理完成后自动生成出库单并提供下载 - 前端拆分为 static 资源,支持在线编辑 CSV 与分步提交财务系统 - 补充 API.md、README(含 Mermaid 数据流)及 config.example.json Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,6 +10,10 @@ build/
|
|||||||
# Playwright MCP snapshots
|
# Playwright MCP snapshots
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Local secrets & backups
|
||||||
|
config.json
|
||||||
|
*.doc.bak
|
||||||
|
|
||||||
# Uploads (user data)
|
# Uploads (user data)
|
||||||
web/uploads/
|
web/uploads/
|
||||||
|
|
||||||
|
|||||||
463
API.md
Normal file
463
API.md
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
# 财务报销自动化 — API 文档
|
||||||
|
|
||||||
|
> 基础地址: `http://localhost:5000`
|
||||||
|
> 启动: `python web/app.py`
|
||||||
|
|
||||||
|
## 总览
|
||||||
|
|
||||||
|
| # | 方法 | 路径 | 说明 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | GET | `/` | PC 端主页 |
|
||||||
|
| 2 | POST | `/api/session` | 创建会话 |
|
||||||
|
| 3 | POST | `/api/upload/<session_id>` | 上传文件(PDF/图片) |
|
||||||
|
| 4 | POST | `/api/upload-csv/<session_id>` | 上传 CSV 发票数据 |
|
||||||
|
| 5 | GET | `/api/files/<session_id>` | 列出会话目录中的文件 |
|
||||||
|
| 6 | POST | `/api/process/<session_id>` | 启动处理(提取+OCR+出库单) |
|
||||||
|
| 7 | GET | `/api/logs/<session_id>` | SSE 日志流 |
|
||||||
|
| 8 | GET | `/api/download/<session_id>/<filename>` | 下载生成的文件 |
|
||||||
|
| 9 | GET | `/api/data/<session_id>` | 获取发票数据(JSON) |
|
||||||
|
| 10 | POST | `/api/save/<session_id>` | 保存编辑后的发票数据 |
|
||||||
|
| 11 | POST | `/api/submit-financial/<session_id>` | 提交到财务系统 |
|
||||||
|
| 12 | GET | `/mobile/<session_id>` | 移动端上传页面 |
|
||||||
|
| 13 | POST | `/api/mobile-upload/<session_id>` | 移动端上传图片 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 会话与目录
|
||||||
|
|
||||||
|
- 调用 `POST /api/session` 获得 `session_id`
|
||||||
|
- 该会话下所有文件存放在 `web/uploads/<session_id>/`
|
||||||
|
- 典型产物:`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/<session_id>
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| file | File | PDF 发票或支付截图 |
|
||||||
|
|
||||||
|
**响应(成功):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "filename": "1. 电容一批.pdf" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应(失败):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "未选择文件" }
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP `400`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 上传 CSV 发票数据
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/upload-csv/<session_id>
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| file | File | 发票汇总 CSV |
|
||||||
|
|
||||||
|
**响应(成功):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "filename": "invoice_summary.csv" }
|
||||||
|
```
|
||||||
|
|
||||||
|
> 上传 CSV 后可跳过 PDF 提取和 OCR,直接进入处理/编辑流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 列出会话文件
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/files/<session_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pdfs": ["1. 电容一批.pdf"],
|
||||||
|
"images": ["payment_01.jpg"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`images` 包含扩展名:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 启动管道处理
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/process/<session_id>
|
||||||
|
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/<session_id>/config.json`。
|
||||||
|
|
||||||
|
**响应(立即):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "started" }
|
||||||
|
```
|
||||||
|
|
||||||
|
处理在后台线程执行,进度与结果通过 `GET /api/logs/<session_id>`(SSE)获取。
|
||||||
|
|
||||||
|
**SSE 完成时 `result` 示例(成功):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"elapsed": "45.2s",
|
||||||
|
"invoice_count": 4,
|
||||||
|
"csv_url": "/api/download/<session_id>/invoice_summary.csv",
|
||||||
|
"md_url": "/api/download/<session_id>/invoice_summary.md",
|
||||||
|
"doc_url": "/api/download/<session_id>/%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/<session_id>/invoice_summary.csv",
|
||||||
|
"doc_ok": false,
|
||||||
|
"doc_error": "服务器未安装 pywin32,无法生成 Word 出库单"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. SSE 日志流
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/logs/<session_id>
|
||||||
|
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/<session_id>/<filename>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:** 文件二进制流,带 `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/<session_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"csv_filename": "invoice_summary.csv",
|
||||||
|
"fields": [
|
||||||
|
"序号", "发票号码", "开票日期", "项目名称", "规格型号",
|
||||||
|
"价税合计", "销售方名称", "人员姓名", "刷卡日期",
|
||||||
|
"公务卡号", "刷卡金额", "备注", "工号"
|
||||||
|
],
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"__row": 0,
|
||||||
|
"序号": "1",
|
||||||
|
"发票号码": "26442000005432755951",
|
||||||
|
"价税合计": "2900.00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `fields`:列顺序
|
||||||
|
- `data[].__row`:内部行索引(保存时不需要提交,服务端按数组顺序写回)
|
||||||
|
|
||||||
|
**错误:** `404` 未找到 CSV;`500` 读取失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 保存编辑后的发票数据
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/save/<session_id>
|
||||||
|
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/<session_id>/%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/<session_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**前置条件:**
|
||||||
|
|
||||||
|
- 会话目录存在 `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/<session_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
返回移动端 HTML 页面。扫码上传的图片与 PC 端共用同一会话目录;PC 通过轮询 `GET /api/files/<session_id>` 同步文件列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. 移动端上传图片
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/mobile-upload/<session_id>
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| file | File | 图片文件 |
|
||||||
|
|
||||||
|
逻辑与 `POST /api/upload/<session_id>` 相同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码汇总
|
||||||
|
|
||||||
|
| 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)。
|
||||||
182
README.md
182
README.md
@@ -1,74 +1,85 @@
|
|||||||
# 财务报销自动化
|
# 财务报销自动化
|
||||||
|
|
||||||
自动从 PDF 发票提取信息,OCR 识别支付记录截图,然后在财务系统中自动填报报销单。
|
自动从 PDF 发票提取信息,OCR 识别支付记录截图,生成发票汇总表与易耗品出库单,并可选在财务系统中自动填报报销单。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── run.py # CLI 入口
|
├── run.py # CLI 入口(发票提取 → OCR → 浏览器填报)
|
||||||
├── config.json # 配置文件(登录凭据、系统 URL 等)
|
├── config.json # 配置文件(登录凭据、默认值、存放地点等)
|
||||||
|
├── 易耗品、出库单.doc # 易耗品出库单 Word 模板(Web/CLI 填写用)
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── config.py # 配置加载
|
│ ├── config.py # 配置加载
|
||||||
│ ├── extractor.py # PDF 发票信息提取
|
│ ├── extractor.py # PDF 发票信息提取
|
||||||
│ ├── ocr.py # OCR 刷卡信息识别
|
│ ├── ocr.py # OCR 刷卡信息识别
|
||||||
│ ├── bot.py # 浏览器自动填报
|
│ ├── bot.py # 浏览器自动填报
|
||||||
│ └── pipeline.py # 流程编排(数据在内存中流转)
|
│ ├── pipeline.py # CLI 流程编排
|
||||||
|
│ └── fill_consumable_doc.py # 将 CSV 填入易耗品出库单(Word COM)
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── app.py # Web 服务入口
|
│ ├── app.py # Web 服务入口
|
||||||
│ ├── templates/
|
│ ├── templates/
|
||||||
│ │ ├── index.html # Web 前端页面
|
│ │ ├── index.html # PC 端主页
|
||||||
│ │ └── mobile_upload.html # 移动端扫码上传页面
|
│ │ └── mobile_upload.html # 移动端扫码上传
|
||||||
│ └── uploads/ # 用户上传文件目录
|
│ ├── static/ # 前端 CSS / JS
|
||||||
├── *.pdf # 发票 PDF(按需放置)
|
│ └── uploads/ # 按会话隔离的上传与产物目录
|
||||||
├── *.png / *.jpg # 与 PDF 同名的支付截图
|
├── *.pdf # 发票 PDF(CLI 模式,放在项目根目录)
|
||||||
├── invoice_summary.csv # 中间产物 — 发票汇总表
|
├── *.png / *.jpg # 与 PDF 配对的支付截图
|
||||||
└── images/ # 调试截图
|
├── invoice_summary.csv # 发票汇总表(CLI 产物)
|
||||||
|
└── images/ # 浏览器调试截图
|
||||||
```
|
```
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
PDF 文件 ──► extractor 提取 ──► 发票列表
|
flowchart TB
|
||||||
│
|
PDF[PDF 发票] --> Extract[extractor 提取]
|
||||||
支付截图 ──► OCR 识别 ────────────► 回填刷卡信息
|
Extract --> List[发票列表]
|
||||||
│
|
Img[支付截图] --> OCR[OCR 识别]
|
||||||
invoice_summary.csv
|
List --> OCR
|
||||||
│
|
OCR --> CSV[(invoice_summary.csv)]
|
||||||
bot 打开浏览器 ──► 自动填报
|
CSV --> Fill[fill_consumable_doc]
|
||||||
|
Fill --> Doc[易耗品、出库单.doc]
|
||||||
|
CSV --> Bot[bot 浏览器自动化]
|
||||||
|
Bot --> Submit[财务系统填报<br/>可选]
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- 依赖见下方安装步骤
|
- Windows(易耗品出库单填写依赖 Microsoft Word + COM,仅 Windows 可用)
|
||||||
|
- 主要依赖见下方安装步骤
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 安装依赖
|
### 1. 安装依赖
|
||||||
|
|
||||||
> 以下依赖需在 MinerU 虚拟环境中安装:
|
> OCR 相关依赖建议在已有 PaddleOCR 的环境中安装(如 MinerU 虚拟环境)。
|
||||||
> ```bash
|
|
||||||
> conda activate MinerU
|
|
||||||
> ```
|
|
||||||
|
|
||||||
```bash
|
```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
|
playwright install chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 准备数据
|
| 依赖 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| pdfplumber | PDF 发票文本提取 |
|
||||||
|
| paddleocr | 支付截图 OCR |
|
||||||
|
| playwright | 财务系统浏览器自动化 |
|
||||||
|
| flask | Web 服务 |
|
||||||
|
| pywin32 | 填写 Word 出库单(`fill_consumable_doc`) |
|
||||||
|
|
||||||
|
### 2. 准备数据(CLI 模式)
|
||||||
|
|
||||||
将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图:
|
将发票 PDF 和对应的支付截图放在项目根目录下。脚本会自动匹配 PDF 与截图:
|
||||||
|
|
||||||
1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf` ↔ `发票.png`)
|
1. **优先文件名匹配** — PDF 与截图同名(如 `发票.pdf` ↔ `发票.png`)
|
||||||
2. **金额近邻匹配** — 文件名不同时,自动提取 PDF 的价税合计和截图的刷卡金额进行配对
|
2. **金额近邻匹配** — 文件名不同时,按价税合计与刷卡金额就近配对
|
||||||
|
|
||||||
截图支持格式:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。
|
截图支持格式:`.png`、`.jpg`、`.jpeg`、`.bmp`、`.webp`。
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 配置
|
### 3. 配置
|
||||||
|
|
||||||
编辑 `config.json`,填写登录凭据和默认值:
|
编辑 `config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -76,13 +87,21 @@ playwright install chromium
|
|||||||
"password": "你的密码",
|
"password": "你的密码",
|
||||||
"default_name": "默认报销人姓名",
|
"default_name": "默认报销人姓名",
|
||||||
"default_card_no": "默认公务卡号",
|
"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
|
```bash
|
||||||
# 全流程(发票提取 → OCR 识别 → 浏览器填报)
|
# 全流程(发票提取 → OCR 识别 → 浏览器填报)
|
||||||
@@ -97,52 +116,93 @@ python run.py --step submit # 仅浏览器填报
|
|||||||
python run.py -u 工号 -p 密码
|
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` |
|
| 发票提取 | `--step invoice` | 扫描根目录 PDF,生成 `invoice_summary.csv` / `.md` |
|
||||||
| OCR 识别 | `--step ocr` | 对支付截图执行 OCR,识别刷卡日期、刷卡金额、持卡人姓名,回填到 CSV |
|
| OCR 识别 | `--step ocr` | 识别支付截图,回填刷卡日期、金额、持卡人等到 CSV |
|
||||||
| 浏览器填报 | `--step submit` | 打开浏览器,登录信息门户 → 进入报销系统 → 自动填单、录入明细、上传附件 |
|
| 浏览器填报 | `--step submit` | 登录信息门户 → 报销系统 → 自动填单、上传附件 |
|
||||||
|
|
||||||
> 分步执行时,上一步的 CSV 产物会自动成为下一步的输入。
|
> 分步执行时,上一步的 CSV 会自动成为下一步的输入。
|
||||||
|
|
||||||
|
### CSV 字段说明
|
||||||
|
|
||||||
|
| 列名 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 序号 | 行号 |
|
||||||
|
| 发票号码 | 电子发票号码 |
|
||||||
|
| 开票日期 | 开票日期 |
|
||||||
|
| 项目名称 / 规格型号 | 货物或应税劳务信息 |
|
||||||
|
| 价税合计 | 发票含税金额 |
|
||||||
|
| 销售方名称 | 销方名称 |
|
||||||
|
| 人员姓名 / 刷卡日期 / 公务卡号 / 刷卡金额 | OCR 自支付截图回填 |
|
||||||
|
| 备注 / 工号 | 可手工或 Web 端编辑补充 |
|
||||||
|
|
||||||
## Web 服务
|
## Web 服务
|
||||||
|
|
||||||
提供浏览器界面,上传文件即可自动处理:
|
提供浏览器界面:上传文件 → 自动处理 → 在线编辑 → 下载产物 → 可选提交财务系统。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python web/app.py
|
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 → 可选浏览器填报 |
|
| **PDF 模式** | 上传 PDF + 截图 | 提取发票 → OCR → 生成 CSV + 出库单 |
|
||||||
| **CSV 快捷模式** | 上传已有 CSV 文件 | 跳过提取和 OCR,直接使用 CSV 数据进行浏览器填报 |
|
| **CSV 快捷模式** | 仅上传 CSV | 跳过提取与 OCR,直接生成出库单并进入编辑/提交 |
|
||||||
|
|
||||||
### 功能说明
|
### 功能一览
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 发票提取 + OCR | 上传 PDF 后自动完成,无需手动操作 |
|
| 发票提取 + OCR | 上传 PDF 后自动完成 |
|
||||||
| CSV 快捷上传 | 已有发票数据 CSV 可直接上传,跳过前面的步骤 |
|
| 易耗品出库单 | 处理完成后自动生成 Word,可下载 |
|
||||||
| 浏览器填报 | 勾选「同时提交到财务系统」后自动运行 |
|
| 表格在线编辑 | 处理完成后可修改 CSV 各字段;保存后重新生成出库单 |
|
||||||
| 实时日志 | 处理进度通过 SSE 实时推送 |
|
| 财务系统填报 | 单独按钮触发,处理阶段不会自动提交 |
|
||||||
| 下载 CSV | 处理后下载发票汇总表 |
|
| 实时日志 | SSE 推送处理进度 |
|
||||||
| 配置上传 | 可上传 `config.json` 自动填充表单 |
|
| 配置上传 | 支持上传 `config.json` 填充表单 |
|
||||||
| 手机扫码上传 | 支付截图区域显示二维码,手机端扫码后可拍照/选图上传,PC 端实时同步接收 |
|
| 手机扫码上传 | 二维码打开移动端页面,拍照上传支付截图,PC 端轮询同步 |
|
||||||
|
|
||||||
> Web 模式下浏览器以无头模式运行,不会弹出窗口。
|
> Web 端浏览器填报以无头模式运行。未上传 PDF 时,填报阶段会跳过附件上传。
|
||||||
> 若未上传 PDF 附件,浏览器填报阶段将自动跳过附件上传步骤。
|
> 出库单生成需要 **Windows + Word + pywin32**;若失败,页面会显示具体原因,CSV 等其它产物仍可正常使用。
|
||||||
|
|
||||||
|
接口说明见 [API.md](./API.md)。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 第三步会打开浏览器窗口,请勿关闭或切换标签页
|
- 浏览器填报时会打开或使用 Chromium,请勿手动干扰自动化流程
|
||||||
- 首次运行可能需要手动处理 SSO 登录(如已保存会话则跳过)
|
- 首次运行可能需要处理 SSO 登录(已保存会话时可跳过)
|
||||||
- 调试截图保存在 `images/` 目录,出错时可查看
|
- 调试截图保存在 `images/` 目录
|
||||||
- `invoice_summary.csv` 中空白的字段会在 OCR 步骤自动回填,不会覆盖已有数据
|
- OCR 不会覆盖 CSV 中已有非空字段
|
||||||
- 提交按钮默认未启用,确认数据无误后可在 `app/bot.py` 中取消注释 `bot.submit()`
|
- 项目根目录需保留 `易耗品、出库单.doc` 模板;Web 每次从模板复制到会话目录再填写,不修改原模板
|
||||||
|
- `config.json` 含敏感信息,请勿提交到公开仓库
|
||||||
|
|||||||
18
app/bot.py
18
app/bot.py
@@ -297,21 +297,11 @@ class ReimburseBot:
|
|||||||
|
|
||||||
for i, inv in enumerate(invoices):
|
for i, inv in enumerate(invoices):
|
||||||
file_path = attachment_files[i] if i < len(attachment_files) else None
|
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:
|
try:
|
||||||
|
self._wait_for("#insertAcc", timeout=20000) # 等待增加按钮出现
|
||||||
|
self.page.click("#insertAcc", timeout=5000) #点击增加按钮出现
|
||||||
self._wait_for("#fjlx", timeout=5000)
|
self._wait_for("#fjlx", timeout=5000)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.page.select_option("#fjlx", "1")
|
self.page.select_option("#fjlx", "1")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
explanation = f"{inv['item_name']} - {inv['invoice_no']}"
|
||||||
self.page.fill("#fpsmxx", explanation)
|
self.page.fill("#fpsmxx", explanation)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -323,16 +313,14 @@ class ReimburseBot:
|
|||||||
self.page.wait_for_timeout(1000)
|
self.page.wait_for_timeout(1000)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"文件上传失败: {e}")
|
log.error(f"文件上传失败: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.page.click("#cjtj", timeout=5000)
|
self.page.click("#cjtj", timeout=5000)
|
||||||
self.page.wait_for_timeout(1500)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
self.page.press("body", "Escape")
|
self.page.press("body", "Escape")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
log.info("上传附件完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"附件上传失败: {e}")
|
log.error(f"附件上传失败: {e}")
|
||||||
self._screenshot("step6_error")
|
self._screenshot("step6_error")
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ def load_config() -> dict:
|
|||||||
"default_name": raw.get("default_name", ""),
|
"default_name": raw.get("default_name", ""),
|
||||||
"default_card_no": raw.get("default_card_no", ""),
|
"default_card_no": raw.get("default_card_no", ""),
|
||||||
"default_person_id": raw.get("default_person_id", ""),
|
"default_person_id": raw.get("default_person_id", ""),
|
||||||
|
"consumable_storage": raw.get("consumable_storage", "躬行楼 C205"),
|
||||||
"attachment_dir": project_root / "attachments",
|
"attachment_dir": project_root / "attachments",
|
||||||
}
|
}
|
||||||
243
app/fill_consumable_doc.py
Normal file
243
app/fill_consumable_doc.py
Normal file
@@ -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()
|
||||||
12
config.example.json
Normal file
12
config.example.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
288
web/app.py
288
web/app.py
@@ -16,6 +16,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
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.config import load_config as load_project_config
|
||||||
from app.extractor import extract_invoices, save_csv, save_markdown
|
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
|
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")
|
app = Flask(__name__, template_folder="templates")
|
||||||
|
|
||||||
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads"
|
UPLOAD_BASE = PROJECT_ROOT / "web" / "uploads"
|
||||||
@@ -73,7 +82,7 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
|||||||
handler.setFormatter(fmt)
|
handler.setFormatter(fmt)
|
||||||
handler.setLevel(logging.INFO)
|
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 = logging.getLogger(name)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
@@ -82,21 +91,79 @@ def _install_log_collector(session_dir: Path) -> _SSELogHandler:
|
|||||||
|
|
||||||
|
|
||||||
def _remove_log_collector(handler: _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)
|
logging.getLogger(name).removeHandler(handler)
|
||||||
handler.close_file()
|
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):
|
def run_pipeline_web(session_dir: Path, config: dict):
|
||||||
"""在 Web 会话目录中执行管道,结果写入 session 目录下的文件"""
|
"""在 Web 会话目录中执行提取+OCR,结果写入 session 目录下的文件
|
||||||
|
|
||||||
|
注意:不再自动提交财务系统。提交通由 /api/submit-financial/<session_id> 触发。
|
||||||
|
"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
# ---- Step 1: 发票提取 ----
|
# ---- 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_ocr_csv(csv_path, rows)
|
||||||
save_markdown_from_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
|
elapsed = time.time() - start
|
||||||
return {
|
result = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"elapsed": f"{elapsed:.1f}s",
|
"elapsed": f"{elapsed:.1f}s",
|
||||||
"invoice_count": len(rows),
|
"invoice_count": len(rows),
|
||||||
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
"csv_url": f"/api/download/{session_dir.name}/invoice_summary.csv",
|
||||||
"md_url": f"/api/download/{session_dir.name}/invoice_summary.md",
|
"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):
|
def run_csv_pipeline_web(session_dir: Path, config: dict, csv_filename: str):
|
||||||
"""直接使用上传的 CSV 文件进行填报,跳过 PDF 提取和 OCR"""
|
"""直接使用上传的 CSV 文件,跳过 PDF 提取和 OCR"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
csv_path = session_dir / csv_filename
|
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:
|
if rows is None:
|
||||||
return {"ok": False, "error": "CSV 读取失败"}
|
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
|
elapsed = time.time() - start
|
||||||
return {
|
result = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"elapsed": f"{elapsed:.1f}s",
|
"elapsed": f"{elapsed:.1f}s",
|
||||||
"invoice_count": len(rows),
|
"invoice_count": len(rows),
|
||||||
"csv_url": f"/api/download/{session_dir.name}/{csv_filename}",
|
"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/<session_id>", methods=["POST"])
|
@app.route("/api/process/<session_id>", methods=["POST"])
|
||||||
def start_process(session_id: str):
|
def start_process(session_id: str):
|
||||||
"""启动管道处理"""
|
"""启动管道处理(仅提取+OCR,不自动提交财务系统)"""
|
||||||
session_dir = _validate_session(session_id)
|
session_dir = _validate_session(session_id)
|
||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
return session_dir
|
return session_dir
|
||||||
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
run_bot_flag = body.get("submit", False)
|
|
||||||
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
|
mode = body.get("mode", "auto") # "pdf", "csv", or "auto"
|
||||||
|
|
||||||
# 读取配置
|
# 读取配置
|
||||||
@@ -253,27 +324,24 @@ def start_process(session_id: str):
|
|||||||
result = {"ok": False, "error": "未知错误"}
|
result = {"ok": False, "error": "未知错误"}
|
||||||
try:
|
try:
|
||||||
if mode == "csv":
|
if mode == "csv":
|
||||||
# CSV 模式:直接使用上传的 CSV,跳过 PDF 提取和 OCR
|
|
||||||
csv_files = list(session_dir.glob("*.csv"))
|
csv_files = list(session_dir.glob("*.csv"))
|
||||||
if not csv_files:
|
if not csv_files:
|
||||||
result = {"ok": False, "error": "未找到 CSV 文件"}
|
result = {"ok": False, "error": "未找到 CSV 文件"}
|
||||||
else:
|
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:
|
else:
|
||||||
# 自动检测:如果有 CSV 则走 csv 管道,否则走 pdf 管道
|
|
||||||
csv_files = list(session_dir.glob("*.csv"))
|
csv_files = list(session_dir.glob("*.csv"))
|
||||||
pdf_files = list(session_dir.glob("*.pdf"))
|
pdf_files = list(session_dir.glob("*.pdf"))
|
||||||
if csv_files and not pdf_files:
|
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:
|
else:
|
||||||
result = run_pipeline_web(session_dir, config, run_bot_flag)
|
result = run_pipeline_web(session_dir, config)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
result = {"ok": False, "error": str(e)}
|
result = {"ok": False, "error": str(e)}
|
||||||
if isinstance(e, (KeyboardInterrupt, SystemExit)):
|
if isinstance(e, (KeyboardInterrupt, SystemExit)):
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
# 原子写入:先写临时文件,再重命名,避免 SSE 读到截断的空文件
|
|
||||||
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
tmp_path = session_dir / (SESSION_RESULT_FILE + ".tmp")
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(result, f, ensure_ascii=False)
|
json.dump(result, f, ensure_ascii=False)
|
||||||
@@ -337,11 +405,156 @@ def download_file(session_id: str, filename: str):
|
|||||||
if isinstance(session_dir, tuple):
|
if isinstance(session_dir, tuple):
|
||||||
return session_dir
|
return session_dir
|
||||||
|
|
||||||
filepath = session_dir / filename
|
# 防止路径穿越
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
filepath = session_dir / safe_name
|
||||||
if not filepath.exists():
|
if not filepath.exists():
|
||||||
return jsonify({"error": "文件不存在"}), 404
|
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/<session_id>", 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/<session_id>", 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/<session_id>", 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:
|
def _build_web_config(body: dict) -> dict:
|
||||||
"""从请求体构建配置"""
|
"""从请求体构建配置"""
|
||||||
config = load_project_config()
|
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):
|
if body.get(key):
|
||||||
config[key] = body[key]
|
config[key] = body[key]
|
||||||
return config
|
return config
|
||||||
|
|||||||
24
web/static/css/index.css
Normal file
24
web/static/css/index.css
Normal file
@@ -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; }
|
||||||
517
web/static/js/index.js
Normal file
517
web/static/js/index.js
Normal file
@@ -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) =>
|
||||||
|
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
||||||
|
).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 =
|
||||||
|
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">×</span></span>`;
|
||||||
|
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 = '<span class="badge bg-warning status-badge">上传中...</span>';
|
||||||
|
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 = '<span class="badge bg-info status-badge">处理中...</span>';
|
||||||
|
|
||||||
|
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
|
||||||
|
? '<span class="badge bg-success status-badge">完成</span>'
|
||||||
|
: '<span class="badge bg-danger status-badge">失败</span>';
|
||||||
|
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 = '<span class="badge bg-danger status-badge">连接中断</span>';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '开始处理';
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
alert('请求失败: ' + (e.message || '未知错误'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '开始处理';
|
||||||
|
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 下载链接 ----
|
||||||
|
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 =>
|
||||||
|
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
|
||||||
|
).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 = `
|
||||||
|
<tr>
|
||||||
|
<th style="width:40px">#</th>
|
||||||
|
${fields.map(f => `<th>${f}</th>`).join('')}
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 表体:每行首列为序号,其后为各字段 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 `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
|
||||||
|
onchange="${onChangeStr}"></td>`;
|
||||||
|
}).join('');
|
||||||
|
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
|
||||||
|
}).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,'>').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 += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,26 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>财务报销自动化</title>
|
<title>财务报销自动化</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<style>
|
<link href="/static/css/index.css" rel="stylesheet">
|
||||||
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; }
|
|
||||||
.result-box { background: #fff; border-radius: 8px; padding: 16px; display: none; }
|
|
||||||
.result-box a { text-decoration: none; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -103,11 +84,9 @@ body { background: #f5f7fa; }
|
|||||||
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
|
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
|
||||||
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
|
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6 d-flex align-items-end">
|
<div class="col-sm-6">
|
||||||
<div class="form-check">
|
<label class="form-label" style="font-size:12px;margin-bottom:2px">存放地点(出库单)</label>
|
||||||
<input class="form-check-input" type="checkbox" id="cfg-submit">
|
<input type="text" class="form-control form-control-sm" id="cfg-storage" placeholder="新工科楼">
|
||||||
<label class="form-check-label" for="cfg-submit" style="font-size:13px">同时提交到财务系统(需浏览器自动化)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,10 +98,32 @@ body { background: #f5f7fa; }
|
|||||||
<span id="status" class="ms-3"></span>
|
<span id="status" class="ms-3"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 结果 -->
|
<!-- 下载区 -->
|
||||||
<div class="result-box mb-3" id="result-box">
|
<div class="card mb-4" id="download-section" style="display:none">
|
||||||
<div class="section-title">📊 处理结果</div>
|
<div class="card-body py-3">
|
||||||
<div id="result-content"></div>
|
<div class="section-title mb-2">📥 下载文件</div>
|
||||||
|
<div id="download-links" class="d-flex flex-wrap gap-2"></div>
|
||||||
|
<div id="doc-fill-warning" class="text-warning mt-2" style="font-size:13px;display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 发票数据编辑区 -->
|
||||||
|
<div class="card mb-4" id="edit-section" style="display:none">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="section-title d-flex justify-content-between align-items-center">
|
||||||
|
<span>📝 发票数据(可编辑)</span>
|
||||||
|
<div class="d-flex justify-content-end align-items-center">
|
||||||
|
<button class="btn btn-primary" id="btn-submit" onclick="submitFinancial()">🚀 提交到财务系统</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="edit-note">直接点击单元格即可编辑,提交时自动保存当前修改。</p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="table table-sm table-bordered table-editable mb-0" id="invoice-table">
|
||||||
|
<thead id="invoice-thead"></thead>
|
||||||
|
<tbody id="invoice-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 日志 -->
|
<!-- 日志 -->
|
||||||
@@ -132,307 +133,6 @@ body { background: #f5f7fa; }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||||
<script>
|
<script src="/static/js/index.js"></script>
|
||||||
let sessionId = null;
|
|
||||||
const pdfFiles = [], imgFiles = [];
|
|
||||||
let csvFile = null;
|
|
||||||
|
|
||||||
// ---- 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)) 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) =>
|
|
||||||
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
|
||||||
).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 =
|
|
||||||
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">×</span></span>`;
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
for (const [id, val] of Object.entries(map)) {
|
|
||||||
if (val) document.getElementById(id).value = val;
|
|
||||||
}
|
|
||||||
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 => { 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 = '<span class="badge bg-warning status-badge">上传中...</span>';
|
|
||||||
document.getElementById('log-box').innerHTML = '';
|
|
||||||
document.getElementById('result-box').style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensureSession();
|
|
||||||
|
|
||||||
if (isCsvMode) {
|
|
||||||
// CSV 模式:只上传 CSV 文件
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', csvFile);
|
|
||||||
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
|
|
||||||
} else {
|
|
||||||
// 传统模式:上传 PDF 和图片
|
|
||||||
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 = '<span class="badge bg-info status-badge">处理中...</span>';
|
|
||||||
|
|
||||||
// 启动管道
|
|
||||||
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,
|
|
||||||
submit: document.getElementById('cfg-submit').checked,
|
|
||||||
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; }
|
|
||||||
// 尝试解析 JSON(完成信号),否则按日志文本处理
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(e.data);
|
|
||||||
if (msg.type === 'done') {
|
|
||||||
es.close();
|
|
||||||
const result = msg.result;
|
|
||||||
document.getElementById('status').innerHTML = result.ok
|
|
||||||
? '<span class="badge bg-success status-badge">完成</span>'
|
|
||||||
: '<span class="badge bg-danger status-badge">失败</span>';
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '开始处理';
|
|
||||||
if (result.ok) {
|
|
||||||
showResult(result);
|
|
||||||
} else {
|
|
||||||
document.getElementById('result-box').style.display = 'block';
|
|
||||||
document.getElementById('result-content').innerHTML = '<span class="text-danger">' + (result.error || '处理失败') + '</span>';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
logBox.innerHTML += e.data;
|
|
||||||
logBox.scrollTop = logBox.scrollHeight;
|
|
||||||
});
|
|
||||||
|
|
||||||
es.onerror = () => {
|
|
||||||
es.close();
|
|
||||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">连接中断</span>';
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '开始处理';
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
alert('请求失败: ' + (e.message || '未知错误'));
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '开始处理';
|
|
||||||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showResult(r) {
|
|
||||||
const box = document.getElementById('result-box');
|
|
||||||
box.style.display = 'block';
|
|
||||||
const mdLink = r.md_url ? `<a class="btn btn-sm btn-outline-secondary" href="${r.md_url}" download>下载 Markdown</a>` : '';
|
|
||||||
document.getElementById('result-content').innerHTML = `
|
|
||||||
<p>处理 <b>${r.invoice_count}</b> 张发票,耗时 ${r.elapsed}</p>
|
|
||||||
<a class="btn btn-sm btn-outline-primary me-2" href="${r.csv_url}" download>下载 CSV</a>
|
|
||||||
${mdLink}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 二维码 / 手机扫码上传 ----
|
|
||||||
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 });
|
|
||||||
imgFiles.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检测本地有但服务器没有的图片(被手机端删除了)
|
|
||||||
for (const f of [...imgFiles]) {
|
|
||||||
if (!serverNames.has(f.name)) {
|
|
||||||
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();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
BIN
易耗品、出库单.doc
Normal file
BIN
易耗品、出库单.doc
Normal file
Binary file not shown.
Reference in New Issue
Block a user