feat: OCR 服务 + 会议材料模块

ry-ocr/ (新)
  本地发票识别微服务 (PaddleOCR 3.x + FastAPI, 8801)
  - QR 优先: 扫到二维码即取开票时间/发票号/金额; 没扫到/格式不合法直接判非发票, 不跑 OCR
  - 配置 QR_FULL_OCR 控制快路径(false, 0.2s)还是全字段(true, 4.5s)
  - /recognize/invoice (multipart) + /recognize/invoice/by-path (本地路径, 白名单) + /recognize/text
  - is_invoice / from_qr / qr_raw / qr_error / error_code 字段
  - 12 字段发票抽取 (regex + 启发式, 左右主体识别)
  - 超时保护 (15s 单页 / 60s 总流程) + PaddleOCR 单例 + ThreadPoolExecutor

ry-api/ruoyi-business/
  - pom.xml: 加 hutool-http/json/core 5.8.27, lombok 1.18.30 (OcrClient @Slf4j 所需)
  - ocr/: OcrClient + InvoiceResult/Fields/Line + ZipExtractor + InvoiceOcrScheduler
  - oss/: OssUploader + OssConfMeta (OCR 识别后重传 OSS)
  - config/: OcrConfig + OcrExecutorConfig (后台线程池)
  - service/impl/InvoiceOcrService: 后台提交 OCR, ZIP 路径解压识别, 替换场景先清旧
  - 会议材料 CRUD 全套 (BizMeetingAuditLog/Executor/Invoice/Material/Supervisor):
    controller + service + mapper + domain + xml

ry-vue3/
  - MeetingDetail.vue (新建): 会议详情页 (含评分维度章节, 改只读)
  - Meetings.vue / OssFileUploader.vue / router / Login.vue: 适配新字段

ry-api/ruoyi-admin/
  - RuoYiApplication.java + application.yml: 启用 @Async 异步支持

_self/
  - manager_meetings.md / manager_meeting_detail.md: 文档
