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:
@@ -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
@@ -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,6 +3,7 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动程序
|
* 启动程序
|
||||||
@@ -10,6 +11,7 @@ import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
|||||||
* @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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
-18
@@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+48
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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; }
|
||||||
|
}
|
||||||
+45
@@ -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; }
|
||||||
|
}
|
||||||
+28
@@ -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);
|
||||||
|
}
|
||||||
+37
@@ -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);
|
||||||
|
}
|
||||||
+61
@@ -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);
|
||||||
|
}
|
||||||
+34
@@ -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);
|
||||||
|
}
|
||||||
+37
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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);
|
||||||
|
}
|
||||||
+22
@@ -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);
|
||||||
|
}
|
||||||
+24
@@ -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);
|
||||||
|
}
|
||||||
+35
@@ -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);
|
||||||
|
}
|
||||||
+22
@@ -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);
|
||||||
|
}
|
||||||
+45
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+333
@@ -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 < 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>
|
||||||
+85
@@ -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>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 不打包进镜像的内容
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
env
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
uploads/
|
||||||
|
test_files/
|
||||||
|
tests/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
client/
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ===== OCR 服务配置 =====
|
||||||
|
# 服务端口
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8801
|
||||||
|
|
||||||
|
# 是否使用 GPU(true/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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 系统依赖:libgl/opencv(PyMuPDF 自带 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"]
|
||||||
@@ -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=150;OCR 移动端模型单页约 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 负载均衡
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""ry-ocr: 本地发票识别服务 (PaddleOCR + FastAPI)"""
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""API 层"""
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""数据模型"""
|
||||||
|
from .schemas import OCRLine, InvoiceFields, InvoiceResult, HealthResponse, PathRecognizeRequest
|
||||||
|
|
||||||
|
__all__ = ["OCRLine", "InvoiceFields", "InvoiceResult", "HealthResponse", "PathRecognizeRequest"]
|
||||||
@@ -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"])
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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,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,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:
|
||||||
@@ -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
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""tests"""
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -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")
|
||||||
@@ -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>
|
||||||
@@ -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: '新建专家' } },
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 || '' } })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user