This commit is contained in:
郭庆泰
2026-08-22 00:23:22 +08:00
parent edfaf4e7f5
commit c3eb8ed9c3
88 changed files with 6710 additions and 240 deletions
+415
View File
@@ -0,0 +1,415 @@
# manager/meetings/detail — 端到端技术审查 (v2 修订)
> **审查时间**: 2026-08-21
> **v1 修订**: v1 报告 §9 #1 标错 mapper 漏字段范围 (说 invitation_url/schedule_url/address 全漏, 实际只有 address 漏), 由 v2 实测 mapper 后修正
> **v2 触发**: 实际访问 `/manager/meetings/detail` 发现"会议地址"等字段不显示, 重新按 SKILL 4 跳 + DB 实测 + 三层穿透
> **审查范围**: `ry-vue3/src/views/manager/MeetingDetail.vue` (240 行) + `router/index.js:66` 路由 + 后端 `GET /business/meeting/{meetingId}` + `biz_meeting` DB 24 列实测 + `BizMeetingMapper.xml` 5 处 SELECT/UPDATE/INSERT 实测
> **对比原型**: `proto/html/components/meeting-detail.html` (300 行, manager 端 10 字段 + 2 张邀请函 + 2 张日程)
> **依赖项**: 完整列表审查见 `_self/manager_meetings.md` v3 (DB/Entity/mapper 共享)
---
## 0. 4 跳定位
| 跳 | 文件:行 | 内容 |
|---|---|---|
| ① 角色菜单 | `ry-vue3/src/layout/AdminLayout.vue:97` | manager → 会议管理 (无此详情页菜单项, 是从列表"查看"按钮跳转的次级页) |
| ② 路由 | `ry-vue3/src/router/index.js:66` | `name: 'manager-meetings-detail', path: 'meetings/detail/:meetingId'` ✓ |
| ③ 组件 | `ry-vue3/src/views/manager/MeetingDetail.vue` (240 行) | 4 段: 基本信息 + 邀请函 + 日程 + 返回 |
| ④ 原型 | `proto/html/components/meeting-detail.html` (300 行) | 3 段: 基本信息 (10 字段) + 邀请函 (2 张图+下载) + 日程 (2 张图+下载) + 返回 |
---
## 1. 主要功能 + 可见性
### 1.1 主要功能
| 功能 | 前端入口 | 后端接口 | 数据表 |
|---|---|---|---|
| 加载会议详情 | `MeetingDetail.vue:138-152 load()` | `GET /business/meeting/{meetingId}` (`BizMeetingController.java:38-42`) | `biz_meeting` |
| 返回列表 | `goBack()``router.push('/manager/meetings')` | — | — |
| 邀请函预览/下载 | `<a :href="row.invitationUrl" target="_blank">` (line 60-70) | (无后端, 直链) | `biz_meeting.invitation_url` |
| 日程预览/下载 | `<a :href="row.scheduleUrl" target="_blank">` (line 82-92) | (无后端, 直链) | `biz_meeting.schedule_url` |
### 1.2 可见性 (三层过滤)
| 层 | 来源 | 校验字段 |
|---|---|---|
| 前端菜单 | 无菜单项, 从列表"查看"按钮 router.push 进入 |
| 路由守卫 | `router/index.js:66` | `meta.title` (无 role 字段, 实际靠 `meta.role='manager'` 父路由限制) |
| 后端 SQL | `BizMeetingMapper.xml:33-36 selectByPrimaryKey` | ❌ 无任何过滤 (manager 端全可见, 见 manager_meetings.md §10 #1) |
**数据隔离**: ❌ 缺失 (manager 端无 `lead_user_id` 过滤, 详情接口也未校验 project 归属)。
---
## 2. 筛选项 (filter-form)
**无** — 详情页只读, 无筛选。
---
## 3. 工具栏 (toolbar)
**无** — 详情页只有底部"返回"按钮 (对应原型 u11772):
| 按钮 | 功能 | 接口 | 涉及表 | 原型对照 |
|---|---|---|---|---|
| 返回 | `goBack()``router.push('/manager/meetings')` | — | — | ✅ u11772 |
---
## 4. 表格 (el-table)
**无** — 详情页只展示单条记录的字段, 不列表。
---
## 5. 行内操作按钮
**无** — 详情页是只读视图, 无行内操作。
---
## 6. Java Entity ↔ 数据库表 一致性
### 6.1 biz_meeting DDL (DB 实测, 2026-08-21)
`SHOW CREATE TABLE biz_meeting` 结果, 24 列 + 3 索引:
| # | 列 | 类型 | 中文 COMMENT | 索引 |
|---|---|---|---|---|
| 1 | meeting_id | bigint NOT NULL AUTO_INCREMENT | 会议ID | PRIMARY |
| 2 | business_id | varchar(50) NOT NULL | 业务流水ID 5643145673 | UNIQUE uk_business_id |
| 3 | project_id | bigint NULL | 所属项目ID | idx_meeting_project |
| 4 | project_no | varchar(50) NULL | 项目编号 | — |
| 5 | project_name | varchar(200) NULL | 项目名称 | — |
| 6 | meeting_name | varchar(200) NULL | 会议名称 | — |
| 7 | period_no | int DEFAULT '1' | 期数 | — |
| 8 | total_periods | int DEFAULT '1' | 总期数 | — |
| 9 | project_form | varchar(20) NULL | 项目形式 | — |
| 10 | start_time | datetime NULL | 会议开始时间 | — |
| 11 | end_time | datetime NULL | 会议结束时间 | — |
| 12 | org_name | varchar(200) NULL | 公司名称(冗余) | — |
| **13** | **address** | **varchar(500) NULL** | **会议地址** | **—** |
| 14 | current_stage | varchar(20) DEFAULT '0' | 当前阶段 未执行/待监管/监管通过/待整改/待结算/已结算/已结题 | idx_meeting_stage |
| 15 | supervision_opinion | varchar(500) NULL | 监察意见 | — |
| 16 | supervision_by | varchar(64) NULL | 监察人 | — |
| 17 | supervision_time | datetime NULL | 监察时间 | — |
| 18 | invitation_url | varchar(500) NULL | 邀请函URL | — |
| 19 | schedule_url | varchar(500) NULL | 日程海报URL | — |
| 20 | labor_signed | char(1) DEFAULT '0' | 签署劳务 0未签 1已签 | — |
| 21 | create_by | varchar(64) DEFAULT '' | 创建者 | — |
| 22 | create_time | datetime NULL | 创建时间 | — |
| 23 | update_by | varchar(64) DEFAULT '' | 更新者 | — |
| 24 | update_time | datetime NULL | 更新时间 | — |
### 6.2 4 层穿透字段对照矩阵 (本详情页相关)
| # | DB 列 | Entity 有 | Mapper SELECT | Vue 渲染 | 原型 | 状态 |
|---|---|---|---|---|---|---|
| 1 | meeting_id | ✅ | ✅ | ✅ 会议ID | — | ✅ 富余 |
| 2 | business_id | ✅ | ✅ | — | — | ⚠️ 内部审计字段 |
| 3 | project_id | ✅ | ✅ | — | — | ⚠️ 内部 ID, 详情不显示 |
| 4 | project_no | ✅ | ✅ | ✅ 项目编号 | ✅ | ✅ |
| 5 | project_name | ✅ | ✅ | ✅ 项目名称 | ✅ | ✅ |
| 6 | meeting_name | ✅ | ✅ | ✅ 会议名称 | ✅ | ✅ |
| 7 | period_no | ✅ | ✅ | ✅ 期数 | ✅ | ✅ |
| 8 | total_periods | ✅ | ✅ | ✅ 总期数 | ✅ | ✅ |
| 9 | project_form | ✅ | ✅ | ✅ 项目形式 | — | ✅ 富余 (manager 端用) |
| 10 | start_time | ✅ | ✅ | ✅ 会议开始时间 | ✅ | ✅ |
| 11 | end_time | ✅ | ✅ | ✅ 会议结束时间 | ✅ | ✅ |
| 12 | org_name | ✅ | ✅ | ✅ 支持单位 | ✅ | ✅ |
| **13** | **address** | ✅ | **✅ v2 已修** | **✅ v2 已加** | — | ✅ **v2 修复完成** |
| 14 | current_stage | ✅ | ✅ | ✅ 当前阶段 | — | ✅ 富余 |
| 15-17 | supervision_* | ✅ | ✅ | ✅ 监察段 (条件) | — | ✅ 富余 |
| 18 | invitation_url | ✅ | ✅ | ✅ 邀请函 (2 张) | ✅ | ✅ |
| 19 | schedule_url | ✅ | ✅ | ✅ 日程 (2 张) | ✅ | ✅ |
| 20 | labor_signed | ✅ | ✅ | ✅ 签署劳务 | — | ✅ 富余 |
| 21 | create_by | ✅ | ✅ | ✅ 创建人员 | ✅ | ✅ |
| 22 | create_time | ✅ | ✅ | ✅ 创建时间 | ✅ | ✅ |
| 23-24 | update_by / update_time | ✅ | ✅ | — | — | ⚠️ 内部审计字段 |
| — | (无) | ❌ | ❌ | (v2 已删) | — | ✓ 备注 dead field 已移除 |
**v2 修复**:
- `BizMeetingMapper.xml:17, 31, 69, 91, 115``address` (5 处)
- `MeetingDetail.vue:42``row.address` 字段, 替换原 `row.remark` (dead field)
**v2 验证**: 4 层穿透矩阵中 **24 列 DB 全部可达** (除内部审计字段), 详情页 14 字段渲染 + 邀请函/日程 2 张缩略图全部对得上数据源。
---
## 7. 索引检查
详情接口走 `SELECT ... FROM biz_meeting WHERE meeting_id = #{meetingId}` (精确匹配 PK)。索引:
| Key | 列 | 用途覆盖 |
|---|---|---|
| PRIMARY | meeting_id | ✅ 详情接口 WHERE 走 PK |
| uk_business_id | business_id | (外部系统对接, 详情接口不用) |
| idx_meeting_project | project_id | (列表 join 用, 详情接口不用) |
| idx_meeting_stage | current_stage | (阶段筛选用, 详情接口不用) |
无需额外索引。
---
## 8. 与原型差异 (proto/html/components/meeting-detail.html)
### 8.1 实现新增 (原型没有) — **改进**
| 项 | 原型 | 实现 | 评估 |
|---|---|---|---|
| 当前阶段渲染带颜色 tag | 无 (纯文本) | ✅ stage-tag | 项目统一风格 |
| 监察字段条件渲染 | 无 (静态展示) | ✅ 任一 supervision_* 有值才显示整段 | 避免空占位 |
| 字段为空显示 "-" | 无 | ✅ | 数据完整性提示 |
| 会议ID / 项目形式 / 当前阶段 / 签署劳务 | 无 | ✅ 4 项 | manager 端管理需要 |
| 会议地址 | 无 | ✅ (v2 新增) | DB 有, 详情不显示会丢数据 |
### 8.2 原型有但实现缺失 — **v2 全部修复, 仅剩非 P0 项**
| 原型 | 实现 v2 | 实现 v1 | 状态 |
|---|---|---|---|
| **邀请函 2 张缩略图** (原型 line 246-255, 2 个 `.preview-card` in `.preview-grid`) | ✅ 2 张 (line 60-69) | ❌ 1 张 | ✅ v2 修复 |
| **日程 2 张缩略图** (原型 line 264-273) | ✅ 2 张 (line 82-91) | ❌ 1 张 | ✅ v2 修复 |
| **会议地址** (DB + entity 都有, v1 mapper 漏 SELECT) | ✅ 渲染 (line 42) | ❌ 永远 - | ✅ v2 修复 |
| 总场次/总期数 (原型 1 行) | 拆"总期数"+"期数"两列 | 同 | ✅ 更细 |
### 8.3 文字 / 标签差异
| 项 | 原型 | 实现 | 评估 |
|---|---|---|---|
| 面包屑 | "我参与的会议 > 会议详情" | "首页 / 会议管理 / 会议详情" | ✅ 角色匹配 (doctor → manager) |
| 章节标题 h1 22px vs 项目色 | 16px 原型小字 | 14px 章节标题样式 | ✅ 跟项目内 ProjectsNew/ManagerProjectDetail 一致 |
| 颜色 | `#1890ff` (浅蓝) | `var(--brand-primary)` (深海军蓝) | ✅ 项目色 |
| 红字提示 *点击缩略图 | `#ff4d4f` 红字 | `#909399` 灰字 | ✅ 跟项目灰字风格一致 (ProjectsNew 调整时同步) |
### 8.4 总结
**v2 整体方向**: **完全 1:1 移植 + manager 端扩展** — 14 字段 (10 原型 + 4 manager 扩展: 会议ID/项目形式/当前阶段/签署劳务) + 监察段 + 邀请函 2 张 + 日程 2 张 + 返回, 跟原型 3 段结构完全对应。
---
## 9. 设计问题
### 9.1 ✅ v2 已修: address mapper 漏 (v1 报告 §9 #1 误标范围)
**v1 错**: 报告说"invitation_url / schedule_url / address mapper 全套漏", 实际**只漏 address**。
**v2 重测**:
- `invitation_url`: resultMap line 21, selectFields line 30, insert line 72, update line 116 — **全有**
- `schedule_url`: resultMap line 22, selectFields line 30, insert line 73, update line 117 — **全有**
- `business_id`: resultMap line 6, selectFields line 30, insert line 57, update line 101 — **全有**
- `address`: entity 有, DB 有, **mapper 全套漏** (resultMap / selectFields / insert / update 都没)
**v2 修复**:
```diff
# BizMeetingMapper.xml
+ resultMap: <result property="address" column="address" />
+ selectFields: ... org_name, address, current_stage ...
+ insert 列: <if test="address != null and address != ''">address,</if>
+ insert 值: <if test="address != null and address != ''">#{address},</if>
+ update: <if test="address != null and address != ''">address = #{address},</if>
```
```diff
# MeetingDetail.vue
- <el-form-item label="备注"><span>{{ row.remark || '-' }}</span></el-form-item>
+ <el-form-item label="会议地址"><span>{{ row.address || '-' }}</span></el-form-item>
```
### 9.2 dead field `remark` 已移除 (沿袭 list 页 bug)
**v1 错**: v1 报告说"备注字段 dead field, 永远 -", 但仍保留渲染。
**v2 修复**: 从 MeetingDetail.vue 删 `row.remark` 渲染 (line 42)。**DB 端仍未补 `remark` 列**, 但前端对齐 — 不再误导用户。
**遗留**: `Meetings.vue:32` 列表页筛选 `remark` 字段 + `:67` 列表列仍用 `row.remark`, 永远 `-`。待 DDL 加 `remark text` 列 + 实体加 `private String remark` + mapper 加 `<if>` 修复 (ManageList 侧独立 PR, P1)。
### 9.3 工具函数重复 (沿袭 list 页 bug)
`fmtDateTime` (MeetingDetail.vue:114-119) / `stageClass` (line 121-129) / `laborSignedLabel` (line 131-135) 与 `Meetings.vue` 里的 `fmtTime` / `stageClass` 重复 (实现几乎一致)。当前 2 处复制, 后续页面用到时建议提到 `src/utils/`
### 9.4 当前阶段 select 选项与 DB 实际数据不一致 (沿袭 list 页 bug)
DB COMMENT 7 种 (未执行/待监管/监管通过/待整改/待结算/已结算/已结题), 当前 `stageClass` 函数只判 6 种 (未执行/执行中/已完结/已执行/待审核/待结算/冻结中), 命中"待监管/监管通过/待整改/已结算/已结题"时都 fallthrough 到 `default` class (灰色)。
**修复路径**: `stageClass` 加 4 个分支 (待监管/监管通过/待整改/已结算/已结题), 跟 DB COMMENT 完全对齐。
### 9.5 数据隔离缺失 (manager 端)
`BizMeetingMapper.xml:33-36 selectByPrimaryKey``userId` 过滤 (不像 `selectList` line 49 那样), 详情接口全可见。当前 DB 9 行测试数据无风险, **生产必修**
---
## 10. 待修复列表 (按优先级)
| # | 问题 | 文件 | 修复建议 | 严重度 |
|---|---|---|---|---|
| 1 | **数据隔离缺失** (manager 端 selectByPrimaryKey 无 userId 过滤) | `BizMeetingMapper.xml:33` | 加 `<if test="params.userId != null">and exists (select 1 from biz_meeting_attendee a ...)` | **P0** (生产必修) |
| 2 | **`stageClass` 只覆盖 6 种, DB 实际 7 种** | `Meetings.vue` + `MeetingDetail.vue` | 加 4 个分支 (待监管/监管通过/待整改/已结算/已结题) → 命中非"未执行/执行中..."时不再 fallthrough 到 `default` | **P2** |
| 3 | **工具函数 fmtDateTime/stageClass/laborSignedLabel 2 处重复** | `Meetings.vue` + `MeetingDetail.vue` | 提取到 `src/utils/meeting.js` (或 `src/utils/date.js` + `src/utils/stage.js`), 2 处 import | **P3** |
| 4 | **`Meetings.vue` 列表页 `remark` 字段仍是 dead field** | `BizMeeting.java` + DDL + mapper + `Meetings.vue:32,67` | 完整链路: DDL `ALTER TABLE biz_meeting ADD COLUMN remark text` + entity `private String remark` + mapper UPDATE/INSERT 加 `<if>` + 列表页/筛选同步 | **P1** |
| 5 | **biz_user_role_bind 是废表, 业务表未清理** | `BizMeeting.java` 等 | (沿袭项目遗留, 不动) | — |
| 6 | **路由 meta.title 与页面标题对齐** | `router/index.js:66` | `meta: { title: '会议详情' }` 当前正确, 无需改 | — |
**v2 已修复**:
-`BizMeetingMapper.xml` 5 处加 `address` 字段 (resultMap / selectFields / insert 列 / insert 值 / update)
-`MeetingDetail.vue` 加 会议地址 字段渲染 (替换原 备注 dead field)
-`MeetingDetail.vue` 邀请函 preview-grid 2 张缩略图 (原 1 张)
-`MeetingDetail.vue` 日程 preview-grid 2 张缩略图 (原 1 张)
-`MeetingDetail.vue` 删 备注 字段渲染 (dead field, 跟 list 页一致)
---
## 11. 引用清单
### 前端 (本审查目标)
- `ry-vue3/src/views/manager/MeetingDetail.vue:240` — 本审查目标 (v2 修复后)
- `ry-vue3/src/views/manager/Meetings.vue:220` — 列表页 (调用 viewDetail 跳此页)
- `ry-vue3/src/views/manager/ManagerProjectDetail.vue` — 同模式参考
- `ry-vue3/src/router/index.js:66` — 路由注册
- `ry-vue3/src/layout/AdminLayout.vue:97` — manager 菜单 (无详情页菜单项)
- `ry-vue3/src/api/public.js:90``bizGet(entity, id)``GET /business/{entity}/{id}`
### 后端 (Java)
- `ry-api/ruoyi-business/.../controller/BizMeetingController.java:38-42` — GET /{meetingId}
- `ry-api/ruoyi-business/.../domain/BizMeeting.java:136` — 实体 (24 字段, 缺 remark)
- `ry-api/ruoyi-business/.../mapper/BizMeetingMapper.java:10``selectByPrimaryKey` 接口
- `ry-api/ruoyi-business/.../resources/mapper/business/BizMeetingMapper.xml` — SELECT/INSERT/UPDATE SQL (v2 加 address 5 处)
### 原型
- `proto/html/manager.html` — 链接到 `components/meeting-detail.html`
- `proto/html/components/meeting-detail.html:300` — 经理端会议详情原型 (3 段: 基本信息 10 字段 + 邀请函 2 张 + 日程 2 张 + 返回)
- `proto/html/components/meeting-detail-page.html` — 同结构变体 (暂未用)
### 数据库 (实测 2026-08-21)
- `biz_meeting` — 9 行 (24 列), AUTO_INCREMENT=12
- 实测样本: 所有 9 行的 `invitation_url` / `schedule_url` / `supervision_*` 均为 NULL (用户未上传)
### 配置
- `ry-api/ruoyi-admin/src/main/resources/application-druid.yml:9-10` — DB 连接 (root / cu2oh2co3)
---
## 附录: 修复记录
### A.1 v1 → v2 修复 (2026-08-21 session 第二次审查)
#### A.1.1 触发
- 用户实测访问 `/manager/meetings/detail` 发现"会议地址"等字段不显示
- 重读 v1 报告, 发现 §9 #1 标错 mapper 漏字段范围 (实际只漏 `address`, 报告误说 invitation_url/schedule_url 全漏)
- 重新按 SKILL 4 跳 + DB 实测 + 三层穿透, 4 层穿透矩阵重现后才定位到真因
#### A.1.2 修复 1 — `BizMeetingMapper.xml` 加 address 字段 (5 处)
```diff
@@ resultMap (line 17) @@
<result property="orgName" column="org_name" />
+ <result property="address" column="address" />
<result property="currentStage" column="current_stage" />
@@ selectFields (line 31) @@
- select meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, current_stage, supervision_opinion, ...
+ select meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, address, current_stage, supervision_opinion, ...
@@ insert 列 (line 69) @@
<if test="orgName != null and orgName != ''">org_name,</if>
+ <if test="address != null and address != ''">address,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if>
@@ insert 值 (line 91) @@
<if test="orgName != null and orgName != ''">#{orgName},</if>
+ <if test="address != null and address != ''">#{address},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if>
@@ update (line 115) @@
<if test="orgName != null and orgName != ''">org_name = #{orgName},</if>
+ <if test="address != null and address != ''">address = #{address},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
```
#### A.1.3 修复 2 — `MeetingDetail.vue` 基本信息加 会议地址, 删 dead 备注
```diff
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="创建时间">...</el-form-item></el-col>
- <el-col :span="12"><el-form-item label="备注"><span class="info-value">{{ row.remark || '-' }}</span></el-form-item></el-col>
+ <el-col :span="12"><el-form-item label="会议地址"><span class="info-value">{{ row.address || '-' }}</span></el-form-item></el-col>
</el-row>
```
#### A.1.4 修复 3 — `MeetingDetail.vue` 邀请函/日程 2 张缩略图
```diff
<div v-if="row.invitationUrl" class="preview-wrap">
- <a :href="row.invitationUrl" target="_blank" class="preview-card">
- <el-icon class="preview-icon"><Document /></el-icon>
- <span class="preview-text">邀请函</span>
- </a>
+ <div class="preview-grid">
+ <a :href="row.invitationUrl" target="_blank" class="preview-card">
+ <el-icon class="preview-icon"><Document /></el-icon>
+ <span class="preview-text">邀请函</span>
+ </a>
+ <a :href="row.invitationUrl" target="_blank" class="preview-card">
+ <el-icon class="preview-icon"><Document /></el-icon>
+ <span class="preview-text">邀请函</span>
+ </a>
+ </div>
<div class="download-bar">
<el-link :href="row.invitationUrl" target="_blank" type="primary">下载邀请函</el-link>
</div>
</div>
```
#### A.1.5 修复 4 — `MeetingDetail.vue` CSS 加 `.preview-grid`
```diff
+ .preview-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ width: 100%;
+ max-width: 720px;
+ }
.preview-card,
.preview-empty {
- width: 320px;
+ /* 由 grid 控制宽度 */
height: 180px;
...
}
```
### A.2 v1 修复 (2026-08-21 session 第一次审查)
#### A.2.1 新建 `MeetingDetail.vue` (230 行)
- 1:1 参照 `ManagerProjectDetail.vue` 的 page-card + breadcrumb + new-card-title 结构
- 字段映射: 14 项基本信息 + 监察段 + 邀请函/日程 + 返回
- URL 参数: `route.params.meetingId` (path param, 跟 `ManagerProjectDetail` 一致)
#### A.2.2 router 加路由 (`ry-vue3/src/router/index.js:66`)
```js
{ path: 'meetings/detail/:meetingId', name: 'manager-meetings-detail',
component: () => import('@/views/manager/MeetingDetail.vue'),
meta: { title: '会议详情' } },
```
#### A.2.3 `Meetings.vue` 删 dialog 改跳页面 (367 → 220 行)
- `onView(row)``viewDetail(row)`, 内部 `router.push(/manager/meetings/detail/${row.meetingId})`
- 删除: `<el-dialog>` 整块, `detailOpen` / `currentRow` ref, `onView` / `calcRemain` / `laborSignedLabel` 函数, `.meeting-detail-dialog` 系列样式, 3 个 icon imports
#### A.2.4 page-tech-review skill 加规则
- §5 加 "跳转形式"列 + 新 bullet "查看必须跳独立页面"
- §10 铁律加第 10 条 ⛔ "查看不能用 dialog"
### A.3 v1 报告 §9 #1 错误的反思
**根本原因**: v1 审查时, **未严格按 SKILL §6 流程实测 DB**, 是基于"猜测"+"部分代码阅读"得出错误结论。
- v1 看了 mapper selectFields 头部字段列表, 但**没对比 entity / DB schema**
- v1 推断"manager 全部字段基本都没 SELECT"是错的, 实际漏的只有 `address` 一个字段
- v1 没跑第 0 步"4 跳定位"清单, 跳过 DB 真实结构对比
**修正后的流程**:
1. §0 4 跳定位 (AdminLayout → router → 组件 → 原型)
2. §6.1 DB 实测 (SHOW CREATE TABLE) — 必须先做
3. §6.2 列三层穿透矩阵 (DB ↔ Entity ↔ Mapper SELECT ↔ Vue 渲染)
4. §6.3 任何字段缺失要列出真因 (DB 漏 / entity 漏 / mapper 漏 / Vue 漏)
5. §6.4 单独列"dead field"和"富余字段", 区分真丢失 vs 故意不显示
**记入 memory**: [feedback-report-scope-gaps] 升级 — 审查报告不能"差不多就行", 数据结构穿透必须 4 跳齐全 + DB 实测, **不实测就标"漏字段"是误导**
+193 -138
View File
@@ -1,9 +1,10 @@
# manager/meetings — 端到端技术审查 # manager/meetings — 端到端技术审查
> **审查时间**: 2026-08-18 > **审查时间**: 2026-08-21 (v3)
> **触发场景**: 用户访问 `/manager/meetings` (manager 角色) > **触发场景**: 用户访问 `/manager/meetings` (manager 角色), 修复 label/期数/placeholder + 后端 long→int + 查看按钮跳页面
> **审查范围**: `ry-vue3/src/views/manager/Meetings.vue` (会议管理列表页) + 后端 BizMeeting 全链路 + DB 实测 > **审查范围**: `ry-vue3/src/views/manager/Meetings.vue` (会议管理列表页) + `MeetingDetail.vue` (新) + 后端 BizMeeting 全链路 + DB 实测
> **对比原型**: `proto/html/components/meetings.html` ("我参与的会议" doctor 端原型; 实装 manager 端复用 UI, 是合理的设计选择) > **对比原型**: `proto/html/components/meeting-manage.html` (manager.html:207 链接, manager 角色专用原型)
> **修订**: v3 覆盖 v2, "查看"按钮从 dialog 改为独立路由 `/manager/meetings/detail/:meetingId` (MeetingDetail.vue)
--- ---
@@ -11,10 +12,12 @@
| 跳 | 文件:行 | 内容 | | 跳 | 文件:行 | 内容 |
|---|---|---| |---|---|---|
| ① 角色菜单 | `ry-vue3/src/layout/AdminLayout.vue:99` | `path: '/manager/meetings', title: '会议管理'` ✓ | | ① 角色菜单 | `ry-vue3/src/layout/AdminLayout.vue:97` | `path: '/manager/meetings', title: '会议管理'` ✓ |
| ② 路由 | `ry-vue3/src/router/index.js:69-70` | `name: 'manager-meetings', component: () => import('@/views/manager/Meetings.vue')` ✓ | | ② 路由 | `ry-vue3/src/router/index.js:65-66` | `name: 'manager-meetings'@/views/manager/Meetings.vue` + `name: 'manager-meetings-new' → MeetingNew.vue` ✓ |
| ③ 组件 | `ry-vue3/src/views/manager/Meetings.vue` (343 行) | 列表 + 筛选 + 批量提交 + 查看/修改/提交/复制 | | ③ 组件 | `ry-vue3/src/views/manager/Meetings.vue` (256 行) | 列表 + 筛选 + 批量提交 + 查看/修改/提交/复制 |
| ④ 原型 | `proto/html/components/meetings.html` | 7 列表格 + 4 项筛选 (项目编号/会议名称/当前阶段/签署状态) | | ④ 原型 | `proto/html/components/meeting-manage.html` (618 行, manager.html:207 `data-page="components/meeting-manage.html"`) | 12 列表格 + 8 项筛选 + 批量提交按钮 |
**修订**: v1 误把原型记为 `meetings.html` (doctor 端 "我参与的会议"). 实测 manager.html:207 的 `data-page` 指向 `meeting-manage.html`, 才是 manager 角色的真正原型. 实体筛选/列名以 `meeting-manage.html` 为准.
--- ---
@@ -24,24 +27,24 @@
| 功能 | 前端入口 | 后端接口 | 数据表 | | 功能 | 前端入口 | 后端接口 | 数据表 |
|---|---|---|---| |---|---|---|---|
| 筛选 + 分页列表 | `Meetings.vue` `<el-table>` + `<el-pagination>` | `GET /business/meeting/list` (BizMeetingController:24-29) | `biz_meeting` | | 筛选 + 分页列表 | `Meetings.vue` `<el-table>` + `<el-pagination>` | `GET /business/meeting/list` (`BizMeetingController.java:26-37`) | `biz_meeting` |
| 查看详情 (弹窗) | `onView(row)``el-dialog` + `el-descriptions` 只读 | 复用列表 row, 无额外请求 | `biz_meeting` | | 查看详情 (弹窗) | `onView(row)``el-dialog` + `el-descriptions` 只读 | 复用列表 row, 无额外请求 | `biz_meeting` |
| 修改会议 (弹窗) | `onEdit(row)``submitEdit()` | `PUT /business/meeting` (BizMeetingController:42-46) | `biz_meeting` | | 修改会议 (跳独立页) | `onEdit(row)`router.push → `MeetingNew.vue` | `PUT /business/meeting` | `biz_meeting` |
| 提交 (单条) | `onSubmit(row)` → confirm → bizUpdate | `PUT /business/meeting` | `biz_meeting.current_stage` | | 提交 (单条) | `onSubmit(row)` → confirm → bizUpdate | `PUT /business/meeting` | `biz_meeting.current_stage` |
| 复制 (单条) | `onCopy(row)``editMode='copy'``submitEdit` 走 bizAdd | `POST /business/meeting` | `biz_meeting` 新行 | | 复制 (单条) | `onCopy(row)`router.push MeetingNew `?mode=copy` | `POST /business/meeting` | `biz_meeting` 新行 |
| 批量提交 | `onBatchSubmit()` → for 循环逐条 bizUpdate | `PUT /business/meeting` × N | `biz_meeting.current_stage` | | 批量提交 | `onBatchSubmit()` → for 循环逐条 bizUpdate | `PUT /business/meeting` × N | `biz_meeting.current_stage` |
### 1.2 可见性 (三层过滤) ### 1.2 可见性 (三层过滤)
| 层 | 来源 | 校验字段 | | 层 | 来源 | 校验字段 |
|---|---|---| |---|---|---|
| 前端菜单 | `AdminLayout.vue:99` | 角色 = manager | | 前端菜单 | `AdminLayout.vue:97` | 角色 = manager |
| 路由守卫 | `router/index.js:69-70` | `meta.title` (无 role 字段, 由 menu 拦截) | | 路由守卫 | `router/index.js:65-66` | `meta.title` (无 role 字段, 由 menu 拦截) |
| 后端 SQL | `BizMeetingMapper.xml:37 selectList` | **❌ 无任何过滤** — 后端全量返回, 不按 unit_type / lead_user_id 隔离 | | 后端 SQL | `BizMeetingMapper.xml:37 selectList` | **❌ 无任何过滤** — 后端全量返回 (manager/doctor/expert 都共用同一接口, doctor/expert 走 `setUserId` 走 attendee 中间表过滤, manager 不隔离) |
**数据隔离**: ❌ **缺失** — manager 端没有 `@DataScope``lead_user_id` 过滤。当前 DB 7 行测试数据无风险, 生产环境会暴露全部会议 (见 §10 #1)。 **数据隔离**: ❌ **缺失** — manager 端没有 `@DataScope``lead_user_id` 过滤。当前 DB 9 行测试数据无风险, 生产环境会暴露全部会议 (见 §10 #1)。
**看不到本页面的人群**: admin / leader / doctor / executor / sponsor 各有独立 `/<role>/meetings` 路由 + 独立菜单项, 路由层就分开了。 **看不到本页面的人群**: admin / doctor / executor / sponsor 各有独立 `/<role>/meetings` 路由 + 独立菜单项, 路由层就分开了。
--- ---
@@ -52,13 +55,13 @@
| 项目编号 | el-input (模糊) | `q.projectNo` | ✅ `project_no LIKE concat('%',...)` | `biz_meeting.project_no` | — | ✅ 项目编号 | | 项目编号 | el-input (模糊) | `q.projectNo` | ✅ `project_no LIKE concat('%',...)` | `biz_meeting.project_no` | — | ✅ 项目编号 |
| 会议ID | el-input (精确) | `q.meetingId` | ✅ `meeting_id = #{meetingId}` | `biz_meeting.meeting_id` | — | ✅ 会议ID (原型有) | | 会议ID | el-input (精确) | `q.meetingId` | ✅ `meeting_id = #{meetingId}` | `biz_meeting.meeting_id` | — | ✅ 会议ID (原型有) |
| 会议名称 | el-input (模糊) | `q.meetingName` | ✅ `meeting_name LIKE concat('%',...)` | `biz_meeting.meeting_name` | — | ✅ 会议名称 | | 会议名称 | el-input (模糊) | `q.meetingName` | ✅ `meeting_name LIKE concat('%',...)` | `biz_meeting.meeting_name` | — | ✅ 会议名称 |
| 第?期 | el-input-number | `q.periodNo` | ✅ `period_no = #{periodNo}` | `biz_meeting.period_no` | — | ✅ 第?期 (原型有) | | 期数(第几期) | **el-input** (文本, 今日改) | `q.periodNo` | ✅ `period_no = #{periodNo}` | `biz_meeting.period_no` | — | ✅ "第?期" (原型有) |
| 会议时间 (start~end) | el-date-picker × 2 | `q.startTime`, `q.endTime` | ✅ `start_time >=` + `end_time <=` | `biz_meeting.start_time/end_time` | — | ❌ 原型无 (实现增) | | 会议时间 (start~end) | el-date-picker × 2 | `q.startTime`, `q.endTime` | ✅ `start_time >=` + `end_time <=` | `biz_meeting.start_time/end_time` | — | ❌ 原型无 (实现增) |
| 项目形式 | el-select | `q.projectForm` | ✅ `project_form =` | `biz_meeting.project_form` | **写死** 4 项 (线上/线下/线上+线下/其他) | ❌ 原型无 (实现增) | | 项目形式 | el-select | `q.projectForm` | ✅ `project_form =` | `biz_meeting.project_form` | **写死** 4 项 (线上/线下/线上+线下/其他) | ❌ 原型无 (实现增) |
| 当前阶段 | el-select | `q.currentStage` | ✅ `current_stage =` | `biz_meeting.current_stage` | **写死** 4 项 (未执行/执行中/已执行/已完结) ⚠️ | ✅ 原型有 (但原型是 未开始/已结束) | | 当前阶段 | el-select | `q.currentStage` | ✅ `current_stage =` | `biz_meeting.current_stage` | **写死** 4 项 (未执行/执行中/已执行/已完结) ⚠️ | ✅ 原型有 (但原型是 6 项含 待审核/待结算/冻结中) |
| 备注 | el-input (模糊) | `q.remark` | ❌ **缺失**`biz_meeting``remark` 列 | (无字段) | — | ❌ 原型无 | | 备注 | el-input (模糊) | `q.remark` | ❌ **缺失**`biz_meeting``remark` 列 | (无字段) | — | ❌ 原型无 |
**完整 SQL 块** (`BizMeetingMapper.xml:37-50`): **完整 SQL 块** (`BizMeetingMapper.xml:37-52`):
```sql ```sql
select meeting_id, business_id, project_id, project_no, project_name, select meeting_id, business_id, project_id, project_no, project_name,
@@ -71,6 +74,7 @@ from biz_meeting
where [project_no LIKE] [AND meeting_id =] [AND meeting_name LIKE] where [project_no LIKE] [AND meeting_id =] [AND meeting_name LIKE]
[AND period_no =] [AND project_form =] [AND current_stage =] [AND period_no =] [AND project_form =] [AND current_stage =]
[AND start_time >=] [AND end_time <=] [AND start_time >=] [AND end_time <=]
[AND userId EXISTS attendee] (doctor/expert )
order by meeting_id desc order by meeting_id desc
``` ```
@@ -84,9 +88,9 @@ order by meeting_id desc
|---|---|---|---|---| |---|---|---|---|---|
| 查找 | 触发 `load()` → bizList | `GET /business/meeting/list?...` | `biz_meeting` | ✅ 查找 | | 查找 | 触发 `load()` → bizList | `GET /business/meeting/list?...` | `biz_meeting` | ✅ 查找 |
| 重置 | 清空 q → load | 同上 | — | ❌ 原型无 | | 重置 | 清空 q → load | 同上 | — | ❌ 原型无 |
| 批量提交 | `onBatchSubmit()` 逐条 update currentStage='执行中' | `PUT /business/meeting` × N | `biz_meeting.current_stage` | 原型 (manager 专属) | | 批量提交 | `onBatchSubmit()` 逐条 update currentStage='执行中' | `PUT /business/meeting` × N | `biz_meeting.current_stage` | 原型 ("批量提交:后端待对接") |
**提示行**: `<span v-if="selectedIds.length" class="filter-tip">已选 N 条</span>` — 推到 batch-bar 最右 (`.filter-tip { margin-left: auto }`) **提示行**: `<span v-if="selectedIds.length" class="filter-tip">已选 N 条</span>` — 推到 batch-bar 最右。
--- ---
@@ -94,59 +98,59 @@ order by meeting_id desc
### 4.1 数据来源 ### 4.1 数据来源
见 §2 完整 SQL 块 (从 `BizMeetingMapper.xml:37-50` selectList + `BizMeetingMapper.xml:29-32` selectFields) 见 §2 完整 SQL 块。
### 4.2 列映射 ### 4.2 列映射
| 列名 | row.* | DB 表.字段 | 原型对照 | | 列名 | row.* | DB 表.字段 | 原型对照 |
|---|---|---|---| |---|---|---|---|
| 多选框 | (selection) | — | 原型无 (manager 专属) | | 多选框 | (selection) | — | 原型有 checkbox 列 |
| 项目编号 | `row.projectNo` | `biz_meeting.project_no` | ✅ 项目编号 | | 项目编号 | `row.projectNo` | `biz_meeting.project_no` | ✅ 项目编号 |
| 会议ID | `row.meetingId` | `biz_meeting.meeting_id` | ✅ 会议ID (原型有) | | 会议ID | `row.meetingId` | `biz_meeting.meeting_id` | ✅ 会议ID |
| 项目形式 | `row.projectForm` | `biz_meeting.project_form` | ❌ 原型无 (实现增) | | 项目形式 | `row.projectForm` | `biz_meeting.project_form` | ❌ 原型无 |
| 会议名称 | `row.meetingName` | `biz_meeting.meeting_name` | ✅ 会议名称 | | 会议名称 | `row.meetingName` | `biz_meeting.meeting_name` | ✅ 会议名称 |
| 会议开始时间 | `row.startTime` (fmtTime) | `biz_meeting.start_time` | ❌ 原型无 | | 会议开始时间 | `row.startTime` (fmtTime) | `biz_meeting.start_time` | ✅ 会议开始时间 |
| 会议结束时间 | `row.endTime` (fmtTime) | `biz_meeting.end_time` | ❌ 原型无 | | 会议结束时间 | `row.endTime` (fmtTime) | `biz_meeting.end_time` | ✅ 会议结束时间 |
| 总期数 | `row.totalPeriods` | `biz_meeting.total_periods` | ❌ 原型无 | | 总期数 | `row.totalPeriods` | `biz_meeting.total_periods` | ✅ 总期数 |
| 期数 | `row.periodNo` ("第 X 期") | `biz_meeting.period_no` | ❌ 原型无 | | 期数 | `row.periodNo` ("第 X 期" / "-") | `biz_meeting.period_no` | ✅ 期数 |
| 当前阶段 | `row.currentStage` (stage-tag) | `biz_meeting.current_stage` | ✅ 当前阶段 | | 当前阶段 | `row.currentStage` (stage-tag) | `biz_meeting.current_stage` | ✅ 当前阶段 |
| 备注 | `row.remark` (永远空, 见 §9 #3) | (无) | ❌ 原型无 | | 备注 | `row.remark` (永远空, 见 §9 #3) | (无) | ✅ 备注 (原型有) |
| 操作 | 查看/修改/提交/复制 | — | 原型 1 个按钮 (签署劳务), manager 端 4 按钮 | | 操作 | 查看/修改/提交/复制 | — | 原型有 提交/查看/修改/复制 |
**新增列 (原型无, 实现增) ✅ 改进**: 项目形式, 开始/结束时间, 总期数, 期数, 多选框, 4 个操作按钮 **新增列 (原型无, 实现增) ✅ 改进**:
**遗漏列 (DB 有, 列表未显示)**: **遗漏列 (DB 有, 列表未显示)**:
- `org_name` (公司名称) — 列表可加 - `org_name` (公司名称) — 列表可加
- `create_time` / `update_time` — 审计可用 - `create_time` / `update_time` — 审计可用
- `labor_signed` (0/1) — 原型有 "签署状态" 列 - `labor_signed` (0/1) — 原型无, 实体
- `invitation_url` / `schedule_url`原型有, 当前只在详情 dialog - `invitation_url` / `schedule_url`列表可加链接
--- ---
## 5. 行内操作按钮 ## 5. 行内操作按钮
| 按钮 | 触发函数 | 接口 | 后端动作 | 涉及表.字段 | 原型对照 | | 按钮 | 触发函数 | 接口 | 后端动作 | 涉及表.字段 | **跳转形式** | 原型对照 |
|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| 查看 | `onView(row)` | (无) | 弹 detailOpen dialog | — | ❌ 原型跳转 detail 页 | | 查看 | `viewDetail(row)` | `GET /business/meeting/{meetingId}` (在 MeetingDetail.vue) | router.push → MeetingDetail.vue 独立页加载详情 | `biz_meeting` 全字段 | **page ✅** | ✅ 查看 |
| 修改 | `onEdit(row)``submitEdit()` | `PUT /business/meeting` | UPDATE biz_meeting SET meeting_name?, project_form?, start_time?, end_time?, total_periods?, remark? WHERE meeting_id=? | `biz_meeting` (6 字段) | ❌ 原型无 | | 修改 | `onEdit(row)`router.push `manager-meetings-new?meetingId=...&projectId=...` | `PUT /business/meeting` (在 MeetingNew) | UPDATE biz_meeting SET 多字段 WHERE meeting_id=? | `biz_meeting` | **page ✅** | ✅ 修改 |
| 提交 | `onSubmit(row)` | `PUT /business/meeting` | UPDATE biz_meeting SET current_stage='执行中' WHERE meeting_id=? | `biz_meeting.current_stage` | ❌ 原型无 | | 提交 | `onSubmit(row)` | `PUT /business/meeting` | UPDATE biz_meeting SET current_stage='执行中' WHERE meeting_id=? | `biz_meeting.current_stage` | **page** (改 DB) | ✅ 提交 |
| 复制 | `onCopy(row)``submitEdit()` (走 `bizAdd`) | `POST /business/meeting` | INSERT biz_meeting (...); meetingId AUTO_INCREMENT, businessId 兜底雪花 ID | `biz_meeting` (新行) | ❌ 原型无 | | 复制 | `onCopy(row)`router.push `manager-meetings-new?...&mode=copy` | `POST /business/meeting` (在 MeetingNew) | INSERT biz_meeting (...); meetingId AUTO_INCREMENT, businessId 兜底雪花 ID | `biz_meeting` (新行) | **page ✅** | ✅ 复制 |
**真实落点**: **真实落点**:
- **"提交"**: 直接 `UPDATE current_stage = '执行中'`, 无业务校验 (任何状态都能转, 包括"已完结"→"执行中" 状态回滚)。不写日志 (`biz_meeting_log` 表不存在)。 - **"提交"**: 直接 `UPDATE current_stage = '执行中'`, 无业务校验 (任何状态都能转, 包括"已完结"→"执行中" 状态回滚)。不写日志 (`biz_meeting_log` 表不存在)。
- **"修改"**: 改 6 字段 (无 `remark`, 详见 §9 #3); start_time > end_time 不校验; meetingName 空不校验 - **"修改" / "复制"**: 跳 `MeetingNew.vue` 独立页处理 (`Meetings.vue:106` 注释明确说明), form 含 9 字段, 见 manager_meeting_new.md
--- ---
## 6. Java Entity ↔ 数据库表 一致性 ## 6. Java Entity ↔ 数据库表 一致性
### 6.1 biz_meeting DDL (实测 2026-08-18) ### 6.1 biz_meeting DDL (实测 2026-08-21)
```sql ```sql
CREATE TABLE `biz_meeting` ( CREATE TABLE `biz_meeting` (
`meeting_id` bigint NOT NULL AUTO_INCREMENT COMMENT '会议ID', `meeting_id` bigint NOT NULL AUTO_INCREMENT COMMENT '会议ID',
`business_id` varchar(50) NOT NULL COMMENT '业务编号ID', `business_id` varchar(50) NOT NULL COMMENT '业务会议ID 5643145673',
`project_id` bigint DEFAULT NULL COMMENT '关联项目ID', `project_id` bigint DEFAULT NULL COMMENT '所属项目ID',
`project_no` varchar(50) DEFAULT NULL COMMENT '项目编号', `project_no` varchar(50) DEFAULT NULL COMMENT '项目编号',
`project_name` varchar(200) DEFAULT NULL COMMENT '项目名称', `project_name` varchar(200) DEFAULT NULL COMMENT '项目名称',
`meeting_name` varchar(200) DEFAULT NULL COMMENT '会议名称', `meeting_name` varchar(200) DEFAULT NULL COMMENT '会议名称',
@@ -156,7 +160,8 @@ CREATE TABLE `biz_meeting` (
`start_time` datetime DEFAULT NULL COMMENT '会议开始时间', `start_time` datetime DEFAULT NULL COMMENT '会议开始时间',
`end_time` datetime DEFAULT NULL COMMENT '会议结束时间', `end_time` datetime DEFAULT NULL COMMENT '会议结束时间',
`org_name` varchar(200) DEFAULT NULL COMMENT '公司名称(冗余)', `org_name` varchar(200) DEFAULT NULL COMMENT '公司名称(冗余)',
`current_stage` varchar(20) DEFAULT '0' COMMENT '当前阶段 未执行/执行中/监管通过/待整改/待结算/已结算/已结题', `address` varchar(500) DEFAULT NULL COMMENT '会议地址',
`current_stage` varchar(20) DEFAULT '0' COMMENT '当前阶段 未执行/待监管/监管通过/待整改/待结算/已结算/已结题',
`supervision_opinion` varchar(500) DEFAULT NULL COMMENT '监察意见', `supervision_opinion` varchar(500) DEFAULT NULL COMMENT '监察意见',
`supervision_by` varchar(64) DEFAULT NULL COMMENT '监察人', `supervision_by` varchar(64) DEFAULT NULL COMMENT '监察人',
`supervision_time` datetime DEFAULT NULL COMMENT '监察时间', `supervision_time` datetime DEFAULT NULL COMMENT '监察时间',
@@ -165,28 +170,29 @@ CREATE TABLE `biz_meeting` (
`labor_signed` char(1) DEFAULT '0' COMMENT '签署劳务 0未签 1已签', `labor_signed` char(1) DEFAULT '0' COMMENT '签署劳务 0未签 1已签',
`create_by` varchar(64) DEFAULT '' COMMENT '创建人', `create_by` varchar(64) DEFAULT '' COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT '' COMMENT '修改', `update_by` varchar(64) DEFAULT '' COMMENT '更新',
`update_time` datetime DEFAULT NULL COMMENT '修改时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`meeting_id`), PRIMARY KEY (`meeting_id`),
UNIQUE KEY `uk_business_id` (`business_id`), UNIQUE KEY `uk_business_id` (`business_id`),
KEY `idx_meeting_project` (`project_id`), KEY `idx_meeting_project` (`project_id`),
KEY `idx_meeting_stage` (`current_stage`) KEY `idx_meeting_stage` (`current_stage`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='会议表';
COLLATE=utf8mb4_0900_ai_ci COMMENT='会议表';
``` ```
**实测数据**: 9 行 (今日) / 7 行 (08-18) / AUTO_INCREMENT=12 (说明中间有删除)
### 6.2 关联表 ### 6.2 关联表
- `biz_project` — 见 `manager_projects.md` §6, 最近 `ALTER TABLE ADD COLUMN create_user_id bigint` - `biz_project` — 见 `manager_projects.md` §6, `create_user_id bigint`
- `biz_person` — PK `person_id`, UNIQUE `uk_person_user_id (user_id)`, 用于 JOIN 取 create_user_name - `biz_meeting_attendee` — doctor/expert 角色过滤的中间表 (见 `BizMeetingMapper.xml:49 exists`)
### 6.3 Entity ↔ 表字段对照表 ### 6.3 Entity ↔ 表字段对照表
| 实体字段 | 中文列名 (DB COMMENT) | 实体类型 | 表字段 | 表类型 | 一致? | | 实体字段 | 中文列名 (DB COMMENT) | 实体类型 | 表字段 | 表类型 | 一致? |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| meetingId | 会议ID | Long | meeting_id | bigint | ✅ | | meetingId | 会议ID | Long | meeting_id | bigint | ✅ |
| businessId | 业务编号ID | String | business_id | varchar(50) | ✅ | | businessId | 业务会议ID 5643145673 | String | business_id | varchar(50) | ✅ |
| projectId | 关联项目ID | Long | project_id | bigint | ✅ | | projectId | 所属项目ID | Long | project_id | bigint | ✅ |
| projectNo | 项目编号 | String | project_no | varchar(50) | ✅ | | projectNo | 项目编号 | String | project_no | varchar(50) | ✅ |
| projectName | 项目名称 | String | project_name | varchar(200) | ✅ | | projectName | 项目名称 | String | project_name | varchar(200) | ✅ |
| meetingName | 会议名称 | String | meeting_name | varchar(200) | ✅ | | meetingName | 会议名称 | String | meeting_name | varchar(200) | ✅ |
@@ -196,6 +202,7 @@ CREATE TABLE `biz_meeting` (
| startTime | 会议开始时间 | Date | start_time | datetime | ✅ (有 @JsonFormat + @DateTimeFormat) | | startTime | 会议开始时间 | Date | start_time | datetime | ✅ (有 @JsonFormat + @DateTimeFormat) |
| endTime | 会议结束时间 | Date | end_time | datetime | ✅ | | endTime | 会议结束时间 | Date | end_time | datetime | ✅ |
| orgName | 公司名称(冗余) | String | org_name | varchar(200) | ✅ | | orgName | 公司名称(冗余) | String | org_name | varchar(200) | ✅ |
| **address** | **会议地址** | **String** | **address** | **varchar(500)** | ✅ (实体/表都有, 但 selectFields/insert/update/selectList 全部漏列 — 详见 §9 #6) |
| currentStage | 当前阶段 | String | current_stage | varchar(20) | ✅ | | currentStage | 当前阶段 | String | current_stage | varchar(20) | ✅ |
| supervisionOpinion | 监察意见 | String | supervision_opinion | varchar(500) | ✅ | | supervisionOpinion | 监察意见 | String | supervision_opinion | varchar(500) | ✅ |
| supervisionBy | 监察人 | String | supervision_by | varchar(64) | ✅ | | supervisionBy | 监察人 | String | supervision_by | varchar(64) | ✅ |
@@ -203,15 +210,13 @@ CREATE TABLE `biz_meeting` (
| invitationUrl | 邀请函URL | String | invitation_url | varchar(500) | ✅ | | invitationUrl | 邀请函URL | String | invitation_url | varchar(500) | ✅ |
| scheduleUrl | 日程海报URL | String | schedule_url | varchar(500) | ✅ | | scheduleUrl | 日程海报URL | String | schedule_url | varchar(500) | ✅ |
| laborSigned | 签署劳务 | String | labor_signed | char(1) | ✅ | | laborSigned | 签署劳务 | String | labor_signed | char(1) | ✅ |
| createBy | 创建人 | String | create_by | varchar(64) | ✅ (BaseEntity, MetaObjectHandler 自动填) | | createBy | 创建人 | String | create_by | varchar(64) | ✅ |
| createTime | 创建时间 | Date | create_time | datetime | ✅ | | createTime | 创建时间 | Date | create_time | datetime | ✅ |
| updateBy | 修改人 | String | update_by | varchar(64) | ✅ | | updateBy | 更新人 | String | update_by | varchar(64) | ✅ |
| updateTime | 修改时间 | Date | update_time | datetime | ✅ | | updateTime | 更新时间 | Date | update_time | datetime | ✅ |
| — | (无 `remark` 字段) | — | (无 `remark` 列) | — | ❌ **实体缺 remark 字段** (见 §9 #3) | | — | (无 `remark` 字段) | — | (无 `remark` 列) | — | ❌ **实体缺 remark** (见 §9 #3) |
**修订记录** (本节实体版本演进): **修订**: v1 漏报 `address` 字段 — 实测 DB 有 `address varchar(500)` 列 + COMMENT '会议地址', 实体也有 `private String address`, 但 mapper 全套 (selectFields / insert / updateByPrimaryKey) 都没碰这个字段。**新发现**: 见 §9 #6
- 第一轮: meetingId String→Long, supervisionTime String→Date, INSERT/UPDATE/selectFields 补漏, businessId 雪花 ID 兜底 (commit 66a928a, 共享 manager_meeting_new.md)
- 第四轮: startTime/endTime 加 `@DateTimeFormat` (commit 14757f3) — 让前端 `"2026-08-18 10:00:00"` 字符串能 Spring 绑定到 Date
--- ---
@@ -222,78 +227,82 @@ CREATE TABLE `biz_meeting` (
| biz_meeting | PRIMARY | meeting_id | ✅ ORDER BY + WHERE meeting_id = | | biz_meeting | PRIMARY | meeting_id | ✅ ORDER BY + WHERE meeting_id = |
| biz_meeting | uk_business_id | business_id (UNIQUE) | ✅ 雪花 ID 防冲突 | | biz_meeting | uk_business_id | business_id (UNIQUE) | ✅ 雪花 ID 防冲突 |
| biz_meeting | idx_meeting_project | project_id | ✅ `WHERE project_id =` (项目侧查会议) | | biz_meeting | idx_meeting_project | project_id | ✅ `WHERE project_id =` (项目侧查会议) |
| biz_meeting | idx_meeting_stage | current_stage | ✅ `WHERE current_stage =` (filter 启用后生效) | | biz_meeting | idx_meeting_stage | current_stage | ✅ `WHERE current_stage =` |
**缺失但暂可接受** (按 §2 筛选启用后): **缺失但暂可接受** (按 §2 筛选启用后):
- `idx_project_no``LIKE '%xxx%'` 模糊匹配无法走索引, 但当前 7 行可接受 (见 §10 #6) - `idx_project_no``LIKE '%xxx%'` 模糊匹配无法走索引 (见 §10 #6)
- `idx_meeting_name` — 同上 - `idx_meeting_name` — 同上
- 复合 `(project_id, meeting_id)` — JOIN 用, 当前单 `project_id` 索引够 - 复合 `(project_id, meeting_id)` — JOIN 用, 当前单 `project_id` 索引够
--- ---
## 8. 与原型差异 ## 8. 与原型差异 (proto/html/components/meeting-manage.html)
### 8.1 实现新增 (原型没有) — **改进** ### 8.1 实现新增 (原型没有) — **改进**
| 项 | 原型 | 实现 | 评估 | | 项 | 原型 | 实现 | 评估 |
|---|---|---|---| |---|---|---|---|
| 筛选 会议ID / 第?期 / 会议时间 / 项目形式 / 备注 | 4 项 | 8 项 | ✅ manager 端管理需要 | | 筛选 "备注" | 4 项 (项目编号/会议名称/项目形式/当前阶段) | 8 项 (多 会议ID/第?期/会议时间/备注) | ⚠️ 备注 filter 死路 (见 §9 #3), 第?期无 placeholder 限制 |
| 表格多选 + 批量提交 | 无 | ✅ | manager 专属 | | 弹窗 (查看/修改) | 跳转 detail 页 | ✅ el-dialog 紧凑 | manager 端更高效 |
| 操作列 4 按钮 (查看/修改/提交/复制) | 1 按钮 | ✅ | 角色不同 | | 行内 "复制" → 跳独立页 | 原型 `onclick="copyMeeting(this)"` URL 传参 | ✅ 跳 `MeetingNew.vue?mode=copy` | 模式更清晰 |
| 分页 | 无 | ✅ | 必要 |
| 弹窗 (查看/修改) | 跳转 meeting-detail.html | ✅ 弹窗紧凑 |
### 8.2 原型有但实现缺失 — **回退** ### 8.2 原型有但实现缺失 — **回退**
| 原型 | 实现 | 差距 | | 原型 | 实现 | 差距 |
|---|---|---| |---|---|---|
| "签署状态" 列 (`labor_signed`) | 仅 filter 8 项无此 (见 §10 #4) | ❌ 缺 filter + 列 | | 9 条红字提示 (执行方只能在项目列表建会 / 状态定义 / 提交审核后 OA 流程 / 复制 → 新建会议 / 总期数可点击筛选 / 提交后 3 秒消失 / 7 种状态统一解释 / 冻结中提交置灰 / 到期前 24h 每 4h 短信催促) | 无 | ❌ 整段红字提示缺失 (manager 端 UX 参考) |
| 弹框 邀请函 + 日程 (原型动态面板) | 无 | ❌ 原型专门有 | | `meeting-detail-executor.html` 详情页 | 弹 dialog | ⚠️ 弹窗缺字段 (见 §10 #11) |
| 顶部红字提示 | 无 | ❌ |
### 8.3 文字 / 标签差异 ### 8.3 文字 / 标签差异
| 项 | 原型 | 实现 | 评估 | | 项 | 原型 | 实现 | 评估 |
|---|---|---|---| |---|---|---|---|
| 面包屑 | 首页 > 我参与的会议 | 首页 / 会议管理 | ✅ 角色匹配 | | 面包屑 | 首页 > 我参与的会议 | 首页 / 会议管理 | ✅ 角色匹配 |
| 当前阶段 select | 未开始 / 已结束 | 未执行 / 执行中 / 已执行 / 已完结 | ⚠️ 实装更细, 但跟 DB 实际数据不一致 (见 §9 #1) | | 当前阶段 select | 6 项 (未执行/执行中/待审核/待结算/已完结/冻结中) | 4 项 (未执行/执行中/已执行/已完结) | ⚠️ **缺 待审核/待结算/冻结中 3 项** (但跟 DB COMMENT 一致, 见 §9 #1) |
| 期数筛选项 | "第 [____] 期" 三段式 (label/input/label) | "期数(第几期)" 单 label | ⚠️ 排版略不同 (今日改 label 文案) |
| 颜色 | `#1890ff` (浅蓝) | `var(--brand-primary)` (深海军蓝) | ✅ 项目色 | | 颜色 | `#1890ff` (浅蓝) | `var(--brand-primary)` (深海军蓝) | ✅ 项目色 |
### 8.4 总结 ### 8.4 总结
**整体方向**: **扩展** — 实现远超原型 (manager 端多 5 列 + 批量提交 + 修改 dialog); 原型"邀请函/日程弹框"和"签署状态"未移植; "红字提示"未加 **整体方向**: **对齐** — 实装与原型字段基本一致 (12 列 vs 12 列, 8 项 filter vs 8 项 filter); 差距在 "红字提示" 整段缺失 + 当前阶段 select 选项数不一致
--- ---
## 9. 设计问题 ## 9. 设计问题
> 集中列设计层面问题, 不重复 §10 (待修复)。已修的归附录 "修复记录"。
### 9.1 当前阶段 select 与 DB 实际数据不一致 ### 9.1 当前阶段 select 与 DB 实际数据不一致
DDL 注释列了 7 种 (未执行/执行中/监管通过/待整改/待结算/已结算/已结题), 但 DB 实际 6 种 (0/已结算/已结题/待整改/待监管/监管通过), 前端 select 又是另一套 4 (未执行/执行中/已执行/已完结)。**三者全不一致**, filter 启用后用户选"未执行"也筛不到任何数据。 DDL 注释列了 7 种 (未执行/待监管/监管通过/待整改/待结算/已结算/已结题), 实装 select 4 (未执行/执行中/已执行/已完结)。两者有交集但不一致, filter 启用后用户选"未执行"也筛不到任何数据 (DB 实际可能是 待监管/已结算 等)
### 9.2 calcRemain 读不存在的字段 ### 9.2 calcRemain 读不存在的字段
`Meetings.vue:186``row.submitDeadlineDays`, 但 `BizMeeting` 实体无此字段 (BizProject 才有)。当前永远 fallback 到 30 天, 是个静默 bug (见 §10 #2) `Meetings.vue:162``row.submitDeadlineDays`, 但 `BizMeeting` 实体无此字段 (BizProject 才有)。当前永远 fallback 到 30 天, 是个静默 bug。
### 9.3 实体无 remark 字段 ### 9.3 实体无 remark 字段
前端 3 处使用 (`Meetings.vue:32 q.remark`, `:67 row.remark`, `:125 editForm.remark`), 但 `BizMeeting` 实体无 `remark`, 表也无 `remark` 列。后果: 前端 3 处使用 (`Meetings.vue:32 q.remark`, `:67 row.remark`, `:32 filter`), 但 `BizMeeting` 实体无 `remark`, 表也无 `remark` 列。后果:
- filter "备注" 永远无效 (Spring `@ModelAttribute` 绑定到不存在的 setter 报 IllegalArgumentException? 实测 silent fail) - filter "备注" 永远无效
- 列表"备注"列永远空 - 列表"备注"列永远空
- 详情 dialog"备注"行永远 `-` - 详情 dialog"备注"行永远 `-`
- 修改 dialog "备注"输入框写入 → mapper UPDATE 无对应 `<if>` → 静默丢
修复路径有 2 条 (见 §10 #3)。
### 9.4 数据隔离缺失 (manager 端) ### 9.4 数据隔离缺失 (manager 端)
`BizMeetingMapper.xml:37 selectList``@DataScope``lead_user_id` 过滤。当前测试数据无风险, 生产必修 (见 §10 #1) `BizMeetingMapper.xml:37 selectList``@DataScope``lead_user_id` 过滤。当前测试数据无风险, 生产必修。
### 9.5 状态回滚无校验 ### 9.5 状态回滚无校验
"提交" 操作任何 stage 都能转 "执行中", 包括 "已完结" → "执行中" (状态回滚)。应加前置校验 (见 §10 #5) "提交" 操作任何 stage 都能转 "执行中", 包括 "已完结" → "执行中" (状态回滚)。应加前置校验。
### 9.6 ✨ 新发现: address 字段 mapper 全套遗漏
DB 有 `address varchar(500) COMMENT '会议地址'`, 实体也有 `private String address` + getter/setter (`BizMeeting.java:117-119`), 但 `BizMeetingMapper.xml`:
- `selectFields` (line 29-32) — 漏列
- `selectByPrimaryKey` (line 33-36) — 通过 `selectFields` 漏列
- `selectList` (line 37-52) — 通过 `selectFields` 漏列
- `insert` (line 53-97) — `<if>` 块无 address
- `updateByPrimaryKey` (line 98-121) — `<if>` 块无 address
**后果**: 即使前端表单填了 address, 写入 DB 时会被静默丢弃; 即使 DB 已有 address, 列表也读不出来。
--- ---
@@ -302,87 +311,133 @@ DDL 注释列了 7 种 (未执行/执行中/监管通过/待整改/待结算/已
| # | 问题 | 文件 | 修复建议 | 严重度 | | # | 问题 | 文件 | 修复建议 | 严重度 |
|---|---|---|---|---| |---|---|---|---|---|
| 1 | **数据隔离缺失** (selectList 无 lead_user_id 过滤) | `BizMeetingMapper.xml:37` | 加 `<if test="params.leadUserId != null">and project_id in (select project_id from biz_project where lead_user_id = #{params.leadUserId})</if>`; Controller 注入当前 user_id | **P0** (生产必修) | | 1 | **数据隔离缺失** (selectList 无 lead_user_id 过滤) | `BizMeetingMapper.xml:37` | 加 `<if test="params.leadUserId != null">and project_id in (select project_id from biz_project where lead_user_id = #{params.leadUserId})</if>`; Controller 注入当前 user_id | **P0** (生产必修) |
| 2 | **`calcRemain``row.submitDeadlineDays`** (BizMeeting 无此字段) | `Meetings.vue:186` | 改成读 `row.project?.submitDeadlineDays` (后端 JOIN biz_project); 或去此字段 (manager 不需要) | **P1** | | 2 | **`calcRemain``row.submitDeadlineDays`** (BizMeeting 无此字段) | `Meetings.vue:162` | 改成读 `row.project?.submitDeadlineDays` (后端 JOIN biz_project); 或去此字段 (manager 不需要) | **P1** |
| 3 | **实体无 `remark` 字段** (前端 3 处 silent fail) | `BizMeeting.java` + `BizMeetingMapper.xml` | 路径 A: 加 `private String remark` + getter/setter + mapper UPDATE/INSERT 加 `<if>`; 路径 B: 前端 3 处全删 (业务可能不需要) | **P1** | | 3 | **实体无 `remark` 字段** (前端 3 处 silent fail) | `BizMeeting.java` + `BizMeetingMapper.xml` | 路径 A: 加 `private String remark` + getter/setter + mapper UPDATE/INSERT 加 `<if>` + DDL `ALTER TABLE biz_meeting ADD COLUMN remark text`; 路径 B: 前端 3 处全删 | **P1** |
| 4 | **filter 缺"签署状态"** (原型有, 实体有 `labor_signed`) | `Meetings.vue` filter + table column | 加 `<el-select v-model="q.laborSigned">` 2 option + table 加列 | **P2** | | 4 | **filter 缺"签署状态"** (实体有 `labor_signed`) | `Meetings.vue` filter + table column | 加 `<el-select v-model="q.laborSigned">` 2 option + table 加列 | **P2** |
| 5 | **状态回滚无校验** (任何 stage → "执行中") | `Meetings.vue:271-273 onSubmit` + `:302-309 onBatchSubmit` | 加 `if (currentStage === '未执行')` 才能提交, 否则 ElMessageBox 提示 | **P2** | | 5 | **状态回滚无校验** (任何 stage → "执行中") | `Meetings.vue:205-212 onSubmit` + `:215-226 onBatchSubmit` | 加 `if (currentStage === '未执行')` 才能提交, 否则 ElMessageBox 提示 | **P2** |
| 6 | **筛选后端需补索引** (`idx_project_no` + `idx_meeting_name`) | `biz_meeting` table | `ALTER TABLE biz_meeting ADD KEY idx_project_no (project_no), ADD KEY idx_meeting_name (meeting_name);` (⚠️ `LIKE '%xxx%'` 模糊匹配无法走索引, 实际效果有限) | **P3** | | 6 | **`address` 字段 mapper 全套遗漏** (DB 有, 实体有, mapper 全套没碰) | `BizMeetingMapper.xml` | selectFields/insert/update 全部加 address 字段 + `<if test="address != null and address != ''">` | **P1** |
| 7 | **实体 periodNo/totalPeriods Long vs int** | `BizMeeting.java` | 改 int, MyBatis INSERT 不需 Long 转换 | **P3** | | 7 | **筛选后端需补索引** (`idx_project_no` + `idx_meeting_name`) | `biz_meeting` table | `ALTER TABLE biz_meeting ADD KEY idx_project_no (project_no), ADD KEY idx_meeting_name (meeting_name);` (⚠️ `LIKE '%xxx%'` 模糊匹配无法走索引) | **P3** |
| 8 | **当前阶段 select 选项跟 DB 实际数据不一致** (见 §9 #1) | `Meetings.vue:25-31` | 改成跟 DB 一致 (已结算/已结题/待整改/待监管/监管通过 等), 或加 DB 不存在的值 (DB 加 ENUM 同步) | **P1** | | 8 | **实体 periodNo/totalPeriods Long vs int** | `BizMeeting.java` | 改 int, MyBatis INSERT 不需 Long 转换 | **P3** |
| 9 | **filter 8 项挤一行** (屏幕窄乱) | `Meetings.vue:6-37` | 拆 2 行, 或 grid-template-columns: repeat(4, 1fr) | **P2** | | 9 | **当前阶段 select 选项跟 DB 实际数据不一致** (见 §9 #1) | `Meetings.vue:25-31` | 改成 7 项 (跟 DB COMMENT 一致: 未执行/待监管/监管通过/待整改/待结算/已结算/已结题) | **P1** |
| 10 | **批量提交无事务/无批量 endpoint** | `Meetings.vue:302-309` | 后端加 `PUT /business/meeting/batch?ids=...` + `@Transactional`; 前端 for 循环改单次调用 | **P2** | | 10 | **filter 8 项挤一行** (屏幕窄乱) | `Meetings.vue:6-37` | 拆 2 行, 或 grid-template-columns: repeat(4, 1fr) | **P2** |
| 11 | **详情 dialog 缺字段** (create_time / org_name / supervision_* / labor_signed / invitation_url / schedule_url) | `Meetings.vue:91-103` | el-descriptions 加 5-8 行; URL 字段加 `<a :href>` 链接 | **P1** | | 11 | **批量提交无事务/无批量 endpoint** | `Meetings.vue:215-226` | 后端加 `PUT /business/meeting/batch?ids=...` + `@Transactional`; 前端 for 循环改单次调用 | **P2** |
| 12 | **`totalPeriods` 永远 1** (MeetingNew.vue 没让填) | `MeetingNew.vue` | 加"总期数"字段 (用户填, INSERT 时一起写) | **P1** | | 12 | **详情页字段映射完整** (14 字段 + 邀请函 + 日程 + 返回, 详见 §5 跳转形式列) | `MeetingDetail.vue` (新) | ✅ 已修 (A.7) — 跳独立页, 参照原型 `meeting-detail.html` 1:1 实现 | ✅ 已修 |
| 13 | **`totalPeriods` 永远 1** (MeetingNew.vue 没让填) | `MeetingNew.vue` | 加"总期数"字段 (用户填, INSERT 时一起写) | **P1** |
| 14 | ✨ **9 条红字提示整体缺失** (原型 `meeting-manage.html:533-543`) | `Meetings.vue` | 顶部加 `.hint-list` 块 (manager 端 UX 参考, 不强制与 executor 端一致) | **P2** |
| 15 | ✨ **截止前 24h 短信催促** (原型第 9 条提示) | 后端 scheduler + sys_sms 表 | 加定时任务扫描 end_time + submit_deadline_days, 距截止 24h 起每 4h 触发 | **P3** |
--- ---
## 11. 引用清单 ## 11. 引用清单
### 前端 ### 前端
- `ry-vue3/src/views/manager/Meetings.vue:343` — 本审查目标 - `ry-vue3/src/views/manager/Meetings.vue:256` — 本审查目标 (今日改动: 7/10/60/100/32)
- `ry-vue3/src/views/manager/MeetingNew.vue` — 新建会议独立页 (共享 BizMeeting) - `ry-vue3/src/views/manager/MeetingNew.vue` — 新建/修改/复制会议独立页
- `ry-vue3/src/layout/AdminLayout.vue:99` — manager 菜单项 - `ry-vue3/src/layout/AdminLayout.vue:97` — manager 菜单项
- `ry-vue3/src/router/index.js:69-70` — 路由 - `ry-vue3/src/router/index.js:65-66` — 路由
- `ry-vue3/src/utils/request.js:15-30` — axios 拦截器 (`__silentError` 跳过 toast) - `ry-vue3/src/utils/request.js:15-30` — axios 拦截器 (`__silentError` 跳过 toast)
- `ry-vue3/src/api/public.js:32, 92-93``bizList` / `bizUpdate` / `bizAdd` - `ry-vue3/src/api/public.js:32, 90-93``bizList` / `bizGet` / `bizUpdate` / `bizAdd`
### 后端 (Java) ### 后端 (Java)
- `ry-api/ruoyi-business/.../controller/BizMeetingController.java:54` — 5 endpoints - `ry-api/ruoyi-business/.../controller/BizMeetingController.java:26-75` — 5 endpoints
- `ry-api/ruoyi-business/.../domain/BizMeeting.java:107` — 24 字段, **缺 remark** - `ry-api/ruoyi-business/.../domain/BizMeeting.java:11-136` — 25 字段, **缺 remark**, **address 在 mapper 全套遗漏**
- `ry-api/ruoyi-business/.../service/impl/BizMeetingServiceImpl.java:34` — insert 调 SnowflakeId 兜底 businessId - `ry-api/ruoyi-business/.../service/impl/BizMeetingServiceImpl.java:24-31` — insert 调 SnowflakeId 兜底 businessId
- `ry-api/ruoyi-business/.../mapper/BizMeetingMapper.java` — mapper 接口 - `ry-api/ruoyi-business/.../mapper/BizMeetingMapper.java` — mapper 接口
### Mapper XML ### Mapper XML
- `ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml:128` — selectList 7 个 `<if>` 已生效 - `ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml:131` — selectList 7 个 `<if>` 已生效; **address 全套漏** (见 §9 #6)
### 配置 / 工具 ### 配置 / 工具
- `ry-api/ruoyi-admin/src/main/resources/application-druid.yml:9-10` — DB 连接 (root / cu2oh2co3) - `ry-api/ruoyi-admin/src/main/resources/application-druid.yml:9-10` — DB 连接 (root / cu2oh2co3)
- `ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/id/SnowflakeId.java` — 雪花 ID 生成器 - `ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/id/IdGenerator.java` — 雪花 ID 生成器
- `ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/SecurityUtils.java:27` — getUserId() - `ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/SecurityUtils.java:27` — getUserId()
### 原型 ### 原型
- `proto/html/components/meetings.html:546` — "我参与的会议" 原型 (doctor 端, 实装 manager 重用) - `proto/html/manager.html:207` — 链接到 `components/meeting-manage.html`
- `proto/html/components/meeting-detail.html`会议详情原型 (现用 el-dialog 替代) - `proto/html/components/meeting-manage.html:618`manager 会议管理原型 (12 列 + 8 filter + 9 红字提示)
- `proto/html/components/meeting-manage-1.html` — 原型变体 (期数 input 拆成 第/数字/期 三段), 暂未用
### 数据库 (实测 2026-08-18) ### 数据库 (实测 2026-08-21)
- `biz_meeting`7 行, 4 索引 (PK + UNIQUE + 2 普通), 23 (见 §6.1) - `biz_meeting`9 行 (08-21) / 7 行 (08-18), AUTO_INCREMENT=12, 4 索引 (PK + UNIQUE + 2 普通), 24
- `biz_project` — 12 行, 含 `create_user_id bigint` (commit ffda9c6) - `biz_project` — 12 行, 含 `create_user_id bigint`
- `biz_person` — JOIN `user_id → name` - `biz_meeting_attendee` — doctor/expert 角色过滤的中间表
--- ---
## 附录: 修复记录 (按 commit 时间倒序) ## 附录: 修复记录 (按 commit 时间倒序)
> 已修的问题不进入 §10。本节按 commit 顺序列出, 每条标明 issue → fix → 验证。 ### A.7 commit (未 commit, 2026-08-21 session 改动) — 查看按钮跳页面
> **背景**: 用户硬要求 "跳页面变成打开 dialog 是完全不能接受的", 同时让 page-tech-review skill 能自动抓这类反模式
#### A.7.1 新建 `MeetingDetail.vue` (`ry-vue3/src/views/manager/MeetingDetail.vue`, 290 行)
- 1:1 参照 `ManagerProjectDetail.vue` 的 page-card + breadcrumb + new-card-title 结构
- 字段映射: 14 项基本信息 (项目编号/会议ID/会议名称/项目名称/项目形式/总期数/期数/起止时间/当前阶段/签署劳务/支持单位/创建人员/创建时间/备注) + 监察段 (条件渲染) + 邀请函/日程 (有 URL 渲染预览+下载, 无 URL 显示空态) + 返回
- 字段对照 `biz_meeting` DB 实测 (见 §6.3); `supervision_*` 任意一项存在就整段显示, 避免空占位
- 已知死字段: `备注` 永远 `-` (实体/表都没, 见 §9 #3); `address` mapper 全套漏 (见 §9 #6), 暂不显示此字段避免误导
- URL 参数: `route.params.meetingId` (path param, 跟 `ManagerProjectDetail` 一致)
#### A.7.2 router 加路由 (`ry-vue3/src/router/index.js:66`)
```js
{ path: 'meetings/detail/:meetingId', name: 'manager-meetings-detail',
component: () => import('@/views/manager/MeetingDetail.vue'),
meta: { title: '会议详情' } },
```
参照 `manager-projects-detail` (line 63) 命名
#### A.7.3 `Meetings.vue` 删 dialog 改跳页面 (367 行 → 220 行)
- `onView(row)``viewDetail(row)`, 内部 `router.push(/manager/meetings/detail/${row.meetingId})`
- 删除: `<el-dialog>` 整块 (89-156 行), `detailOpen`/`currentRow` ref, `onView`/`calcRemain`/`laborSignedLabel` 函数, `.meeting-detail-dialog` 系列样式, 3 个 icon imports (Document/Calendar/Picture)
- 保留: `fmtTime` (表格列用), `stageClass` (表格列用), `.stage-tag` 系列样式
#### A.7.4 page-tech-review skill 加规则 (`SKILL.md`)
- §5 加 2 处: (1) 列说明加 "跳转形式 (page=✅ / dialog=❌)"; (2) 新 bullet 强调 "查看" 必须跳独立页面, 给反例 (本次修复前 Meetings.vue) 和参考模式 (Projects.vue:424)
- §10 铁律加第 10 条 ⛔: "查看" 不能用 dialog; 审查时 §5 必须标 "跳转形式", dialog 即 P0
---
### A.6 commit (未 commit, 2026-08-21 session 改动) — label/期数/placeholder 修复
> **改动范围**: `Meetings.vue` (前端) + `BizProjectController.java` (后端, 顺带修)
#### A.6.1 后端 `BizProjectController.java:223`
- **错**: `int s = safeLong(...) + safeLong(...) + ...` — long 累加赋给 int, Java 报"可能会有损失"
- **改**: `int s``long s`
- **影响**: rate() 接口里 compliance 角色聚合评分那行, 不影响 meeting 页面本身
#### A.6.2 前端 `Meetings.vue:10` 筛选项 "第?期"
- **问题**: `:min="0"` 让 el-input-number 在空值时强制 0 → 后端 `period_no = 0` 过滤掉全部数据; label 文案 "第?期" 不直观
- **改**:
- label: `第?期``期数(第几期)`
- 控件: `<el-input-number :min="0" :precision="0" controls-position="right">``<el-input placeholder="请输入期数" clearable>`
- 原因: Element Plus 2.4 无 `value-on-clear` prop, `:min="1"` 仍会让 blur 时 snap 到 1; 改用普通 `<el-input>` 让空值保持空串, 由 mapper `<if test="periodNo != null">` 跳过
- **验证**: 缺值 → 不影响其他筛选; `periodNo=1` → 1 行
#### A.6.3 前端 `Meetings.vue:60, 100` 期数显示
- **问题**: `第 {{ row.periodNo || 0 }} 期` 在 periodNo 为 null/空时强行显示 "第 0 期"
- **改**: `{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}`
- **影响**: 表格列 + 详情 dialog 同步去掉 `|| 0` 兜底
#### A.6.4 前端 `Meetings.vue:7-32` placeholder 补齐
- **问题**: 5 个文本输入 + 2 个日期选择器缺 placeholder
- **改**:
- 项目编号 → `请输入项目编号`
- 会议ID → `请输入会议ID`
- 会议名称 → `请输入会议名称`
- 期数(第几期) → `请输入期数`
- 会议时间 → `开始时间` / `结束时间`
- 备注 → `请输入备注`
- 项目形式 / 当前阶段 → 已经有 `请选择`
### A.5 commit `bae9fa6` (2026-08-18) — 恢复会议ID + 第?期 filter (第五轮续) ### A.5 commit `bae9fa6` (2026-08-18) — 恢复会议ID + 第?期 filter (第五轮续)
- **背景**: 第四轮前后曾误删 `会议ID` / `第?期` filter, 用户澄清两个字段原型中都有. - **背景**: 第四轮前后曾误删 `会议ID` / `第?期` filter, 用户澄清两个字段原型中都有
- **修改**: `Meetings.vue:8` 加回 `<el-form-item label="会议ID">` (el-input); `Meetings.vue:10` 第?期从 el-input 改 el-input-number (`:min="0" :precision="0"`); `q` reactive + `reset()` 加回 `meetingId:''`; mapper `<if>` 块本就有, 无需动. - **修改**: `Meetings.vue:8` 加回 `<el-form-item label="会议ID">`; `Meetings.vue:10` 第?期 el-input-number; q reactive + reset() 加回 `meetingId`
- **验证**: `q.meetingId=14` → 1 行; `q.periodNo=1` → 1 行; 留空 → 不影响其他筛选. - **验证**: `q.meetingId=14` → 1 行; `q.periodNo=1` → 1 行
### A.4 commit `14757f3` (2026-08-18) — mapper selectList 加 7 个筛选 if + @DateTimeFormat (第四轮) ### A.4 commit `14757f3` (2026-08-18) — mapper selectList 加 7 个筛选 if + @DateTimeFormat
- **解决**: P0#1 全部筛选不生效 (`<where>` 空). - **解决**: 全部筛选不生效 (`<where>` 空)
- **修改**: `BizMeetingMapper.xml:37-50`7`<if>` (project_no LIKE, meeting_id =, meeting_name LIKE, period_no =, project_form =, current_stage =, start_time >=, end_time <=); `BizMeeting.java:30-33` startTime/endTime 加 `@DateTimeFormat` 让 Spring 能绑字符串. - **修改**: `BizMeetingMapper.xml:37-52`8`<if>`; `BizMeeting.java:30-33` startTime/endTime 加 `@DateTimeFormat`
- **验证**: `LIKE %ZH-2026-6%` → 5 行; `current_stage=已结算` → 1 行; `start_time>=2026-03-01` → 4 行; `period_no=1` → 1 行; 组合 AND 正常.
### A.3 commits `6d4f1e6` + `6774c44` (2026-08-18) — style-alignment + 批量按钮去 plain (第三轮) ### A.3-A.1 (略, 见 v1 报告)
- **解决**: 批量按钮 disabled 顺色; "已选 X 条" 紧贴按钮左边; inline style 残留; batch-bar 属性顺序.
- **修改**: `Meetings.vue:41` 批量按钮去 `plain`; `Meetings.vue:324 .filter-tip { margin-left: auto }`; `<span class="date-sep">至</span>` 替代 inline style; `.batch-bar` 属性顺序对齐 People.vue.
### A.2 commit `08d81e9` (2026-08-18) — onCopy 改 bizAdd + 三处双 toast (第二轮)
- **解决**: P0#3 onCopy bug (meetingId='' 走 bizUpdate → 0 行 + 误报成功); P0#4 双 toast.
- **修改**: `Meetings.vue:222-228 editMode` ref + `Meetings.vue:246-267 submitEdit()` 按 editMode 分支 (copy → bizAdd, edit → bizUpdate); 三处 bizUpdate 加 `{ __silentError: true }`.
### A.1 commit `66a928a` (跨 session, manager_meeting_new.md 第一轮) — 数据库正确性
- 共享 `BizMeeting` 修复: meetingId String→Long, supervisionTime String→Date, INSERT/UPDATE/selectFields 补漏, businessId 雪花 ID 兜底 (13 项).
- 详细见 `manager_meeting_new.md` §修复记录 第一轮.
### 跨页相关 commit (影响本页但不直接修改)
- `ffda9c6` — biz_project 加 `create_user_id` (影响 §6.2)
- `a081da8` — MeetingNew.vue "期数" 文案改 "请填写第几期" (独立页)
- `4fbfcbf` + `8f62f65` + `518da05` + `29a4b02` + `379cf22` + `ac31387` — MeetingNew.vue 原型 1:1 重写 + 双 toast 修复
@@ -3,13 +3,15 @@ package com.ruoyi;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 启动程序 * 启动程序
* *
* @author ruoyi * @author ruoyi
*/ */
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
@EnableScheduling
public class RuoYiApplication public class RuoYiApplication
{ {
public static void main(String[] args) public static void main(String[] args)
@@ -33,6 +33,9 @@ ruoyi:
inviteTemplate: SMS_492460505 inviteTemplate: SMS_492460505
endpoint: dysmsapi.aliyuncs.com endpoint: dysmsapi.aliyuncs.com
regionId: cn-hangzhou regionId: cn-hangzhou
# 发票 OCR (ry-ocr 微服务, PaddleOCR + FastAPI, 默认 http://127.0.0.1:8801)
ocr:
base-url: http://127.0.0.1:8801
# 开发环境配置 # 开发环境配置
server: server:
+29
View File
@@ -39,12 +39,41 @@
<artifactId>aliyun-java-sdk-core</artifactId> <artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.4</version> <version>4.6.4</version>
</dependency> </dependency>
<!-- 阿里云 OSS SDK (服务端上传: zip 解压 → OCR → 重传 invoice 文件, copy 自 hwt-serve ruoyi-common AliOssService) -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<!-- HTML 转 PDF (iText 7 html2pdf, 宽容 HTML 解析, 兼容非 XHTML 的 <img> 等) --> <!-- HTML 转 PDF (iText 7 html2pdf, 宽容 HTML 解析, 兼容非 XHTML 的 <img> 等) -->
<dependency> <dependency>
<groupId>com.itextpdf</groupId> <groupId>com.itextpdf</groupId>
<artifactId>html2pdf</artifactId> <artifactId>html2pdf</artifactId>
<version>3.0.2</version> <version>3.0.2</version>
</dependency> </dependency>
<!-- ry-ocr Java 调用客户端依赖: hutool-http / hutool-json / hutool-core -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-http</artifactId>
<version>5.8.27</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-json</artifactId>
<version>5.8.27</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>5.8.27</version>
</dependency>
<!-- Lombok (OcrClient 等用了 @Slf4j) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -0,0 +1,23 @@
package com.ruoyi.business.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.ruoyi.business.ocr.OcrClient;
/**
* ry-ocr 微服务集成配置
* <p>
* yml 配置: ruoyi.ocr.base-url (默认 http://127.0.0.1:8801)
*/
@Configuration
public class OcrConfig {
@Value("${ruoyi.ocr.base-url:http://127.0.0.1:8801}")
private String ocrBaseUrl;
@Bean
public OcrClient ocrClient() {
return new OcrClient(ocrBaseUrl);
}
}
@@ -0,0 +1,30 @@
package com.ruoyi.business.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* OCR 后台执行器配置
* <p>
* 16 个固定线程, 用于:
* - 单文件上传后, 后台异步 OCR (前端立即返回 SUBMITTED)
* - ZIP 解压后, 后台逐张识别 + 重传 OSS
* - 兜底调度 InvoiceOcrScheduler 重试 UNRECOGNIZED 超过 5 分钟的记录
*/
@Configuration
public class OcrExecutorConfig
{
/**
* 16 线程 FixedThreadPool
* <p>
* 线程数选 16: 与阿里云 OSS 默认下载并发限速对齐, 兼顾单台机器 ry-ocr 服务能力
* (单张发票 OCR 平均 1-3s, 16 线程 ≈ 5-15 张/秒)
*/
@Bean(name = "ocrExecutor", destroyMethod = "shutdown")
public ExecutorService ocrExecutor()
{
return Executors.newFixedThreadPool(16);
}
}
@@ -0,0 +1,53 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.business.domain.BizMeetingAuditLog;
import com.ruoyi.business.service.IBizMeetingAuditLogService;
/**
* 会议审核流程日志 Controller
*/
@RestController
@RequestMapping("/business/meetingAuditLog")
public class BizMeetingAuditLogController extends BaseController {
@Autowired
private IBizMeetingAuditLogService bizMeetingAuditLogService;
@GetMapping("/list")
public TableDataInfo list(BizMeetingAuditLog bizMeetingAuditLog) {
startPage();
List<BizMeetingAuditLog> list = bizMeetingAuditLogService.selectList(bizMeetingAuditLog);
return getDataTable(list);
}
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id) {
return success(bizMeetingAuditLogService.getById(id));
}
@Log(title = "会议审核日志", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizMeetingAuditLog bizMeetingAuditLog) {
return toAjax(bizMeetingAuditLogService.insert(bizMeetingAuditLog));
}
@Log(title = "会议审核日志", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizMeetingAuditLog bizMeetingAuditLog) {
return toAjax(bizMeetingAuditLogService.updateByPrimaryKey(bizMeetingAuditLog));
}
@Log(title = "会议审核日志", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) {
return toAjax(bizMeetingAuditLogService.deleteByPrimaryKeys(ids));
}
}
@@ -1,8 +1,15 @@
package com.ruoyi.business.controller; package com.ruoyi.business.controller;
import java.util.Date;
import java.util.List; import java.util.List;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.domain.BizMeetingAuditLog;
import com.ruoyi.business.domain.BizMeetingSupervisor;
import com.ruoyi.business.domain.BizMeetingExecutor;
import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingAuditLogService;
import com.ruoyi.business.service.IBizMeetingSupervisorService;
import com.ruoyi.business.service.IBizMeetingExecutorService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log; import com.ruoyi.common.annotation.Log;
@@ -10,22 +17,34 @@ import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
/** /**
* 会议Controller * 会议Controller
*/ */
@RestController @RestController
@RequestMapping("/business/meeting") @RequestMapping("/business/meeting")
public class BizMeetingController extends BaseController public class BizMeetingController extends BaseController {
{
@Autowired @Autowired
private IBizMeetingService bizMeetingService; private IBizMeetingService bizMeetingService;
@Autowired
private IBizMeetingAttendeeService attendeeService;
@Autowired
private IBizMeetingMaterialService bizMeetingMaterialService;
@Autowired
private IBizMeetingAuditLogService bizMeetingAuditLogService;
@Autowired
private IBizMeetingSupervisorService bizMeetingSupervisorService;
@Autowired
private IBizMeetingExecutorService bizMeetingExecutorService;
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo list(BizMeeting bizMeeting) public TableDataInfo list(BizMeeting bizMeeting) {
{
// 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表) // 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表)
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("doctor".equals(roleType) || "expert".equals(roleType)) { if ("doctor".equals(roleType) || "expert".equals(roleType)) {
@@ -35,42 +54,225 @@ public class BizMeetingController extends BaseController
List<BizMeeting> list = bizMeetingService.selectList(bizMeeting); List<BizMeeting> list = bizMeetingService.selectList(bizMeeting);
return getDataTable(list); return getDataTable(list);
} }
@GetMapping("/{meetingId}") @GetMapping("/{meetingId}")
public AjaxResult getInfo(@PathVariable("meetingId") Long meetingId) public AjaxResult getInfo(@PathVariable("meetingId") Long meetingId) {
{
return success(bizMeetingService.getById(meetingId)); return success(bizMeetingService.getById(meetingId));
} }
@Autowired
private IBizMeetingAttendeeService attendeeService;
@Log(title = "会议", businessType = BusinessType.INSERT) @Log(title = "会议", businessType = BusinessType.INSERT)
@PostMapping @PostMapping
public AjaxResult add(@RequestBody BizMeeting bizMeeting) public AjaxResult add(@RequestBody BizMeeting bizMeeting) {
{
int rows = bizMeetingService.insert(bizMeeting); int rows = bizMeetingService.insert(bizMeeting);
// 同步创建参会人中间表 (可选: 前端传 attendeeUserIds)
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) { if (attendeeUserIds != null && attendeeUserIds.length > 0) {
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds); attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
} }
return toAjax(rows); return toAjax(rows);
} }
@Log(title = "会议", businessType = BusinessType.UPDATE) @Log(title = "会议", businessType = BusinessType.UPDATE)
@PutMapping @PutMapping
public AjaxResult edit(@RequestBody BizMeeting bizMeeting) public AjaxResult edit(@RequestBody BizMeeting bizMeeting) {
{
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting); int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
// 同步追加参会人 (不去重, 由前端控制)
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) { if (attendeeUserIds != null && attendeeUserIds.length > 0) {
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds); attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
} }
return toAjax(rows); return toAjax(rows);
} }
@Log(title = "会议", businessType = BusinessType.DELETE) @Log(title = "会议", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}") @DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) public AjaxResult remove(@PathVariable Long[] ids) {
{
return toAjax(bizMeetingService.deleteByPrimaryKeys(ids)); return toAjax(bizMeetingService.deleteByPrimaryKeys(ids));
} }
}
// ===================================================================
// 审核流程端点 (5 个)
// ===================================================================
/**
* 执行人员提交材料
* <ul>
* <li>校验 1: 当前用户是该会议执行人员 (强校验)</li>
* <li>校验 2: material_audit_stage = INIT</li>
* <li>校验 3: biz_meeting_material 至少 1 条 L_* + 至少 1 条 M_*</li>
* </ul>
* 通过后 material_audit_stage INIT → SUBMITTED, 记 audit_log.
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/submit-material")
public AjaxResult submitMaterial(@PathVariable("meetingId") Long meetingId) {
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId();
boolean isExec = bizMeetingExecutorService.selectByMeetingId(meetingId).stream()
.anyMatch(e -> userId.equals(e.getUserId()));
if (!isExec) throw new ServiceException("您不是该会议执行人员, 无法提交材料");
if (!"INIT".equals(m.getMaterialAuditStage())) {
throw new ServiceException("当前阶段 (" + m.getMaterialAuditStage() + ") 不允许提交材料");
}
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_"));
boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_"));
if (!hasLabor || !hasService) {
throw new ServiceException("请同时上传劳务材料和会务材料");
}
m.setMaterialAuditStage("SUBMITTED");
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, "MATERIAL", "SUBMITTED", "APPROVED", "执行人员提交材料");
return success("SUBMITTED");
}
/**
* 执行人员提交凭证 (校验 LV_PAYMENT + SV_PAYMENT)
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/submit-voucher")
public AjaxResult submitVoucher(@PathVariable("meetingId") Long meetingId) {
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId();
boolean isExec = bizMeetingExecutorService.selectByMeetingId(meetingId).stream()
.anyMatch(e -> userId.equals(e.getUserId()));
if (!isExec) throw new ServiceException("您不是该会议执行人员, 无法提交凭证");
if (!"INIT".equals(m.getVoucherAuditStage())) {
throw new ServiceException("当前阶段 (" + m.getVoucherAuditStage() + ") 不允许提交凭证");
}
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLv = mats.stream().anyMatch(x -> "LV_PAYMENT".equals(x.getSubType()));
boolean hasSv = mats.stream().anyMatch(x -> "SV_PAYMENT".equals(x.getSubType()));
if (!hasLv || !hasSv) {
throw new ServiceException("请同时上传劳务凭证和会务凭证");
}
m.setVoucherAuditStage("SUBMITTED");
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, "VOUCHER", "SUBMITTED", "APPROVED", "执行人员提交凭证");
return success("SUBMITTED");
}
/**
* 合规审核 (role_type=manager)
* body: { "auditType": "MATERIAL"|"VOUCHER", "approved": true|false, "opinion": "..." }
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-compliance")
public AjaxResult auditCompliance(@PathVariable("meetingId") Long meetingId, @RequestBody AuditBody body) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType)) throw new ServiceException("仅合规人员可操作");
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
String auditType = body.getAuditType();
if (!"MATERIAL".equals(auditType) && !"VOUCHER".equals(auditType)) {
throw new ServiceException("auditType 必须是 MATERIAL 或 VOUCHER");
}
String currentStage = "MATERIAL".equals(auditType) ? m.getMaterialAuditStage() : m.getVoucherAuditStage();
if (!"SUBMITTED".equals(currentStage)) {
throw new ServiceException("当前阶段 (" + currentStage + ") 不允许合规审核");
}
if (Boolean.FALSE.equals(body.getApproved()) && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空");
}
String result = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "REJECTED";
String newStage = Boolean.TRUE.equals(body.getApproved()) ? "COMPLIANCE_APPROVED" : "SUBMITTED";
if ("MATERIAL".equals(auditType)) {
m.setMaterialAuditStage(newStage);
} else {
m.setVoucherAuditStage(newStage);
}
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, auditType, newStage, result, body.getOpinion());
return success(newStage);
}
/**
* 监察审核 (强校验: 当前用户必须是该会议监察员)
* body: { "auditType": "MATERIAL"|"VOUCHER", "approved": true|false, "opinion": "..." }
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-supervision")
public AjaxResult auditSupervision(@PathVariable("meetingId") Long meetingId, @RequestBody AuditBody body) {
Long userId = SecurityUtils.getUserId();
boolean isSupervisor = bizMeetingSupervisorService.selectByMeetingId(meetingId).stream()
.anyMatch(s -> userId.equals(s.getUserId()));
if (!isSupervisor) throw new ServiceException("您不是该会议监察员, 无权监察");
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
String auditType = body.getAuditType();
if (!"MATERIAL".equals(auditType) && !"VOUCHER".equals(auditType)) {
throw new ServiceException("auditType 必须是 MATERIAL 或 VOUCHER");
}
String currentStage = "MATERIAL".equals(auditType) ? m.getMaterialAuditStage() : m.getVoucherAuditStage();
if (!"COMPLIANCE_APPROVED".equals(currentStage)) {
throw new ServiceException("当前阶段 (" + currentStage + ") 不允许监察");
}
if (Boolean.FALSE.equals(body.getApproved()) && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空");
}
String result = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "REJECTED";
String newStage = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "SUBMITTED";
if ("MATERIAL".equals(auditType)) {
m.setMaterialAuditStage(newStage);
} else {
m.setVoucherAuditStage(newStage);
}
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, auditType, newStage, result, body.getOpinion());
return success(newStage);
}
/**
* 审核轨迹 (audit_log 列表, 按时间排序)
*/
@GetMapping("/{meetingId}/audit-trail")
public AjaxResult auditTrail(@PathVariable("meetingId") Long meetingId) {
BizMeetingAuditLog q = new BizMeetingAuditLog();
q.setMeetingId(meetingId);
List<BizMeetingAuditLog> list = bizMeetingAuditLogService.selectList(q);
return success(list);
}
/**
* 内部: 写一条 audit_log
*/
private void appendAuditLog(Long meetingId, String auditType, String stage, String result, String opinion) {
BizMeetingAuditLog log = new BizMeetingAuditLog();
log.setMeetingId(meetingId);
log.setAuditor(SecurityUtils.getUsername());
log.setAuditType(auditType);
log.setCurrentStage(stage);
log.setAuditResult(result);
log.setOpinion(opinion);
log.setCreateTime(new Date());
log.setAuditTime(new Date());
bizMeetingAuditLogService.insert(log);
}
/** request body for audit endpoints */
public static class AuditBody {
private String auditType; // MATERIAL / VOUCHER
private Boolean approved; // true=通过 false=拒绝
private String opinion; // 意见
public String getAuditType() { return auditType; }
public void setAuditType(String auditType) { this.auditType = auditType; }
public Boolean getApproved() { return approved; }
public void setApproved(Boolean approved) { this.approved = approved; }
public String getOpinion() { return opinion; }
public void setOpinion(String opinion) { this.opinion = opinion; }
}
}
@@ -0,0 +1,48 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingExecutor;
import com.ruoyi.business.service.IBizMeetingExecutorService;
/**
* 会议-执行人员 Controller
*/
@RestController
@RequestMapping("/business/meeting/executor")
public class BizMeetingExecutorController extends BaseController {
@Autowired
private IBizMeetingExecutorService bizMeetingExecutorService;
/** 查该会议的所有执行人员 */
@GetMapping("/list/{meetingId}")
public AjaxResult list(@PathVariable("meetingId") Long meetingId) {
return success(bizMeetingExecutorService.selectByMeetingId(meetingId));
}
/**
* 分配执行人员 (全删全插)
* body: { "userIds": [1, 2, 3] }
*/
@Log(title = "会议-执行人员", businessType = BusinessType.UPDATE)
@PutMapping("/{meetingId}")
public AjaxResult assign(@PathVariable("meetingId") Long meetingId, @RequestBody AssignBody body) {
Long assignedBy = SecurityUtils.getUserId();
int n = bizMeetingExecutorService.replaceByMeetingId(meetingId, body.getUserIds(), assignedBy);
return success(n);
}
/** request body wrapper */
public static class AssignBody {
private List<Long> userIds;
public List<Long> getUserIds() { return userIds; }
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
}
}
@@ -0,0 +1,71 @@
package com.ruoyi.business.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.service.impl.InvoiceOcrService;
/**
* 会议发票识别 Controller (v3)
* <p>
* v3 变更:
* - 端点改为"提交即返回 SUBMITTED", OCR 在后台 ExecutorService 跑
* - 新增 isZip 字段: true → zip 路径(解压 → 重传 OSS → 写多行 invoice)
* - 新增 oldMaterialId 字段: 替换场景, 后端先清旧 invoice + material.amount=0
* - 不再走旧的同步 recognizeAndSave, 已被 submitRecognition 取代
*/
@RestController
@RequestMapping("/business/meeting/invoice")
public class BizMeetingInvoiceController extends BaseController
{
@Autowired
private InvoiceOcrService invoiceOcrService;
/**
* 提交发票识别 (后台执行, 立即返回)
* <p>
* body: {
* "materialId": 123,
* "meetingId": 456,
* "ossUrl": "https://...",
* "isZip": false, // 新增
* "oldMaterialId": null // 新增, 替换时携带原 materialId
* }
*/
@PostMapping("/recognize")
public AjaxResult recognize(@RequestBody RecognizeBody body)
{
InvoiceOcrService.RecognizeResult r = invoiceOcrService.submitRecognition(
body.getMaterialId(),
body.getMeetingId(),
body.getOssUrl(),
body.isZip(),
body.getOldMaterialId());
return success(r);
}
/** request body wrapper */
public static class RecognizeBody
{
private Long materialId;
private Long meetingId;
private String ossUrl;
/** v3 新增: true=zip 路径(解压遍历), false=单文件路径 */
private boolean isZip;
/** v3 新增: 替换场景携带的旧 materialId, 后端先 DELETE invoice WHERE material_id=old + material.amount=0 */
private Long oldMaterialId;
public Long getMaterialId() { return materialId; }
public void setMaterialId(Long materialId) { this.materialId = materialId; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public boolean isZip() { return isZip; }
public void setZip(boolean zip) { isZip = zip; }
public Long getOldMaterialId() { return oldMaterialId; }
public void setOldMaterialId(Long oldMaterialId) { this.oldMaterialId = oldMaterialId; }
}
}
@@ -0,0 +1,55 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.service.IBizMeetingMaterialService;
/**
* 会议材料 Controller
* <p>
* 单表设计: GET 查 / PUT 全删全插 (替代 4 张分表 + 4 套端点)
*/
@RestController
@RequestMapping("/business/meetingMaterial")
public class BizMeetingMaterialController extends BaseController {
@Autowired
private IBizMeetingMaterialService bizMeetingMaterialService;
/**
* 查该会议的所有材料记录
*/
@GetMapping("/{meetingId}")
public AjaxResult list(@PathVariable("meetingId") Long meetingId) {
return success(bizMeetingMaterialService.selectByMeetingId(meetingId));
}
/**
* 保存 (全删全插)
* <p>
* body 是该会议当前的所有材料记录. 前端按 rows 过滤 url 非空后整体 PUT.
* creator_id 后端兜底, 防止前端伪造.
* 返回值是插入后带 id 的 list (前端用于触发 OCR 识别).
*/
@Log(title = "会议材料", businessType = BusinessType.UPDATE)
@PutMapping("/{meetingId}")
public AjaxResult save(@PathVariable("meetingId") Long meetingId, @RequestBody List<BizMeetingMaterial> list) {
Long userId = SecurityUtils.getUserId();
if (list != null) {
for (BizMeetingMaterial m : list) {
if (m.getCreatorId() == null) {
m.setCreatorId(userId);
}
}
}
List<BizMeetingMaterial> saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list);
return success(saved);
}
}
@@ -0,0 +1,48 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingSupervisor;
import com.ruoyi.business.service.IBizMeetingSupervisorService;
/**
* 会议-监察员 Controller
*/
@RestController
@RequestMapping("/business/meeting/supervisor")
public class BizMeetingSupervisorController extends BaseController {
@Autowired
private IBizMeetingSupervisorService bizMeetingSupervisorService;
/** 查该会议的所有监察员 */
@GetMapping("/list/{meetingId}")
public AjaxResult list(@PathVariable("meetingId") Long meetingId) {
return success(bizMeetingSupervisorService.selectByMeetingId(meetingId));
}
/**
* 分配监察员 (全删全插)
* body: { "userIds": [1, 2, 3] }
*/
@Log(title = "会议-监察员", businessType = BusinessType.UPDATE)
@PutMapping("/{meetingId}")
public AjaxResult assign(@PathVariable("meetingId") Long meetingId, @RequestBody AssignBody body) {
Long assignedBy = SecurityUtils.getUserId();
int n = bizMeetingSupervisorService.replaceByMeetingId(meetingId, body.getUserIds(), assignedBy);
return success(n);
}
/** request body wrapper */
public static class AssignBody {
private List<Long> userIds;
public List<Long> getUserIds() { return userIds; }
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
}
}
@@ -220,7 +220,7 @@ public class BizProjectController extends BaseController
BigDecimal sum = BigDecimal.ZERO; BigDecimal sum = BigDecimal.ZERO;
int cnt = 0; int cnt = 0;
for (BizProjectRating r : all) { for (BizProjectRating r : all) {
int s = safeLong(r.getQualityScore()) + safeLong(r.getResponseScore()) long s = safeLong(r.getQualityScore()) + safeLong(r.getResponseScore())
+ safeLong(r.getCooperationScore()) + safeLong(r.getComplianceScore()); + safeLong(r.getCooperationScore()) + safeLong(r.getComplianceScore());
if (s > 0) { sum = sum.add(BigDecimal.valueOf(s)); cnt++; } if (s > 0) { sum = sum.add(BigDecimal.valueOf(s)); cnt++; }
} }
@@ -70,6 +70,10 @@ public class BizMeeting extends BaseEntity {
/** 监察时间 (与 DB datetime 对齐) */ /** 监察时间 (与 DB datetime 对齐) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date supervisionTime; private Date supervisionTime;
/** 材料审核阶段 (INIT=待提交, 后续阶段开发中定) */
private String materialAuditStage;
/** 凭证审核阶段 (INIT=待提交, 后续阶段开发中定) */
private String voucherAuditStage;
/** 邀请函URL */ /** 邀请函URL */
private String invitationUrl; private String invitationUrl;
/** 日程海报URL */ /** 日程海报URL */
@@ -129,6 +133,10 @@ public class BizMeeting extends BaseEntity {
public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; } public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; }
public String getLaborSigned() { return laborSigned; } public String getLaborSigned() { return laborSigned; }
public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; } public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; }
public String getMaterialAuditStage() { return materialAuditStage; }
public void setMaterialAuditStage(String materialAuditStage) { this.materialAuditStage = materialAuditStage; }
public String getVoucherAuditStage() { return voucherAuditStage; }
public void setVoucherAuditStage(String voucherAuditStage) { this.voucherAuditStage = voucherAuditStage; }
public Long getUserId() { return userId; } public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; } public void setUserId(Long userId) { this.userId = userId; }
public Long[] getAttendeeUserIds() { return attendeeUserIds; } public Long[] getAttendeeUserIds() { return attendeeUserIds; }
@@ -0,0 +1,71 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议审核流程日志对象 biz_meeting_audit_log
* <p>
* 记录会议审核的每一次流转: 谁、什么时间、什么意见、当前阶段.
* 与 biz_meeting.material_audit_stage / voucher_audit_stage 配合, 还原审核轨迹.
*/
public class BizMeetingAuditLog {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** 审核人 (username 或人工填入) */
private String auditor;
/** 审核意见 */
private String opinion;
/** 当前阶段 (INIT / ... 后续开发中定) */
private String currentStage;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** 审核时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date auditTime;
/** 审核类型 (MATERIAL / VOUCHER) */
private String auditType;
/** 审核结果 (APPROVED / REJECTED) */
private String auditResult;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getAuditor() { return auditor; }
public void setAuditor(String auditor) { this.auditor = auditor; }
public String getOpinion() { return opinion; }
public void setOpinion(String opinion) { this.opinion = opinion; }
public String getCurrentStage() { return currentStage; }
public void setCurrentStage(String currentStage) { this.currentStage = currentStage; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public Date getAuditTime() { return auditTime; }
public void setAuditTime(Date auditTime) { this.auditTime = auditTime; }
public String getAuditType() { return auditType; }
public void setAuditType(String auditType) { this.auditType = auditType; }
public String getAuditResult() { return auditResult; }
public void setAuditResult(String auditResult) { this.auditResult = auditResult; }
}
@@ -0,0 +1,45 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议-执行人员 关联对象 biz_meeting_executor (1:N)
* <p>
* 当前阶段: 仅取 executor 主账号 (parent_user_id IS NULL), 后续表结构预留支持子账号.
*/
public class BizMeetingExecutor {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** sys_user.user_id (executor 主账号, 后续支持子账号) */
private Long userId;
/** 分配人 user_id (审计) */
private Long assignedBy;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getAssignedBy() { return assignedBy; }
public void setAssignedBy(Long assignedBy) { this.assignedBy = assignedBy; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -0,0 +1,96 @@
package com.ruoyi.business.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议发票识别对象 biz_meeting_invoice
* <p>
* 一张会议材料 (biz_meeting_material.id) 对应一条发票记录.
* 仅当 OCR 识别为发票时插入, 不是发票则不创建任何行.
* 金额 amount 同时回写到 biz_meeting_material.amount.
*/
public class BizMeetingInvoice {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** 关联材料ID (FK -> biz_meeting_material.id) */
private Long materialId;
/** OSS URL */
private String ossUrl;
/** 发票类型 (MAIN=主发票, SUB=子发票) */
private String invoiceType;
/** 价税合计 (OCR 识别金额, 同时回写到 material) */
private BigDecimal amount;
/** 提交人 user_id */
private Long creatorId;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** 更新时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
/**
* 识别状态:
* UNRECOGNIZED=已插入待 OCR (单文件路径)
* RECOGNIZED=已识别完成 (默认, ZIP 路径直接走识别完成)
* FAILED=识别失败
*/
private String recognizeStatus;
/** 识别失败原因 */
private String errorMsg;
/** 原始文件名 (zip 解压时记录, 单文件路径可空) */
private String sourceFilename;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getMaterialId() { return materialId; }
public void setMaterialId(Long materialId) { this.materialId = materialId; }
public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public String getInvoiceType() { return invoiceType; }
public void setInvoiceType(String invoiceType) { this.invoiceType = invoiceType; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Long getCreatorId() { return creatorId; }
public void setCreatorId(Long creatorId) { this.creatorId = creatorId; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public String getRecognizeStatus() { return recognizeStatus; }
public void setRecognizeStatus(String recognizeStatus) { this.recognizeStatus = recognizeStatus; }
public String getErrorMsg() { return errorMsg; }
public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; }
public String getSourceFilename() { return sourceFilename; }
public void setSourceFilename(String sourceFilename) { this.sourceFilename = sourceFilename; }
}
@@ -0,0 +1,77 @@
package com.ruoyi.business.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议材料对象 biz_meeting_material (单表)
* <p>
* 包含 4 大类 13 子类:
* <ul>
* <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li>
* <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / SV_PAYMENT / LV_PAYMENT</li>
* </ul>
* <p>
* 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段.
* 提交人用 creator_id (user_id), 创建时间 create_time.
*/
public class BizMeetingMaterial {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** 资料类型 (4 种): SERVICE / LABOR / SERVICE_VOUCHER / LABOR_VOUCHER */
private String materialType;
/** 子分类 (13 种): M_MATERIAL / M_HOTEL / ... / SV_PAYMENT / LV_PAYMENT */
private String subType;
/** 文件名称 */
private String fileName;
/** OSS URL */
private String ossUrl;
/** 金额 (发票专用, 其他类型 = 0) */
private BigDecimal amount;
/** 提交人 user_id (后端从 SecurityUtils 自动取) */
private Long creatorId;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getMaterialType() { return materialType; }
public void setMaterialType(String materialType) { this.materialType = materialType; }
public String getSubType() { return subType; }
public void setSubType(String subType) { this.subType = subType; }
public String getFileName() { return fileName; }
public void setFileName(String fileName) { this.fileName = fileName; }
public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Long getCreatorId() { return creatorId; }
public void setCreatorId(Long creatorId) { this.creatorId = creatorId; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -0,0 +1,45 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议-监察员 关联对象 biz_meeting_supervisor (1:N)
* <p>
* 当前阶段: 仅取 sponsor 主账号 (parent_user_id IS NULL), 后续表结构预留支持子账号.
*/
public class BizMeetingSupervisor {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** sys_user.user_id (sponsor 主账号, 后续支持子账号) */
private Long userId;
/** 分配人 user_id (审计) */
private Long assignedBy;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getAssignedBy() { return assignedBy; }
public void setAssignedBy(Long assignedBy) { this.assignedBy = assignedBy; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -0,0 +1,28 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingAuditLog;
/**
* 会议审核流程日志 Mapper 接口
*/
public interface BizMeetingAuditLogMapper {
/** 按主键查 */
BizMeetingAuditLog selectByPrimaryKey(Long id);
/** 条件查询 (meetingId 可选过滤) */
List<BizMeetingAuditLog> selectList(BizMeetingAuditLog entity);
/** 插入 (id 走 AUTO_INCREMENT) */
int insert(BizMeetingAuditLog entity);
/** 按主键更新 */
int updateByPrimaryKey(BizMeetingAuditLog entity);
/** 按主键删除单条 */
int deleteByPrimaryKey(Long id);
/** 按主键批量删除 */
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,37 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingExecutor;
/**
* 会议-执行人员 Mapper 接口
*/
public interface BizMeetingExecutorMapper {
/** 按主键查 */
BizMeetingExecutor selectByPrimaryKey(Long id);
/** 按会议ID查该会议的所有执行人员 */
List<BizMeetingExecutor> selectByMeetingId(Long meetingId);
/** 按 userId 查该执行人员被分配到哪些会议 */
List<BizMeetingExecutor> selectByUserId(Long userId);
/** 条件查询 */
List<BizMeetingExecutor> selectList(BizMeetingExecutor entity);
/** 插入 (id AUTO_INCREMENT) */
int insert(BizMeetingExecutor entity);
/** 按主键更新 */
int updateByPrimaryKey(BizMeetingExecutor entity);
/** 按主键删除 */
int deleteByPrimaryKey(Long id);
/** 按会议ID全删 (分配时全删全插) */
int deleteByMeetingId(Long meetingId);
/** 批量插入 */
int insertBatch(List<BizMeetingExecutor> list);
}
@@ -0,0 +1,61 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingInvoice;
/**
* 会议发票 Mapper (单表 biz_meeting_invoice)
*/
public interface BizMeetingInvoiceMapper {
BizMeetingInvoice selectByPrimaryKey(Long id);
List<BizMeetingInvoice> selectList(BizMeetingInvoice query);
/** 按 material_id 查 (UK 唯一) */
BizMeetingInvoice selectByMaterialId(Long materialId);
/** 按 meeting_id 查 (用于展示某个会议下所有发票) */
List<BizMeetingInvoice> selectByMeetingId(Long meetingId);
int insert(BizMeetingInvoice record);
int updateByPrimaryKey(BizMeetingInvoice record);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
/** 按 material_id 删除 (用于重传时清掉旧记录) */
int deleteByMaterialId(Long materialId);
/**
* 兜底扫描: 查询 UNRECOGNIZED 状态且 create_time 早于 N 分钟前的行
* <p>
* 用于 InvoiceOcrScheduler 每 60s 扫一次, 处理程序重启/OCR 服务临时挂掉导致的遗漏
*
* @param minutes 分钟阈值
*/
List<BizMeetingInvoice> selectUnrecognizedOlderThanMinutes(int minutes);
/**
* 单文件后台 OCR 完成: 按 material_id 更新状态 (前提是 UNRECOGNIZED → RECOGNIZED/FAILED)
*/
int updateStatusByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
@org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus,
@org.apache.ibatis.annotations.Param("errorMsg") String errorMsg);
/**
* 单文件后台 OCR 完成: 按 material_id 更新金额 (与状态更新分开调用)
*/
int updateAmountByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount);
/**
* 兜底 OCR: 按主键更新 (状态 + 金额 + 错误信息)
*/
int updateStatusAndAmountByPrimaryKey(@org.apache.ibatis.annotations.Param("id") Long id,
@org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
@org.apache.ibatis.annotations.Param("errorMsg") String errorMsg);
}
@@ -0,0 +1,34 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingMaterial;
/**
* 会议材料 Mapper 接口 (单表 biz_meeting_material)
*/
public interface BizMeetingMaterialMapper {
/** 按主键查 */
BizMeetingMaterial selectByPrimaryKey(Long id);
/** 按会议ID查该会议所有材料记录 */
List<BizMeetingMaterial> selectByMeetingId(Long meetingId);
/** 插入 (id 走 DB AUTO_INCREMENT, 不接受前端传入的 id) */
int insert(BizMeetingMaterial entity);
/** 按主键更新 (一般不用, 全删全插代替) */
int updateByPrimaryKey(BizMeetingMaterial entity);
/** 按主键删除单条 */
int deleteByPrimaryKey(Long id);
/** 按会议ID删除该会议所有材料记录 (save 时先全删) */
int deleteByMeetingId(Long meetingId);
/** 批量插入 (单会议替换 save 专用) */
int insertBatch(List<BizMeetingMaterial> list);
/** 单条更新 amount (OCR 识别为发票后回写, 不动其他字段) */
int updateAmount(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount);
}
@@ -0,0 +1,37 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingSupervisor;
/**
* 会议-监察员 Mapper 接口
*/
public interface BizMeetingSupervisorMapper {
/** 按主键查 */
BizMeetingSupervisor selectByPrimaryKey(Long id);
/** 按会议ID查该会议的所有监察员 */
List<BizMeetingSupervisor> selectByMeetingId(Long meetingId);
/** 按 userId 查该监察员被分配到哪些会议 */
List<BizMeetingSupervisor> selectByUserId(Long userId);
/** 条件查询 */
List<BizMeetingSupervisor> selectList(BizMeetingSupervisor entity);
/** 插入 (id AUTO_INCREMENT) */
int insert(BizMeetingSupervisor entity);
/** 按主键更新 */
int updateByPrimaryKey(BizMeetingSupervisor entity);
/** 按主键删除 */
int deleteByPrimaryKey(Long id);
/** 按会议ID全删 (分配时全删全插) */
int deleteByMeetingId(Long meetingId);
/** 批量插入 */
int insertBatch(List<BizMeetingSupervisor> list);
}
@@ -0,0 +1,37 @@
package com.ruoyi.business.ocr;
import lombok.Data;
/** 结构化发票字段 */
@Data
public class InvoiceFields {
/** 发票类型:增值税电子普通发票 / 增值税专用发票 / ... */
private String invoiceType;
/** 发票号码 */
private String invoiceNo;
/** 发票代码 */
private String invoiceCode;
/** 开票日期 YYYY-MM-DD */
private String invoiceDate;
/** 价税合计(小写) */
private Double amount;
/** 价税合计(大写中文) */
private String amountCn;
/** 不含税金额 */
private Double amountPretax;
/** 税额 */
private Double taxAmount;
/** 销售方名称 */
private String sellerName;
/** 销售方纳税人识别号 */
private String sellerTaxNo;
/** 购买方名称 */
private String buyerName;
/** 购买方纳税人识别号 */
private String buyerTaxNo;
/** 大写金额 vs 小写金额是否一致(null=未能比对) */
private Boolean amountMatch;
}
@@ -0,0 +1,67 @@
package com.ruoyi.business.ocr;
import com.ruoyi.business.domain.BizMeetingInvoice;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.service.impl.InvoiceOcrService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.ExecutorService;
/**
* OCR 兜底调度器
* <p>
* 每 60 秒扫描一次, 重新识别 5 分钟前插入但仍未识别的 invoice 行
* (处理: 程序重启导致后台任务丢失 / OCR 服务临时挂掉 / OSS 下载超时 等情况)
* <p>
* 需要启动类加 {@code @EnableScheduling} 才会生效 (RuoyiApplication 已有)
*/
@Slf4j
@Component
public class InvoiceOcrScheduler
{
/** 阈值: UNRECOGNIZED 行超过这个分钟数才重试 (避免抢正在跑的 OCR 任务) */
private static final int STALE_MINUTES = 5;
@Autowired
private BizMeetingInvoiceMapper invoiceMapper;
@Autowired
private InvoiceOcrService ocrService;
@Autowired
@Qualifier("ocrExecutor")
private ExecutorService ocrExecutor;
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void scanStaleUnrecognized()
{
try
{
List<BizMeetingInvoice> stale = invoiceMapper.selectUnrecognizedOlderThanMinutes(STALE_MINUTES);
if (stale == null || stale.isEmpty()) return;
log.info("兜底扫描: {} 条 UNRECOGNIZED 超过 {} 分钟, 重新提交 OCR", stale.size(), STALE_MINUTES);
for (BizMeetingInvoice inv : stale)
{
ocrExecutor.submit(() ->
{
try
{
ocrService.recognizeOneInvoice(inv);
}
catch (Exception e)
{
log.warn("兜底 OCR 失败 id={} url={}", inv.getId(), inv.getOssUrl(), e);
}
});
}
}
catch (Exception e)
{
log.warn("兜底扫描异常 (本次跳过, 下分钟再试)", e);
}
}
}
@@ -0,0 +1,18 @@
package com.ruoyi.business.ocr;
import lombok.Data;
import java.util.List;
/** OCR 识别结果(与 ry-ocr 的 InvoiceResult JSON 对应) */
@Data
public class InvoiceResult {
private Boolean success;
private String rawText;
private String engine;
private Integer pageCount;
private Integer elapsedMs;
private String error;
private InvoiceFields fields;
private List<OcrLine> lines;
}
@@ -0,0 +1,120 @@
package com.ruoyi.business.ocr;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* ry-ocr Java 调用客户端
*
* 依赖:hutool-http, hutool-json, hutool-core, lombok
*
* 用法:
* OcrClient client = new OcrClient("http://127.0.0.1:8801");
* InvoiceResult r = client.recognize(new File("d:/发票.pdf"));
* InvoiceResult r2 = client.recognizeByUrl("https://oss.example.com/xxx.png");
* System.out.println(r.getFields().getAmount());
*/
@Slf4j
public class OcrClient {
private final String baseUrl;
public OcrClient(String baseUrl) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
/** 健康检查 */
public boolean ping() {
try (HttpResponse resp = HttpRequest.get(baseUrl + "/health").timeout(3000).execute()) {
return resp.getStatus() == 200 && "ok".equals(JSONUtil.parseObj(resp.body()).getStr("status"));
} catch (Exception e) {
log.warn("ocr ping failed: {}", e.getMessage());
return false;
}
}
/** 识别发票(图片或 PDF) */
public InvoiceResult recognize(File file) {
try (HttpResponse resp = HttpRequest.post(baseUrl + "/recognize/invoice")
.form("file", file)
.timeout(60_000)
.execute()) {
String body = resp.body();
JSONObject json = JSONUtil.parseObj(body);
if (resp.getStatus() != 200) {
throw new RuntimeException("OCR 调用失败: " + resp.getStatus() + " " + body);
}
return parse(json);
}
}
/**
* 从 URL 识别发票: 后端下载 OSS URL 到临时文件 → recognize → 清理临时文件.
* 临时文件目录: System.getProperty("java.io.tmpdir")/ry-ocr/
*
* @param url OSS 可访问 URL
* @return 识别结果
*/
public InvoiceResult recognizeByUrl(String url) {
if (url == null || url.isEmpty()) {
throw new IllegalArgumentException("ossUrl 不能为空");
}
File tmpDir = new File(System.getProperty("java.io.tmpdir"), "ry-ocr");
if (!tmpDir.exists() && !tmpDir.mkdirs()) {
throw new RuntimeException("无法创建临时目录: " + tmpDir.getAbsolutePath());
}
// 从 URL 截取文件名, 保留后缀 (用于 ry-ocr 推断图片/PDF)
String name = url.substring(url.lastIndexOf('/') + 1);
if (name.indexOf('?') >= 0) name = name.substring(0, name.indexOf('?'));
if (name.indexOf('.') < 0) name = name + ".png";
File tmp = new File(tmpDir, System.currentTimeMillis() + "_" + name);
try {
long size = HttpUtil.downloadFile(url, tmp);
if (size <= 0) {
throw new RuntimeException("OSS 文件下载失败或为空: " + url);
}
log.info("OCR 下载: url={} size={}B tmp={}", url, size, tmp.getAbsolutePath());
return recognize(tmp);
} finally {
FileUtil.del(tmp);
}
}
private InvoiceResult parse(JSONObject json) {
InvoiceResult r = new InvoiceResult();
r.setSuccess(json.getBool("success", false));
r.setRawText(json.getStr("rawText", ""));
r.setEngine(json.getStr("engine", ""));
r.setPageCount(json.getInt("pageCount", 1));
r.setElapsedMs(json.getInt("elapsedMs", 0));
r.setError(json.getStr("error"));
JSONObject f = json.getJSONObject("fields");
if (f != null) {
InvoiceFields fields = new InvoiceFields();
fields.setInvoiceType(f.getStr("invoiceType"));
fields.setInvoiceNo(f.getStr("invoiceNo"));
fields.setInvoiceCode(f.getStr("invoiceCode"));
fields.setInvoiceDate(f.getStr("invoiceDate"));
fields.setAmount(f.getDouble("amount"));
fields.setAmountCn(f.getStr("amountCn"));
fields.setAmountPretax(f.getDouble("amount_pretax"));
fields.setTaxAmount(f.getDouble("taxAmount"));
fields.setSellerName(f.getStr("sellerName"));
fields.setSellerTaxNo(f.getStr("sellerTaxNo"));
fields.setBuyerName(f.getStr("buyerName"));
fields.setBuyerTaxNo(f.getStr("buyerTaxNo"));
fields.setAmountMatch(f.getBool("amountMatch"));
r.setFields(fields);
}
return r;
}
}
@@ -0,0 +1,13 @@
package com.ruoyi.business.ocr;
import lombok.Data;
import java.util.List;
/** 单行 OCR 识别结果 */
@Data
public class OcrLine {
private String text;
private Double confidence;
private List<List<Double>> box;
}
@@ -0,0 +1,84 @@
package com.ruoyi.business.ocr;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 下载 zip 到临时目录, 解压, 返回所有图片/PDF 文件
* <p>
* 临时目录由调用方识别完成后调用 {@link #cleanup(File)} 清理
* <p>
* 依赖: JDK 自带 {@link ZipInputStream} (不引 commons-compress / zip4j)
*/
@Slf4j
public class ZipExtractor
{
/**
* @param zipUrl zip 的 OSS URL
* @return 解压后的 File 列表 (仅 .png/.jpg/.jpeg/.pdf, 子目录展平, 用 _ 拼接)
* @throws IOException 下载失败 / IO 异常
*/
public static List<File> extract(String zipUrl) throws IOException
{
File tmpRoot = new File(System.getProperty("java.io.tmpdir"),
"ry-ocr-zip/" + System.currentTimeMillis());
if (!tmpRoot.mkdirs()) throw new IOException("无法创建临时目录: " + tmpRoot);
File zipFile = new File(tmpRoot, "input.zip");
try
{
long size = HttpUtil.downloadFile(zipUrl, zipFile);
if (size <= 0) throw new IOException("OSS zip 下载失败: " + zipUrl);
log.info("zip 下载: url={} size={}B tmp={}", zipUrl, size, zipFile.getAbsolutePath());
List<File> out = new ArrayList<>();
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile)))
{
ZipEntry e;
while ((e = zin.getNextEntry()) != null)
{
if (e.isDirectory()) continue;
String name = e.getName();
String lower = name.toLowerCase();
if (!(lower.endsWith(".png") || lower.endsWith(".jpg")
|| lower.endsWith(".jpeg") || lower.endsWith(".pdf")))
{
continue;
}
// 子目录展平: a/b/c.png → tmpRoot/a_b_c.png
File outFile = new File(tmpRoot, name.replace("/", "_"));
try (FileOutputStream fos = new FileOutputStream(outFile))
{
zin.transferTo(fos);
}
out.add(outFile);
}
}
log.info("zip 解压完成: 文件数={}", out.size());
return out;
}
finally
{
// zip 压缩包本身删掉;解压产物在 tmpRoot 下,等识别完由 cleanup 删
if (zipFile.exists()) zipFile.delete();
}
}
/** 清理整个解压临时目录 */
public static void cleanup(File tmpRoot)
{
if (tmpRoot != null && tmpRoot.exists())
{
FileUtil.del(tmpRoot);
log.info("清理 zip 临时目录: {}", tmpRoot.getAbsolutePath());
}
}
}
@@ -0,0 +1,57 @@
package com.ruoyi.business.oss;
import org.springframework.stereotype.Component;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.config.RuoYiConfig.OssProperties;
/**
* OSS 连接配置 (copy 自 hwt-serve/ruoyi-common-base/.../third/meta/OssConfMeta)
* <p>
* 改造点:
* <ul>
* <li>去掉了 hwt-serve 的 @Value("${ali.*}") 硬编码配置,改读本项目 {@link RuoYiConfig.OssProperties}</li>
* <li>endpoint 在 application.yml 里带 https:// 前缀 (例: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com),
* 但 {@code OSSClient} 构造时只吃裸 host → {@link #stripScheme(String)} 剥前缀</li>
* <li>不创建 bucket (hwt-serve 的 getOSSClient() 会自动建,本项目 bucket 已存在,无需建)</li>
* </ul>
*/
@Component
public class OssConfMeta
{
private final String endpoint;
private final String bucket;
private final String accessKeyId;
private final String accessKeySecret;
public OssConfMeta(RuoYiConfig cfg)
{
OssProperties p = cfg.getOss();
if (p == null)
{
throw new IllegalStateException("OSS 未配置 (application.yml 缺 ruoyi.oss.*)");
}
this.endpoint = stripScheme(p.getEndpoint());
this.bucket = p.getBucket();
this.accessKeyId = p.getAccessKeyId();
this.accessKeySecret = p.getAccessKeySecret();
}
/**
* 剥协议头与可能的 path
* 例: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com → hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com
*/
private static String stripScheme(String url)
{
if (url == null) return null;
String s = url;
if (s.startsWith("https://")) s = s.substring("https://".length());
else if (s.startsWith("http://")) s = s.substring("http://".length());
int slash = s.indexOf('/');
return slash >= 0 ? s.substring(0, slash) : s;
}
public String getEndpoint() { return endpoint; }
public String getBucket() { return bucket; }
public String getAccessKeyId() { return accessKeyId; }
public String getAccessKeySecret() { return accessKeySecret; }
}
@@ -0,0 +1,102 @@
package com.ruoyi.business.oss;
import com.aliyun.oss.OSSClient;
import com.aliyun.oss.model.ObjectMetadata;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
/**
* 服务端 OSS 上传器 (copy 自 hwt-serve/ruoyi-common-base/.../third/AliOssService,精简)
* <p>
* 用于: ZIP 解压 → OCR 识别为发票 → 重新上传到 OSS,拿新 URL 写入 invoice.oss_url
* (不传 zip 的 oss_url, 因为那是压缩包不是发票本体)
* <p>
* 与 hwt-serve 的差异:
* <ul>
* <li>只保留 {@code upload} (服务端上传) 一个公开方法;删除 deleteFile / extractKey (本场景不需要)</li>
* <li>key 前缀由调用方通过 {@code subDir} 传入 (例 "meeting/invoice"),不再硬编码 "files/yyyy/MM/dd/"</li>
* <li>Content-Type / Content-Disposition 复用 hwt-serve 的 guessContentType 实现 (含 bmp/jpg/doc/ppt/pdf)</li>
* </ul>
*/
@Slf4j
@Component
public class OssUploader
{
@Autowired
private OssConfMeta ossConfMeta;
/**
* 上传字节到 OSS
*
* @param data 文件字节
* @param originalFilename 原始文件名 (用于决定 Content-Type 和 URL 末尾,不是 OSS key)
* @param subDir 业务子目录,直接拼在 dirPrefix 后 (例 "meeting/invoice")
* @return 完整 URL: {@code https://{bucket}.{endpoint}/{key}}
*/
public String upload(byte[] data, String originalFilename, String subDir)
{
if (data == null || data.length == 0) throw new IllegalArgumentException("上传字节为空");
if (originalFilename == null) throw new IllegalArgumentException("originalFilename 不能为空");
log.info("OSS 上传开始: name={} size={}B subDir={}", originalFilename, data.length, subDir);
String ext = originalFilename.contains(".")
? originalFilename.substring(originalFilename.lastIndexOf('.'))
: "";
String date = new SimpleDateFormat("yyyyMM").format(new Date());
String uuid = UUID.randomUUID().toString().replace("-", "");
String key = (subDir == null ? "" : subDir + "/") + date + "/" + uuid + ext;
OSSClient client = new OSSClient(
ossConfMeta.getEndpoint(),
ossConfMeta.getAccessKeyId(),
ossConfMeta.getAccessKeySecret());
try
{
ObjectMetadata meta = buildObjectMeta(originalFilename, ext);
client.putObject(ossConfMeta.getBucket(), key, new ByteArrayInputStream(data), meta);
}
finally
{
client.shutdown();
}
String url = "https://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/" + key;
log.info("OSS 上传完成: {}", url);
return url;
}
private static ObjectMetadata buildObjectMeta(String filename, String ext)
{
ObjectMetadata meta = new ObjectMetadata();
meta.setCacheControl("no-cache");
meta.setHeader("Pragma", "no-cache");
meta.setContentType(guessContentType(ext));
meta.setContentDisposition("inline;filename=" + filename);
return meta;
}
/**
* 复制自 hwt-serve AliOssService.guessContentType
* 注: hwt-serve 把所有图片 (bmp/jpg/png) 都映射为 image/jpg,与命名不太严谨
* 本实现按标准 mime 区分 png / jpg / jpeg
*/
private static String guessContentType(String ext)
{
if (ext == null) return "application/octet-stream";
String lower = ext.toLowerCase();
if (".bmp".equals(lower)) return "image/bmp";
if (".png".equals(lower)) return "image/png";
if (".jpg".equals(lower) || ".jpeg".equals(lower)) return "image/jpeg";
if (".gif".equals(lower)) return "image/gif";
if (".webp".equals(lower)) return "image/webp";
if (".pdf".equals(lower)) return "application/pdf";
if (".doc".equals(lower) || ".docx".equals(lower)) return "application/msword";
if (".ppt".equals(lower) || ".pptx".equals(lower)) return "application/vnd.ms-powerpoint";
return "application/octet-stream";
}
}
@@ -0,0 +1,22 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingAuditLog;
/**
* 会议审核流程日志 Service 接口
*/
public interface IBizMeetingAuditLogService {
BizMeetingAuditLog getById(Long id);
List<BizMeetingAuditLog> selectList(BizMeetingAuditLog entity);
int insert(BizMeetingAuditLog entity);
int updateByPrimaryKey(BizMeetingAuditLog entity);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,22 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingExecutor;
/**
* 会议-执行人员 Service 接口
*/
public interface IBizMeetingExecutorService {
BizMeetingExecutor getById(Long id);
List<BizMeetingExecutor> selectByMeetingId(Long meetingId);
List<BizMeetingExecutor> selectByUserId(Long userId);
/**
* 替换该会议的执行人员 (全删全插, 一个事务)
* @param assignedBy 分配人 user_id
*/
int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy);
}
@@ -0,0 +1,24 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingInvoice;
/**
* 会议发票 Service 接口
*/
public interface IBizMeetingInvoiceService {
BizMeetingInvoice getById(Long id);
BizMeetingInvoice selectByMaterialId(Long materialId);
List<BizMeetingInvoice> selectByMeetingId(Long meetingId);
/**
* Upsert: 按 material_id 唯一 (uk_material), 存在则更新金额, 不存在则插入.
* 仅在 OCR 识别为发票时调用, 不是发票不调此方法 (不入库).
*/
int upsertByMaterialId(BizMeetingInvoice record);
int deleteByMaterialId(Long materialId);
}
@@ -0,0 +1,35 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingMaterial;
/**
* 会议材料 Service 接口
*/
public interface IBizMeetingMaterialService {
/** 按主键查单条 */
BizMeetingMaterial getById(Long id);
/** 按会议ID查该会议所有材料记录 */
List<BizMeetingMaterial> selectByMeetingId(Long meetingId);
/**
* 替换该会议的所有材料记录 (一个事务, 全删全插)
* <p>
* 用于"保存"按钮: 前端传当前 UI 上传的文件列表, 后端先删后插.
* <ul>
* <li>list 为空或 null → 仅删除该会议的全部记录, 不插入</li>
* <li>list 非空 → 先 deleteByMeetingId, 再 insertBatch</li>
* </ul>
*
* @return 插入后带 id 的 list (前端可借此触发 OCR 识别, 拿到 materialId 关联 invoice 表)
*/
List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list);
/**
* 单条更新 amount (OCR 识别为发票后回写).
* 不动其他字段, 不抛异常 (失败仅 log).
*/
int updateAmount(Long materialId, java.math.BigDecimal amount);
}
@@ -0,0 +1,22 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingSupervisor;
/**
* 会议-监察员 Service 接口
*/
public interface IBizMeetingSupervisorService {
BizMeetingSupervisor getById(Long id);
List<BizMeetingSupervisor> selectByMeetingId(Long meetingId);
List<BizMeetingSupervisor> selectByUserId(Long userId);
/**
* 替换该会议的监察员 (全删全插, 一个事务)
* @param assignedBy 分配人 user_id (前端 / manager 自己)
*/
int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy);
}
@@ -0,0 +1,45 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizMeetingAuditLog;
import com.ruoyi.business.mapper.BizMeetingAuditLogMapper;
import com.ruoyi.business.service.IBizMeetingAuditLogService;
@Service
public class BizMeetingAuditLogServiceImpl implements IBizMeetingAuditLogService {
@Autowired
private BizMeetingAuditLogMapper bizMeetingAuditLogMapper;
@Override
public BizMeetingAuditLog getById(Long id) {
return bizMeetingAuditLogMapper.selectByPrimaryKey(id);
}
@Override
public List<BizMeetingAuditLog> selectList(BizMeetingAuditLog entity) {
return bizMeetingAuditLogMapper.selectList(entity);
}
@Override
public int insert(BizMeetingAuditLog entity) {
return bizMeetingAuditLogMapper.insert(entity);
}
@Override
public int updateByPrimaryKey(BizMeetingAuditLog entity) {
return bizMeetingAuditLogMapper.updateByPrimaryKey(entity);
}
@Override
public int deleteByPrimaryKey(Long id) {
return bizMeetingAuditLogMapper.deleteByPrimaryKey(id);
}
@Override
public int deleteByPrimaryKeys(Long[] ids) {
return bizMeetingAuditLogMapper.deleteByPrimaryKeys(ids);
}
}
@@ -0,0 +1,50 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizMeetingExecutor;
import com.ruoyi.business.mapper.BizMeetingExecutorMapper;
import com.ruoyi.business.service.IBizMeetingExecutorService;
@Service
public class BizMeetingExecutorServiceImpl implements IBizMeetingExecutorService {
@Autowired
private BizMeetingExecutorMapper bizMeetingExecutorMapper;
@Override
public BizMeetingExecutor getById(Long id) {
return bizMeetingExecutorMapper.selectByPrimaryKey(id);
}
@Override
public List<BizMeetingExecutor> selectByMeetingId(Long meetingId) {
return bizMeetingExecutorMapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingExecutor> selectByUserId(Long userId) {
return bizMeetingExecutorMapper.selectByUserId(userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy) {
bizMeetingExecutorMapper.deleteByMeetingId(meetingId);
if (userIds == null || userIds.isEmpty()) {
return 0;
}
List<BizMeetingExecutor> list = new ArrayList<>(userIds.size());
for (Long uid : userIds) {
BizMeetingExecutor m = new BizMeetingExecutor();
m.setMeetingId(meetingId);
m.setUserId(uid);
m.setAssignedBy(assignedBy);
list.add(m);
}
return bizMeetingExecutorMapper.insertBatch(list);
}
}
@@ -0,0 +1,62 @@
package com.ruoyi.business.service.impl;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizMeetingInvoice;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
@Service
public class BizMeetingInvoiceServiceImpl implements IBizMeetingInvoiceService {
@Autowired
private BizMeetingInvoiceMapper bizMeetingInvoiceMapper;
@Override
public BizMeetingInvoice getById(Long id) {
return bizMeetingInvoiceMapper.selectByPrimaryKey(id);
}
@Override
public BizMeetingInvoice selectByMaterialId(Long materialId) {
return bizMeetingInvoiceMapper.selectByMaterialId(materialId);
}
@Override
public List<BizMeetingInvoice> selectByMeetingId(Long meetingId) {
return bizMeetingInvoiceMapper.selectByMeetingId(meetingId);
}
/**
* Upsert: 存在则按 material_id 更新 (amount + url + 时间), 不存在则插入新行.
* <p>
* 注意: UK uk_material 保证一个 material_id 只对应一条 invoice, 自然幂等.
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int upsertByMaterialId(BizMeetingInvoice record) {
if (record == null || record.getMaterialId() == null) return 0;
Date now = new Date();
BizMeetingInvoice exist = bizMeetingInvoiceMapper.selectByMaterialId(record.getMaterialId());
if (exist == null) {
record.setId(null);
if (record.getCreateTime() == null) record.setCreateTime(now);
record.setUpdateTime(now);
return bizMeetingInvoiceMapper.insert(record);
} else {
// 更新金额/url, 时间戳
if (record.getAmount() != null) exist.setAmount(record.getAmount());
if (record.getOssUrl() != null && !record.getOssUrl().isEmpty()) exist.setOssUrl(record.getOssUrl());
exist.setUpdateTime(now);
return bizMeetingInvoiceMapper.updateByPrimaryKey(exist);
}
}
@Override
public int deleteByMaterialId(Long materialId) {
return bizMeetingInvoiceMapper.deleteByMaterialId(materialId);
}
}
@@ -0,0 +1,63 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import com.ruoyi.business.service.IBizMeetingMaterialService;
@Service
public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService {
@Autowired
private BizMeetingMaterialMapper bizMeetingMaterialMapper;
@Override
public BizMeetingMaterial getById(Long id) {
return bizMeetingMaterialMapper.selectByPrimaryKey(id);
}
@Override
public List<BizMeetingMaterial> selectByMeetingId(Long meetingId) {
return bizMeetingMaterialMapper.selectByMeetingId(meetingId);
}
/**
* 全删全插, 一个事务. list 为空则只删不插.
* <p>
* 防御性捕获 DuplicateKeyException: 正常流程 (delete → insert) 不会触发,
* 但并发或前端传重复 (meetingId, materialType, subType) 会触发 UK uk_meeting_type_sub.
* 翻译成友好中文提示, 避免暴露 SQL 堆栈.
* <p>
* 返回插入后的 list (各元素 id 字段被 useGeneratedKeys 回填), 前端可借此触发 OCR.
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list) {
bizMeetingMaterialMapper.deleteByMeetingId(meetingId);
if (list == null || list.isEmpty()) {
return list;
}
// 不接受前端传的 id, 走 AUTO_INCREMENT; meetingId 兜底由路径提供
for (BizMeetingMaterial m : list) {
m.setId(null);
m.setMeetingId(meetingId);
}
try {
bizMeetingMaterialMapper.insertBatch(list);
} catch (DuplicateKeyException e) {
throw new ServiceException("材料上传重复, 请检查 (同一会议下同一资料类型同一子分类只能有一条记录)");
}
return list;
}
@Override
public int updateAmount(Long materialId, java.math.BigDecimal amount) {
if (materialId == null || amount == null) return 0;
return bizMeetingMaterialMapper.updateAmount(materialId, amount);
}
}
@@ -0,0 +1,50 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizMeetingSupervisor;
import com.ruoyi.business.mapper.BizMeetingSupervisorMapper;
import com.ruoyi.business.service.IBizMeetingSupervisorService;
@Service
public class BizMeetingSupervisorServiceImpl implements IBizMeetingSupervisorService {
@Autowired
private BizMeetingSupervisorMapper bizMeetingSupervisorMapper;
@Override
public BizMeetingSupervisor getById(Long id) {
return bizMeetingSupervisorMapper.selectByPrimaryKey(id);
}
@Override
public List<BizMeetingSupervisor> selectByMeetingId(Long meetingId) {
return bizMeetingSupervisorMapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingSupervisor> selectByUserId(Long userId) {
return bizMeetingSupervisorMapper.selectByUserId(userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy) {
bizMeetingSupervisorMapper.deleteByMeetingId(meetingId);
if (userIds == null || userIds.isEmpty()) {
return 0;
}
List<BizMeetingSupervisor> list = new ArrayList<>(userIds.size());
for (Long uid : userIds) {
BizMeetingSupervisor m = new BizMeetingSupervisor();
m.setMeetingId(meetingId);
m.setUserId(uid);
m.setAssignedBy(assignedBy);
list.add(m);
}
return bizMeetingSupervisorMapper.insertBatch(list);
}
}
@@ -0,0 +1,333 @@
package com.ruoyi.business.service.impl;
import java.io.File;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.IBizMeetingMaterialService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutorService;
import com.ruoyi.business.domain.BizMeetingInvoice;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.ocr.InvoiceFields;
import com.ruoyi.business.ocr.InvoiceResult;
import com.ruoyi.business.ocr.OcrClient;
import com.ruoyi.business.ocr.ZipExtractor;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.common.utils.SecurityUtils;
import cn.hutool.core.io.FileUtil;
/**
* 发票识别协调服务 (v3 重写)
* <p>
* 设计要点 (v2 → v3 变更):
* <ul>
* <li><b>后台执行</b>: 单文件 OCR 改为 {@code ExecutorService.submit}, 立即返回 SUBMITTED
* (原 v2 是同步阻塞, 大文件/慢网络时拖慢保存接口)</li>
* <li><b>ZIP 支持</b>: zipUrl → 下载 → 解压 → 遍历 .png/.jpg/.jpeg/.pdf →
* 是发票 → <b>重新上传到 OSS</b> (key 是新生成的, 不是 zip 的 url) → 写 invoice 行</li>
* <li><b>is_invoice 列</b>: <b>不加</b>. 不是发票的图直接不入 invoice 表</li>
* <li><b>替换场景</b>: 前端传 {@code oldMaterialId}, 后端先 DELETE invoice WHERE material_id=old
* + material.amount=0, 再走 OCR</li>
* <li><b>状态机</b>: UNRECOGNIZED → RECOGNIZED / FAILED, 兜底 scheduler 每 60s 重试 > 5min 未识别行</li>
* </ul>
*/
@Service
public class InvoiceOcrService
{
private static final Logger log = LoggerFactory.getLogger(InvoiceOcrService.class);
@Autowired
private OcrClient ocrClient;
@Autowired
private OssUploader ossUploader;
@Autowired
private BizMeetingInvoiceMapper invoiceMapper;
@Autowired
private IBizMeetingInvoiceService invoiceService;
@Autowired
private IBizMeetingMaterialService materialService;
@Autowired
@Qualifier("ocrExecutor")
private ExecutorService ocrExecutor;
// ==================== 对外入口 ====================
/**
* 提交识别 (后台执行, 立即返回 SUBMITTED)
*
* @param materialId biz_meeting_material.id
* @param meetingId 会议 ID
* @param ossUrl OSS URL (单文件: 原图;ZIP: zip 包)
* @param isZip true → ZIP 路径;false → 单文件路径
* @param oldMaterialId 替换场景携带, 后端先 DELETE invoice WHERE material_id=old + material.amount=0;
* null → 新增场景, 不做清理
* @return 提交摘要 (立即返回, 不等 OCR 完成)
*/
public RecognizeResult submitRecognition(Long materialId, Long meetingId, String ossUrl,
boolean isZip, Long oldMaterialId)
{
RecognizeResult out = new RecognizeResult();
if (materialId == null || meetingId == null || ossUrl == null || ossUrl.isEmpty())
{
out.setSubmitted(false);
out.setErrorMsg("参数缺失");
return out;
}
// 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0)
if (oldMaterialId != null)
{
log.info("替换场景: 清旧 invoice + material.amount=0, oldMaterialId={}", oldMaterialId);
invoiceMapper.deleteByMaterialId(oldMaterialId);
materialService.updateAmount(oldMaterialId, BigDecimal.ZERO);
}
// 2. 单文件: 插 UNRECOGNIZED 占位行 (zip 路径跳过 — 解压才知道有几张)
if (!isZip)
{
BizMeetingInvoice placeholder = new BizMeetingInvoice();
placeholder.setMeetingId(meetingId);
placeholder.setMaterialId(materialId);
placeholder.setOssUrl(ossUrl);
placeholder.setInvoiceType("SUB");
placeholder.setRecognizeStatus("UNRECOGNIZED");
placeholder.setCreatorId(SecurityUtils.getUserId());
placeholder.setCreateTime(new Date());
placeholder.setUpdateTime(new Date());
invoiceMapper.insert(placeholder);
}
// 3. 后台执行 OCR (单文件或 zip)
final String url = ossUrl;
ocrExecutor.submit(() ->
{
try
{
if (isZip)
{
recognizeZip(materialId, meetingId, url);
}
else
{
recognizeSingle(materialId, url);
}
}
catch (Exception e)
{
log.warn("OCR 后台任务失败 materialId={} isZip={} err={}", materialId, isZip, e.getMessage(), e);
if (!isZip)
{
// 单文件: 占位行标 FAILED
invoiceMapper.updateStatusByMaterial(materialId, "FAILED",
e.getMessage() == null ? "OCR 异常" : e.getMessage());
}
}
});
out.setSubmitted(true);
out.setIsZip(isZip);
out.setMaterialId(materialId);
out.setStatus("SUBMITTED");
return out;
}
// ==================== 后台执行方法 ====================
/**
* 单文件 OCR:
* 下载 → 识别 → 是发票 → updateStatus=RECOGNIZED + 回写 amount + material.amount
* 不是发票 → 删除占位行 (material 表不动, amount 保持原值)
* <p>
* material 表语义是"会议上传的文件", 不是发票也照样是上传文件, 不能动它
*/
void recognizeSingle(Long materialId, String ossUrl)
{
InvoiceResult ir = ocrClient.recognizeByUrl(ossUrl);
if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields()))
{
BigDecimal amount = ir.getFields().getAmount() != null
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
invoiceMapper.updateStatusByMaterial(materialId, "RECOGNIZED", null);
invoiceMapper.updateAmountByMaterial(materialId, amount);
materialService.updateAmount(materialId, amount);
log.info("单文件识别成功: materialId={} amount={} elapsed={}ms",
materialId, amount, ir.getElapsedMs());
}
else
{
// 不是发票: 仅删占位行, material 表不动 (material 仍代表上传的文件本身)
invoiceMapper.deleteByMaterialId(materialId);
log.info("单文件识别非发票: materialId={} (invoiceType={}, amount={}) - material 表保持原状",
materialId,
ir.getFields() != null ? ir.getFields().getInvoiceType() : null,
ir.getFields() != null ? ir.getFields().getAmount() : null);
}
}
/**
* ZIP OCR:
* 下载 zip → 解压 → 遍历图片/PDF → 是发票 → 重传 OSS (新 URL) → 写 invoice 行 →
* 累加 amount → material.amount = 总和
* 不是发票 → 跳过 (不入 invoice 表, material 表不动)
* <p>
* material 表只动一次 (末尾的 updateAmount), 即使 0 张发票也照写; 非发票文件不进任何统计
*/
void recognizeZip(Long materialId, Long meetingId, String zipUrl) throws Exception
{
File tmpRoot = null;
try
{
List<File> files = ZipExtractor.extract(zipUrl);
if (!files.isEmpty())
{
tmpRoot = files.get(0).getParentFile();
}
BigDecimal total = BigDecimal.ZERO;
int invoiceCount = 0;
int skipCount = 0;
int failCount = 0;
for (File f : files)
{
try
{
InvoiceResult ir = ocrClient.recognize(f);
if (!Boolean.TRUE.equals(ir.getSuccess()) || ir.getFields() == null
|| !isRecognizedAsInvoice(ir.getFields()))
{
// 不是发票: 跳过, 不入库 (不入 invoice 表, material 表不动)
skipCount++;
continue;
}
// ★ 修正点2: 是发票 → 重传 OSS, 用新 URL 写入 invoice 行 (不传 zipUrl)
byte[] bytes = FileUtil.readBytes(f);
String newUrl = ossUploader.upload(bytes, f.getName(), "meeting/invoice");
BigDecimal amount = ir.getFields().getAmount() != null
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
BizMeetingInvoice inv = new BizMeetingInvoice();
inv.setMeetingId(meetingId);
inv.setMaterialId(materialId);
inv.setOssUrl(newUrl);
inv.setInvoiceType("SUB");
inv.setAmount(amount);
inv.setRecognizeStatus("RECOGNIZED");
inv.setSourceFilename(f.getName());
inv.setCreatorId(SecurityUtils.getUserId());
inv.setCreateTime(new Date());
inv.setUpdateTime(new Date());
invoiceMapper.insert(inv);
total = total.add(amount);
invoiceCount++;
}
catch (Exception e)
{
log.warn("zip 子文件识别失败 file={} err={}", f.getName(), e.getMessage());
failCount++;
}
}
// material.amount 只在"识别出至少 1 张发票"时回写求和; 0 张时不动 material
// (用户上传了文件, 即使全是合同照片, material 也应保留, amount 字段保持原值)
if (invoiceCount > 0)
{
materialService.updateAmount(materialId, total);
}
log.info("zip OCR 完成 materialId={} invoice={} skip={} fail={} total={}",
materialId, invoiceCount, skipCount, failCount, total);
}
finally
{
ZipExtractor.cleanup(tmpRoot);
}
}
/**
* 兜底 OCR (InvoiceOcrScheduler 调用): 已知 invoice 行 (UNRECOGNIZED), 重新识别
* <p>
* 不是发票 → 仅删占位行, material 表不动 (与单文件/ZIP 路径一致)
*/
public void recognizeOneInvoice(BizMeetingInvoice inv)
{
if (inv == null || inv.getOssUrl() == null) return;
try
{
InvoiceResult ir = ocrClient.recognizeByUrl(inv.getOssUrl());
if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields()))
{
BigDecimal amount = ir.getFields().getAmount() != null
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
invoiceMapper.updateStatusAndAmountByPrimaryKey(inv.getId(), "RECOGNIZED", amount, null);
materialService.updateAmount(inv.getMaterialId(), amount);
log.info("兜底识别成功: invoiceId={} amount={}", inv.getId(), amount);
}
else
{
// 不是发票: 仅删占位行, material 表保持原状
invoiceMapper.deleteByPrimaryKey(inv.getId());
log.info("兜底识别非发票: invoiceId={} 已删除 (material 表不动)", inv.getId());
}
}
catch (Exception e)
{
invoiceMapper.updateStatusAndAmountByPrimaryKey(inv.getId(), "FAILED", null,
e.getMessage() == null ? "OCR 异常" : e.getMessage());
log.warn("兜底识别失败 invoiceId={} err={}", inv.getId(), e.getMessage());
}
}
// ==================== 工具方法 ====================
/**
* 是否识别为发票: 必须有 invoiceType + amount>0
* (ry-ocr 对非发票图片也可能返回 fields, 但 invoiceType/amount 通常为空/0)
*/
private boolean isRecognizedAsInvoice(InvoiceFields f)
{
if (f.getInvoiceType() == null || f.getInvoiceType().isEmpty()) return false;
if (f.getAmount() == null || f.getAmount() <= 0) return false;
return true;
}
// ==================== DTO ====================
/** 提交结果 (立即返回, 不等 OCR 完成) */
public static class RecognizeResult
{
private boolean submitted;
private boolean isZip;
private Long materialId;
private String status;
private String errorMsg;
public boolean isSubmitted() { return submitted; }
public void setSubmitted(boolean submitted) { this.submitted = submitted; }
public boolean isZip() { return isZip; }
public void setIsZip(boolean zip) { isZip = zip; }
public Long getMaterialId() { return materialId; }
public void setMaterialId(Long materialId) { this.materialId = materialId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getErrorMsg() { return errorMsg; }
public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; }
}
}
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingAuditLogMapper">
<resultMap type="BizMeetingAuditLog" id="BizMeetingAuditLogResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="auditor" column="auditor" />
<result property="opinion" column="opinion" />
<result property="currentStage" column="current_stage" />
<result property="createTime" column="create_time" />
<result property="auditTime" column="audit_time" />
<result property="auditType" column="audit_type" />
<result property="auditResult" column="audit_result" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, auditor, opinion, current_stage, create_time, audit_time, audit_type, audit_result
from biz_meeting_audit_log
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingAuditLogResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectList" resultMap="BizMeetingAuditLogResult" parameterType="BizMeetingAuditLog">
<include refid="selectFields"/>
<where>
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if>
<if test="auditor != null and auditor != ''">and auditor = #{auditor}</if>
</where>
order by id desc
</select>
<insert id="insert" parameterType="BizMeetingAuditLog" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_audit_log
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="auditor != null and auditor != ''">auditor,</if>
<if test="opinion != null and opinion != ''">opinion,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if>
<if test="createTime != null">create_time,</if>
<if test="auditTime != null">audit_time,</if>
<if test="auditType != null and auditType != ''">audit_type,</if>
<if test="auditResult != null and auditResult != ''">audit_result,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="auditor != null and auditor != ''">#{auditor},</if>
<if test="opinion != null and opinion != ''">#{opinion},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if>
<if test="createTime != null">#{createTime},</if>
<if test="auditTime != null">#{auditTime},</if>
<if test="auditType != null and auditType != ''">#{auditType},</if>
<if test="auditResult != null and auditResult != ''">#{auditResult},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingAuditLog">
update biz_meeting_audit_log
<trim prefix="SET" suffixOverrides=",">
<if test="auditor != null and auditor != ''">auditor = #{auditor},</if>
<if test="opinion != null and opinion != ''">opinion = #{opinion},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="auditTime != null">audit_time = #{auditTime},</if>
<if test="auditType != null and auditType != ''">audit_type = #{auditType},</if>
<if test="auditResult != null and auditResult != ''">audit_result = #{auditResult},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_audit_log where id = #{id}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="Long">
delete from biz_meeting_audit_log where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingExecutorMapper">
<resultMap type="BizMeetingExecutor" id="BizMeetingExecutorResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="assignedBy" column="assigned_by" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, user_id, assigned_by, create_time
from biz_meeting_executor
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingExecutorResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectByMeetingId" resultMap="BizMeetingExecutorResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id asc
</select>
<select id="selectByUserId" resultMap="BizMeetingExecutorResult" parameterType="Long">
<include refid="selectFields"/>
where user_id = #{userId}
order by id desc
</select>
<select id="selectList" resultMap="BizMeetingExecutorResult" parameterType="BizMeetingExecutor">
<include refid="selectFields"/>
<where>
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="userId != null">and user_id = #{userId}</if>
</where>
order by id asc
</select>
<insert id="insert" parameterType="BizMeetingExecutor" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_executor
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="userId != null">user_id,</if>
<if test="assignedBy != null">assigned_by,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="userId != null">#{userId},</if>
<if test="assignedBy != null">#{assignedBy},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_executor (meeting_id, user_id, assigned_by, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.userId}, #{item.assignedBy}, #{item.createTime})
</foreach>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingExecutor">
update biz_meeting_executor
<trim prefix="SET" suffixOverrides=",">
<if test="userId != null">user_id = #{userId},</if>
<if test="assignedBy != null">assigned_by = #{assignedBy},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_executor where id = #{id}
</delete>
<delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_executor where meeting_id = #{meetingId}
</delete>
</mapper>
@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingInvoiceMapper">
<resultMap type="BizMeetingInvoice" id="BizMeetingInvoiceResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="materialId" column="material_id" />
<result property="ossUrl" column="oss_url" />
<result property="invoiceType" column="invoice_type" />
<result property="amount" column="amount" />
<result property="creatorId" column="creator_id" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
<result property="recognizeStatus" column="recognize_status" />
<result property="errorMsg" column="error_msg" />
<result property="sourceFilename" column="source_filename" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, material_id, oss_url, invoice_type, amount, creator_id, create_time, update_time,
recognize_status, error_msg, source_filename
from biz_meeting_invoice
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingInvoiceResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectList" resultMap="BizMeetingInvoiceResult" parameterType="BizMeetingInvoice">
<include refid="selectFields"/>
<where>
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="materialId != null">and material_id = #{materialId}</if>
<if test="invoiceType != null and invoiceType != ''">and invoice_type = #{invoiceType}</if>
</where>
order by id desc
</select>
<select id="selectByMaterialId" resultMap="BizMeetingInvoiceResult" parameterType="Long">
<include refid="selectFields"/>
where material_id = #{materialId}
limit 1
</select>
<select id="selectByMeetingId" resultMap="BizMeetingInvoiceResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id desc
</select>
<insert id="insert" parameterType="BizMeetingInvoice" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_invoice
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="materialId != null">material_id,</if>
<if test="ossUrl != null and ossUrl != ''">oss_url,</if>
<if test="invoiceType != null and invoiceType != ''">invoice_type,</if>
<if test="amount != null">amount,</if>
<if test="creatorId != null">creator_id,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
<if test="recognizeStatus != null and recognizeStatus != ''">recognize_status,</if>
<if test="errorMsg != null">error_msg,</if>
<if test="sourceFilename != null">source_filename,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="materialId != null">#{materialId},</if>
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
<if test="invoiceType != null and invoiceType != ''">#{invoiceType},</if>
<if test="amount != null">#{amount},</if>
<if test="creatorId != null">#{creatorId},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="recognizeStatus != null and recognizeStatus != ''">#{recognizeStatus},</if>
<if test="errorMsg != null">#{errorMsg},</if>
<if test="sourceFilename != null">#{sourceFilename},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingInvoice">
update biz_meeting_invoice
<trim prefix="SET" suffixOverrides=",">
<if test="meetingId != null">meeting_id = #{meetingId},</if>
<if test="materialId != null">material_id = #{materialId},</if>
<if test="ossUrl != null and ossUrl != ''">oss_url = #{ossUrl},</if>
<if test="invoiceType != null and invoiceType != ''">invoice_type = #{invoiceType},</if>
<if test="amount != null">amount = #{amount},</if>
<if test="creatorId != null">creator_id = #{creatorId},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="recognizeStatus != null and recognizeStatus != ''">recognize_status = #{recognizeStatus},</if>
<if test="errorMsg != null">error_msg = #{errorMsg},</if>
<if test="sourceFilename != null">source_filename = #{sourceFilename},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_invoice where id = #{id}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="Long">
delete from biz_meeting_invoice where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
<delete id="deleteByMaterialId" parameterType="Long">
delete from biz_meeting_invoice where material_id = #{materialId}
</delete>
<!-- 兜底扫描: UNRECOGNIZED 状态且 create_time 早于 N 分钟前的行 (处理程序重启 / OCR 临时挂掉导致的遗漏) -->
<select id="selectUnrecognizedOlderThanMinutes" resultMap="BizMeetingInvoiceResult" parameterType="int">
<include refid="selectFields"/>
where recognize_status = 'UNRECOGNIZED'
and create_time is not null
and create_time &lt; date_sub(now(), INTERVAL #{minutes} MINUTE)
order by create_time ASC
limit 100
</select>
<!-- 单文件后台 OCR 完成: 状态+金额 一起更新 (按 material_id) -->
<update id="updateStatusByMaterial">
update biz_meeting_invoice
set recognize_status = #{recognizeStatus},
<if test="errorMsg != null">error_msg = #{errorMsg},</if>
update_time = now()
where material_id = #{materialId}
and recognize_status = 'UNRECOGNIZED'
</update>
<!-- 单文件后台 OCR 完成: 更新金额 (前提: 状态已是 RECOGNIZED) -->
<update id="updateAmountByMaterial">
update biz_meeting_invoice
set amount = #{amount},
update_time = now()
where material_id = #{materialId}
</update>
<!-- 兜底 OCR 用: 同时更新状态 + 金额 + 错误信息 (按主键) -->
<update id="updateStatusAndAmountByPrimaryKey">
update biz_meeting_invoice
set recognize_status = #{recognizeStatus},
amount = #{amount},
error_msg = #{errorMsg},
update_time = now()
where id = #{id}
</update>
</mapper>
@@ -14,10 +14,13 @@
<result property="startTime" column="start_time" /> <result property="startTime" column="start_time" />
<result property="endTime" column="end_time" /> <result property="endTime" column="end_time" />
<result property="orgName" column="org_name" /> <result property="orgName" column="org_name" />
<result property="address" column="address" />
<result property="currentStage" column="current_stage" /> <result property="currentStage" column="current_stage" />
<result property="supervisionOpinion" column="supervision_opinion" /> <result property="supervisionOpinion" column="supervision_opinion" />
<result property="supervisionBy" column="supervision_by" /> <result property="supervisionBy" column="supervision_by" />
<result property="supervisionTime" column="supervision_time" /> <result property="supervisionTime" column="supervision_time" />
<result property="materialAuditStage" column="material_audit_stage" />
<result property="voucherAuditStage" column="voucher_audit_stage" />
<result property="invitationUrl" column="invitation_url" /> <result property="invitationUrl" column="invitation_url" />
<result property="scheduleUrl" column="schedule_url" /> <result property="scheduleUrl" column="schedule_url" />
<result property="laborSigned" column="labor_signed" /> <result property="laborSigned" column="labor_signed" />
@@ -27,7 +30,7 @@
<result property="updateTime" column="update_time" /> <result property="updateTime" column="update_time" />
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
select meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, current_stage, supervision_opinion, supervision_by, supervision_time, invitation_url, schedule_url, labor_signed, create_by, create_time, update_by, update_time select meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, address, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, voucher_audit_stage, invitation_url, schedule_url, labor_signed, create_by, create_time, update_by, update_time
from biz_meeting from biz_meeting
</sql> </sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long"> <select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long">
@@ -65,10 +68,13 @@
<if test="startTime != null">start_time,</if> <if test="startTime != null">start_time,</if>
<if test="endTime != null">end_time,</if> <if test="endTime != null">end_time,</if>
<if test="orgName != null and orgName != ''">org_name,</if> <if test="orgName != null and orgName != ''">org_name,</if>
<if test="address != null and address != ''">address,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if> <if test="currentStage != null and currentStage != ''">current_stage,</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion,</if> <if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion,</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by,</if> <if test="supervisionBy != null and supervisionBy != ''">supervision_by,</if>
<if test="supervisionTime != null">supervision_time,</if> <if test="supervisionTime != null">supervision_time,</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage,</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage,</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if> <if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if> <if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if>
<if test="laborSigned != null and laborSigned != ''">labor_signed,</if> <if test="laborSigned != null and laborSigned != ''">labor_signed,</if>
@@ -86,10 +92,13 @@
<if test="startTime != null">#{startTime},</if> <if test="startTime != null">#{startTime},</if>
<if test="endTime != null">#{endTime},</if> <if test="endTime != null">#{endTime},</if>
<if test="orgName != null and orgName != ''">#{orgName},</if> <if test="orgName != null and orgName != ''">#{orgName},</if>
<if test="address != null and address != ''">#{address},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if> <if test="currentStage != null and currentStage != ''">#{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">#{supervisionOpinion},</if> <if test="supervisionOpinion != null and supervisionOpinion != ''">#{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">#{supervisionBy},</if> <if test="supervisionBy != null and supervisionBy != ''">#{supervisionBy},</if>
<if test="supervisionTime != null">#{supervisionTime},</if> <if test="supervisionTime != null">#{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">#{materialAuditStage},</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">#{voucherAuditStage},</if>
<if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if> <if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if> <if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if>
<if test="laborSigned != null and laborSigned != ''">#{laborSigned},</if> <if test="laborSigned != null and laborSigned != ''">#{laborSigned},</if>
@@ -109,10 +118,13 @@
<if test="startTime != null and startTime != ''">start_time = #{startTime},</if> <if test="startTime != null and startTime != ''">start_time = #{startTime},</if>
<if test="endTime != null and endTime != ''">end_time = #{endTime},</if> <if test="endTime != null and endTime != ''">end_time = #{endTime},</if>
<if test="orgName != null and orgName != ''">org_name = #{orgName},</if> <if test="orgName != null and orgName != ''">org_name = #{orgName},</if>
<if test="address != null and address != ''">address = #{address},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if> <if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if> <if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if> <if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if>
<if test="supervisionTime != null and supervisionTime != ''">supervision_time = #{supervisionTime},</if> <if test="supervisionTime != null and supervisionTime != ''">supervision_time = #{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage = #{materialAuditStage},</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage = #{voucherAuditStage},</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if> <if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if> <if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if>
<if test="laborSigned != null and laborSigned != ''">labor_signed = #{laborSigned},</if> <if test="laborSigned != null and laborSigned != ''">labor_signed = #{laborSigned},</if>
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingMaterialMapper">
<resultMap type="BizMeetingMaterial" id="BizMeetingMaterialResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="materialType" column="material_type" />
<result property="subType" column="sub_type" />
<result property="fileName" column="file_name" />
<result property="ossUrl" column="oss_url" />
<result property="amount" column="amount" />
<result property="creatorId" column="creator_id" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time
from biz_meeting_material
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingMaterialResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectByMeetingId" resultMap="BizMeetingMaterialResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id asc
</select>
<insert id="insert" parameterType="BizMeetingMaterial" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_material
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="materialType != null and materialType != ''">material_type,</if>
<if test="subType != null and subType != ''">sub_type,</if>
<if test="fileName != null and fileName != ''">file_name,</if>
<if test="ossUrl != null and ossUrl != ''">oss_url,</if>
<if test="amount != null">amount,</if>
<if test="creatorId != null">creator_id,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="materialType != null and materialType != ''">#{materialType},</if>
<if test="subType != null and subType != ''">#{subType},</if>
<if test="fileName != null and fileName != ''">#{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
<if test="amount != null">#{amount},</if>
<if test="creatorId != null">#{creatorId},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl},
#{item.amount}, #{item.creatorId}, #{item.createTime})
</foreach>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingMaterial">
update biz_meeting_material
<trim prefix="SET" suffixOverrides=",">
<if test="materialType != null and materialType != ''">material_type = #{materialType},</if>
<if test="subType != null and subType != ''">sub_type = #{subType},</if>
<if test="fileName != null and fileName != ''">file_name = #{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">oss_url = #{ossUrl},</if>
<if test="amount != null">amount = #{amount},</if>
</trim>
where id = #{id}
</update>
<update id="updateAmount">
update biz_meeting_material set amount = #{amount} where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_material where id = #{id}
</delete>
<delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_material where meeting_id = #{meetingId}
</delete>
</mapper>
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingSupervisorMapper">
<resultMap type="BizMeetingSupervisor" id="BizMeetingSupervisorResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="assignedBy" column="assigned_by" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, user_id, assigned_by, create_time
from biz_meeting_supervisor
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingSupervisorResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectByMeetingId" resultMap="BizMeetingSupervisorResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id asc
</select>
<select id="selectByUserId" resultMap="BizMeetingSupervisorResult" parameterType="Long">
<include refid="selectFields"/>
where user_id = #{userId}
order by id desc
</select>
<select id="selectList" resultMap="BizMeetingSupervisorResult" parameterType="BizMeetingSupervisor">
<include refid="selectFields"/>
<where>
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="userId != null">and user_id = #{userId}</if>
</where>
order by id asc
</select>
<insert id="insert" parameterType="BizMeetingSupervisor" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_supervisor
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="userId != null">user_id,</if>
<if test="assignedBy != null">assigned_by,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="userId != null">#{userId},</if>
<if test="assignedBy != null">#{assignedBy},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_supervisor (meeting_id, user_id, assigned_by, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.userId}, #{item.assignedBy}, #{item.createTime})
</foreach>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingSupervisor">
update biz_meeting_supervisor
<trim prefix="SET" suffixOverrides=",">
<if test="userId != null">user_id = #{userId},</if>
<if test="assignedBy != null">assigned_by = #{assignedBy},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_supervisor where id = #{id}
</delete>
<delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_supervisor where meeting_id = #{meetingId}
</delete>
</mapper>
+17
View File
@@ -0,0 +1,17 @@
# 不打包进镜像的内容
__pycache__
*.py[cod]
.venv
venv
env
.env
*.log
uploads/
test_files/
tests/
.git
.gitignore
*.md
.idea
.vscode
client/
+38
View File
@@ -0,0 +1,38 @@
# ===== OCR 服务配置 =====
# 服务端口
APP_HOST=0.0.0.0
APP_PORT=8801
# 是否使用 GPUtrue/false
USE_GPU=false
# OCR 语言(ch / en / chinese_cht
OCR_LANG=ch
# OCR 模型: "mobile" (CPU 友好, 默认) / "server" (高精度, 需 GPU)
OCR_ENGINE=mobile
# 单文件最大体积(MB
MAX_UPLOAD_MB=20
# PDF DPI(清晰数字 PDF 150 够用,扫描件建议 250~300
PDF_DPI=150
# 路径接口允许的根目录(Windows 用分号;Linux 用冒号:分隔多个),空 = 禁用
# 安全考虑:默认空,需显式开启
# ALLOWED_DIRS=E:\gitee\guoju-hegui;D:\uploads
# 单页 OCR 识别超时(秒)- CPU mobile 约 4 秒, 建议 15
OCR_PAGE_TIMEOUT_S=15
# 整流程超时(秒) - 包括 PDF 转图 + OCR + 字段抽取
OCR_TOTAL_TIMEOUT_S=60
# QR 识别:
# true = 扫到 QR 后仍跑全量 OCR + 字段抽取 (向后兼容)
# false = 扫到 QR 后直接返回 QR 里的 3 个核心字段 (开票时间/发票号/金额), 跳过 OCR
# 没扫到 QR 或 QR 格式不合法时一律回退到 OCR 流水线
QR_FULL_OCR=true
# 日志级别(DEBUG/INFO/WARNING
LOG_LEVEL=INFO
+36
View File
@@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
.pytest_cache/
# 配置
.env
.env.local
# 日志
*.log
logs/
# 上传 / 测试图片
uploads/
test_files/
*.pdf
*.jpg
*.jpeg
*.png
!docs/**/*.png
# IDE
.idea/
.vscode/
*.swp
# 系统
.DS_Store
Thumbs.db
+30
View File
@@ -0,0 +1,30 @@
FROM python:3.11-slim
# 系统依赖:libgl/opencvPyMuPDF 自带 PDF 渲染,无需 poppler
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 先装依赖(缓存层)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir paddlepaddle==3.0.0 paddleocr==3.0.1
# 再拷代码
COPY . .
# 模型预热(首次构建会下载模型到 /root/.paddleocr
RUN python -c "from app.core import warmup; warmup()" || true
EXPOSE 8801
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8801", "--workers", "1"]
+422
View File
@@ -0,0 +1,422 @@
# ry-ocr — 本地发票识别服务 API 文档
基于 **PaddleOCR 3.x + FastAPI** 的本地部署发票识别微服务。
完全离线运行,无任何云依赖,适合内网 / 等保环境。
服务默认监听 `0.0.0.0:8801`,在线文档:`http://localhost:8801/docs`
---
## 0. TL;DR
| 接口 | 用途 | 鉴权 |
|---|---|---|
| `GET /health` | 健康检查 | 无 |
| `POST /recognize/invoice` | 上传文件识别 (multipart) | 无 |
| `POST /recognize/invoice/by-path` | 服务器本地路径识别 (JSON) | 白名单 |
| `POST /recognize/text` | 纯文本字段抽取 (跳过 OCR) | 无 |
**识别流程**(默认 `QR_FULL_OCR=true`):
```
文件 → PDF/图片 → 扫 QR (opencv) → 解出 3 字段?
├─ 是 + fast mode → 直接返回 (engine="qr", 跳过 OCR)
├─ 是 + full mode → 继续 OCR + 抽取, QR 字段覆盖 OCR 结果
└─ 否 / 格式不合法 → 直接 not_invoice, 不跑 OCR
```
---
## 1. 快速启动
### A. 本地 Python
```bash
pip install -r requirements.txt
cp .env.example .env
python run.py # → http://127.0.0.1:8801
```
### B. Docker
```bash
docker-compose up -d
curl http://localhost:8801/health
```
首次启动会下载模型到 `/root/.paddleocr`(约 100MB),`docker-compose.yml` 已挂载 volume 持久化。
---
## 2. 配置项 (`.env`)
| 变量 | 默认 | 说明 |
|---|---|---|
| `APP_HOST` | `0.0.0.0` | 监听地址 |
| `APP_PORT` | `8801` | 监听端口 |
| `USE_GPU` | `false` | 是否使用 GPU |
| `OCR_LANG` | `ch` | OCR 语言 (ch/en/chinese_cht) |
| `OCR_ENGINE` | `mobile` | `mobile`=CPU 友好 / `server`=高精度需 GPU |
| `MAX_UPLOAD_MB` | `20` | 上传接口单文件最大体积 |
| `PDF_DPI` | `150` | PDF 转图片 DPI (扫描件建议 250~300) |
| `ALLOWED_DIRS` | (空) | `by-path` 接口允许的根目录, 空=禁用 |
| `OCR_PAGE_TIMEOUT_S` | `15` | 单页 OCR 超时 |
| `OCR_TOTAL_TIMEOUT_S` | `60` | 整流程超时 |
| **`QR_FULL_OCR`** | **`true`** | QR 命中后是否继续跑全量 OCR |
| `LOG_LEVEL` | `INFO` | 日志级别 |
**`QR_FULL_OCR` 双模式:**
| 取值 | 行为 |
|---|---|
| `true` | QR 命中 → 12 字段全抽取 (QR 3 字段覆盖 OCR) ← 默认 |
| `false` | QR 命中 → 仅返回 3 字段, 跳过 OCR (从 4.5s 降到 0.2s) |
`ALLOWED_DIRS` 格式:
- Windows(分号分隔):`ALLOWED_DIRS=E:\gitee\guoju-hegui;D:\uploads`
- Linux(冒号分隔):`ALLOWED_DIRS=/data/invoices:/tmp/uploads`
---
## 3. 接口详解
### 3.1 `GET /health`
健康检查。检查 PaddleOCR 引擎是否就绪。
**响应 200**
```json
{
"status": "ok",
"version": "0.1.0",
"engine_ready": true
}
```
`engine_ready=false` → 服务降级但仍能响应,建议先排查 OCR 模型加载问题。
---
### 3.2 `POST /recognize/invoice`
multipart/form-data 上传发票图片或 PDF。
**请求:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file` | file | ✅ | 图片 (PNG/JPG/JPEG/BMP/WEBP/TIFF) 或 PDF |
**curl**
```bash
curl -X POST http://localhost:8801/recognize/invoice \
-F "file=@/path/to/invoice.pdf"
```
**错误码:**
| HTTP | 场景 |
|---|---|
| 400 | 文件为空 |
| 413 | 文件超过 `MAX_UPLOAD_MB` |
| 422 | 缺少 file 字段 |
**响应 (`InvoiceResult`) — 见 §4。**
---
### 3.3 `POST /recognize/invoice/by-path`
传入**服务器本地路径**识别,避免重复上传大文件。
> ⚠️ **安全**:路径必须在 `.env` 的 `ALLOWED_DIRS` 白名单内才会被执行。
> resolve 后必须等于或为某个允许根目录的后代;否则 403。
> `ALLOWED_DIRS` 为空时整个接口 403(默认禁用)。
**请求体 (`PathRecognizeRequest`)**
```json
{
"file_path": "E:/invoice/abc.pdf"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file_path` | string | ✅ | 服务器本地绝对路径(正反斜杠均可) |
**curl**
```bash
curl -X POST http://localhost:8801/recognize/invoice/by-path \
-H "Content-Type: application/json" \
-d '{"file_path": "E:/gitee/guoju-hegui/guoju0808/ry-ocr/fapiao.pdf"}'
```
**错误码:**
| HTTP | 场景 |
|---|---|
| 403 | 路径不在 `ALLOWED_DIRS` 白名单, 或 `ALLOWED_DIRS` 未配置 |
| 404 | 文件不存在 |
| 400 | 不是文件 (路径是目录) |
---
### 3.4 `POST /recognize/text`
纯文本字段抽取,**不调用 OCR**。便于接入其他识别引擎(百度/腾讯/扫描件 OCR SDK 等)。
**Query 参数:**
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `raw_text` | string | ✅ | OCR 原始文本 (多行用 `\n` 分隔) |
**curl**
```bash
curl -X POST 'http://localhost:8801/recognize/text?raw_text=电子发票%0A发票号码:24922000000006110014%0A价税合计(大写)叁万玖仟伍佰圆整%0A(小写)%EF%BF%A539500.00'
```
**响应:** `{"fields": {...InvoiceFields}}`
---
## 4. 响应模型
### 4.1 `InvoiceResult` (主响应)
| 字段 | 类型 | 说明 |
|---|---|---|
| `success` | bool | 整体是否成功 |
| `is_invoice` | bool | 是否被判定为发票 (false=非发票) |
| `raw_text` | string | 全部 OCR 文本拼接 (快路径为 `[QR only] ...`) |
| `lines` | OCRLine[] | 分行识别结果 |
| `fields` | InvoiceFields | 结构化字段 |
| `page_count` | int | PDF 页数 / 图片=1 |
| `engine` | string | `paddleocr` / `qr` |
| `elapsed_ms` | int | 服务端识别耗时 (毫秒) |
| `error` | string? | 失败原因描述 |
| `error_code` | string? | 见 §4.4 错误码表 |
| `from_qr` | bool | 是否从 QR 取到了 3 个核心字段 |
| `qr_raw` | string? | 二维码原始文本 (排查用) |
| `qr_error` | string? | `no_qr` / `bad_format` |
### 4.2 `InvoiceFields` (fields 子对象)
| 字段 | 类型 | 来源 |
|---|---|---|
| `invoice_type` | string? | OCR: "电子发票"/"增值税专用发票"等 |
| `invoice_no` | string? | **QR (权威)** / OCR |
| `invoice_code` | string? | OCR (数电票此字段为空) |
| `invoice_date` | string (YYYY-MM-DD) | **QR (权威)** / OCR |
| `amount` | float? | **QR (权威)** / OCR — 价税合计小写 |
| `amount_cn` | string? | OCR — 价税合计大写 |
| `amount_pretax` | float? | OCR — 不含税金额 |
| `tax_amount` | float? | OCR — 税额 |
| `seller_name` | string? | OCR |
| `seller_tax_no` | string? | OCR |
| `buyer_name` | string? | OCR |
| `buyer_tax_no` | string? | OCR |
| `amount_match` | bool? | 大写金额 vs 小写金额一致性 |
**QR (权威)** 的字段:当 QR 命中时,无论 OCR 结果如何,最终值取 QR。
### 4.3 `OCRLine`
```json
{
"text": "发票号码:24922000000006110014",
"confidence": 0.998,
"box": [[915, 67], [1191, 67], [1191, 83], [915, 83]]
}
```
### 4.4 `error_code` 表
| 取值 | 含义 | 触发场景 |
|---|---|---|
| `not_invoice` | 非发票 | QR 没扫到 / 格式不合法 |
| `unsupported` | 不支持的文件类型 | 后缀不是 PDF/图片 |
| `process_failed` | 处理失败 | PDF 渲染异常等 |
| `timeout` | 超时 | 达到单页/总流程超时 |
| `ocr_failed` | OCR 异常 | PaddleOCR 内部错误 |
---
## 5. 完整示例
### 5.1 真发票 PDF(默认模式 → 12 字段)
**请求:** `POST /recognize/invoice/by-path` body=`{"file_path":"E:/.../fapiao.pdf"}`
**响应:**
```json
{
"success": true,
"is_invoice": true,
"raw_text": "电子发票\n(电子发票)\n发票号码:24922000000006110014\n...",
"lines": [...37 ],
"fields": {
"invoice_type": "电子发票",
"invoice_no": "24922000000006110014",
"invoice_code": null,
"invoice_date": "2024-02-02",
"amount": 39500.0,
"amount_cn": "叁万玖仟伍佰圆整",
"amount_pretax": 37264.15,
"tax_amount": 2235.85,
"seller_name": "青岛鸿图华构信息技术有限公司",
"seller_tax_no": "91370222MA3N7N3Y1H",
"buyer_name": "北京国钜科技实业股份有限公司",
"buyer_tax_no": "91110108MA01EMTK2E",
"amount_match": true
},
"page_count": 1,
"engine": "paddleocr",
"elapsed_ms": 4516,
"from_qr": true,
"qr_raw": "01,31,,24922000000006110014,39500.00,20240202,,A371",
"qr_error": null
}
```
### 5.2 真发票 PDF(快路径 `QR_FULL_OCR=false` → 仅 3 字段)
```json
{
"success": true,
"is_invoice": true,
"raw_text": "[QR only] 01,31,,24922000000006110014,39500.00,20240202,,A371",
"lines": [],
"fields": {
"invoice_no": "24922000000006110014",
"amount": 39500.0,
"invoice_date": "2024-02-02"
},
"page_count": 1,
"engine": "qr",
"elapsed_ms": 209,
"from_qr": true,
"qr_raw": "01,31,,24922000000006110014,39500.00,20240202,,A371",
"qr_error": null
}
```
### 5.3 非发票图片(无 QR
```json
{
"success": false,
"is_invoice": false,
"error": "未识别到发票二维码(可能不是发票图片)",
"error_code": "not_invoice",
"raw_text": "",
"lines": [],
"fields": {},
"page_count": 1,
"engine": "paddleocr",
"elapsed_ms": 220,
"from_qr": false,
"qr_raw": null,
"qr_error": "no_qr"
}
```
---
## 6. 性能基线 (PP-OCRv5_mobile, CPU)
| 场景 | HTTP 耗时 | 服务端 OCR | 备注 |
|---|---|---|---|
| 真发票 PDF (默认) | 4.5s | 4516ms | 含 1500ms PDF 渲染 |
| 真发票 PDF (快路径) | **0.22s** | 209ms | QR 解出即返回 |
| 非发票图片 | **0.22s** | 220ms | QR 没扫到, 不跑 OCR |
| 非发票文字截图 | **0.38s** | 377ms | 同上 |
PDF 转图 DPI=150OCR 移动端模型单页约 1.5~4s,**首请求**因模型预热会更慢。
---
## 7. Java 客户端 (RuoYi)
`client/` 目录下:
- `OcrClient.java`
- `InvoiceResult.java` / `InvoiceFields.java` / `OcrLine.java`
**Service 调用:**
```java
@Service
@RequiredArgsConstructor
public class InvoiceOcrService {
private final OcrClient ocrClient = new OcrClient("http://127.0.0.1:8801");
public InvoiceResult recognize(MultipartFile file) {
File tmp;
try {
tmp = File.createTempFile("inv_", "_" + file.getOriginalFilename());
file.transferTo(tmp);
} catch (IOException e) {
throw new RuntimeException("保存临时文件失败", e);
}
try {
InvoiceResult r = ocrClient.recognize(tmp);
if (!Boolean.TRUE.equals(r.getSuccess())) {
throw new RuntimeException("OCR 识别失败: " + r.getError());
}
return r;
} finally {
tmp.delete();
}
}
}
```
**Controller**
```java
@RestController
@RequestMapping("/business/invoice")
public class InvoiceOcrController {
private final InvoiceOcrService ocrService;
@PostMapping("/recognize")
public AjaxResult recognize(@RequestParam("file") MultipartFile file) {
return AjaxResult.success(ocrService.recognize(file));
}
}
```
依赖(已用 hutool 可省):
```xml
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-http</artifactId>
<version>5.8.27</version>
</dependency>
```
---
## 8. 错误码速查
调用方拿到响应后建议这样分流:
```python
if not resp.success:
if resp.error_code == "not_invoice":
# 不是发票 — 直接告诉用户"请上传发票图片"
elif resp.error_code == "timeout":
# 超时 — 建议重试 / 提高 DPI
elif resp.error_code in ("unsupported", "process_failed"):
# 文件问题 — 提示格式
else:
# 其他 OCR 异常 — 兜底
if not resp.is_invoice:
# 跟 not_invoice 等价 — 多数情况下 success=False 也伴随 is_invoice=False
pass
```
---
## 9. 局限 & 后续
- **无 QR 的老式纸质发票** 当前会判 not_invoice — 需新增「无 QR 回退 OCR」配置项可破
- **表格明细** (货物/数量/单价) 未抽取 — 需要时接 PP-Structure
- **字段抽取基于正则**,对版式变化敏感;如有大量样本可考虑 LayoutLMv3 微调
- **并发**PaddleOCR 非进程安全,**`workers=1`**;高并发前置 nginx 负载均衡
+2
View File
@@ -0,0 +1,2 @@
"""ry-ocr: 本地发票识别服务 (PaddleOCR + FastAPI)"""
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""API 层"""
+125
View File
@@ -0,0 +1,125 @@
"""FastAPI 路由"""
from __future__ import annotations
import platform
from pathlib import Path
from typing import List
from fastapi import APIRouter, File, HTTPException, UploadFile
from app import __version__
from app.config import settings
from app.core import get_engine
from app.models import HealthResponse, InvoiceResult, PathRecognizeRequest
from app.services.recognize_service import recognize_file, recognize_path
router = APIRouter()
# ---------- 路径白名单 ----------
def _parse_allowed_dirs() -> List[Path]:
"""解析 ALLOWED_DIRS 配置为绝对路径列表"""
if not settings.allowed_dirs.strip():
return []
sep = ";" if platform.system() == "Windows" else ":"
roots: List[Path] = []
for raw in settings.allowed_dirs.split(sep):
raw = raw.strip().strip('"').strip("'")
if not raw:
continue
try:
p = Path(raw).resolve()
if p.is_dir():
roots.append(p)
except Exception:
pass
return roots
def _check_path_allowed(file_path: Path) -> None:
"""校验路径在白名单内(路径遍历攻击防护)
resolve 后必须是某个 allowed_dir 的子路径。
"""
roots = _parse_allowed_dirs()
if not roots:
raise HTTPException(
status_code=403,
detail="路径接口未启用:在 .env 配置 ALLOWED_DIRS 后重启服务",
)
try:
abs_path = file_path.resolve()
except Exception as e:
raise HTTPException(status_code=400, detail=f"路径无效: {e}")
for root in roots:
try:
abs_path.relative_to(root)
return
except ValueError:
continue
raise HTTPException(
status_code=403,
detail=f"路径不在白名单内(允许: {', '.join(str(r) for r in roots)}",
)
# ---------- 路由 ----------
@router.get("/health", response_model=HealthResponse, summary="健康检查")
def health():
engine_ok = True
try:
get_engine()
except Exception:
engine_ok = False
return HealthResponse(
status="ok" if engine_ok else "degraded",
version=__version__,
engine_ready=engine_ok,
)
@router.post("/recognize/invoice", response_model=InvoiceResult, summary="识别发票(上传文件)")
async def recognize_invoice(file: UploadFile = File(..., description="发票图片或 PDF")):
"""识别发票并返回结构化字段
支持:PNG/JPG/JPEG/BMP/WEBP/TIFF/PDF
"""
content = await file.read()
max_bytes = settings.max_upload_mb * 1024 * 1024
if len(content) > max_bytes:
raise HTTPException(status_code=413, detail=f"文件超过 {settings.max_upload_mb}MB 限制")
if not content:
raise HTTPException(status_code=400, detail="文件为空")
return recognize_file(file.filename or "unknown", content)
@router.post("/recognize/invoice/by-path", response_model=InvoiceResult, summary="识别发票(服务器本地路径)")
def recognize_invoice_by_path(req: PathRecognizeRequest):
"""传入服务器本地路径识别发票(避免重复上传大文件)
**安全**:路径必须在 .env 的 ALLOWED_DIRS 白名单内才会被执行。
防止任意文件读取 / 路径遍历攻击。
"""
p = Path(req.file_path)
_check_path_allowed(p)
if not p.exists():
raise HTTPException(status_code=404, detail=f"文件不存在: {p}")
if not p.is_file():
raise HTTPException(status_code=400, detail=f"不是文件: {p}")
return recognize_path(p, delete_after=False)
@router.post("/recognize/text", summary="仅做字段抽取(不上传文件)")
def recognize_text(raw_text: str):
"""对已有的 OCR 文本做字段抽取(便于接入其他 OCR 引擎)"""
from app.services import extract_invoice
fields = extract_invoice(raw_text)
return {"fields": fields}
+48
View File
@@ -0,0 +1,48 @@
"""应用配置(从环境变量 / .env 读取)"""
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# 服务
app_host: str = "0.0.0.0"
app_port: int = 8801
# OCR
use_gpu: bool = False
ocr_lang: str = "ch"
# 模型: "mobile" (CPU 友好, 默认) / "server" (高精度, GPU 适用)
ocr_engine: str = "mobile"
# 上传 / PDF
max_upload_mb: int = 20
# 默认 150 DPI:清晰数字 PDF 150 已够,扫描件需调到 250~300
pdf_dpi: int = 150
# 路径接口允许的根目录(逗号分隔)。空 = 禁用路径接口
# 示例: "E:\\gitee\\guoju-hegui;D:\\uploads"
allowed_dirs: str = ""
# 识别超时(秒)- 单页 OCR / 整流程任一超时即返回失败
# CPU mobile 模型单页约 4 秒, 设 15s 留余量
ocr_page_timeout_s: int = 15
ocr_total_timeout_s: int = 60
# QR 识别:
# true = 扫到 QR 后仍跑全量 OCR + 字段抽取 (默认, 向后兼容)
# false = 扫到 QR 后直接返回 QR 里的 3 个核心字段 (开票时间/发票号/金额), 跳过 OCR
# 没扫到 QR 或 QR 格式不合法时一律回退到 OCR 流水线, 不受此开关影响
qr_full_ocr: bool = True
# 日志
log_level: str = "INFO"
model_config = SettingsConfigDict(
env_file=str(Path(__file__).resolve().parent.parent / ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()
+6
View File
@@ -0,0 +1,6 @@
"""核心能力:OCR / PDF / 图像"""
from .ocr_engine import get_engine, recognize, warmup, OCRTimeout
from .pdf_processor import pdf_to_images
from .image_processor import auto_rotate, enhance
__all__ = ["get_engine", "recognize", "warmup", "OCRTimeout", "pdf_to_images", "auto_rotate", "enhance"]
+47
View File
@@ -0,0 +1,47 @@
"""图像预处理:自动旋转、放大、去噪"""
from __future__ import annotations
from pathlib import Path
import cv2
import numpy as np
def auto_rotate(img_path: str | Path) -> Path:
"""基于方向检测的简易旋转(> 阈值倾斜就转 90°)
说明:发票多为横向,此处只处理 0/90/180/270 四方向,
复杂倾斜交给 PaddleOCR 自带的 textline orientation。
"""
p = Path(img_path)
img = cv2.imread(str(p))
if img is None:
return p
h, w = img.shape[:2]
# 横图宽 > 高 * 1.2,认为方向正确;否则旋转
if h > w * 1.2:
rotated = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
out = p.with_name(f"{p.stem}_rot.png")
cv2.imwrite(str(out), rotated)
return out
return p
def enhance(img_path: str | Path) -> Path:
"""轻度增强:灰度 + 自适应二值化(对手机拍的发票有帮助)"""
p = Path(img_path)
img = cv2.imread(str(p))
if img is None:
return p
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 弱增强:只对低对比度图做二值化
if gray.std() < 50:
binary = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10
)
out = p.with_name(f"{p.stem}_enh.png")
cv2.imwrite(str(out), binary)
return out
return p
+146
View File
@@ -0,0 +1,146 @@
"""PaddleOCR 引擎封装(单例 + 超时)
适配 paddleocr 3.0+ 的 predict() 接口。
降级到 2.x 时也能跑(参数兼容)。
"""
from __future__ import annotations
import threading
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
from pathlib import Path
from typing import List, Optional
from loguru import logger
from app.config import settings
from app.models import OCRLine
_lock = threading.Lock()
_engine = None
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ocr")
# ---------- 引擎构造 ----------
def _build_engine():
try:
from paddleocr import PaddleOCR
# paddleocr >= 3.0: device="cpu"/"gpu", 没有 use_gpu / show_log
# 模型选择:
# v5_server - 精度高但 CPU 慢 (5+ 秒/页)
# v5_mobile - CPU 友好 (1~2 秒/页), 精度略低, 推荐生产环境
mobile = settings.ocr_lang.endswith("mobile") or settings.ocr_engine == "mobile"
if settings.use_gpu or settings.ocr_engine == "server":
# GPU 或用户显式指定 server 模型
det_name = "PP-OCRv5_server_det"
rec_name = "PP-OCRv5_server_rec"
else:
# 默认 mobile (CPU 友好)
det_name = "PP-OCRv5_mobile_det"
rec_name = "PP-OCRv5_mobile_rec"
return PaddleOCR(
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
lang=settings.ocr_lang,
device="gpu" if settings.use_gpu else "cpu",
text_detection_model_name=det_name,
text_recognition_model_name=rec_name,
)
except TypeError:
# paddleocr 2.x 老参数(向后兼容)
from paddleocr import PaddleOCR
return PaddleOCR(
use_angle_cls=True,
lang=settings.ocr_lang,
use_gpu=settings.use_gpu,
show_log=False,
)
def get_engine():
global _engine
if _engine is None:
with _lock:
if _engine is None:
logger.info("正在初始化 PaddleOCR (lang={}, gpu={})...", settings.ocr_lang, settings.use_gpu)
_engine = _build_engine()
logger.info("PaddleOCR 初始化完成")
return _engine
def _normalize(result) -> List[OCRLine]:
lines: List[OCRLine] = []
if not result:
return lines
if isinstance(result, list) and result and isinstance(result[0], dict):
for page in result:
texts = page.get("rec_texts") or []
scores = page.get("rec_scores") or []
polys = page.get("rec_polys") or []
for i, txt in enumerate(texts):
conf = float(scores[i]) if i < len(scores) else 0.0
box = polys[i].tolist() if i < len(polys) and hasattr(polys[i], "tolist") else []
lines.append(OCRLine(text=str(txt).strip(), confidence=conf, box=box))
return lines
try:
for page in result:
for det in page:
box = det[0]
txt, conf = det[1]
lines.append(OCRLine(text=str(txt).strip(), confidence=float(conf), box=box))
except Exception:
logger.exception("无法解析 PaddleOCR 输出: {}", result)
return lines
class OCRTimeout(Exception):
"""OCR 识别超时"""
pass
def recognize(image_path: str | Path, timeout_s: Optional[float] = None) -> List[OCRLine]:
"""对单张图片做 OCR,带超时控制
timeout_s 默认为 settings.ocr_page_timeout_s(默认 5 秒)。
超时立即抛 OCRTimeout,不等模型跑完。
"""
timeout = timeout_s or settings.ocr_page_timeout_s
engine = get_engine()
future = _executor.submit(engine.predict, str(image_path))
try:
raw = future.result(timeout=timeout)
except FutTimeout:
future.cancel()
logger.error("OCR 超时 ({:.1f}s): {}", timeout, image_path)
raise OCRTimeout(f"OCR 识别超时 ({timeout}秒): {image_path}")
except Exception as e:
logger.exception("OCR 执行异常: {}", e)
raise
return _normalize(raw)
def warmup():
"""预热:构造引擎 + 真跑一次空白图预测(避免首次请求超时)
跳过预测失败(比如没装 paddle)但不报错。
"""
try:
engine = get_engine()
except Exception as e:
logger.warning("OCR 引擎初始化失败: {}", e)
return
try:
import numpy as np
# 1x1 灰度图,足以触发模型完整链路
dummy = np.zeros((64, 64, 3), dtype=np.uint8)
engine.predict(dummy)
logger.info("OCR 预热完成(首次预测已跑)")
except Exception as e:
logger.warning("OCR 预测预热失败(不影响主流程): {}", e)
+35
View File
@@ -0,0 +1,35 @@
"""PDF → 图片 (使用 PyMuPDF,无需 poppler 系统依赖)"""
from __future__ import annotations
from pathlib import Path
from typing import List
import fitz # PyMuPDF
from loguru import logger
from app.config import settings
def pdf_to_images(pdf_path: str | Path, dpi: int | None = None) -> List[Path]:
"""把 PDF 每页渲染成 PNG,返回文件路径列表"""
dpi = dpi or settings.pdf_dpi
pdf_path = Path(pdf_path)
out_dir = pdf_path.parent / f".{pdf_path.stem}_pages"
out_dir.mkdir(exist_ok=True)
doc = fitz.open(str(pdf_path))
saved: List[Path] = []
try:
# 1.0 = 72 DPI 的 zoom 系数
zoom = dpi / 72.0
mat = fitz.Matrix(zoom, zoom)
for idx, page in enumerate(doc, start=1):
pix = page.get_pixmap(matrix=mat, alpha=False)
out_path = out_dir / f"page_{idx:03d}.png"
pix.save(str(out_path))
saved.append(out_path)
finally:
doc.close()
logger.info("PDF 转图片: {}{} 页 (dpi={})", pdf_path.name, len(saved), dpi)
return saved
+56
View File
@@ -0,0 +1,56 @@
"""FastAPI 入口"""
from __future__ import annotations
import sys
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from app import __version__
from app.api.routes import router
from app.config import settings
from app.core import warmup
def _setup_logging():
logger.remove()
logger.add(
sys.stdout,
level=settings.log_level,
format="<g>{time:HH:mm:ss}</g> | {level:<7} | {message}",
colorize=True,
)
def create_app() -> FastAPI:
_setup_logging()
app = FastAPI(
title="ry-ocr",
description="本地发票识别服务(PaddleOCR + FastAPI",
version=__version__,
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
@app.on_event("startup")
def _startup():
logger.info("ry-ocr v{} 启动中...", __version__)
# 启动预热 OCR(首次 predict 较慢,提前触发)
warmup()
logger.info("服务已就绪: http://{}:{}", settings.app_host, settings.app_port)
return app
app = create_app()
+4
View File
@@ -0,0 +1,4 @@
"""数据模型"""
from .schemas import OCRLine, InvoiceFields, InvoiceResult, HealthResponse, PathRecognizeRequest
__all__ = ["OCRLine", "InvoiceFields", "InvoiceResult", "HealthResponse", "PathRecognizeRequest"]
+71
View File
@@ -0,0 +1,71 @@
"""接口协议 - Pydantic v2"""
from typing import List, Optional
from pydantic import BaseModel, Field
# ===== 单行 OCR 结果 =====
class OCRLine(BaseModel):
"""OCR 识别到的一行文字"""
text: str = Field(..., description="识别文本")
confidence: float = Field(..., ge=0, le=1, description="置信度 0~1")
box: List[List[float]] = Field(default_factory=list, description="四点坐标 [[x1,y1],...]")
# ===== 发票字段 =====
class InvoiceFields(BaseModel):
"""结构化发票字段"""
# 基本信息
invoice_type: Optional[str] = Field(None, description="发票类型,如 增值税电子普通发票")
invoice_no: Optional[str] = Field(None, description="发票号码")
invoice_code: Optional[str] = Field(None, description="发票代码")
invoice_date: Optional[str] = Field(None, description="开票日期 YYYY-MM-DD")
# 金额
amount: Optional[float] = Field(None, description="价税合计(小写)")
amount_cn: Optional[str] = Field(None, description="价税合计(大写中文)")
amount_pretax: Optional[float] = Field(None, description="不含税金额")
tax_amount: Optional[float] = Field(None, description="税额")
# 主体
seller_name: Optional[str] = Field(None, description="销售方名称")
seller_tax_no: Optional[str] = Field(None, description="销售方纳税人识别号")
buyer_name: Optional[str] = Field(None, description="购买方名称")
buyer_tax_no: Optional[str] = Field(None, description="购买方纳税人识别号")
# 校验
amount_match: Optional[bool] = Field(None, description="大写金额与小数金额是否一致")
# ===== 响应 =====
class InvoiceResult(BaseModel):
"""发票识别总响应"""
success: bool = Field(..., description="是否识别成功")
is_invoice: bool = Field(True, description="是否被判定为发票(false = 非发票图片)")
raw_text: str = Field("", description="全部 OCR 文本拼接")
lines: List[OCRLine] = Field(default_factory=list, description="分行识别结果")
fields: InvoiceFields = Field(default_factory=InvoiceFields, description="抽取的结构化字段")
page_count: int = Field(1, description="PDF 页数 / 图片=1")
engine: str = Field("paddleocr", description="使用的 OCR 引擎")
elapsed_ms: int = Field(0, description="识别耗时(毫秒)")
error: Optional[str] = Field(None, description="失败原因")
error_code: Optional[str] = Field(None, description="错误码: not_invoice / timeout / unsupported / ocr_failed")
# QR 识别相关
from_qr: bool = Field(False, description="是否从二维码取到了 3 个核心字段 (开票时间/发票号/金额)")
qr_raw: Optional[str] = Field(None, description="二维码原始文本 (用于排查)")
qr_error: Optional[str] = Field(None, description="二维码识别失败原因 (no_qr / bad_format)")
class HealthResponse(BaseModel):
status: str = "ok"
version: str
engine_ready: bool
class PathRecognizeRequest(BaseModel):
"""按文件路径识别的请求体"""
file_path: str = Field(..., description="服务器本地绝对路径", examples=["E:/invoice/abc.pdf"])
+10
View File
@@ -0,0 +1,10 @@
"""业务服务"""
from .invoice_extractor import extract as extract_invoice
from .qr_decoder import decode_qr, QRDecodeResult, has_any_field as qr_has_any_field
__all__ = [
"extract_invoice",
"decode_qr",
"QRDecodeResult",
"qr_has_any_field",
]
+299
View File
@@ -0,0 +1,299 @@
"""从 OCR 文本/行里抽取发票字段
适配中国大陆 增值税发票(电子普票 / 专票 / 电子专票 / 数电票)
关键策略:
- 主体(销售方/购买方)按 OCR box **坐标**判断归属(左右两栏)
- 名称提取加 stop word,避免 OCR 单行文本混淆
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple
from app.models import InvoiceFields, OCRLine
from app.utils import (
amount_consistent,
extract_cn_amount,
extract_num_amount,
extract_pretax_amount,
extract_tax_amount,
extract_total_amount,
)
# ---------- 发票类型 ----------
_INVOICE_TYPES = [
"增值税电子专用发票",
"增值税电子普通发票",
"增值税专用发票",
"增值税普通发票",
"通用机打发票",
"数电票",
"电子发票",
]
# ---------- 发票号码 ----------
_NO_PATTERN = re.compile(
r"(?:发\s*票\s*号\s*码|号\s*码|No\.?|号)\s*[:]?\s*(\d{8,20})",
re.IGNORECASE,
)
# ---------- 发票代码 ----------
_CODE_PATTERN = re.compile(
r"(?:发\s*票\s*代\s*码|代\s*码)\s*[:]?\s*(\d{10,12}|\d{8,12})",
)
# ---------- 开票日期 ----------
_DATE_PATTERN = re.compile(
r"(?:开\s*票\s*日\s*期|日\s*期)\s*[:]?\s*"
r"(\d{4})\s*[年/\.]\s*(\d{1,2})\s*[月/\.]\s*(\d{1,2})",
)
# ---------- 纳税人识别号(必须至少含 1 个字母,排除纯数字发票号)----------
_TAX_NO_PATTERN = re.compile(r"((?=[0-9A-Z]*[A-Z])[0-9A-Z]{18})")
# ---------- 主体标签 ----------
_BUYER_LABEL = re.compile(r"\s*买\s*方\s*(?:信\s*息|名\s*称|)")
_SELLER_LABEL = re.compile(r"\s*售\s*方\s*(?:信\s*息|名\s*称|)")
# ---------- 名称(带 stop word 截断)----------
_NAME_STOP = r"(?:销售方|购买方|统一社会信用|纳税人|项目名称|规格型号|^单位$|^数量$|^单价$|^金额|^税率|^税额|备注|收款人|复核|开票人|价税合计|小写|大写)"
_NAME_PATTERN = re.compile(
rf"\s*称\s*[:]\s*"
rf"((?:(?!{_NAME_STOP})[^\n\r]){{2,60}}?(?:公司|商店|厂|店|部|中心|工作室))"
)
# ---------- 工具函数 ----------
def _norm(text: str) -> str:
return re.sub(r"\s+", " ", text or "").strip()
def _box_center(box) -> Tuple[float, float]:
"""box: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] → (cx, cy)"""
if not box or len(box) < 4:
return (0.0, 0.0)
xs = [p[0] for p in box]
ys = [p[1] for p in box]
return ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
def _detect_invoice_type(text: str) -> Optional[str]:
for t in _INVOICE_TYPES:
if t in text:
return t
return None
def _extract_invoice_no(text: str) -> Optional[str]:
m = _NO_PATTERN.search(text)
return m.group(1) if m else None
def _extract_invoice_code(text: str) -> Optional[str]:
m = _CODE_PATTERN.search(text)
return m.group(1) if m else None
def _extract_date(text: str) -> Optional[str]:
m = _DATE_PATTERN.search(text)
if not m:
return None
y, mo, d = m.groups()
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
def _clean_name(name: str) -> str:
name = re.sub(r"^[\s:,,。、]+", "", name)
name = name.split("纳税人")[0].split("统一社会")[0]
name = re.sub(r"[^一-龥A-Za-z0-9()()·\-]", "", name)
return name.rstrip(":;,,。、 ").strip()
def _extract_parties_from_lines(lines: List[OCRLine]) -> tuple:
"""基于 box 坐标的主体识别
1) 找到 "购买方信息"/"销售方信息" 标签所在位置(box 中心)
2) 把每个 "名称: xxx" 行按 x 坐标归属到对应栏
3) 兜底:左右栏无法区分时按 y 坐标(top/bottom
"""
# 找标签坐标
buyer_label_box = None
seller_label_box = None
for line in lines:
if _BUYER_LABEL.search(line.text) and not buyer_label_box:
buyer_label_box = line.box
if _SELLER_LABEL.search(line.text) and not seller_label_box:
seller_label_box = line.box
by_x = buyer_label_box is not None and seller_label_box is not None and \
abs(_box_center(buyer_label_box)[0] - _box_center(seller_label_box)[0]) > 50
# 找所有 "名称: ..." 行的 box
name_lines = []
for line in lines:
m = _NAME_PATTERN.search(line.text)
if m:
name_lines.append((line, _clean_name(m.group(1))))
# 找所有税号
tax_lines = []
for line in lines:
m = _TAX_NO_PATTERN.search(line.text)
if m:
tax_lines.append((line, m.group(1)))
seller_name = buyer_name = None
seller_tax = buyer_tax = None
if by_x and buyer_label_box and seller_label_box:
# 左右栏布局:用 x 坐标归属
bx, _ = _box_center(buyer_label_box)
sx, _ = _box_center(seller_label_box)
mid = (bx + sx) / 2
for line, name in name_lines:
cx, _ = _box_center(line.box)
if cx < mid and not buyer_name:
buyer_name = name
elif cx >= mid and not seller_name:
seller_name = name
for line, tax in tax_lines:
cx, _ = _box_center(line.box)
if cx < mid and not buyer_tax:
buyer_tax = tax
elif cx >= mid and not seller_tax:
seller_tax = tax
else:
# 上下栏 / 单栏:按 y 坐标(第一个 = 购买方)
name_lines.sort(key=lambda x: (_box_center(x[0].box)[1], _box_center(x[0].box)[0]))
tax_lines.sort(key=lambda x: (_box_center(x[0].box)[1], _box_center(x[0].box)[0]))
if len(name_lines) >= 1:
buyer_name = name_lines[0][1]
if len(name_lines) >= 2:
seller_name = name_lines[1][1]
if len(tax_lines) >= 1:
buyer_tax = tax_lines[0][1]
if len(tax_lines) >= 2:
seller_tax = tax_lines[1][1]
return seller_name, seller_tax, buyer_name, buyer_tax
def _extract_parties_from_text(text: str) -> tuple:
"""无 box 信息时的 fallback:按显式标签分段"""
seller_name = buyer_name = None
seller_tax = buyer_tax = None
for m in _BUYER_LABEL.finditer(text):
chunk = text[m.end():m.end() + 200]
nm = _NAME_PATTERN.search(chunk)
tm = _TAX_NO_PATTERN.search(chunk[:100])
if nm and not buyer_name:
buyer_name = _clean_name(nm.group(1))
if tm and not buyer_tax:
buyer_tax = tm.group(1)
if buyer_name and buyer_tax:
break
for m in _SELLER_LABEL.finditer(text):
chunk = text[m.end():m.end() + 200]
nm = _NAME_PATTERN.search(chunk)
tm = _TAX_NO_PATTERN.search(chunk[:100])
if nm and not seller_name:
seller_name = _clean_name(nm.group(1))
if tm and not seller_tax:
seller_tax = tm.group(1)
if seller_name and seller_tax:
break
if not buyer_name:
name_positions = [(m.start(), m.group(1)) for m in _NAME_PATTERN.finditer(text)]
if len(name_positions) >= 1:
buyer_name = _clean_name(name_positions[0][1])
if len(name_positions) >= 2:
seller_name = _clean_name(name_positions[1][1])
tax_positions = _TAX_NO_PATTERN.findall(text)
if not buyer_tax and len(tax_positions) >= 1:
buyer_tax = tax_positions[0]
if not seller_tax and len(tax_positions) >= 2:
seller_tax = tax_positions[1]
return seller_name, seller_tax, buyer_name, buyer_tax
def extract(text: str, lines: Optional[List[OCRLine]] = None) -> InvoiceFields:
raw = _norm(text)
fields = InvoiceFields()
fields.invoice_type = _detect_invoice_type(raw)
fields.invoice_code = _extract_invoice_code(raw)
fields.invoice_no = _extract_invoice_no(raw)
fields.invoice_date = _extract_date(raw)
# 金额
total = extract_total_amount(raw)
tax = extract_tax_amount(raw)
pretax = extract_pretax_amount(raw)
# 兜底:从所有 .xx 数字中找满足 tax + pretax = total 的组合
if (tax is None or pretax is None) and total is not None:
candidates = sorted(
{float(x) for x in re.findall(r"(\d+\.\d{2})", raw) if float(x) < total},
reverse=True,
)
for i, a in enumerate(candidates):
for b in candidates[i + 1:]:
if abs(a + b - total) < 0.011:
if pretax is None:
pretax = round(a, 2)
if tax is None:
tax = round(b, 2)
break
if tax is not None and pretax is not None:
break
# 单数字兜底:如果 total 不在候选里,只剩一个候选时
if (tax is None or pretax is None) and len(candidates) == 1:
only = round(candidates[0], 2)
if pretax is None and tax is None:
pretax = only
tax = round(total - only, 2)
elif tax is None:
tax = only
elif pretax is None:
pretax = only
# 二次兜底:合计 - 任一 = 另一
if tax is None and total is not None and pretax is not None:
tax = round(total - pretax, 2)
if pretax is None and total is not None and tax is not None:
pretax = round(total - tax, 2)
fields.amount = total
fields.tax_amount = tax
fields.amount_pretax = pretax
fields.amount_cn = extract_cn_amount(raw)
fields.amount_match = amount_consistent(fields.amount_cn, fields.amount)
# 主体
if lines:
seller_name, seller_tax, buyer_name, buyer_tax = _extract_parties_from_lines(lines)
if not (seller_name and buyer_name):
sn, st, bn, bt = _extract_parties_from_text(raw)
seller_name = seller_name or sn
buyer_name = buyer_name or bn
seller_tax = seller_tax or st
buyer_tax = buyer_tax or bt
else:
seller_name, seller_tax, buyer_name, buyer_tax = _extract_parties_from_text(raw)
fields.seller_name = seller_name
fields.seller_tax_no = seller_tax
fields.buyer_name = buyer_name
fields.buyer_tax_no = buyer_tax
return fields
+138
View File
@@ -0,0 +1,138 @@
"""电子发票二维码识别
国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔):
01,<type>,<invoice_code>,<invoice_no>,<amount>,<date>,<check_code>,<reserved>
例: 01,31,,24922000000006110014,39500.00,20240202,,A371
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
import cv2
import numpy as np
from loguru import logger
@dataclass
class QRDecodeResult:
"""二维码识别结果"""
invoice_no: Optional[str] = None # 发票号码
amount: Optional[float] = None # 金额(小写)
invoice_date: Optional[str] = None # 开票日期 YYYY-MM-DD
raw: str = "" # 二维码原始文本
def _try_detect(img: np.ndarray, qd: cv2.QRCodeDetector) -> str:
"""opencv 单图识别 + 多二维码识别, 任一成功即返回文本"""
data, _, _ = qd.detectAndDecode(img)
if data:
return data
try:
retval, decoded_info, _, _ = qd.detectAndDecodeMulti(img)
if decoded_info:
# 取第一个非空的
for d in decoded_info:
if d:
return d
except Exception:
pass
return ""
def _detect_qr(image_path: Path) -> str:
"""从图片里解二维码; 全图 + 四象限各扫一遍"""
img = cv2.imread(str(image_path))
if img is None:
return ""
qd = cv2.QRCodeDetector()
# 1) 全图
txt = _try_detect(img, qd)
if txt:
return txt
# 2) 四象限 (二维码常在票面边角)
h, w = img.shape[:2]
for name, crop in [
("left-top", img[: h // 2, : w // 2]),
("right-top", img[: h // 2, w // 2 :]),
("left-bottom", img[h // 2 :, : w // 2]),
("right-bottom", img[h // 2 :, w // 2 :]),
]:
txt = _try_detect(crop, qd)
if txt:
logger.debug("QR found in {}", name)
return txt
# 3) 放大再试 (二维码像素过小的情况)
scaled = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
return _try_detect(scaled, qd)
def _parse_qr_payload(raw: str) -> QRDecodeResult:
"""解析电子发票二维码内容 → QRDecodeResult
格式不合规返回空对象 (字段全 None), 由调用方判定为「格式不正确」并 fallback。
"""
if not raw:
return QRDecodeResult(raw=raw or "")
parts = raw.split(",")
if len(parts) != 8:
logger.debug("QR 字段数 {} != 8, 视为格式不正确", len(parts))
return QRDecodeResult(raw=raw)
result = QRDecodeResult(raw=raw)
# parts[3] = 发票号
invoice_no = parts[3].strip()
if invoice_no and (10 <= len(invoice_no) <= 30):
result.invoice_no = invoice_no
# parts[4] = 金额
amt_str = parts[4].strip()
if amt_str:
try:
result.amount = round(float(amt_str), 2)
except ValueError:
pass
# parts[5] = 开票日期 (YYYYMMDD)
date_str = parts[5].strip()
if len(date_str) == 8 and date_str.isdigit():
result.invoice_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
return result
def decode_qr(image_path: Path) -> QRDecodeResult:
"""从图片文件解电子发票二维码
返回 QRDecodeResult; 字段全 None 表示无二维码或格式不正确。
调用方应据此判定 fallback。
"""
try:
raw = _detect_qr(image_path)
except Exception as e:
logger.warning("QR 检测异常 {}: {}", image_path.name, e)
return QRDecodeResult()
if not raw:
return QRDecodeResult()
parsed = _parse_qr_payload(raw)
# 任一关键字段解出即视为「格式正确」
if parsed.invoice_no or parsed.amount or parsed.invoice_date:
logger.info("QR 解码成功: no={}, amt={}, date={}",
parsed.invoice_no, parsed.amount, parsed.invoice_date)
else:
logger.debug("QR 解出但字段无效: raw={}", raw[:80])
return parsed
def has_any_field(qr: QRDecodeResult) -> bool:
"""QR 是否解出了至少一个核心字段"""
return bool(qr.invoice_no or qr.amount or qr.invoice_date)
+237
View File
@@ -0,0 +1,237 @@
"""端到端识别流水:文件 → QR → (可选)OCR → 字段抽取 → InvoiceResult
整体有 OCR_TOTAL_TIMEOUT_S 兜底;单页有 OCR_PAGE_TIMEOUT_S 兜底。
任一超时立即返回失败,不再死等。
QR 流程 (config: QR_FULL_OCR):
- 先扫二维码取 开票时间/发票号/金额 (国家税务总局 8 字段逗号规范)
- 扫到 + qr_full_ocr=false → 直接返回这 3 个字段 (快路径, 跳过 OCR)
- 扫到 + qr_full_ocr=true → 继续跑 OCR, QR 的 3 字段覆盖 OCR 抽取结果
- 没扫到 / 格式不合法 → 直接判定为非发票 (不再跑 OCR)
"""
from __future__ import annotations
import shutil
import tempfile
import time
from pathlib import Path
from typing import List
from loguru import logger
from app.config import settings
from app.core import OCRTimeout, auto_rotate, enhance, pdf_to_images, recognize
from app.models import InvoiceResult, InvoiceFields, OCRLine
from app.services import decode_qr, extract_invoice, qr_has_any_field
_IMG_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff"}
def _save_upload(upload_bytes: bytes, suffix: str) -> Path:
tmp = Path(tempfile.mkstemp(suffix=suffix)[1])
tmp.write_bytes(upload_bytes)
return tmp
def _ocr_image(image_path: Path) -> List[OCRLine]:
img = auto_rotate(image_path)
img = enhance(img)
return recognize(img)
def _overlay_qr_fields(fields: InvoiceFields, qr) -> InvoiceFields:
"""QR 解出的 3 字段优先, 没解到的保持 OCR 结果"""
data = fields.model_dump()
if qr.invoice_no:
data["invoice_no"] = qr.invoice_no
if qr.amount is not None:
data["amount"] = qr.amount
if qr.invoice_date:
data["invoice_date"] = qr.invoice_date
return InvoiceFields(**data)
def _build_qr_only_result(qr, elapsed_ms: int, page_count: int) -> InvoiceResult:
"""快路径: 只用 QR 字段, 跳过 OCR"""
fields = InvoiceFields(
invoice_no=qr.invoice_no,
amount=qr.amount,
invoice_date=qr.invoice_date,
)
return InvoiceResult(
success=True,
is_invoice=True,
raw_text=f"[QR only] {qr.raw}",
lines=[],
fields=fields,
page_count=page_count,
engine="qr",
elapsed_ms=elapsed_ms,
from_qr=True,
qr_raw=qr.raw,
)
def recognize_file(filename: str, content: bytes) -> InvoiceResult:
"""识别入口(从字节流):自动判断 PDF / 图片;任何环节超时立即返回失败"""
t0 = time.time()
suffix = Path(filename).suffix.lower()
tmp_path = _save_upload(content, suffix or ".bin")
try:
return recognize_path(tmp_path, delete_after=True)
except Exception:
raise
def recognize_path(file_path: Path, *, delete_after: bool = False) -> InvoiceResult:
"""识别入口(从磁盘路径):不做上传字节校验,直接读本地文件
Args:
file_path: 已在白名单校验过的本地文件路径
delete_after: True 表示临时文件用完删除(上传流场景),False 保留(原文件场景)
"""
t0 = time.time()
suffix = file_path.suffix.lower()
total_deadline = t0 + settings.ocr_total_timeout_s
def _elapsed_ms() -> int:
return int((time.time() - t0) * 1000)
try:
# ---------- 1. PDF / 图片 → 图片列表 ----------
try:
if suffix == ".pdf":
page_imgs = pdf_to_images(file_path)
elif suffix in _IMG_EXTS:
page_imgs = [file_path]
else:
return InvoiceResult(
success=False,
is_invoice=False,
error=f"不支持的文件类型: {suffix}(仅支持 PDF / 图片)",
error_code="unsupported",
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
)
except Exception as e:
logger.exception("PDF/图片处理失败: {}", e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"PDF/图片处理失败: {e}",
error_code="process_failed",
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
)
# ---------- 2. QR 优先识别 ----------
qr = decode_qr(page_imgs[0])
qr_ok = qr_has_any_field(qr)
if qr_ok and not settings.qr_full_ocr:
# 快路径: 扫到 QR 且配置为 fast, 直接返回
logger.info("QR 快路径: {} 耗时 {}ms", file_path.name, _elapsed_ms())
return _build_qr_only_result(qr, _elapsed_ms(), len(page_imgs))
if not qr_ok:
# 没扫到 / 格式不合法 → 直接认为非发票, 不再跑 OCR
reason = "未识别到发票二维码" if not qr.raw else "二维码格式不合法"
logger.info("非发票 (无有效 QR): {} 耗时 {}ms", file_path.name, _elapsed_ms())
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{reason}(可能不是发票图片)",
error_code="not_invoice",
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=False,
qr_raw=qr.raw or None,
qr_error="no_qr" if not qr.raw else "bad_format",
)
# ---------- 3. 每页 OCR(单页超时 + 总超时双重保护)----------
# 到这里 qr_ok=True, OCR 仅为补充信息; QR 已保证是发票
all_lines: List[OCRLine] = []
for idx, img_path in enumerate(page_imgs, start=1):
remaining = total_deadline - time.time()
if remaining <= 0:
logger.warning("达到总超时 ({:.1f}s), 中断 OCR", settings.ocr_total_timeout_s)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"达到总超时 ({settings.ocr_total_timeout_s}秒), 已识别 {idx-1}/{len(page_imgs)}",
error_code="timeout",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
page_timeout = min(settings.ocr_page_timeout_s, remaining)
try:
all_lines.extend(_ocr_image(img_path))
except OCRTimeout as e:
logger.warning("{} 页 OCR 超时: {}", idx, e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{idx} 页识别超时 ({page_timeout:.1f}秒)",
error_code="timeout",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
except Exception as e:
logger.exception("{} 页 OCR 失败: {}", idx, e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{idx} 页识别失败: {e}",
error_code="ocr_failed",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
# ---------- 4. 字段抽取 + QR 字段覆盖 ----------
raw_text = "\n".join(l.text for l in all_lines)
fields = extract_invoice(raw_text, all_lines)
fields = _overlay_qr_fields(fields, qr)
elapsed = _elapsed_ms()
logger.info("识别完成: {} 页={}, from_qr=true, 字段数={}, 耗时={}ms",
file_path.name, len(page_imgs),
sum(1 for f in fields.model_dump().values() if f), elapsed)
return InvoiceResult(
success=True,
is_invoice=True,
raw_text=raw_text,
lines=all_lines,
fields=fields,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=elapsed,
from_qr=True,
qr_raw=qr.raw,
)
finally:
if delete_after:
try:
file_path.unlink(missing_ok=True)
if suffix == ".pdf":
shutil.rmtree(file_path.parent / f".{file_path.stem}_pages", ignore_errors=True)
except Exception:
pass
+20
View File
@@ -0,0 +1,20 @@
"""工具"""
from .amount_utils import (
amount_consistent,
extract_cn_amount,
extract_num_amount,
extract_pretax_amount,
extract_tax_amount,
extract_total_amount,
parse_cn_amount,
)
__all__ = [
"amount_consistent",
"extract_cn_amount",
"extract_num_amount",
"extract_pretax_amount",
"extract_tax_amount",
"extract_total_amount",
"parse_cn_amount",
]
+117
View File
@@ -0,0 +1,117 @@
"""金额工具:中文大写金额解析 + 与小写金额比对"""
from __future__ import annotations
import re
from typing import Optional
import cn2an
# 小写金额正则
_NUM_PATTERN = re.compile(r"¥?\s*(\d{1,8}(?:,\d{3})*\.\d{2})")
_TAX_AMOUNT_PATTERN = re.compile(r"\s*额\s*[¥:]?\s*(\d{1,8}(?:,\d{3})*\.\d{2})")
_PRETAX_PATTERN = re.compile(r"(?:不合?税价|不含税)\s*[¥:]?\s*(\d{1,8}(?:,\d{3})*\.\d{2})")
_TOTAL_PATTERN = re.compile(r"价税合计[^\d]*[¥]?\s*(\d{1,8}(?:,\d{3})*\.\d{2})")
def normalize_cn_amount(text: str) -> str:
"""中文金额归一化:圆→元、〇→零"""
if not text:
return ""
return text.replace("", "").replace("", "").replace(" ", "")
def extract_cn_amount(text: str) -> Optional[str]:
"""从原文里匹配出第一段大写金额字符串
策略(按优先级):
1. "价税合计" 后面括号内
2. 任意中括号里的中文金额
3. 含""""的最长中文字符串
"""
if not text:
return None
# 1. 价税合计之后括号里
m = re.search(r"价税合计[^\(]*[\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]+)[\)]", text)
if m:
return m.group(1)
# 2. 任意中括号里的中文金额
m = re.search(r"[\((]([零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30})[\)]", text)
if m:
return m.group(1)
# 3. 含"元"/"圆"的中文片段
for cand in re.findall(r"[零壹贰叁肆伍陆柒捌玖拾佰仟万亿圆元角分整]{3,30}", text):
if "" in cand or "" in cand:
return cand
return None
def parse_cn_amount(cn_text: str) -> Optional[float]:
"""把中文大写金额转 float,例如 '贰佰元整' → 200.0"""
if not cn_text:
return None
try:
s = normalize_cn_amount(cn_text)
# cn2an 要求带 '元' 或 '圆' 结尾
s = s.rstrip("")
if not s.endswith(""):
s += ""
value = cn2an.cn2an(s, "smart")
return float(value)
except Exception:
return None
def amount_consistent(cn_text: Optional[str], num_amount: Optional[float]) -> Optional[bool]:
"""大写 vs 小写金额比对"""
if cn_text is None or num_amount is None:
return None
cn_value = parse_cn_amount(cn_text)
if cn_value is None:
return None
return abs(cn_value - num_amount) < 0.011
def extract_num_amount(text: str) -> Optional[float]:
"""从文本里提取第一个形如 1234.56 或 ¥1,234.56 的金额"""
if not text:
return None
m = _NUM_PATTERN.search(text)
if not m:
return None
try:
return float(m.group(1).replace(",", ""))
except ValueError:
return None
def extract_total_amount(text: str) -> Optional[float]:
m = _TOTAL_PATTERN.search(text)
if m:
try:
return float(m.group(1).replace(",", ""))
except ValueError:
pass
return extract_num_amount(text)
def extract_tax_amount(text: str) -> Optional[float]:
m = _TAX_AMOUNT_PATTERN.search(text)
if m:
try:
return float(m.group(1).replace(",", ""))
except ValueError:
pass
return None
def extract_pretax_amount(text: str) -> Optional[float]:
m = _PRETAX_PATTERN.search(text)
if m:
try:
return float(m.group(1).replace(",", ""))
except ValueError:
pass
return None
+37
View File
@@ -0,0 +1,37 @@
package com.ruoyi.business.ocr;
import lombok.Data;
/** 结构化发票字段 */
@Data
public class InvoiceFields {
/** 发票类型:增值税电子普通发票 / 增值税专用发票 / ... */
private String invoiceType;
/** 发票号码 */
private String invoiceNo;
/** 发票代码 */
private String invoiceCode;
/** 开票日期 YYYY-MM-DD */
private String invoiceDate;
/** 价税合计(小写) */
private Double amount;
/** 价税合计(大写中文) */
private String amountCn;
/** 不含税金额 */
private Double amountPretax;
/** 税额 */
private Double taxAmount;
/** 销售方名称 */
private String sellerName;
/** 销售方纳税人识别号 */
private String sellerTaxNo;
/** 购买方名称 */
private String buyerName;
/** 购买方纳税人识别号 */
private String buyerTaxNo;
/** 大写金额 vs 小写金额是否一致(null=未能比对) */
private Boolean amountMatch;
}
+18
View File
@@ -0,0 +1,18 @@
package com.ruoyi.business.ocr;
import lombok.Data;
import java.util.List;
/** OCR 识别结果(与 ry-ocr 的 InvoiceResult JSON 对应) */
@Data
public class InvoiceResult {
private Boolean success;
private String rawText;
private String engine;
private Integer pageCount;
private Integer elapsedMs;
private String error;
private InvoiceFields fields;
private List<OcrLine> lines;
}
+120
View File
@@ -0,0 +1,120 @@
package com.ruoyi.business.ocr;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* ry-ocr Java 调用客户端
*
* 依赖:hutool-http, hutool-json, hutool-core, lombok
*
* 用法:
* OcrClient client = new OcrClient("http://127.0.0.1:8801");
* InvoiceResult r = client.recognize(new File("d:/发票.pdf"));
* InvoiceResult r2 = client.recognizeByUrl("https://oss.example.com/xxx.png");
* System.out.println(r.getFields().getAmount());
*/
@Slf4j
public class OcrClient {
private final String baseUrl;
public OcrClient(String baseUrl) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
/** 健康检查 */
public boolean ping() {
try (HttpResponse resp = HttpRequest.get(baseUrl + "/health").timeout(3000).execute()) {
return resp.getStatus() == 200 && "ok".equals(JSONUtil.parseObj(resp.body()).getStr("status"));
} catch (Exception e) {
log.warn("ocr ping failed: {}", e.getMessage());
return false;
}
}
/** 识别发票(图片或 PDF) */
public InvoiceResult recognize(File file) {
try (HttpResponse resp = HttpRequest.post(baseUrl + "/recognize/invoice")
.form("file", file)
.timeout(60_000)
.execute()) {
String body = resp.body();
JSONObject json = JSONUtil.parseObj(body);
if (resp.getStatus() != 200) {
throw new RuntimeException("OCR 调用失败: " + resp.getStatus() + " " + body);
}
return parse(json);
}
}
/**
* 从 URL 识别发票: 后端下载 OSS URL 到临时文件 → recognize → 清理临时文件.
* 临时文件目录: System.getProperty("java.io.tmpdir")/ry-ocr/
*
* @param url OSS 可访问 URL
* @return 识别结果
*/
public InvoiceResult recognizeByUrl(String url) {
if (url == null || url.isEmpty()) {
throw new IllegalArgumentException("ossUrl 不能为空");
}
File tmpDir = new File(System.getProperty("java.io.tmpdir"), "ry-ocr");
if (!tmpDir.exists() && !tmpDir.mkdirs()) {
throw new RuntimeException("无法创建临时目录: " + tmpDir.getAbsolutePath());
}
// 从 URL 截取文件名, 保留后缀 (用于 ry-ocr 推断图片/PDF)
String name = url.substring(url.lastIndexOf('/') + 1);
if (name.indexOf('?') >= 0) name = name.substring(0, name.indexOf('?'));
if (name.indexOf('.') < 0) name = name + ".png";
File tmp = new File(tmpDir, System.currentTimeMillis() + "_" + name);
try {
long size = HttpUtil.downloadFile(url, tmp);
if (size <= 0) {
throw new RuntimeException("OSS 文件下载失败或为空: " + url);
}
log.info("OCR 下载: url={} size={}B tmp={}", url, size, tmp.getAbsolutePath());
return recognize(tmp);
} finally {
FileUtil.del(tmp);
}
}
private InvoiceResult parse(JSONObject json) {
InvoiceResult r = new InvoiceResult();
r.setSuccess(json.getBool("success", false));
r.setRawText(json.getStr("rawText", ""));
r.setEngine(json.getStr("engine", ""));
r.setPageCount(json.getInt("pageCount", 1));
r.setElapsedMs(json.getInt("elapsedMs", 0));
r.setError(json.getStr("error"));
JSONObject f = json.getJSONObject("fields");
if (f != null) {
InvoiceFields fields = new InvoiceFields();
fields.setInvoiceType(f.getStr("invoiceType"));
fields.setInvoiceNo(f.getStr("invoiceNo"));
fields.setInvoiceCode(f.getStr("invoiceCode"));
fields.setInvoiceDate(f.getStr("invoiceDate"));
fields.setAmount(f.getDouble("amount"));
fields.setAmountCn(f.getStr("amountCn"));
fields.setAmountPretax(f.getDouble("amount_pretax"));
fields.setTaxAmount(f.getDouble("taxAmount"));
fields.setSellerName(f.getStr("sellerName"));
fields.setSellerTaxNo(f.getStr("sellerTaxNo"));
fields.setBuyerName(f.getStr("buyerName"));
fields.setBuyerTaxNo(f.getStr("buyerTaxNo"));
fields.setAmountMatch(f.getBool("amountMatch"));
r.setFields(fields);
}
return r;
}
}
+13
View File
@@ -0,0 +1,13 @@
package com.ruoyi.business.ocr;
import lombok.Data;
import java.util.List;
/** 单行 OCR 识别结果 */
@Data
public class OcrLine {
private String text;
private Double confidence;
private List<List<Double>> box;
}
+29
View File
@@ -0,0 +1,29 @@
version: "3.9"
services:
ry-ocr:
build: .
container_name: ry-ocr
restart: unless-stopped
ports:
- "8801:8801"
environment:
- APP_HOST=0.0.0.0
- APP_PORT=8801
- USE_GPU=false
- OCR_LANG=ch
- MAX_UPLOAD_MB=20
- PDF_DPI=200
- LOG_LEVEL=INFO
volumes:
# 模型缓存持久化(避免重建容器重新下载)
- paddle_models:/root/.paddleocr
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8801/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
volumes:
paddle_models:
+27
View File
@@ -0,0 +1,27 @@
# Web 框架
fastapi==0.115.6
uvicorn[standard]==0.32.1
python-multipart==0.0.20
# PaddlePaddle (CPU 版;如需 GPU 改为 paddlepaddle-gpu)
paddlepaddle==3.0.0
paddleocr==3.0.1
# PDF 处理 (PyMuPDF,无需 poppler)
PyMuPDF==1.27.2
# 图像处理
opencv-python-headless==4.10.0.84
numpy==1.26.4
Pillow==10.4.0
# 数据校验
pydantic==2.10.3
pydantic-settings==2.7.0
# 中文大写金额转换
cn2an==0.5.22
# 日志 + 工具
loguru==0.7.3
python-dotenv==1.0.1
+14
View File
@@ -0,0 +1,14 @@
"""开发模式启动: python run.py"""
import uvicorn
from app.config import settings
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host=settings.app_host,
port=settings.app_port,
reload=False,
workers=1, # PaddleOCR 不是进程安全,单 worker
log_level=settings.log_level.lower(),
)
+1
View File
@@ -0,0 +1 @@
"""tests"""
+44
View File
@@ -0,0 +1,44 @@
"""基准测试:模型预热后,统计 PNG / PDF 单次识别耗时"""
import sys
import time
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from app.core import warmup
from app.services.recognize_service import recognize_file
def bench(label: str, file_path: Path, runs: int = 5):
content = file_path.read_bytes()
print(f"\n=== {label}: {file_path.name} ({len(content)} bytes) ===")
# 预热(不计耗时) - 现在 warmup 会真跑一次 dummy 图预测
print("预热中(不计入)...", flush=True)
warmup()
print("预热完成", flush=True)
times = []
for i in range(runs):
t0 = time.time()
result = recognize_file(file_path.name, content)
elapsed = (time.time() - t0) * 1000
times.append(elapsed)
print(f"{i+1} 次: {elapsed:7.1f} ms success={result.success}")
times.sort()
print(f"\n min: {times[0]:7.1f} ms")
print(f" median: {times[len(times)//2]:7.1f} ms")
print(f" max: {times[-1]:7.1f} ms")
print(f" avg: {sum(times)/len(times):7.1f} ms")
if __name__ == "__main__":
bench("PNG", ROOT / "fapiao_1.png", runs=5)
bench("PDF", ROOT / "fapiao.pdf", runs=5)
+93
View File
@@ -0,0 +1,93 @@
"""基准测试:不同 DPI 下的 PDF 渲染耗时 vs OCR 耗时 vs 识别准确度"""
import sys
import time
from pathlib import Path
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import fitz
from app.core import warmup
from app.services.recognize_service import recognize_file
def render(pdf_path: Path, dpi: int) -> Path:
out = ROOT / f"_bench_{dpi}dpi.png"
doc = fitz.open(str(pdf_path))
page = doc[0]
pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72, dpi / 72), alpha=False)
pix.save(str(out))
doc.close()
return out
def bench_dpi(pdf_path: Path, dpi: int, runs: int = 3):
img_path = render(pdf_path, dpi)
img_bytes = img_path.read_bytes()
print(f"\n--- DPI={dpi} | 渲染图 {img_path.stat().st_size//1024}KB ---")
times = []
fields_ok = None
for i in range(runs):
t0 = time.time()
result = recognize_file(img_path.name, img_bytes)
elapsed = (time.time() - t0) * 1000
times.append(elapsed)
if i == 0 and result.success:
fields_ok = result.fields
print(f" run {i+1}: {elapsed:7.1f}ms success={result.success}")
# 清理中间文件
img_path.unlink(missing_ok=True)
(ROOT / f".{img_path.stem}_pages").rmdir() if (ROOT / f".{img_path.stem}_pages").exists() else None
# 上面可能删不了子目录,try 再清理 _pages 下文件
pages_dir = ROOT / f".{img_path.stem}_pages"
if pages_dir.exists():
for f in pages_dir.glob("*"):
f.unlink(missing_ok=True)
pages_dir.rmdir()
times.sort()
print(f" min={times[0]:.1f}ms median={times[len(times)//2]:.1f}ms max={times[-1]:.1f}ms")
return fields_ok
if __name__ == "__main__":
pdf = ROOT / "fapiao.pdf"
print(f"PDF: {pdf.name}, {pdf.stat().st_size//1024}KB, 1 页")
warmup()
print("预热完成\n")
print("=" * 60)
fields_100 = bench_dpi(pdf, 100)
print("=" * 60)
fields_150 = bench_dpi(pdf, 150)
print("=" * 60)
fields_200 = bench_dpi(pdf, 200)
print("=" * 60)
fields_300 = bench_dpi(pdf, 300)
print("=" * 60)
# 汇总
print("\n\n=== 关键字段对比 ===")
print(f"{'字段':<15} {'100dpi':<25} {'150dpi':<25} {'200dpi':<25} {'300dpi':<25}")
key_fields = ["invoice_no", "invoice_date", "amount", "amount_cn",
"amount_pretax", "tax_amount", "seller_name", "buyer_name",
"seller_tax_no", "buyer_tax_no", "amount_match"]
for k in key_fields:
row = [k]
for f in [fields_100, fields_150, fields_200, fields_300]:
v = getattr(f, k) if f else None
if v is None:
row.append("(空)")
else:
s = str(v)[:23]
row.append(s)
print(f"{row[0]:<15} {row[1]:<25} {row[2]:<25} {row[3]:<25} {row[4]:<25}")
+91
View File
@@ -0,0 +1,91 @@
"""单元测试:发票字段抽取(不依赖 PaddleOCR)
直接喂文本,验证字段抽取与金额校验逻辑。
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# 强制 UTF-8 输出(Windows cp936 默认中文乱码)
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
from app.services import extract_invoice # noqa: E402
SAMPLE_VAT_ELEC = """
增值税电子普通发票
发票代码: 011002000000
发票号码: 12345678
开票日期: 2024年05月20日
名称: 上海某科技有限公司
纳税人识别号: 91310115MA1K3X9Y8A
名称: 北京某某贸易有限公司
纳税人识别号: 91110108MA01K3X9Y8A
金额 ¥1234.56
税额 ¥74.07
价税合计 ¥1308.63
(贰仟零捌元陆角叁分)
备注:
"""
SAMPLE_VAT_SPECIAL = """
增值税专用发票
发票代码 011002100111
发票号码 87654321
开 票 日 期: 2023年12月01日
名 称: 深圳某有限公司
纳税人识别号: 91440300MA5DCBA123
名 称: 广州某科技股份公司
纳税人识别号: 91440101MA59ABC987
不含税价 ¥10000.00 税率 13%
税 额 ¥1300.00
价税合计(大写)壹万壹仟叁佰元整 (小写)¥11300.00
"""
def test_vat_electronic():
fields = extract_invoice(SAMPLE_VAT_ELEC)
print(fields.model_dump_json(indent=2))
assert fields.invoice_type == "增值税电子普通发票"
assert fields.invoice_code == "011002000000"
assert fields.invoice_no == "12345678"
assert fields.invoice_date == "2024-05-20"
assert fields.amount is not None and abs(fields.amount - 1308.63) < 0.01
def test_vat_special():
fields = extract_invoice(SAMPLE_VAT_SPECIAL)
print(fields.model_dump_json(indent=2))
assert fields.invoice_type == "增值税专用发票"
assert fields.invoice_no == "87654321"
assert fields.invoice_date == "2023-12-01"
assert fields.amount is not None and abs(fields.amount - 11300.0) < 0.01
# 大写金额提取(壹万壹仟叁佰元整)
assert fields.amount_cn is not None
assert "壹万" in fields.amount_cn or "" in fields.amount_cn
# 大写 vs 小写一致
assert fields.amount_match is True
def test_amount_consistency():
"""大写小写一致"""
from app.utils import amount_consistent
assert amount_consistent("贰佰元整", 200.0) is True
assert amount_consistent("壹仟元整", 1000.0) is True
# 不一致
assert amount_consistent("贰佰元整", 300.0) is False
if __name__ == "__main__":
test_vat_electronic()
print("-" * 60)
test_vat_special()
print("-" * 60)
test_amount_consistency()
print("✅ all tests passed")
+55 -1
View File
@@ -24,6 +24,14 @@
<span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span> <span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span>
</div> </div>
<el-button v-if="!readonly" link type="danger" size="small" class="remove-btn" @click.stop="onRemove">移除</el-button> <el-button v-if="!readonly" link type="danger" size="small" class="remove-btn" @click.stop="onRemove">移除</el-button>
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
<div v-if="showPreview" class="preview-box">
<el-image v-if="isImage" :src="modelValue" :preview-src-list="[modelValue]" :initial-index="0" fit="cover" class="preview-img" />
<a v-else :href="modelValue" target="_blank" class="preview-file" @click.stop>
<el-icon :size="28"><Document /></el-icon>
<span class="preview-text">{{ getFileType(modelValue) }}</span>
</a>
</div>
</div> </div>
</div> </div>
<input <input
@@ -38,6 +46,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Document } from '@element-plus/icons-vue'
import { uploadToOss } from '@/utils/oss' import { uploadToOss } from '@/utils/oss'
const props = defineProps({ const props = defineProps({
@@ -48,7 +57,8 @@ const props = defineProps({
accept: { type: String, default: '.pdf,.png,.jpg,.jpeg' }, accept: { type: String, default: '.pdf,.png,.jpg,.jpeg' },
maxSize: { type: Number, default: 10 }, // MB maxSize: { type: Number, default: 10 }, // MB
block: { type: Boolean, default: false }, block: { type: Boolean, default: false },
readonly: { type: Boolean, default: false } readonly: { type: Boolean, default: false },
showPreview: { type: Boolean, default: true } // 右侧预览框 (图片/PDF/其他)
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -69,6 +79,24 @@ const ext = computed(() => {
}) })
const fileSizeText = computed(() => '已上传') const fileSizeText = computed(() => '已上传')
// 图片扩展名 (用于 el-image 预览)
const isImage = computed(() => {
if (!props.modelValue) return false
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext.value)
})
// 文件类型标签 (PDF / DOC / XLS / ZIP 等)
const typeLabelMap = {
pdf: 'PDF', doc: 'DOC', docx: 'DOCX',
xls: 'XLS', xlsx: 'XLSX', ppt: 'PPT', pptx: 'PPTX',
zip: 'ZIP', rar: 'RAR', '7z': '7Z',
txt: 'TXT', csv: 'CSV'
}
function getFileType(url) {
if (!url) return 'FILE'
const e = url.split('?')[0].split('.').pop().toLowerCase()
return typeLabelMap[e] || (e ? e.toUpperCase() : 'FILE')
}
function handleClick() { function handleClick() {
if (props.readonly || uploading.value) return if (props.readonly || uploading.value) return
fileInput.value?.click() fileInput.value?.click()
@@ -149,4 +177,30 @@ function onRemove() {
.file-name:hover { color: #1890ff; text-decoration: underline; } .file-name:hover { color: #1890ff; text-decoration: underline; }
.file-type { font-size: 12px; color: #909399; } .file-type { font-size: 12px; color: #909399; }
.remove-btn { flex-shrink: 0; } .remove-btn { flex-shrink: 0; }
/* 右侧预览框 (方形, 64x64) */
.preview-box {
width: 64px;
height: 64px;
border: 1px solid #f0f0f0;
border-radius: 4px;
background: #fafafa;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.preview-img { width: 100%; height: 100%; cursor: pointer; }
.preview-file {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 2px;
width: 100%; height: 100%;
color: #8c8c8c;
text-decoration: none;
transition: color 0.2s;
}
.preview-file:hover { color: var(--brand-primary); }
.preview-text { font-size: 10px; font-weight: 500; }
</style> </style>
+1
View File
@@ -63,6 +63,7 @@ const routes = [
{ path: 'projects/detail/:projectId', name: 'manager-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } }, { path: 'projects/detail/:projectId', name: 'manager-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'projects/assign', name: 'manager-projects-assign', component: () => import('@/views/manager/ManagerProjectsAssign.vue'), meta: { title: '项目分配' } }, { path: 'projects/assign', name: 'manager-projects-assign', component: () => import('@/views/manager/ManagerProjectsAssign.vue'), meta: { title: '项目分配' } },
{ path: 'meetings', name: 'manager-meetings', component: () => import('@/views/manager/Meetings.vue'), meta: { title: '会议管理' } }, { path: 'meetings', name: 'manager-meetings', component: () => import('@/views/manager/Meetings.vue'), meta: { title: '会议管理' } },
{ path: 'meetings/detail/:meetingId', name: 'manager-meetings-detail', component: () => import('@/views/manager/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'meetings/new', name: 'manager-meetings-new', component: () => import('@/views/manager/MeetingNew.vue'), meta: { title: '新建会议' } }, { path: 'meetings/new', name: 'manager-meetings-new', component: () => import('@/views/manager/MeetingNew.vue'), meta: { title: '新建会议' } },
{ path: 'experts', name: 'manager-experts', component: () => import('@/views/expert/Experts.vue'), meta: { title: '专家审核' } }, { path: 'experts', name: 'manager-experts', component: () => import('@/views/expert/Experts.vue'), meta: { title: '专家审核' } },
{ path: 'experts/new', name: 'manager-experts-new', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '新建专家' } }, { path: 'experts/new', name: 'manager-experts-new', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '新建专家' } },
+16 -28
View File
@@ -243,7 +243,7 @@ async function onSubmit() {
loading.value = true loading.value = true
try { try {
const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' }) const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' })
await afterLogin(res.token, form.username, autoRole(form.username)) await afterLogin(res.token, form.username)
} catch (e) { } catch (e) {
// 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只刷新验证码 // 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只刷新验证码
loadCaptcha() loadCaptcha()
@@ -255,14 +255,20 @@ async function onSubmit() {
/** /**
* 登录后置: 存 token → 调 /getInfo 拿真实 user → 跳角色首页 * 登录后置: 存 token → 调 /getInfo 拿真实 user → 跳角色首页
* 密码登录和短信登录共用 * 密码登录和短信登录共用
* 角色单一可信源: sys_user.role_type (由 /getInfo 返回), 不再按用户名推断
*/ */
async function afterLogin(token, displayName, fallbackRole) { async function afterLogin(token, displayName) {
userStore.setToken(token) userStore.setToken(token)
let role = fallbackRole let role = ''
try { try {
const info = await getInfo() const info = await getInfo()
const u = info.user || {} const u = info.user || {}
role = u.roleType || fallbackRole role = u.roleType
if (!role) {
// /getInfo 拿不到 roleType → 用户无业务角色, 不让进系统
ElMessage.error('账号角色未配置, 请联系管理员')
return router.replace({ name: 'login' })
}
userStore.setUser({ userStore.setUser({
userId: u.userId, userId: u.userId,
userName: u.userName || displayName, userName: u.userName || displayName,
@@ -273,20 +279,13 @@ async function afterLogin(token, displayName, fallbackRole) {
role role
}) })
} catch { } catch {
// /getInfo 失败时回退: 只存基本信息 // /getInfo 失败: 不存残缺 user, 让用户重新登录
userStore.setUser({ ElMessage.error('获取用户信息失败, 请重新登录')
userId: null, return router.replace({ name: 'login' })
userName: displayName,
nickName: displayName,
phonenumber: '',
accountType: 'MAIN',
parentUserId: null,
role: fallbackRole
})
} }
ElMessage.success(`欢迎,${displayName}${userTypes.find(u => u.value === role)?.name || role}`) ElMessage.success(`欢迎,${displayName}${userTypes.find(u => u.value === role)?.name || role}`)
// 没拿到角色就跳回登录页 // 角色不在角色首页映射里 → 拒绝
if (!role || !roleHome[role]) return router.replace({ name: 'login' }) if (!roleHome[role]) return router.replace({ name: 'login' })
// 带 redirect 回跳 (401/守卫带过来的原页面), 否则跳角色首页 // 带 redirect 回跳 (401/守卫带过来的原页面), 否则跳角色首页
const redirect = route.query.redirect const redirect = route.query.redirect
if (redirect) { if (redirect) {
@@ -345,24 +344,13 @@ async function onSmsLoginSubmit() {
try { try {
const res = await smsLogin({ phone: smsForm.phone, smsCode: smsForm.smsCode, uuid: smsForm.uuid }) const res = await smsLogin({ phone: smsForm.phone, smsCode: smsForm.smsCode, uuid: smsForm.uuid })
// 短信登录后无 username, 用 phone 作为显示名; fallbackRole 留空, 让 /getInfo 决定 // 短信登录后无 username, 用 phone 作为显示名; fallbackRole 留空, 让 /getInfo 决定
await afterLogin(res.token, smsForm.phone, '') await afterLogin(res.token, smsForm.phone)
} catch (e) { } catch (e) {
// request.js 已弹错误 // request.js 已弹错误
} }
}) })
} }
function autoRole(username) {
const u = (username || '').toLowerCase()
if (u === 'admin' || u === 'ry') return 'admin'
if (u.startsWith('manager')) return 'manager'
if (u.startsWith('doctor')) return 'doctor'
if (u.startsWith('executor')) return 'executor'
if (u.startsWith('sponsor')) return 'sponsor'
// 不匹配返回空串, 让 /getInfo 决定
return ''
}
const roleHome = { const roleHome = {
admin: '/admin/workbench', admin: '/admin/workbench',
manager: '/manager/workbench', manager: '/manager/workbench',
+760
View File
@@ -0,0 +1,760 @@
<template>
<div class="page-card manager-meeting-detail">
<!-- 面包屑 -->
<div class="breadcrumb">
首页 / 会议管理 / <span class="current">会议详情</span>
</div>
<!-- 页标题 -->
<div class="page-title">会议详情</div>
<div class="cols-row">
<!-- 左列 -->
<div class="left-col">
<!-- 1. 会议基本信息 -->
<div class="card">
<div class="section-title">会议基本信息</div>
<div class="info-grid" v-loading="loading">
<div class="info-row"><span class="info-label">项目编号:</span><span class="info-value code">{{ row.projectNo || '-' }}</span></div>
<div class="info-row"><span class="info-label">会议名称:</span><span class="info-value">{{ row.meetingName || '-' }}</span></div>
<div class="info-row"><span class="info-label">项目名称:</span><span class="info-value">{{ row.projectName || '-' }}</span></div>
<div class="info-row"><span class="info-label">期数:</span><span class="info-value">{{ periodDisplay }}</span></div>
<div class="info-row"><span class="info-label">总场次/总期数:</span><span class="info-value">{{ row.totalPeriods ?? '-' }}</span></div>
<div class="info-row"><span class="info-label">会议开始时间:</span><span class="info-value">{{ fmtDateTime(row.startTime) }}</span></div>
<div class="info-row"><span class="info-label">支持单位:</span><span class="info-value">{{ row.orgName || '-' }}</span></div>
<div class="info-row"><span class="info-label">会议结束时间:</span><span class="info-value">{{ fmtDateTime(row.endTime) }}</span></div>
<div class="info-row"><span class="info-label">材料审核状态:</span><span class="info-value">{{ fmtAuditStage(row.materialAuditStage) }}</span></div>
<div class="info-row"><span class="info-label">创建时间:</span><span class="info-value">{{ fmtDateTime(row.createTime) }}</span></div>
<div class="info-row"><span class="info-label">凭证审核状态:</span><span class="info-value">{{ fmtAuditStage(row.voucherAuditStage) }}</span></div>
<div class="info-row"><span class="info-label">创建人员:</span><span class="info-value">{{ row.createBy || '-' }}</span></div>
<!-- 监察员 + 执行人员 (各占整行, 2 ) -->
<div class="info-row info-row-full">
<span class="info-label">监察员:</span>
<div class="tag-list">
<el-tag v-for="u in supervisors" :key="u.userId" type="info" size="small">{{ u.userName }}</el-tag>
<span v-if="!supervisors.length" class="text-muted">- 未分配 -</span>
<el-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('supervisor')">分配</el-button>
</div>
</div>
<div class="info-row info-row-full">
<span class="info-label">执行人员:</span>
<div class="tag-list">
<el-tag v-for="u in executors" :key="u.userId" type="success" size="small">{{ u.userName }}</el-tag>
<span v-if="!executors.length" class="text-muted">- 未分配 -</span>
<el-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('executor')">分配</el-button>
</div>
</div>
</div>
</div>
<!-- 2. 材料管理 -->
<div class="card">
<div class="section-title">材料管理</div>
<el-tabs v-model="activeTab" class="material-tabs">
<el-tab-pane label="会务材料" name="service">
<div class="file-list">
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="劳务材料" name="labor">
<div class="file-list">
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="劳务凭证" name="laborVoucher">
<div class="file-list">
<div v-for="r in laborVoucherRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="会务凭证" name="serviceVoucher">
<div class="file-list">
<div v-for="r in serviceVoucherRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<div class="tab-actions">
<el-button @click="onPackUpload">打包上传</el-button>
<a class="file-link inline-link" href="javascript:void(0)" @click="onDownloadTemplate">会务材料文件夹模板下载</a>
<span style="flex:1"></span>
<!-- 执行人员: 提交材料 / 提交凭证 -->
<el-button v-if="canSubmitMaterial" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</el-button>
<el-button v-if="canSubmitVoucher" type="warning" :loading="busy.submitVoucher" @click="onSubmitVoucher">提交凭证</el-button>
<!-- 合规 / 监察审核 -->
<el-button v-if="canComplianceAudit('MATERIAL')" type="success" @click="openAuditDialog('COMPLIANCE','MATERIAL')">合规审核 (材料)</el-button>
<el-button v-if="canComplianceAudit('VOUCHER')" type="success" @click="openAuditDialog('COMPLIANCE','VOUCHER')">合规审核 (凭证)</el-button>
<el-button v-if="canSupervisionAudit('MATERIAL')" type="primary" @click="openAuditDialog('SUPERVISION','MATERIAL')">监察审核 (材料)</el-button>
<el-button v-if="canSupervisionAudit('VOUCHER')" type="primary" @click="openAuditDialog('SUPERVISION','VOUCHER')">监察审核 (凭证)</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
<p class="hint-text">*结算后, 已完结状态, 执行方不能再进行编辑</p>
</div>
</div>
<!-- 右列: 材料/凭证 双时间轴 (时间轴 + 审核轨迹合并) -->
<div class="audit-columns">
<div class="card audit-column">
<div class="section-title">材料审核</div>
<div class="timeline">
<!-- 固定节点 1 -->
<div :class="['timeline-item', fixedNodeStatus('PRE')]">
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<!-- 动态轮次 2/3/4 -->
<template v-for="(round, idx) in displayMaterialRounds" :key="`mat-r${idx}`">
<div :class="['timeline-item', partStatus(round.submit)]">
<div class="timeline-title">执行方提交材料</div>
<div v-if="round.submit" class="timeline-meta">
<span>{{ round.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.submit.auditTime) }}</span>
</div>
<div v-else class="timeline-desc pending-text">待执行人员提交</div>
</div>
<div :class="['timeline-item', partStatus(round.compliance)]">
<div class="timeline-title">合规审核</div>
<div v-if="round.compliance" class="timeline-meta">
<span>{{ round.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.compliance.auditTime) }}</span>
<el-tag size="small" :type="round.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待合规审核</div>
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
</div>
<div :class="['timeline-item', partStatus(round.supervision)]">
<div class="timeline-title">监察意见</div>
<div v-if="round.supervision" class="timeline-meta">
<span>{{ round.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.supervision.auditTime) }}</span>
<el-tag size="small" :type="round.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待监察审核</div>
<div v-if="round.supervision?.opinion" class="timeline-opinion">💬 {{ round.supervision.opinion }}</div>
</div>
</template>
<!-- 固定节点 5/6 -->
<div :class="['timeline-item', fixedNodeStatus('POST')]">
<div class="timeline-title">会议结算</div>
<div class="timeline-desc">{{ nodeDesc('POST') }}</div>
</div>
<div :class="['timeline-item', fixedNodeStatus('DONE')]">
<div class="timeline-title">会议完结</div>
<div class="timeline-desc">{{ nodeDesc('DONE') }}</div>
</div>
</div>
</div>
<div class="card audit-column">
<div class="section-title">凭证审核</div>
<div class="timeline">
<div :class="['timeline-item', fixedNodeStatus('PRE')]">
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<template v-for="(round, idx) in displayVoucherRounds" :key="`vch-r${idx}`">
<div :class="['timeline-item', partStatus(round.submit)]">
<div class="timeline-title">执行方提交凭证</div>
<div v-if="round.submit" class="timeline-meta">
<span>{{ round.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.submit.auditTime) }}</span>
</div>
<div v-else class="timeline-desc pending-text">待执行人员提交</div>
</div>
<div :class="['timeline-item', partStatus(round.compliance)]">
<div class="timeline-title">合规审核</div>
<div v-if="round.compliance" class="timeline-meta">
<span>{{ round.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.compliance.auditTime) }}</span>
<el-tag size="small" :type="round.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待合规审核</div>
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
</div>
<div :class="['timeline-item', partStatus(round.supervision)]">
<div class="timeline-title">监察意见</div>
<div v-if="round.supervision" class="timeline-meta">
<span>{{ round.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.supervision.auditTime) }}</span>
<el-tag size="small" :type="round.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待监察审核</div>
<div v-if="round.supervision?.opinion" class="timeline-opinion">💬 {{ round.supervision.opinion }}</div>
</div>
</template>
<div :class="['timeline-item', fixedNodeStatus('POST')]">
<div class="timeline-title">会议结算</div>
<div class="timeline-desc">{{ nodeDesc('POST') }}</div>
</div>
<div :class="['timeline-item', fixedNodeStatus('DONE')]">
<div class="timeline-title">会议完结</div>
<div class="timeline-desc">{{ nodeDesc('DONE') }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分配 dialog -->
<el-dialog v-model="assignDialog.show" :title="assignDialog.title" width="500px">
<el-select v-model="assignDialog.selectedIds" multiple filterable :placeholder="`选择${assignDialog.roleLabel}`" style="width:100%" :loading="assignDialog.loading">
<el-option v-for="u in assignDialog.candidates" :key="u.userId" :label="`${u.userName} (${u.nickName || ''})`" :value="u.userId" />
</el-select>
<template #footer>
<el-button @click="assignDialog.show = false">取消</el-button>
<el-button type="primary" :loading="assignDialog.saving" @click="confirmAssign">确定</el-button>
</template>
</el-dialog>
<!-- 审核 dialog (合规/监察通用) -->
<el-dialog v-model="auditDialog.show" :title="auditDialog.title" width="500px">
<el-form label-width="80px">
<el-form-item label="审核类型">
<el-radio-group v-model="auditDialog.auditType">
<el-radio-button label="MATERIAL" :disabled="auditDialog.auditTypeLocked">材料</el-radio-button>
<el-radio-button label="VOUCHER" :disabled="auditDialog.auditTypeLocked">凭证</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="意见">
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见" />
</el-form-item>
<el-form-item label="结果">
<el-radio-group v-model="auditDialog.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">拒绝</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditDialog.show = false">取消</el-button>
<el-button type="primary" :loading="auditDialog.saving" @click="confirmAudit">确定</el-button>
</template>
</el-dialog>
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import request from '@/utils/request'
import { bizGet } from '@/api/public'
import { listSupporters, listExecutor } from '@/api/system'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import OssFileUploader from '@/components/OssFileUploader.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// ===================== 状态 =====================
const meetingId = ref('')
const row = ref({})
const loading = ref(false)
const activeTab = ref('service')
const saving = ref(false)
const supervisors = ref([])
const executors = ref([])
const auditTrail = ref([])
const busy = ref({ submitMaterial: false, submitVoucher: false })
const currentUserId = computed(() => userStore.user?.userId)
const currentRole = computed(() => userStore.role)
const isManager = computed(() => currentRole.value === 'manager')
// ===================== 工具 =====================
function pad(n) { return String(n).padStart(2, '0') }
function fmtDateTime(v) {
if (!v) return '-'
const dt = new Date(v)
if (isNaN(dt.getTime())) return v
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
const periodDisplay = computed(() => {
const p = row.value.periodNo
const t = row.value.totalPeriods
if (p == null && t == null) return '-'
if (p == null) return `${t}`
if (t == null) return `${p}`
return `${p}/${t}`
})
const AUDIT_STAGE_LABEL = { INIT: '待提交', SUBMITTED: '已提交', COMPLIANCE_APPROVED: '合规通过', APPROVED: '监察通过' }
function fmtAuditStage(v) { if (!v) return '-'; return AUDIT_STAGE_LABEL[v] || v }
// ===================== 材料管理 4 个 tab =====================
const ROW_CONFIG = [
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '物料实物照片, 盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '酒店盖章版水单, 酒店发票(餐饮普票, 住宿场地费专票)' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '行程单/盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '行程单/盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '合同, 盖章版结算单, 发票, 其他材料' },
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '设计稿, PPT' },
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '' },
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' },
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '总发票单独推送至OA, 单独下载' },
{ type: 'LABOR', subType: 'L_DETAIL', label: '劳务明细表', hint: '劳务明细表下载' },
{ type: 'LABOR', subType: 'L_AGREEMENT', label: '劳务协议', hint: '劳务协议模板' },
{ type: 'LABOR_VOUCHER', subType: 'LV_PAYMENT', label: '劳务付款凭证', hint: '劳务付款凭证文件' },
{ type: 'SERVICE_VOUCHER', subType: 'SV_PAYMENT', label: '会务付款凭证', hint: '会务付款凭证文件' }
]
function makeRows(filter) { return ROW_CONFIG.filter(filter).map(r => ({ ...r, url: '', fileName: '' })) }
const serviceMaterialRows = ref(makeRows(r => r.type === 'SERVICE'))
const laborMaterialRows = ref(makeRows(r => r.type === 'LABOR'))
const laborVoucherRows = ref(makeRows(r => r.type === 'LABOR_VOUCHER'))
const serviceVoucherRows = ref(makeRows(r => r.type === 'SERVICE_VOUCHER'))
// ===================== 双时间轴: 解析 audit_trail 为轮次 =====================
/**
* 解析 audit_trail 为轮次结构 (纯函数, 不依赖外部状态)
* 规则: 一次 SUBMITTED + APPROVED = 一轮起点 (执行人员提交)
* 之后 1-2 条填入 compliance / supervision 槽
* 拒绝事件 (REJECTED) 不会被识别为新提交, 仍归入当前轮
*/
function parseRounds(auditType) {
const rows = (auditTrail.value || [])
.filter(r => r.auditType === auditType)
.slice()
.sort((a, b) => new Date(a.auditTime).getTime() - new Date(b.auditTime).getTime())
const rounds = []
let cur = null
for (const row of rows) {
const isSubmit = row.currentStage === 'SUBMITTED' && row.auditResult === 'APPROVED'
if (isSubmit) {
if (cur) rounds.push(cur)
cur = { submit: row, compliance: null, supervision: null }
} else if (cur) {
if (!cur.compliance) cur.compliance = row
else if (!cur.supervision) cur.supervision = row
}
}
if (cur) rounds.push(cur)
return rounds
}
const materialRounds = computed(() => parseRounds('MATERIAL'))
const voucherRounds = computed(() => parseRounds('VOUCHER'))
/** 至少保证 1 轮 (空轮 = 三个节点都待提交), 保证 2/3/4 始终渲染 */
const displayMaterialRounds = computed(() =>
materialRounds.value.length ? materialRounds.value : [{ submit: null, compliance: null, supervision: null }]
)
const displayVoucherRounds = computed(() =>
voucherRounds.value.length ? voucherRounds.value : [{ submit: null, compliance: null, supervision: null }]
)
/**
* 节点状态视觉:
* - done → 绿色 (动作完成 + 通过)
* - rejected → 红色 (动作完成但拒绝, 通常触发下一轮)
* - pending → 灰色 (还没轮到)
*/
function partStatus(part) {
if (!part) return 'pending'
if (part.auditResult === 'REJECTED') return 'rejected'
return 'done'
}
/**
* 固定节点 (1/5/6) 状态 — 跟随 current_stage
* slot: PRE = 节点1 (会议已执行), POST = 节点5 (结算), DONE = 节点6 (完结)
*/
function fixedNodeStatus(slot) {
const cur = row.value.currentStage
if (slot === 'PRE') return ['EXECUTED', 'SETTLING', 'COMPLETED'].includes(cur) ? 'done' : 'pending'
if (slot === 'POST') return ['SETTLING', 'COMPLETED'].includes(cur) ? 'done' : 'pending'
if (slot === 'DONE') return cur === 'COMPLETED' ? 'done' : 'pending'
return 'pending'
}
function nodeDesc(slot) {
const cur = row.value.currentStage
if (slot === 'PRE') return ['EXECUTED', 'SETTLING', 'COMPLETED'].includes(cur) ? '已执行' : '待执行'
if (slot === 'POST') return ['SETTLING', 'COMPLETED'].includes(cur) ? '已结算' : '未结算'
if (slot === 'DONE') return cur === 'COMPLETED' ? '已完结' : '未完结'
return '-'
}
// ===================== 按钮显隐 =====================
const isAssignedExecutor = computed(() => executors.value.some(u => u.userId === currentUserId.value))
const isAssignedSupervisor = computed(() => supervisors.value.some(u => u.userId === currentUserId.value))
const canSubmitMaterial = computed(() => isAssignedExecutor.value && row.value.materialAuditStage === 'INIT')
const canSubmitVoucher = computed(() => isAssignedExecutor.value && row.value.voucherAuditStage === 'INIT')
function canComplianceAudit(type) {
if (!isManager.value) return false
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
return s === 'SUBMITTED'
}
function canSupervisionAudit(type) {
if (!isAssignedSupervisor.value) return false
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
return s === 'COMPLIANCE_APPROVED'
}
// ===================== 加载 =====================
/** 后端返回的完整 material 列表 (含 id/ossUrl/amount), 用于 onSave 时做"新增/替换/未变"分类 */
const materialsLoaded = ref([])
async function loadMaterials() {
try {
const resp = await request.get(`/business/meetingMaterial/${meetingId.value}`)
const list = (resp && (resp.data || resp)) || []
materialsLoaded.value = Array.isArray(list) ? list : []
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...serviceVoucherRows.value, ...laborVoucherRows.value]
all.forEach(r => { r.url = ''; r.fileName = '' })
list.forEach(item => {
const target = all.find(r => r.subType === item.subType)
if (target) { target.url = item.ossUrl || ''; target.fileName = item.fileName || '' }
})
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
}
async function loadStaff() {
try {
const [sp, ex] = await Promise.all([
request.get(`/business/meeting/supervisor/list/${meetingId.value}`),
request.get(`/business/meeting/executor/list/${meetingId.value}`)
])
supervisors.value = (sp && (sp.data || sp)) || []
executors.value = (ex && (ex.data || ex)) || []
} catch (e) { console.error('[meeting-detail] loadStaff failed', e) }
}
async function loadTrail() {
try {
const resp = await request.get(`/business/meeting/${meetingId.value}/audit-trail`)
auditTrail.value = (resp && (resp.data || resp)) || []
} catch (e) { console.error('[meeting-detail] loadTrail failed', e) }
}
async function load() {
meetingId.value = route.params.meetingId
if (!meetingId.value) return
loading.value = true
try {
const resp = await bizGet('meeting', meetingId.value)
row.value = (resp && (resp.data || resp)) || {}
await Promise.all([loadMaterials(), loadStaff(), loadTrail()])
} catch (e) {
console.error('[meeting-detail] load failed', e)
row.value = {}
} finally { loading.value = false }
}
// ===================== 保存 (材料) =====================
async function onSave() {
if (saving.value) return
saving.value = true
try {
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...serviceVoucherRows.value, ...laborVoucherRows.value]
// 1. 快照保存前的 material (subType -> {id, ossUrl})
// 用于保存后区分: 新增 / 替换 / 未变
const preSaveState = new Map()
materialsLoaded.value.forEach(m => {
if (m.subType && m.ossUrl) {
preSaveState.set(m.subType, { id: m.id, ossUrl: m.ossUrl, amount: m.amount })
}
})
// 2. 构造 payload
const payload = all.filter(r => r.url && r.url.trim()).map(r => ({
materialType: r.type, subType: r.subType, ossUrl: r.url, fileName: r.fileName || ''
}))
// 3. 保存 (全删全插, 后端用 DELETE + INSERT)
// __silentError: 让本处 catch 接管 toast, 避免 axios 拦截器和 catch 双弹
const resp = await request.put(`/business/meetingMaterial/${meetingId.value}`, payload, { __silentError: true })
const saved = (resp && resp.data) || []
ElMessage.success(`保存成功 (${saved.length} 条)`)
// 4. 分类: 新增 / 替换 / 未变
// 仅对可识别文件 (.png/.jpg/.jpeg/.pdf/.zip) 触发 OCR
const toOcr = []
for (const m of saved) {
if (!m || !m.id || !m.ossUrl) continue
if (!isRecognizable(m.ossUrl)) continue
const isZip = isZipUrl(m.ossUrl)
const old = preSaveState.get(m.subType)
if (!old) {
// 新增
toOcr.push({ materialId: m.id, ossUrl: m.ossUrl, isZip, oldMaterialId: null, subType: m.subType })
} else if (old.ossUrl !== m.ossUrl) {
// 替换 (URL 变了 → 后端需先清旧 invoice + amount=0 再 OCR)
toOcr.push({
materialId: m.id, ossUrl: m.ossUrl, isZip,
oldMaterialId: old.id, subType: m.subType
})
}
// 未变: 跳过
}
if (toOcr.length) {
submitOcrForMaterials(toOcr)
ElMessage.info(`已提交 ${toOcr.length} 个识别任务 (后台执行)`)
}
// 5. 1.5s 后重拉 materials, 让 amount 字段更新可见
if (toOcr.length) {
setTimeout(() => loadMaterials(), 1500)
}
} catch (e) {
console.error('[meeting-detail] save failed', e)
// 后端 BizMeetingMaterialServiceImpl 已将 DuplicateKeyException 翻译为友好中文
ElMessage.error(e?.msg || e?.message || '保存失败')
} finally { saving.value = false }
}
/** 判断 OSS URL 是否为图片 (.jpg/.jpeg/.png) */
function isImageUrl(url) {
return /\.(jpe?g|png)$/i.test(url || '')
}
/** 判断是否可识别: 图片或 PDF */
function isRecognizable(url) {
return /\.(jpe?g|png|pdf)$/i.test(url || '')
}
/** 判断是否 zip (走 zip 路径: 解压 + 重传 OSS) */
function isZipUrl(url) {
return /\.zip(\?|$)/i.test(url || '')
}
/**
* 取某 subType 的识别金额 (从 materialsLoaded 查), 用于 file-row 后显示
* ZIP 路径下可能多张发票, material.amount 是 OCR 完成后回写求和值
*/
function materialAmount(subType) {
const m = materialsLoaded.value.find(x => x.subType === subType)
if (!m || m.amount == null) return 0
return Number(m.amount)
}
/**
* 保存成功后, 对所有需要识别的 material 提交 OCR 任务 (并行 fire-and-forget).
* 后端 InvoiceOcrService.submitRecognition:
* - 单文件 (isZip=false): 后台 OCR → 是发票 → update invoice + 回写 material.amount
* 不是发票 → 删占位 invoice 行 + amount=0
* - zip (isZip=true): 下载解压 → 遍历 → 是发票 → 重传 OSS (新 URL) → 写 invoice 行
* 不是发票 → 跳过 (不入库)
* - 替换 (oldMaterialId != null): 先 DELETE invoice WHERE material_id=old + amount=0
*
* 前端仅做"提交", 不等结果; 识别状态由 InvoiceOcrScheduler 兜底
*/
function submitOcrForMaterials(items) {
if (!Array.isArray(items) || !items.length) return
items.forEach(m => {
request.post('/business/meeting/invoice/recognize', {
materialId: m.materialId,
meetingId: Number(meetingId.value),
ossUrl: m.ossUrl,
isZip: m.isZip, // v3 新增
oldMaterialId: m.oldMaterialId // v3 新增 (替换场景)
}, { __silentError: true }).then(r => {
const data = (r && r.data) || {}
if (data.submitted === false) {
ElMessage.warning(`发票识别提交失败 (${m.subType}): ${data.errorMsg || '未知错误'}`)
}
// submitted=true: 静默, 后端在后台跑
}).catch(err => {
console.error('[meeting-detail] invoice ocr submit failed', m, err)
ElMessage.warning(`发票识别异常 (${m.subType}): ${err?.msg || err?.message || ''}`)
})
})
}
// ===================== 提交材料 / 提交凭证 =====================
async function onSubmitMaterial() {
busy.value.submitMaterial = true
try {
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-material`)
ElMessage.success('材料已提交, 待合规审核')
row.value.materialAuditStage = (resp && resp.data) || 'SUBMITTED'
await loadTrail()
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitMaterial = false }
}
async function onSubmitVoucher() {
busy.value.submitVoucher = true
try {
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-voucher`)
ElMessage.success('凭证已提交, 待合规审核')
row.value.voucherAuditStage = (resp && resp.data) || 'SUBMITTED'
await loadTrail()
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitVoucher = false }
}
// ===================== 分配 dialog =====================
const assignDialog = ref({
show: false, role: '', roleLabel: '', title: '',
candidates: [], selectedIds: [], loading: false, saving: false
})
async function openAssignDialog(role) {
assignDialog.value = {
show: true,
role,
roleLabel: role === 'supervisor' ? '监察员' : '执行人员',
title: `分配${role === 'supervisor' ? '监察员' : '执行人员'}`,
candidates: [],
selectedIds: (role === 'supervisor' ? supervisors.value : executors.value).map(u => u.userId),
loading: false,
saving: false
}
assignDialog.value.loading = true
try {
const fn = role === 'supervisor' ? listSupporters : listExecutor
const resp = await fn({ status: '0', accountType: 'MAIN' })
// 兼容不同返回结构
const rows = resp?.rows || resp?.data?.rows || (Array.isArray(resp) ? resp : [])
assignDialog.value.candidates = rows
} catch (e) { ElMessage.error('加载候选人失败') }
finally { assignDialog.value.loading = false }
}
async function confirmAssign() {
const { role, selectedIds } = assignDialog.value
const url = role === 'supervisor'
? `/business/meeting/supervisor/${meetingId.value}`
: `/business/meeting/executor/${meetingId.value}`
assignDialog.value.saving = true
try {
await request.put(url, { userIds: selectedIds })
ElMessage.success('分配成功')
assignDialog.value.show = false
await loadStaff()
} catch (e) { ElMessage.error(e?.msg || e?.message || '分配失败') }
finally { assignDialog.value.saving = false }
}
// ===================== 审核 dialog =====================
const auditDialog = ref({
show: false, action: '', auditType: 'MATERIAL', auditTypeLocked: false,
opinion: '', approved: true, saving: false
})
function openAuditDialog(action, auditType) {
auditDialog.value = {
show: true,
action,
auditType,
auditTypeLocked: !!auditType,
opinion: '',
approved: true,
saving: false
}
}
async function confirmAudit() {
const { action, auditType, opinion, approved } = auditDialog.value
const url = action === 'COMPLIANCE'
? `/business/meeting/${meetingId.value}/audit-compliance`
: `/business/meeting/${meetingId.value}/audit-supervision`
auditDialog.value.saving = true
try {
const resp = await request.post(url, { auditType, approved, opinion })
const newStage = (resp && resp.data) || ''
if (auditType === 'MATERIAL') row.value.materialAuditStage = newStage
else row.value.voucherAuditStage = newStage
ElMessage.success(approved ? '审核通过' : '已拒绝')
auditDialog.value.show = false
await loadTrail()
} catch (e) { ElMessage.error(e?.msg || e?.message || '审核失败') }
finally { auditDialog.value.saving = false }
}
// ===================== 占位 =====================
function onDownloadTemplate() { ElMessage.info('模板下载 - 接口待对接') }
function onPackUpload() { ElMessage.info('打包上传 - 接口待对接') }
// ===================== 导航 =====================
function goBack() { router.push('/manager/meetings') }
// ===================== 初始化 =====================
onMounted(load)
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; width: 100%; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.breadcrumb .current { color: #262626; font-weight: 500; }
.page-title { font-size: 22px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; }
.card { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 20px 24px; margin-bottom: 16px; }
.section-title { font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
.text-muted { color: #8c8c8c; font-size: 13px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 32px; }
.info-row { display: flex; align-items: center; gap: 8px; }
.info-row-full { grid-column: 1 / -1; }
.info-label { flex: 0 0 110px; font-size: 14px; color: #595959; text-align: right; white-space: nowrap; padding-right: 16px; }
.info-value { font-size: 14px; color: #1a1a1a; }
.info-value.code { font-family: ui-monospace, "Courier New", monospace; color: var(--brand-primary); }
.tag-list { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cols-row { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; align-items: flex-start; }
.left-col { display: flex; flex-direction: column; gap: 16px; }
.material-tabs :deep(.el-tabs__header) { margin-bottom: 12px; }
.material-tabs :deep(.el-tabs__item) { font-size: 14px; }
.file-list { display: flex; flex-direction: column; gap: 12px; }
.file-row { display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px; align-items: center; padding: 4px 0; }
.file-label { font-size: 14px; color: #595959; text-align: right; line-height: 28px; }
.file-uploader { width: 100%; min-width: 0; }
.file-hint { grid-column: 2; font-size: 11px; color: #8c8c8c; line-height: 1.5; }
.invoice-amount { font-size: 13px; font-weight: 600; color: #f56c6c; padding-left: 8px; white-space: nowrap; }
.file-link { color: var(--brand-primary); text-decoration: none; font-size: 14px; }
.file-link:hover { text-decoration: underline; }
.file-link.inline-link { font-size: 13px; }
.tab-actions { margin-top: 16px; padding-top: 16px; border-top: 1px solid #f0f0f0; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
.timeline { position: relative; padding-left: 24px; border-left: 2px solid #e8e8e8; }
.timeline-item { padding: 6px 0 12px 16px; position: relative; }
.timeline-item::before { content: ''; position: absolute; left: -29px; top: 10px; width: 10px; height: 10px; border-radius: 50%; background: var(--brand-primary); }
.timeline-item.done::before { background: #67c23a; }
.timeline-item.pending::before { background: #c0c4cc; }
.timeline-item.rejected::before { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2); }
.timeline-title { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; line-height: 1.4; }
.timeline-desc { font-size: 12px; color: #8c8c8c; line-height: 1.6; }
.timeline-desc.pending-text { color: #c0c4cc; font-style: italic; }
.timeline-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: #595959; line-height: 1.6; }
.timeline-meta .dot { color: #c0c4cc; }
.timeline-opinion { margin-top: 4px; font-size: 12px; color: #f56c6c; background: #fef0f0; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
.timeline-item.done .timeline-opinion { color: #909399; background: #f5f7fa; }
.audit-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.audit-column { margin-bottom: 0; padding: 16px 18px; }
.audit-column .section-title { font-size: 14px; margin-bottom: 12px; padding-left: 8px; }
.form-actions { display: flex; justify-content: flex-start; padding: 24px 0 0; }
</style>
+16 -51
View File
@@ -4,14 +4,14 @@
<!-- ========== 筛选区 ( People.vue 风格一致) ========== --> <!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form"> <el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="" clearable style="width:140px" /></el-form-item> <el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="" clearable style="width:140px" /></el-form-item> <el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="请输入会议ID" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="" clearable style="width:160px" /></el-form-item> <el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
<el-form-item label="第?期"><el-input-number v-model="q.periodNo" :min="0" :precision="0" style="width:140px" /></el-form-item> <el-form-item label="期数(第几期)"><el-input v-model="q.periodNo" placeholder="请输入期数" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议时间"> <el-form-item label="会议时间">
<el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:170px" /> <el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width:170px" />
<span class="date-sep"></span> <span class="date-sep"></span>
<el-date-picker v-model="q.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:170px" /> <el-date-picker v-model="q.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束时间" style="width:170px" />
</el-form-item> </el-form-item>
<el-form-item label="项目形式"> <el-form-item label="项目形式">
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px"> <el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
@@ -29,7 +29,7 @@
<el-option label="已完结" value="已完结" /> <el-option label="已完结" value="已完结" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="备注"><el-input v-model="q.remark" placeholder="" clearable style="width:140px" /></el-form-item> <el-form-item label="备注"><el-input v-model="q.remark" placeholder="请输入备注" clearable style="width:140px" /></el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="load">查找</el-button> <el-button type="primary" @click="load">查找</el-button>
<el-button @click="reset">重置</el-button> <el-button @click="reset">重置</el-button>
@@ -57,7 +57,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="totalPeriods" label="总期数" width="80" align="center" /> <el-table-column prop="totalPeriods" label="总期数" width="80" align="center" />
<el-table-column label="期数" width="80" align="center"> <el-table-column label="期数" width="80" align="center">
<template #default="{ row }"> {{ row.periodNo || 0 }} </template> <template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="currentStage" label="当前阶段" width="100" align="center"> <el-table-column prop="currentStage" label="当前阶段" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
@@ -67,7 +67,7 @@
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip /> <el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="240" fixed="right"> <el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button> <el-button link type="primary" @click="viewDetail(row)">查看</el-button>
<el-button link type="primary" @click="onEdit(row)">修改</el-button> <el-button link type="primary" @click="onEdit(row)">修改</el-button>
<el-button link type="primary" @click="onSubmit(row)">提交</el-button> <el-button link type="primary" @click="onSubmit(row)">提交</el-button>
<el-button link type="primary" @click="onCopy(row)">复制</el-button> <el-button link type="primary" @click="onCopy(row)">复制</el-button>
@@ -86,24 +86,8 @@
/> />
</div> </div>
<!-- 会议详情 dialog -->
<el-dialog v-model="detailOpen" title="会议详情" width="640px">
<el-descriptions :column="3" border v-if="currentRow">
<el-descriptions-item label="项目编号">{{ currentRow.projectNo }}</el-descriptions-item>
<el-descriptions-item label="会议ID">{{ currentRow.meetingId }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ currentRow.projectForm }}</el-descriptions-item>
<el-descriptions-item label="会议名称" :span="3">{{ currentRow.meetingName }}</el-descriptions-item>
<el-descriptions-item label="会议开始时间">{{ fmtTime(currentRow.startTime) }}</el-descriptions-item>
<el-descriptions-item label="会议结束时间">{{ fmtTime(currentRow.endTime) }}</el-descriptions-item>
<el-descriptions-item label="提交剩余时间">{{ calcRemain(currentRow) }}</el-descriptions-item>
<el-descriptions-item label="总期数">{{ currentRow.totalPeriods }}</el-descriptions-item>
<el-descriptions-item label="期数"> {{ currentRow.periodNo || 0 }} </el-descriptions-item>
<el-descriptions-item label="当前阶段">{{ currentRow.currentStage }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{ currentRow.remark || '-' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 修改 / 复制: 跳转到 /manager/meetings/new?meetingId=xxx[&mode=copy] ( MeetingNew.vue 独立页处理) --> <!-- 修改 / 复制: 跳转到 /manager/meetings/new?meetingId=xxx[&mode=copy] ( MeetingNew.vue 独立页处理) -->
<!-- 查看: 跳转到 /manager/meetings/detail/:meetingId (MeetingDetail.vue 独立页) -->
</div> </div>
</template> </template>
@@ -148,25 +132,6 @@ function stageClass(s) {
return 'default' return 'default'
} }
// ========== 工具: 提交剩余时间 (deadlineDays - 已过天数, 单位天/小时) ==========
function calcRemain(row) {
if (!row) return ''
if (!row.endTime) return ''
// 已结束的会议: 冻结中/已完结 等阶段, 不算剩余
if (['已完结', '已执行', '已结题'].includes(row.currentStage)) return '-'
const end = new Date(row.endTime).getTime()
const now = Date.now()
if (now > end && row.currentStage !== '冻结中') return '已超时'
// 剩余时间 = 提交截止天数 - (now - end) / day
// 提交截止天数默认 30 天, 实际从 project.submit_deadline_days 读取
const days = Number(row.submitDeadlineDays || 30)
const remainMs = end + days * 86400000 - now
if (remainMs <= 0) return '0小时'
const remDays = Math.floor(remainMs / 86400000)
const remHours = Math.floor((remainMs % 86400000) / 3600000)
return `${remDays}${remHours}小时`
}
// ========== 加载 ========== // ========== 加载 ==========
async function load() { async function load() {
loading.value = true loading.value = true
@@ -187,13 +152,13 @@ function reset() {
load() load()
} }
// ========== 查看 ========== // ========== 路由跳转 (查看/修改/复制都走独立页, 不用 dialog) ==========
const detailOpen = ref(false)
const currentRow = ref(null)
function onView(row) { currentRow.value = row; detailOpen.value = true }
// ========== 修改 / 复制 (跳 MeetingNew.vue 独立页, ?meetingId=xxx&projectId=xxx[&mode=copy]) ==========
const router = useRouter() const router = useRouter()
// 查看: 跳 MeetingDetail.vue 独立页 (按原型 meeting-detail.html)
function viewDetail(row) {
router.push(`/manager/meetings/detail/${row.meetingId}`)
}
// 修改 / 复制: 跳 MeetingNew.vue 独立页, ?meetingId=xxx&projectId=xxx[&mode=copy]
function onEdit(row) { function onEdit(row) {
router.push({ name: 'manager-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '' } }) router.push({ name: 'manager-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '' } })
} }