diff --git a/_self/manager_meeting_detail.md b/_self/manager_meeting_detail.md new file mode 100644 index 0000000..4eabd82 --- /dev/null +++ b/_self/manager_meeting_detail.md @@ -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')` | — | — | +| 邀请函预览/下载 | `` (line 60-70) | (无后端, 直链) | `biz_meeting.invitation_url` | +| 日程预览/下载 | `` (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: ++ selectFields: ... org_name, address, current_stage ... ++ insert 列: address, ++ insert 值: #{address}, ++ update: address = #{address}, +``` + +```diff +# MeetingDetail.vue +- {{ row.remark || '-' }} ++ {{ row.address || '-' }} +``` + +### 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 加 `` 修复 (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` | 加 `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 加 `` + 列表页/筛选同步 | **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) @@ + ++ + + +@@ 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) @@ + org_name, ++ address, + current_stage, + +@@ insert 值 (line 91) @@ + #{orgName}, ++ #{address}, + #{currentStage}, + +@@ update (line 115) @@ + org_name = #{orgName}, ++ address = #{address}, + current_stage = #{currentStage}, +``` + +#### A.1.3 修复 2 — `MeetingDetail.vue` 基本信息加 会议地址, 删 dead 备注 +```diff + + ... +- {{ row.remark || '-' }} ++ {{ row.address || '-' }} + +``` + +#### A.1.4 修复 3 — `MeetingDetail.vue` 邀请函/日程 2 张缩略图 +```diff +
+- +- +- 邀请函 +- ++
++ ++ ++ 邀请函 ++ ++ ++ ++ 邀请函 ++ ++
+
+ 下载邀请函 +
+
+``` + +#### 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})` +- 删除: `` 整块, `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 实测, **不实测就标"漏字段"是误导**。 diff --git a/_self/manager_meetings.md b/_self/manager_meetings.md index c26793d..2f47ea6 100644 --- a/_self/manager_meetings.md +++ b/_self/manager_meetings.md @@ -1,9 +1,10 @@ # manager/meetings — 端到端技术审查 -> **审查时间**: 2026-08-18 -> **触发场景**: 用户访问 `/manager/meetings` (manager 角色) -> **审查范围**: `ry-vue3/src/views/manager/Meetings.vue` (会议管理列表页) + 后端 BizMeeting 全链路 + DB 实测 -> **对比原型**: `proto/html/components/meetings.html` ("我参与的会议" doctor 端原型; 实装 manager 端复用 UI, 是合理的设计选择) +> **审查时间**: 2026-08-21 (v3) +> **触发场景**: 用户访问 `/manager/meetings` (manager 角色), 修复 label/期数/placeholder + 后端 long→int + 查看按钮跳页面 +> **审查范围**: `ry-vue3/src/views/manager/Meetings.vue` (会议管理列表页) + `MeetingDetail.vue` (新) + 后端 BizMeeting 全链路 + DB 实测 +> **对比原型**: `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/router/index.js:69-70` | `name: 'manager-meetings', component: () => import('@/views/manager/Meetings.vue')` ✓ | -| ③ 组件 | `ry-vue3/src/views/manager/Meetings.vue` (343 行) | 列表 + 筛选 + 批量提交 + 查看/修改/提交/复制 | -| ④ 原型 | `proto/html/components/meetings.html` | 7 列表格 + 4 项筛选 (项目编号/会议名称/当前阶段/签署状态) | +| ① 角色菜单 | `ry-vue3/src/layout/AdminLayout.vue:97` | `path: '/manager/meetings', title: '会议管理'` ✓ | +| ② 路由 | `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` (256 行) | 列表 + 筛选 + 批量提交 + 查看/修改/提交/复制 | +| ④ 原型 | `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` `` + `` | `GET /business/meeting/list` (BizMeetingController:24-29) | `biz_meeting` | +| 筛选 + 分页列表 | `Meetings.vue` `` + `` | `GET /business/meeting/list` (`BizMeetingController.java:26-37`) | `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` | -| 复制 (单条) | `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` | ### 1.2 可见性 (三层过滤) | 层 | 来源 | 校验字段 | |---|---|---| -| 前端菜单 | `AdminLayout.vue:99` | 角色 = manager | -| 路由守卫 | `router/index.js:69-70` | `meta.title` (无 role 字段, 由 menu 拦截) | -| 后端 SQL | `BizMeetingMapper.xml:37 selectList` | **❌ 无任何过滤** — 后端全量返回, 不按 unit_type / lead_user_id 隔离 | +| 前端菜单 | `AdminLayout.vue:97` | 角色 = manager | +| 路由守卫 | `router/index.js:65-66` | `meta.title` (无 role 字段, 由 menu 拦截) | +| 后端 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 各有独立 `//meetings` 路由 + 独立菜单项, 路由层就分开了。 +**看不到本页面的人群**: admin / doctor / executor / sponsor 各有独立 `//meetings` 路由 + 独立菜单项, 路由层就分开了。 --- @@ -52,13 +55,13 @@ | 项目编号 | 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 (原型有) | | 会议名称 | 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` | — | ❌ 原型无 (实现增) | | 项目形式 | 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` 列 | (无字段) | — | ❌ 原型无 | -**完整 SQL 块** (`BizMeetingMapper.xml:37-50`): +**完整 SQL 块** (`BizMeetingMapper.xml:37-52`): ```sql 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] [AND period_no =] [AND project_form =] [AND current_stage =] [AND start_time >=] [AND end_time <=] + [AND userId EXISTS attendee] (doctor/expert 走) order by meeting_id desc ``` @@ -84,9 +88,9 @@ order by meeting_id desc |---|---|---|---|---| | 查找 | 触发 `load()` → bizList | `GET /business/meeting/list?...` | `biz_meeting` | ✅ 查找 | | 重置 | 清空 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` | ✅ 原型有 ("批量提交:后端待对接") | -**提示行**: `已选 N 条` — 推到 batch-bar 最右 (`.filter-tip { margin-left: auto }`)。 +**提示行**: `已选 N 条` — 推到 batch-bar 最右。 --- @@ -94,59 +98,59 @@ order by meeting_id desc ### 4.1 数据来源 -见 §2 完整 SQL 块 (从 `BizMeetingMapper.xml:37-50` selectList + `BizMeetingMapper.xml:29-32` selectFields)。 +见 §2 完整 SQL 块。 ### 4.2 列映射 | 列名 | row.* | DB 表.字段 | 原型对照 | |---|---|---|---| -| 多选框 | (selection) | — | ❌ 原型无 (manager 专属) | +| 多选框 | (selection) | — | ✅ 原型有 checkbox 列 | | 项目编号 | `row.projectNo` | `biz_meeting.project_no` | ✅ 项目编号 | -| 会议ID | `row.meetingId` | `biz_meeting.meeting_id` | ✅ 会议ID (原型有) | -| 项目形式 | `row.projectForm` | `biz_meeting.project_form` | ❌ 原型无 (实现增) | +| 会议ID | `row.meetingId` | `biz_meeting.meeting_id` | ✅ 会议ID | +| 项目形式 | `row.projectForm` | `biz_meeting.project_form` | ❌ 原型无 | | 会议名称 | `row.meetingName` | `biz_meeting.meeting_name` | ✅ 会议名称 | -| 会议开始时间 | `row.startTime` (fmtTime) | `biz_meeting.start_time` | ❌ 原型无 | -| 会议结束时间 | `row.endTime` (fmtTime) | `biz_meeting.end_time` | ❌ 原型无 | -| 总期数 | `row.totalPeriods` | `biz_meeting.total_periods` | ❌ 原型无 | -| 期数 | `row.periodNo` ("第 X 期") | `biz_meeting.period_no` | ❌ 原型无 | +| 会议开始时间 | `row.startTime` (fmtTime) | `biz_meeting.start_time` | ✅ 会议开始时间 | +| 会议结束时间 | `row.endTime` (fmtTime) | `biz_meeting.end_time` | ✅ 会议结束时间 | +| 总期数 | `row.totalPeriods` | `biz_meeting.total_periods` | ✅ 总期数 | +| 期数 | `row.periodNo` ("第 X 期" / "-") | `biz_meeting.period_no` | ✅ 期数 | | 当前阶段 | `row.currentStage` (stage-tag) | `biz_meeting.current_stage` | ✅ 当前阶段 | -| 备注 | `row.remark` (永远空, 见 §9 #3) | (无) | ❌ 原型无 | -| 操作 | 查看/修改/提交/复制 | — | ❌ 原型 1 个按钮 (签署劳务), manager 端 4 按钮 | +| 备注 | `row.remark` (永远空, 见 §9 #3) | (无) | ✅ 备注 (原型有) | +| 操作 | 查看/修改/提交/复制 | — | ✅ 原型有 提交/查看/修改/复制 | -**新增列 (原型无, 实现增) ✅ 改进**: 项目形式, 开始/结束时间, 总期数, 期数, 多选框, 4 个操作按钮。 +**新增列 (原型无, 实现增) ✅ 改进**: 无。 **遗漏列 (DB 有, 列表未显示)**: - `org_name` (公司名称) — 列表可加 - `create_time` / `update_time` — 审计可用 -- `labor_signed` (0/1) — 原型有 "签署状态" 列 -- `invitation_url` / `schedule_url` — 原型有, 当前只在详情 dialog +- `labor_signed` (0/1) — 原型无, 实体有 +- `invitation_url` / `schedule_url` — 列表可加链接 --- ## 5. 行内操作按钮 -| 按钮 | 触发函数 | 接口 | 后端动作 | 涉及表.字段 | 原型对照 | -|---|---|---|---|---|---| -| 查看 | `onView(row)` | (无) | 弹 detailOpen dialog | — | ❌ 原型跳转 detail 页 | -| 修改 | `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 字段) | ❌ 原型无 | -| 提交 | `onSubmit(row)` | `PUT /business/meeting` | UPDATE biz_meeting SET current_stage='执行中' WHERE meeting_id=? | `biz_meeting.current_stage` | ❌ 原型无 | -| 复制 | `onCopy(row)` → `submitEdit()` (走 `bizAdd`) | `POST /business/meeting` | INSERT biz_meeting (...); meetingId AUTO_INCREMENT, businessId 兜底雪花 ID | `biz_meeting` (新行) | ❌ 原型无 | +| 按钮 | 触发函数 | 接口 | 后端动作 | 涉及表.字段 | **跳转形式** | 原型对照 | +|---|---|---|---|---|---|---| +| 查看 | `viewDetail(row)` | `GET /business/meeting/{meetingId}` (在 MeetingDetail.vue) | router.push → MeetingDetail.vue 独立页加载详情 | `biz_meeting` 全字段 | **page ✅** | ✅ 查看 | +| 修改 | `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` | **page** (改 DB) | ✅ 提交 | +| 复制 | `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` 表不存在)。 -- **"修改"**: 改 6 字段 (无 `remark`, 详见 §9 #3); start_time > end_time 不校验; meetingName 空不校验。 +- **"修改" / "复制"**: 跳 `MeetingNew.vue` 独立页处理 (`Meetings.vue:106` 注释明确说明), form 含 9 字段, 见 manager_meeting_new.md。 --- ## 6. Java Entity ↔ 数据库表 一致性 -### 6.1 biz_meeting DDL (实测 2026-08-18) +### 6.1 biz_meeting DDL (实测 2026-08-21) ```sql CREATE TABLE `biz_meeting` ( `meeting_id` bigint NOT NULL AUTO_INCREMENT COMMENT '会议ID', - `business_id` varchar(50) NOT NULL COMMENT '业务编号ID', - `project_id` bigint DEFAULT NULL COMMENT '关联项目ID', + `business_id` varchar(50) NOT NULL COMMENT '业务会议ID 5643145673', + `project_id` bigint DEFAULT NULL COMMENT '所属项目ID', `project_no` varchar(50) DEFAULT NULL COMMENT '项目编号', `project_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 '会议开始时间', `end_time` datetime 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_by` varchar(64) 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已签', `create_by` varchar(64) DEFAULT '' COMMENT '创建人', `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `update_by` varchar(64) DEFAULT '' COMMENT '修改人', - `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `update_by` varchar(64) DEFAULT '' COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`meeting_id`), UNIQUE KEY `uk_business_id` (`business_id`), KEY `idx_meeting_project` (`project_id`), KEY `idx_meeting_stage` (`current_stage`) -) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 - COLLATE=utf8mb4_0900_ai_ci COMMENT='会议表'; +) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='会议表'; ``` +**实测数据**: 9 行 (今日) / 7 行 (08-18) / AUTO_INCREMENT=12 (说明中间有删除) + ### 6.2 关联表 -- `biz_project` — 见 `manager_projects.md` §6, 最近 `ALTER TABLE ADD COLUMN create_user_id bigint` -- `biz_person` — PK `person_id`, UNIQUE `uk_person_user_id (user_id)`, 用于 JOIN 取 create_user_name +- `biz_project` — 见 `manager_projects.md` §6, 含 `create_user_id bigint` +- `biz_meeting_attendee` — doctor/expert 角色过滤的中间表 (见 `BizMeetingMapper.xml:49 exists`) ### 6.3 Entity ↔ 表字段对照表 | 实体字段 | 中文列名 (DB COMMENT) | 实体类型 | 表字段 | 表类型 | 一致? | |---|---|---|---|---|---| | meetingId | 会议ID | Long | meeting_id | bigint | ✅ | -| businessId | 业务编号ID | String | business_id | varchar(50) | ✅ | -| projectId | 关联项目ID | Long | project_id | bigint | ✅ | +| businessId | 业务会议ID 5643145673 | String | business_id | varchar(50) | ✅ | +| projectId | 所属项目ID | Long | project_id | bigint | ✅ | | projectNo | 项目编号 | String | project_no | varchar(50) | ✅ | | projectName | 项目名称 | String | project_name | varchar(200) | ✅ | | meetingName | 会议名称 | String | meeting_name | varchar(200) | ✅ | @@ -196,6 +202,7 @@ CREATE TABLE `biz_meeting` ( | startTime | 会议开始时间 | Date | start_time | datetime | ✅ (有 @JsonFormat + @DateTimeFormat) | | endTime | 会议结束时间 | Date | end_time | datetime | ✅ | | orgName | 公司名称(冗余) | String | org_name | varchar(200) | ✅ | +| **address** | **会议地址** | **String** | **address** | **varchar(500)** | ✅ (实体/表都有, 但 selectFields/insert/update/selectList 全部漏列 — 详见 §9 #6) | | currentStage | 当前阶段 | String | current_stage | varchar(20) | ✅ | | supervisionOpinion | 监察意见 | String | supervision_opinion | varchar(500) | ✅ | | supervisionBy | 监察人 | String | supervision_by | varchar(64) | ✅ | @@ -203,15 +210,13 @@ CREATE TABLE `biz_meeting` ( | invitationUrl | 邀请函URL | String | invitation_url | varchar(500) | ✅ | | scheduleUrl | 日程海报URL | String | schedule_url | varchar(500) | ✅ | | 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 | ✅ | -| updateBy | 修改人 | String | update_by | varchar(64) | ✅ | -| updateTime | 修改时间 | Date | update_time | datetime | ✅ | -| — | (无 `remark` 字段) | — | (无 `remark` 列) | — | ❌ **实体缺 remark 字段** (见 §9 #3) | +| updateBy | 更新人 | String | update_by | varchar(64) | ✅ | +| updateTime | 更新时间 | Date | update_time | datetime | ✅ | +| — | (无 `remark` 字段) | — | (无 `remark` 列) | — | ❌ **实体缺 remark** (见 §9 #3) | -**修订记录** (本节实体版本演进): -- 第一轮: 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 +**修订**: v1 漏报 `address` 字段 — 实测 DB 有 `address varchar(500)` 列 + COMMENT '会议地址', 实体也有 `private String address`, 但 mapper 全套 (selectFields / insert / updateByPrimaryKey) 都没碰这个字段。**新发现**: 见 §9 #6。 --- @@ -222,78 +227,82 @@ CREATE TABLE `biz_meeting` ( | biz_meeting | PRIMARY | meeting_id | ✅ ORDER BY + WHERE meeting_id = | | biz_meeting | uk_business_id | business_id (UNIQUE) | ✅ 雪花 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 筛选启用后): -- `idx_project_no` — `LIKE '%xxx%'` 模糊匹配无法走索引, 但当前 7 行可接受 (见 §10 #6) +- `idx_project_no` — `LIKE '%xxx%'` 模糊匹配无法走索引 (见 §10 #6) - `idx_meeting_name` — 同上 - 复合 `(project_id, meeting_id)` — JOIN 用, 当前单 `project_id` 索引够 --- -## 8. 与原型差异 +## 8. 与原型差异 (proto/html/components/meeting-manage.html) ### 8.1 实现新增 (原型没有) — **改进** | 项 | 原型 | 实现 | 评估 | |---|---|---|---| -| 筛选 会议ID / 第?期 / 会议时间 / 项目形式 / 备注 | 4 项 | 8 项 | ✅ manager 端管理需要 | -| 表格多选 + 批量提交 | 无 | ✅ | manager 专属 | -| 操作列 4 按钮 (查看/修改/提交/复制) | 1 按钮 | ✅ | 角色不同 | -| 分页 | 无 | ✅ | 必要 | -| 弹窗 (查看/修改) | 跳转 meeting-detail.html | ✅ 弹窗紧凑 | +| 筛选 "备注" | 4 项 (项目编号/会议名称/项目形式/当前阶段) | 8 项 (多 会议ID/第?期/会议时间/备注) | ⚠️ 备注 filter 死路 (见 §9 #3), 第?期无 placeholder 限制 | +| 弹窗 (查看/修改) | 跳转 detail 页 | ✅ el-dialog 紧凑 | manager 端更高效 | +| 行内 "复制" → 跳独立页 | 原型 `onclick="copyMeeting(this)"` URL 传参 | ✅ 跳 `MeetingNew.vue?mode=copy` | 模式更清晰 | ### 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 文字 / 标签差异 | 项 | 原型 | 实现 | 评估 | |---|---|---|---| | 面包屑 | 首页 > 我参与的会议 | 首页 / 会议管理 | ✅ 角色匹配 | -| 当前阶段 select | 未开始 / 已结束 | 未执行 / 执行中 / 已执行 / 已完结 | ⚠️ 实装更细, 但跟 DB 实际数据不一致 (见 §9 #1) | +| 当前阶段 select | 6 项 (未执行/执行中/待审核/待结算/已完结/冻结中) | 4 项 (未执行/执行中/已执行/已完结) | ⚠️ **缺 待审核/待结算/冻结中 3 项** (但跟 DB COMMENT 一致, 见 §9 #1) | +| 期数筛选项 | "第 [____] 期" 三段式 (label/input/label) | "期数(第几期)" 单 label | ⚠️ 排版略不同 (今日改 label 文案) | | 颜色 | `#1890ff` (浅蓝) | `var(--brand-primary)` (深海军蓝) | ✅ 项目色 | ### 8.4 总结 -**整体方向**: **扩展** — 实现远超原型 (manager 端多 5 列 + 批量提交 + 修改 dialog); 原型"邀请函/日程弹框"和"签署状态"未移植; "红字提示"未加。 +**整体方向**: **对齐** — 实装与原型字段基本一致 (12 列 vs 12 列, 8 项 filter vs 8 项 filter); 差距在 "红字提示" 整段缺失 + 当前阶段 select 选项数不一致。 --- ## 9. 设计问题 -> 集中列设计层面问题, 不重复 §10 (待修复)。已修的归附录 "修复记录"。 - ### 9.1 当前阶段 select 与 DB 实际数据不一致 -DDL 注释列了 7 种 (未执行/执行中/监管通过/待整改/待结算/已结算/已结题), 但 DB 实际 6 种 (0/已结算/已结题/待整改/待监管/监管通过), 前端 select 又是另一套 4 种 (未执行/执行中/已执行/已完结)。**三者全不一致**, filter 启用后用户选"未执行"也筛不到任何数据。 +DDL 注释列了 7 种 (未执行/待监管/监管通过/待整改/待结算/已结算/已结题), 实装 select 4 项 (未执行/执行中/已执行/已完结)。两者有交集但不一致, filter 启用后用户选"未执行"也筛不到任何数据 (DB 实际可能是 待监管/已结算 等)。 ### 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 字段 -前端 3 处使用 (`Meetings.vue:32 q.remark`, `:67 row.remark`, `:125 editForm.remark`), 但 `BizMeeting` 实体无 `remark`, 表也无 `remark` 列。后果: -- filter "备注" 永远无效 (Spring `@ModelAttribute` 绑定到不存在的 setter 报 IllegalArgumentException? 实测 silent fail) +前端 3 处使用 (`Meetings.vue:32 q.remark`, `:67 row.remark`, `:32 filter`), 但 `BizMeeting` 实体无 `remark`, 表也无 `remark` 列。后果: +- filter "备注" 永远无效 - 列表"备注"列永远空 - 详情 dialog"备注"行永远 `-` -- 修改 dialog "备注"输入框写入 → mapper UPDATE 无对应 `` → 静默丢 - -修复路径有 2 条 (见 §10 #3)。 ### 9.4 数据隔离缺失 (manager 端) -`BizMeetingMapper.xml:37 selectList` 无 `@DataScope` 或 `lead_user_id` 过滤。当前测试数据无风险, 生产必修 (见 §10 #1)。 +`BizMeetingMapper.xml:37 selectList` 无 `@DataScope` 或 `lead_user_id` 过滤。当前测试数据无风险, 生产必修。 ### 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) — `` 块无 address +- `updateByPrimaryKey` (line 98-121) — `` 块无 address + +**后果**: 即使前端表单填了 address, 写入 DB 时会被静默丢弃; 即使 DB 已有 address, 列表也读不出来。 --- @@ -302,87 +311,133 @@ DDL 注释列了 7 种 (未执行/执行中/监管通过/待整改/待结算/已 | # | 问题 | 文件 | 修复建议 | 严重度 | |---|---|---|---|---| | 1 | **数据隔离缺失** (selectList 无 lead_user_id 过滤) | `BizMeetingMapper.xml:37` | 加 `and project_id in (select project_id from biz_project where lead_user_id = #{params.leadUserId})`; Controller 注入当前 user_id | **P0** (生产必修) | -| 2 | **`calcRemain` 读 `row.submitDeadlineDays`** (BizMeeting 无此字段) | `Meetings.vue:186` | 改成读 `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 加 ``; 路径 B: 前端 3 处全删 (业务可能不需要) | **P1** | -| 4 | **filter 缺"签署状态"** (原型有, 实体有 `labor_signed`) | `Meetings.vue` filter + table column | 加 `` 2 option + table 加列 | **P2** | -| 5 | **状态回滚无校验** (任何 stage → "执行中") | `Meetings.vue:271-273 onSubmit` + `:302-309 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** | -| 7 | **实体 periodNo/totalPeriods Long vs int** | `BizMeeting.java` | 改 int, MyBatis INSERT 不需 Long 转换 | **P3** | -| 8 | **当前阶段 select 选项跟 DB 实际数据不一致** (见 §9 #1) | `Meetings.vue:25-31` | 改成跟 DB 一致 (已结算/已结题/待整改/待监管/监管通过 等), 或加 DB 不存在的值 (DB 加 ENUM 同步) | **P1** | -| 9 | **filter 8 项挤一行** (屏幕窄乱) | `Meetings.vue:6-37` | 拆 2 行, 或 grid-template-columns: repeat(4, 1fr) | **P2** | -| 10 | **批量提交无事务/无批量 endpoint** | `Meetings.vue:302-309` | 后端加 `PUT /business/meeting/batch?ids=...` + `@Transactional`; 前端 for 循环改单次调用 | **P2** | -| 11 | **详情 dialog 缺字段** (create_time / org_name / supervision_* / labor_signed / invitation_url / schedule_url) | `Meetings.vue:91-103` | el-descriptions 加 5-8 行; URL 字段加 `` 链接 | **P1** | -| 12 | **`totalPeriods` 永远 1** (MeetingNew.vue 没让填) | `MeetingNew.vue` | 加"总期数"字段 (用户填, INSERT 时一起写) | **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 加 `` + DDL `ALTER TABLE biz_meeting ADD COLUMN remark text`; 路径 B: 前端 3 处全删 | **P1** | +| 4 | **filter 缺"签署状态"** (实体有 `labor_signed`) | `Meetings.vue` filter + table column | 加 `` 2 option + table 加列 | **P2** | +| 5 | **状态回滚无校验** (任何 stage → "执行中") | `Meetings.vue:205-212 onSubmit` + `:215-226 onBatchSubmit` | 加 `if (currentStage === '未执行')` 才能提交, 否则 ElMessageBox 提示 | **P2** | +| 6 | ✨ **`address` 字段 mapper 全套遗漏** (DB 有, 实体有, mapper 全套没碰) | `BizMeetingMapper.xml` | selectFields/insert/update 全部加 address 字段 + `` | **P1** | +| 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 | **实体 periodNo/totalPeriods Long vs int** | `BizMeeting.java` | 改 int, MyBatis INSERT 不需 Long 转换 | **P3** | +| 9 | **当前阶段 select 选项跟 DB 实际数据不一致** (见 §9 #1) | `Meetings.vue:25-31` | 改成 7 项 (跟 DB COMMENT 一致: 未执行/待监管/监管通过/待整改/待结算/已结算/已结题) | **P1** | +| 10 | **filter 8 项挤一行** (屏幕窄乱) | `Meetings.vue:6-37` | 拆 2 行, 或 grid-template-columns: repeat(4, 1fr) | **P2** | +| 11 | **批量提交无事务/无批量 endpoint** | `Meetings.vue:215-226` | 后端加 `PUT /business/meeting/batch?ids=...` + `@Transactional`; 前端 for 循环改单次调用 | **P2** | +| 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. 引用清单 ### 前端 -- `ry-vue3/src/views/manager/Meetings.vue:343` — 本审查目标 -- `ry-vue3/src/views/manager/MeetingNew.vue` — 新建会议独立页 (共享 BizMeeting) -- `ry-vue3/src/layout/AdminLayout.vue:99` — manager 菜单项 -- `ry-vue3/src/router/index.js:69-70` — 路由 +- `ry-vue3/src/views/manager/Meetings.vue:256` — 本审查目标 (今日改动: 7/10/60/100/32) +- `ry-vue3/src/views/manager/MeetingNew.vue` — 新建/修改/复制会议独立页 +- `ry-vue3/src/layout/AdminLayout.vue:97` — manager 菜单项 +- `ry-vue3/src/router/index.js:65-66` — 路由 - `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) -- `ry-api/ruoyi-business/.../controller/BizMeetingController.java:54` — 5 endpoints -- `ry-api/ruoyi-business/.../domain/BizMeeting.java:107` — 24 字段, **缺 remark** -- `ry-api/ruoyi-business/.../service/impl/BizMeetingServiceImpl.java:34` — insert 调 SnowflakeId 兜底 businessId +- `ry-api/ruoyi-business/.../controller/BizMeetingController.java:26-75` — 5 endpoints +- `ry-api/ruoyi-business/.../domain/BizMeeting.java:11-136` — 25 字段, **缺 remark**, **address 在 mapper 全套遗漏** +- `ry-api/ruoyi-business/.../service/impl/BizMeetingServiceImpl.java:24-31` — insert 调 SnowflakeId 兜底 businessId - `ry-api/ruoyi-business/.../mapper/BizMeetingMapper.java` — mapper 接口 ### Mapper XML -- `ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml:128` — selectList 7 个 `` 已生效 +- `ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml:131` — selectList 7 个 `` 已生效; **address 全套漏** (见 §9 #6) ### 配置 / 工具 - `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() ### 原型 -- `proto/html/components/meetings.html:546` — "我参与的会议" 原型 (doctor 端, 实装 manager 重用) -- `proto/html/components/meeting-detail.html` — 会议详情原型 (现用 el-dialog 替代) +- `proto/html/manager.html:207` — 链接到 `components/meeting-manage.html` +- `proto/html/components/meeting-manage.html:618` — manager 会议管理原型 (12 列 + 8 filter + 9 红字提示) +- `proto/html/components/meeting-manage-1.html` — 原型变体 (期数 input 拆成 第/数字/期 三段), 暂未用 -### 数据库 (实测 2026-08-18) -- `biz_meeting` — 7 行, 4 索引 (PK + UNIQUE + 2 普通), 23 列 (见 §6.1) -- `biz_project` — 12 行, 含 `create_user_id bigint` (commit ffda9c6) -- `biz_person` — JOIN `user_id → name` +### 数据库 (实测 2026-08-21) +- `biz_meeting` — 9 行 (08-21) / 7 行 (08-18), AUTO_INCREMENT=12, 4 索引 (PK + UNIQUE + 2 普通), 24 列 +- `biz_project` — 12 行, 含 `create_user_id bigint` +- `biz_meeting_attendee` — doctor/expert 角色过滤的中间表 --- ## 附录: 修复记录 (按 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})` +- 删除: `` 整块 (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: `第?期` → `期数(第几期)` + - 控件: `` → `` + - 原因: Element Plus 2.4 无 `value-on-clear` prop, `:min="1"` 仍会让 blur 时 snap 到 1; 改用普通 `` 让空值保持空串, 由 mapper `` 跳过 +- **验证**: 缺值 → 不影响其他筛选; `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 (第五轮续) -- **背景**: 第四轮前后曾误删 `会议ID` / `第?期` filter, 用户澄清两个字段原型中都有. -- **修改**: `Meetings.vue:8` 加回 `` (el-input); `Meetings.vue:10` 第?期从 el-input 改 el-input-number (`:min="0" :precision="0"`); `q` reactive + `reset()` 加回 `meetingId:''`; mapper `` 块本就有, 无需动. -- **验证**: `q.meetingId=14` → 1 行; `q.periodNo=1` → 1 行; 留空 → 不影响其他筛选. +- **背景**: 第四轮前后曾误删 `会议ID` / `第?期` filter, 用户澄清两个字段原型中都有 +- **修改**: `Meetings.vue:8` 加回 ``; `Meetings.vue:10` 第?期 el-input-number; q reactive + reset() 加回 `meetingId` +- **验证**: `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 全部筛选不生效 (`` 空). -- **修改**: `BizMeetingMapper.xml:37-50` 加 7 个 `` (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 能绑字符串. -- **验证**: `LIKE %ZH-2026-6%` → 5 行; `current_stage=已结算` → 1 行; `start_time>=2026-03-01` → 4 行; `period_no=1` → 1 行; 组合 AND 正常. +- **解决**: 全部筛选不生效 (`` 空) +- **修改**: `BizMeetingMapper.xml:37-52` 加 8 个 ``; `BizMeeting.java:30-33` startTime/endTime 加 `@DateTimeFormat` -### A.3 commits `6d4f1e6` + `6774c44` (2026-08-18) — style-alignment + 批量按钮去 plain (第三轮) - -- **解决**: 批量按钮 disabled 顺色; "已选 X 条" 紧贴按钮左边; inline style 残留; batch-bar 属性顺序. -- **修改**: `Meetings.vue:41` 批量按钮去 `plain`; `Meetings.vue:324 .filter-tip { margin-left: auto }`; `` 替代 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 修复 \ No newline at end of file +### A.3-A.1 (略, 见 v1 报告) \ No newline at end of file diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java index d6f9167..22509b7 100644 --- a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java @@ -3,13 +3,15 @@ package com.ruoyi; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 启动程序 - * + * * @author ruoyi */ @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) +@EnableScheduling public class RuoYiApplication { public static void main(String[] args) diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index a2cd81a..5cfef2a 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -33,6 +33,9 @@ ruoyi: inviteTemplate: SMS_492460505 endpoint: dysmsapi.aliyuncs.com regionId: cn-hangzhou + # 发票 OCR (ry-ocr 微服务, PaddleOCR + FastAPI, 默认 http://127.0.0.1:8801) + ocr: + base-url: http://127.0.0.1:8801 # 开发环境配置 server: diff --git a/ry-api/ruoyi-business/pom.xml b/ry-api/ruoyi-business/pom.xml index 4bbce92..7619979 100644 --- a/ry-api/ruoyi-business/pom.xml +++ b/ry-api/ruoyi-business/pom.xml @@ -39,12 +39,41 @@ aliyun-java-sdk-core 4.6.4 + + + com.aliyun.oss + aliyun-sdk-oss + 3.17.4 + com.itextpdf html2pdf 3.0.2 + + + cn.hutool + hutool-http + 5.8.27 + + + cn.hutool + hutool-json + 5.8.27 + + + cn.hutool + hutool-core + 5.8.27 + + + + org.projectlombok + lombok + 1.18.30 + provided + diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrConfig.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrConfig.java new file mode 100644 index 0000000..473869a --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrConfig.java @@ -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 微服务集成配置 + *

+ * 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); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java new file mode 100644 index 0000000..749a2d2 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java @@ -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 后台执行器配置 + *

+ * 16 个固定线程, 用于: + * - 单文件上传后, 后台异步 OCR (前端立即返回 SUBMITTED) + * - ZIP 解压后, 后台逐张识别 + 重传 OSS + * - 兜底调度 InvoiceOcrScheduler 重试 UNRECOGNIZED 超过 5 分钟的记录 + */ +@Configuration +public class OcrExecutorConfig +{ + /** + * 16 线程 FixedThreadPool + *

+ * 线程数选 16: 与阿里云 OSS 默认下载并发限速对齐, 兼顾单台机器 ry-ocr 服务能力 + * (单张发票 OCR 平均 1-3s, 16 线程 ≈ 5-15 张/秒) + */ + @Bean(name = "ocrExecutor", destroyMethod = "shutdown") + public ExecutorService ocrExecutor() + { + return Executors.newFixedThreadPool(16); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAuditLogController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAuditLogController.java new file mode 100644 index 0000000..430ec5a --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAuditLogController.java @@ -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 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)); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java index db873cd..daac11e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java @@ -1,8 +1,15 @@ package com.ruoyi.business.controller; +import java.util.Date; import java.util.List; - -import com.ruoyi.business.service.IBizMeetingAttendeeService; +import com.ruoyi.business.domain.BizMeetingMaterial; +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.web.bind.annotation.*; 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.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.service.IBizMeetingService; +import com.ruoyi.business.service.IBizMeetingAttendeeService; /** * 会议Controller */ @RestController @RequestMapping("/business/meeting") -public class BizMeetingController extends BaseController -{ +public class BizMeetingController extends BaseController { + @Autowired 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") - public TableDataInfo list(BizMeeting bizMeeting) - { + public TableDataInfo list(BizMeeting bizMeeting) { // 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表) String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); if ("doctor".equals(roleType) || "expert".equals(roleType)) { @@ -35,42 +54,225 @@ public class BizMeetingController extends BaseController List list = bizMeetingService.selectList(bizMeeting); return getDataTable(list); } + @GetMapping("/{meetingId}") - public AjaxResult getInfo(@PathVariable("meetingId") Long meetingId) - { + public AjaxResult getInfo(@PathVariable("meetingId") Long meetingId) { return success(bizMeetingService.getById(meetingId)); } - @Autowired - private IBizMeetingAttendeeService attendeeService; @Log(title = "会议", businessType = BusinessType.INSERT) @PostMapping - public AjaxResult add(@RequestBody BizMeeting bizMeeting) - { + public AjaxResult add(@RequestBody BizMeeting bizMeeting) { int rows = bizMeetingService.insert(bizMeeting); - // 同步创建参会人中间表 (可选: 前端传 attendeeUserIds) Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); if (attendeeUserIds != null && attendeeUserIds.length > 0) { attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds); } return toAjax(rows); } + @Log(title = "会议", businessType = BusinessType.UPDATE) @PutMapping - public AjaxResult edit(@RequestBody BizMeeting bizMeeting) - { + public AjaxResult edit(@RequestBody BizMeeting bizMeeting) { int rows = bizMeetingService.updateByPrimaryKey(bizMeeting); - // 同步追加参会人 (不去重, 由前端控制) Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); if (attendeeUserIds != null && attendeeUserIds.length > 0) { attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds); } return toAjax(rows); } + @Log(title = "会议", businessType = BusinessType.DELETE) @DeleteMapping("/{ids}") - public AjaxResult remove(@PathVariable Long[] ids) - { + public AjaxResult remove(@PathVariable Long[] ids) { return toAjax(bizMeetingService.deleteByPrimaryKeys(ids)); } -} + + // =================================================================== + // 审核流程端点 (5 个) + // =================================================================== + + /** + * 执行人员提交材料 + *

    + *
  • 校验 1: 当前用户是该会议执行人员 (强校验)
  • + *
  • 校验 2: material_audit_stage = INIT
  • + *
  • 校验 3: biz_meeting_material 至少 1 条 L_* + 至少 1 条 M_*
  • + *
+ * 通过后 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 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 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 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; } + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingExecutorController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingExecutorController.java new file mode 100644 index 0000000..6965b11 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingExecutorController.java @@ -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 userIds; + public List getUserIds() { return userIds; } + public void setUserIds(List userIds) { this.userIds = userIds; } + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java new file mode 100644 index 0000000..798009b --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java @@ -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) + *

+ * 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; + + /** + * 提交发票识别 (后台执行, 立即返回) + *

+ * 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; } + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java new file mode 100644 index 0000000..7ac0d01 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java @@ -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 + *

+ * 单表设计: 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)); + } + + /** + * 保存 (全删全插) + *

+ * 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 list) { + Long userId = SecurityUtils.getUserId(); + if (list != null) { + for (BizMeetingMaterial m : list) { + if (m.getCreatorId() == null) { + m.setCreatorId(userId); + } + } + } + List saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list); + return success(saved); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingSupervisorController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingSupervisorController.java new file mode 100644 index 0000000..a047cdd --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingSupervisorController.java @@ -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 userIds; + public List getUserIds() { return userIds; } + public void setUserIds(List userIds) { this.userIds = userIds; } + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java index 0c47e19..6d5ee57 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java @@ -220,7 +220,7 @@ public class BizProjectController extends BaseController BigDecimal sum = BigDecimal.ZERO; int cnt = 0; 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()); if (s > 0) { sum = sum.add(BigDecimal.valueOf(s)); cnt++; } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java index cb0d2be..d28acfe 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java @@ -70,6 +70,10 @@ public class BizMeeting extends BaseEntity { /** 监察时间 (与 DB datetime 对齐) */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date supervisionTime; + /** 材料审核阶段 (INIT=待提交, 后续阶段开发中定) */ + private String materialAuditStage; + /** 凭证审核阶段 (INIT=待提交, 后续阶段开发中定) */ + private String voucherAuditStage; /** 邀请函URL */ private String invitationUrl; /** 日程海报URL */ @@ -129,6 +133,10 @@ public class BizMeeting extends BaseEntity { public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; } public String getLaborSigned() { return 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 void setUserId(Long userId) { this.userId = userId; } public Long[] getAttendeeUserIds() { return attendeeUserIds; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAuditLog.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAuditLog.java new file mode 100644 index 0000000..0dd8895 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAuditLog.java @@ -0,0 +1,71 @@ +package com.ruoyi.business.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 会议审核流程日志对象 biz_meeting_audit_log + *

+ * 记录会议审核的每一次流转: 谁、什么时间、什么意见、当前阶段. + * 与 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; } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingExecutor.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingExecutor.java new file mode 100644 index 0000000..adedb95 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingExecutor.java @@ -0,0 +1,45 @@ +package com.ruoyi.business.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 会议-执行人员 关联对象 biz_meeting_executor (1:N) + *

+ * 当前阶段: 仅取 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; } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingInvoice.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingInvoice.java new file mode 100644 index 0000000..2a2ad3c --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingInvoice.java @@ -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 + *

+ * 一张会议材料 (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; } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java new file mode 100644 index 0000000..6c48991 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java @@ -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 (单表) + *

+ * 包含 4 大类 13 子类: + *

    + *
  • material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证
  • + *
  • 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
  • + *
+ *

+ * 注意: 不继承 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; } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingSupervisor.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingSupervisor.java new file mode 100644 index 0000000..fb27357 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingSupervisor.java @@ -0,0 +1,45 @@ +package com.ruoyi.business.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * 会议-监察员 关联对象 biz_meeting_supervisor (1:N) + *

+ * 当前阶段: 仅取 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; } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAuditLogMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAuditLogMapper.java new file mode 100644 index 0000000..e26da78 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAuditLogMapper.java @@ -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 selectList(BizMeetingAuditLog entity); + + /** 插入 (id 走 AUTO_INCREMENT) */ + int insert(BizMeetingAuditLog entity); + + /** 按主键更新 */ + int updateByPrimaryKey(BizMeetingAuditLog entity); + + /** 按主键删除单条 */ + int deleteByPrimaryKey(Long id); + + /** 按主键批量删除 */ + int deleteByPrimaryKeys(Long[] ids); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingExecutorMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingExecutorMapper.java new file mode 100644 index 0000000..a915feb --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingExecutorMapper.java @@ -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 selectByMeetingId(Long meetingId); + + /** 按 userId 查该执行人员被分配到哪些会议 */ + List selectByUserId(Long userId); + + /** 条件查询 */ + List 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 list); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingInvoiceMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingInvoiceMapper.java new file mode 100644 index 0000000..b5502bf --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingInvoiceMapper.java @@ -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 selectList(BizMeetingInvoice query); + + /** 按 material_id 查 (UK 唯一) */ + BizMeetingInvoice selectByMaterialId(Long materialId); + + /** 按 meeting_id 查 (用于展示某个会议下所有发票) */ + List 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 分钟前的行 + *

+ * 用于 InvoiceOcrScheduler 每 60s 扫一次, 处理程序重启/OCR 服务临时挂掉导致的遗漏 + * + * @param minutes 分钟阈值 + */ + List 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); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMaterialMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMaterialMapper.java new file mode 100644 index 0000000..1bb5e25 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMaterialMapper.java @@ -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 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 list); + + /** 单条更新 amount (OCR 识别为发票后回写, 不动其他字段) */ + int updateAmount(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingSupervisorMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingSupervisorMapper.java new file mode 100644 index 0000000..d5997ea --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingSupervisorMapper.java @@ -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 selectByMeetingId(Long meetingId); + + /** 按 userId 查该监察员被分配到哪些会议 */ + List selectByUserId(Long userId); + + /** 条件查询 */ + List 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 list); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceFields.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceFields.java new file mode 100644 index 0000000..aa10147 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceFields.java @@ -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; +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceOcrScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceOcrScheduler.java new file mode 100644 index 0000000..554c896 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceOcrScheduler.java @@ -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 兜底调度器 + *

+ * 每 60 秒扫描一次, 重新识别 5 分钟前插入但仍未识别的 invoice 行 + * (处理: 程序重启导致后台任务丢失 / OCR 服务临时挂掉 / OSS 下载超时 等情况) + *

+ * 需要启动类加 {@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 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); + } + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceResult.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceResult.java new file mode 100644 index 0000000..ea55cfc --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/InvoiceResult.java @@ -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 lines; +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrClient.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrClient.java new file mode 100644 index 0000000..72558f4 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrClient.java @@ -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; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrLine.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrLine.java new file mode 100644 index 0000000..e4c3ccb --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/OcrLine.java @@ -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> box; +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/ZipExtractor.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/ZipExtractor.java new file mode 100644 index 0000000..6ddaba0 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/ocr/ZipExtractor.java @@ -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 文件 + *

+ * 临时目录由调用方识别完成后调用 {@link #cleanup(File)} 清理 + *

+ * 依赖: 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 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 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()); + } + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssConfMeta.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssConfMeta.java new file mode 100644 index 0000000..93ab9dd --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssConfMeta.java @@ -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) + *

+ * 改造点: + *

    + *
  • 去掉了 hwt-serve 的 @Value("${ali.*}") 硬编码配置,改读本项目 {@link RuoYiConfig.OssProperties}
  • + *
  • endpoint 在 application.yml 里带 https:// 前缀 (例: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com), + * 但 {@code OSSClient} 构造时只吃裸 host → {@link #stripScheme(String)} 剥前缀
  • + *
  • 不创建 bucket (hwt-serve 的 getOSSClient() 会自动建,本项目 bucket 已存在,无需建)
  • + *
+ */ +@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; } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssUploader.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssUploader.java new file mode 100644 index 0000000..8f7cfb6 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssUploader.java @@ -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,精简) + *

+ * 用于: ZIP 解压 → OCR 识别为发票 → 重新上传到 OSS,拿新 URL 写入 invoice.oss_url + * (不传 zip 的 oss_url, 因为那是压缩包不是发票本体) + *

+ * 与 hwt-serve 的差异: + *

    + *
  • 只保留 {@code upload} (服务端上传) 一个公开方法;删除 deleteFile / extractKey (本场景不需要)
  • + *
  • key 前缀由调用方通过 {@code subDir} 传入 (例 "meeting/invoice"),不再硬编码 "files/yyyy/MM/dd/"
  • + *
  • Content-Type / Content-Disposition 复用 hwt-serve 的 guessContentType 实现 (含 bmp/jpg/doc/ppt/pdf)
  • + *
+ */ +@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"; + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAuditLogService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAuditLogService.java new file mode 100644 index 0000000..e1d6620 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAuditLogService.java @@ -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 selectList(BizMeetingAuditLog entity); + + int insert(BizMeetingAuditLog entity); + + int updateByPrimaryKey(BizMeetingAuditLog entity); + + int deleteByPrimaryKey(Long id); + + int deleteByPrimaryKeys(Long[] ids); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingExecutorService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingExecutorService.java new file mode 100644 index 0000000..e8876c4 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingExecutorService.java @@ -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 selectByMeetingId(Long meetingId); + + List selectByUserId(Long userId); + + /** + * 替换该会议的执行人员 (全删全插, 一个事务) + * @param assignedBy 分配人 user_id + */ + int replaceByMeetingId(Long meetingId, List userIds, Long assignedBy); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingInvoiceService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingInvoiceService.java new file mode 100644 index 0000000..4000176 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingInvoiceService.java @@ -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 selectByMeetingId(Long meetingId); + + /** + * Upsert: 按 material_id 唯一 (uk_material), 存在则更新金额, 不存在则插入. + * 仅在 OCR 识别为发票时调用, 不是发票不调此方法 (不入库). + */ + int upsertByMaterialId(BizMeetingInvoice record); + + int deleteByMaterialId(Long materialId); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java new file mode 100644 index 0000000..ddf4ed8 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java @@ -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 selectByMeetingId(Long meetingId); + + /** + * 替换该会议的所有材料记录 (一个事务, 全删全插) + *

+ * 用于"保存"按钮: 前端传当前 UI 上传的文件列表, 后端先删后插. + *

    + *
  • list 为空或 null → 仅删除该会议的全部记录, 不插入
  • + *
  • list 非空 → 先 deleteByMeetingId, 再 insertBatch
  • + *
+ * + * @return 插入后带 id 的 list (前端可借此触发 OCR 识别, 拿到 materialId 关联 invoice 表) + */ + List replaceByMeetingId(Long meetingId, List list); + + /** + * 单条更新 amount (OCR 识别为发票后回写). + * 不动其他字段, 不抛异常 (失败仅 log). + */ + int updateAmount(Long materialId, java.math.BigDecimal amount); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingSupervisorService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingSupervisorService.java new file mode 100644 index 0000000..317753e --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingSupervisorService.java @@ -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 selectByMeetingId(Long meetingId); + + List selectByUserId(Long userId); + + /** + * 替换该会议的监察员 (全删全插, 一个事务) + * @param assignedBy 分配人 user_id (前端 / manager 自己) + */ + int replaceByMeetingId(Long meetingId, List userIds, Long assignedBy); +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAuditLogServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAuditLogServiceImpl.java new file mode 100644 index 0000000..987c9a7 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAuditLogServiceImpl.java @@ -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 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); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingExecutorServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingExecutorServiceImpl.java new file mode 100644 index 0000000..1b958f3 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingExecutorServiceImpl.java @@ -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 selectByMeetingId(Long meetingId) { + return bizMeetingExecutorMapper.selectByMeetingId(meetingId); + } + + @Override + public List selectByUserId(Long userId) { + return bizMeetingExecutorMapper.selectByUserId(userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int replaceByMeetingId(Long meetingId, List userIds, Long assignedBy) { + bizMeetingExecutorMapper.deleteByMeetingId(meetingId); + if (userIds == null || userIds.isEmpty()) { + return 0; + } + List 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); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingInvoiceServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingInvoiceServiceImpl.java new file mode 100644 index 0000000..dad6385 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingInvoiceServiceImpl.java @@ -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 selectByMeetingId(Long meetingId) { + return bizMeetingInvoiceMapper.selectByMeetingId(meetingId); + } + + /** + * Upsert: 存在则按 material_id 更新 (amount + url + 时间), 不存在则插入新行. + *

+ * 注意: 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); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java new file mode 100644 index 0000000..59c71ae --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java @@ -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 selectByMeetingId(Long meetingId) { + return bizMeetingMaterialMapper.selectByMeetingId(meetingId); + } + + /** + * 全删全插, 一个事务. list 为空则只删不插. + *

+ * 防御性捕获 DuplicateKeyException: 正常流程 (delete → insert) 不会触发, + * 但并发或前端传重复 (meetingId, materialType, subType) 会触发 UK uk_meeting_type_sub. + * 翻译成友好中文提示, 避免暴露 SQL 堆栈. + *

+ * 返回插入后的 list (各元素 id 字段被 useGeneratedKeys 回填), 前端可借此触发 OCR. + */ + @Override + @Transactional(rollbackFor = Exception.class) + public List replaceByMeetingId(Long meetingId, List 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); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingSupervisorServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingSupervisorServiceImpl.java new file mode 100644 index 0000000..483b45a --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingSupervisorServiceImpl.java @@ -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 selectByMeetingId(Long meetingId) { + return bizMeetingSupervisorMapper.selectByMeetingId(meetingId); + } + + @Override + public List selectByUserId(Long userId) { + return bizMeetingSupervisorMapper.selectByUserId(userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int replaceByMeetingId(Long meetingId, List userIds, Long assignedBy) { + bizMeetingSupervisorMapper.deleteByMeetingId(meetingId); + if (userIds == null || userIds.isEmpty()) { + return 0; + } + List 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); + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java new file mode 100644 index 0000000..3ead7af --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java @@ -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 重写) + *

+ * 设计要点 (v2 → v3 变更): + *

    + *
  • 后台执行: 单文件 OCR 改为 {@code ExecutorService.submit}, 立即返回 SUBMITTED + * (原 v2 是同步阻塞, 大文件/慢网络时拖慢保存接口)
  • + *
  • ZIP 支持: zipUrl → 下载 → 解压 → 遍历 .png/.jpg/.jpeg/.pdf → + * 是发票 → 重新上传到 OSS (key 是新生成的, 不是 zip 的 url) → 写 invoice 行
  • + *
  • is_invoice 列: 不加. 不是发票的图直接不入 invoice 表
  • + *
  • 替换场景: 前端传 {@code oldMaterialId}, 后端先 DELETE invoice WHERE material_id=old + * + material.amount=0, 再走 OCR
  • + *
  • 状态机: UNRECOGNIZED → RECOGNIZED / FAILED, 兜底 scheduler 每 60s 重试 > 5min 未识别行
  • + *
+ */ +@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 保持原值) + *

+ * 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 表不动) + *

+ * material 表只动一次 (末尾的 updateAmount), 即使 0 张发票也照写; 非发票文件不进任何统计 + */ + void recognizeZip(Long materialId, Long meetingId, String zipUrl) throws Exception + { + File tmpRoot = null; + try + { + List 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), 重新识别 + *

+ * 不是发票 → 仅删占位行, 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; } + } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAuditLogMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAuditLogMapper.xml new file mode 100644 index 0000000..1761915 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAuditLogMapper.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + select id, meeting_id, auditor, opinion, current_stage, create_time, audit_time, audit_type, audit_result + from biz_meeting_audit_log + + + + + + + + insert into biz_meeting_audit_log + + meeting_id, + auditor, + opinion, + current_stage, + create_time, + audit_time, + audit_type, + audit_result, + + + #{meetingId}, + #{auditor}, + #{opinion}, + #{currentStage}, + #{createTime}, + #{auditTime}, + #{auditType}, + #{auditResult}, + + + + + update biz_meeting_audit_log + + auditor = #{auditor}, + opinion = #{opinion}, + current_stage = #{currentStage}, + create_time = #{createTime}, + audit_time = #{auditTime}, + audit_type = #{auditType}, + audit_result = #{auditResult}, + + where id = #{id} + + + + delete from biz_meeting_audit_log where id = #{id} + + + + delete from biz_meeting_audit_log where id in + + #{id} + + + + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingExecutorMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingExecutorMapper.xml new file mode 100644 index 0000000..a6fb795 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingExecutorMapper.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + select id, meeting_id, user_id, assigned_by, create_time + from biz_meeting_executor + + + + + + + + + + + + insert into biz_meeting_executor + + meeting_id, + user_id, + assigned_by, + create_time, + + + #{meetingId}, + #{userId}, + #{assignedBy}, + #{createTime}, + + + + + insert into biz_meeting_executor (meeting_id, user_id, assigned_by, create_time) + values + + (#{item.meetingId}, #{item.userId}, #{item.assignedBy}, #{item.createTime}) + + + + + update biz_meeting_executor + + user_id = #{userId}, + assigned_by = #{assignedBy}, + + where id = #{id} + + + + delete from biz_meeting_executor where id = #{id} + + + + delete from biz_meeting_executor where meeting_id = #{meetingId} + + + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingInvoiceMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingInvoiceMapper.xml new file mode 100644 index 0000000..075b186 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingInvoiceMapper.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + insert into biz_meeting_invoice + + meeting_id, + material_id, + oss_url, + invoice_type, + amount, + creator_id, + create_time, + update_time, + recognize_status, + error_msg, + source_filename, + + + #{meetingId}, + #{materialId}, + #{ossUrl}, + #{invoiceType}, + #{amount}, + #{creatorId}, + #{createTime}, + #{updateTime}, + #{recognizeStatus}, + #{errorMsg}, + #{sourceFilename}, + + + + + update biz_meeting_invoice + + meeting_id = #{meetingId}, + material_id = #{materialId}, + oss_url = #{ossUrl}, + invoice_type = #{invoiceType}, + amount = #{amount}, + creator_id = #{creatorId}, + update_time = #{updateTime}, + recognize_status = #{recognizeStatus}, + error_msg = #{errorMsg}, + source_filename = #{sourceFilename}, + + where id = #{id} + + + + delete from biz_meeting_invoice where id = #{id} + + + + delete from biz_meeting_invoice where id in + + #{id} + + + + + delete from biz_meeting_invoice where material_id = #{materialId} + + + + + + + + update biz_meeting_invoice + set recognize_status = #{recognizeStatus}, + error_msg = #{errorMsg}, + update_time = now() + where material_id = #{materialId} + and recognize_status = 'UNRECOGNIZED' + + + + + update biz_meeting_invoice + set amount = #{amount}, + update_time = now() + where material_id = #{materialId} + + + + + update biz_meeting_invoice + set recognize_status = #{recognizeStatus}, + amount = #{amount}, + error_msg = #{errorMsg}, + update_time = now() + where id = #{id} + + + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml index 01babfb..4e54806 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml @@ -14,10 +14,13 @@ + + + @@ -27,7 +30,7 @@ - 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 + + where id = #{id} + + + + + + insert into biz_meeting_material + + meeting_id, + material_type, + sub_type, + file_name, + oss_url, + amount, + creator_id, + create_time, + + + #{meetingId}, + #{materialType}, + #{subType}, + #{fileName}, + #{ossUrl}, + #{amount}, + #{creatorId}, + #{createTime}, + + + + + insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time) + values + + (#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl}, + #{item.amount}, #{item.creatorId}, #{item.createTime}) + + + + + update biz_meeting_material + + material_type = #{materialType}, + sub_type = #{subType}, + file_name = #{fileName}, + oss_url = #{ossUrl}, + amount = #{amount}, + + where id = #{id} + + + + update biz_meeting_material set amount = #{amount} where id = #{id} + + + + delete from biz_meeting_material where id = #{id} + + + + delete from biz_meeting_material where meeting_id = #{meetingId} + + + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingSupervisorMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingSupervisorMapper.xml new file mode 100644 index 0000000..6fef11f --- /dev/null +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingSupervisorMapper.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + select id, meeting_id, user_id, assigned_by, create_time + from biz_meeting_supervisor + + + + + + + + + + + + insert into biz_meeting_supervisor + + meeting_id, + user_id, + assigned_by, + create_time, + + + #{meetingId}, + #{userId}, + #{assignedBy}, + #{createTime}, + + + + + insert into biz_meeting_supervisor (meeting_id, user_id, assigned_by, create_time) + values + + (#{item.meetingId}, #{item.userId}, #{item.assignedBy}, #{item.createTime}) + + + + + update biz_meeting_supervisor + + user_id = #{userId}, + assigned_by = #{assignedBy}, + + where id = #{id} + + + + delete from biz_meeting_supervisor where id = #{id} + + + + delete from biz_meeting_supervisor where meeting_id = #{meetingId} + + + \ No newline at end of file diff --git a/ry-ocr/.dockerignore b/ry-ocr/.dockerignore new file mode 100644 index 0000000..4b1b535 --- /dev/null +++ b/ry-ocr/.dockerignore @@ -0,0 +1,17 @@ +# 不打包进镜像的内容 +__pycache__ +*.py[cod] +.venv +venv +env +.env +*.log +uploads/ +test_files/ +tests/ +.git +.gitignore +*.md +.idea +.vscode +client/ diff --git a/ry-ocr/.env.example b/ry-ocr/.env.example new file mode 100644 index 0000000..5771bee --- /dev/null +++ b/ry-ocr/.env.example @@ -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 diff --git a/ry-ocr/.gitignore b/ry-ocr/.gitignore new file mode 100644 index 0000000..d6d15c6 --- /dev/null +++ b/ry-ocr/.gitignore @@ -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 diff --git a/ry-ocr/Dockerfile b/ry-ocr/Dockerfile new file mode 100644 index 0000000..4f69fb0 --- /dev/null +++ b/ry-ocr/Dockerfile @@ -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"] diff --git a/ry-ocr/README.md b/ry-ocr/README.md new file mode 100644 index 0000000..6711fa4 --- /dev/null +++ b/ry-ocr/README.md @@ -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 + + cn.hutool + hutool-http + 5.8.27 + +``` + +--- + +## 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 负载均衡 \ No newline at end of file diff --git a/ry-ocr/app/__init__.py b/ry-ocr/app/__init__.py new file mode 100644 index 0000000..7f5d092 --- /dev/null +++ b/ry-ocr/app/__init__.py @@ -0,0 +1,2 @@ +"""ry-ocr: 本地发票识别服务 (PaddleOCR + FastAPI)""" +__version__ = "0.1.0" diff --git a/ry-ocr/app/api/__init__.py b/ry-ocr/app/api/__init__.py new file mode 100644 index 0000000..68a25f5 --- /dev/null +++ b/ry-ocr/app/api/__init__.py @@ -0,0 +1 @@ +"""API 层""" diff --git a/ry-ocr/app/api/routes.py b/ry-ocr/app/api/routes.py new file mode 100644 index 0000000..7833310 --- /dev/null +++ b/ry-ocr/app/api/routes.py @@ -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} \ No newline at end of file diff --git a/ry-ocr/app/config.py b/ry-ocr/app/config.py new file mode 100644 index 0000000..060d543 --- /dev/null +++ b/ry-ocr/app/config.py @@ -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() diff --git a/ry-ocr/app/core/__init__.py b/ry-ocr/app/core/__init__.py new file mode 100644 index 0000000..3eff5da --- /dev/null +++ b/ry-ocr/app/core/__init__.py @@ -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"] diff --git a/ry-ocr/app/core/image_processor.py b/ry-ocr/app/core/image_processor.py new file mode 100644 index 0000000..78ac2c6 --- /dev/null +++ b/ry-ocr/app/core/image_processor.py @@ -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 diff --git a/ry-ocr/app/core/ocr_engine.py b/ry-ocr/app/core/ocr_engine.py new file mode 100644 index 0000000..487f87e --- /dev/null +++ b/ry-ocr/app/core/ocr_engine.py @@ -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) \ No newline at end of file diff --git a/ry-ocr/app/core/pdf_processor.py b/ry-ocr/app/core/pdf_processor.py new file mode 100644 index 0000000..95a7d99 --- /dev/null +++ b/ry-ocr/app/core/pdf_processor.py @@ -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 diff --git a/ry-ocr/app/main.py b/ry-ocr/app/main.py new file mode 100644 index 0000000..784c8d7 --- /dev/null +++ b/ry-ocr/app/main.py @@ -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="{time:HH:mm:ss} | {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() diff --git a/ry-ocr/app/models/__init__.py b/ry-ocr/app/models/__init__.py new file mode 100644 index 0000000..f421d30 --- /dev/null +++ b/ry-ocr/app/models/__init__.py @@ -0,0 +1,4 @@ +"""数据模型""" +from .schemas import OCRLine, InvoiceFields, InvoiceResult, HealthResponse, PathRecognizeRequest + +__all__ = ["OCRLine", "InvoiceFields", "InvoiceResult", "HealthResponse", "PathRecognizeRequest"] diff --git a/ry-ocr/app/models/schemas.py b/ry-ocr/app/models/schemas.py new file mode 100644 index 0000000..4628ec0 --- /dev/null +++ b/ry-ocr/app/models/schemas.py @@ -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"]) diff --git a/ry-ocr/app/services/__init__.py b/ry-ocr/app/services/__init__.py new file mode 100644 index 0000000..fcc8978 --- /dev/null +++ b/ry-ocr/app/services/__init__.py @@ -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", +] \ No newline at end of file diff --git a/ry-ocr/app/services/invoice_extractor.py b/ry-ocr/app/services/invoice_extractor.py new file mode 100644 index 0000000..4db46b4 --- /dev/null +++ b/ry-ocr/app/services/invoice_extractor.py @@ -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 \ No newline at end of file diff --git a/ry-ocr/app/services/qr_decoder.py b/ry-ocr/app/services/qr_decoder.py new file mode 100644 index 0000000..e7a97fd --- /dev/null +++ b/ry-ocr/app/services/qr_decoder.py @@ -0,0 +1,138 @@ +"""电子发票二维码识别 + +国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔): + 01,,,,,,, + +例: 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) \ No newline at end of file diff --git a/ry-ocr/app/services/recognize_service.py b/ry-ocr/app/services/recognize_service.py new file mode 100644 index 0000000..60cf2c1 --- /dev/null +++ b/ry-ocr/app/services/recognize_service.py @@ -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 \ No newline at end of file diff --git a/ry-ocr/app/utils/__init__.py b/ry-ocr/app/utils/__init__.py new file mode 100644 index 0000000..b3d3822 --- /dev/null +++ b/ry-ocr/app/utils/__init__.py @@ -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", +] diff --git a/ry-ocr/app/utils/amount_utils.py b/ry-ocr/app/utils/amount_utils.py new file mode 100644 index 0000000..7e1fcfa --- /dev/null +++ b/ry-ocr/app/utils/amount_utils.py @@ -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 diff --git a/ry-ocr/client/InvoiceFields.java b/ry-ocr/client/InvoiceFields.java new file mode 100644 index 0000000..aa10147 --- /dev/null +++ b/ry-ocr/client/InvoiceFields.java @@ -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; +} diff --git a/ry-ocr/client/InvoiceResult.java b/ry-ocr/client/InvoiceResult.java new file mode 100644 index 0000000..ea55cfc --- /dev/null +++ b/ry-ocr/client/InvoiceResult.java @@ -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 lines; +} diff --git a/ry-ocr/client/OcrClient.java b/ry-ocr/client/OcrClient.java new file mode 100644 index 0000000..72558f4 --- /dev/null +++ b/ry-ocr/client/OcrClient.java @@ -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; + } +} diff --git a/ry-ocr/client/OcrLine.java b/ry-ocr/client/OcrLine.java new file mode 100644 index 0000000..e4c3ccb --- /dev/null +++ b/ry-ocr/client/OcrLine.java @@ -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> box; +} diff --git a/ry-ocr/docker-compose.yml b/ry-ocr/docker-compose.yml new file mode 100644 index 0000000..ad42d8a --- /dev/null +++ b/ry-ocr/docker-compose.yml @@ -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: diff --git a/ry-ocr/requirements.txt b/ry-ocr/requirements.txt new file mode 100644 index 0000000..ff492df --- /dev/null +++ b/ry-ocr/requirements.txt @@ -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 diff --git a/ry-ocr/run.py b/ry-ocr/run.py new file mode 100644 index 0000000..f4c3aa7 --- /dev/null +++ b/ry-ocr/run.py @@ -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(), + ) diff --git a/ry-ocr/tests/__init__.py b/ry-ocr/tests/__init__.py new file mode 100644 index 0000000..30ea453 --- /dev/null +++ b/ry-ocr/tests/__init__.py @@ -0,0 +1 @@ +"""tests""" diff --git a/ry-ocr/tests/bench.py b/ry-ocr/tests/bench.py new file mode 100644 index 0000000..387c438 --- /dev/null +++ b/ry-ocr/tests/bench.py @@ -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) \ No newline at end of file diff --git a/ry-ocr/tests/bench_dpi.py b/ry-ocr/tests/bench_dpi.py new file mode 100644 index 0000000..1b0317c --- /dev/null +++ b/ry-ocr/tests/bench_dpi.py @@ -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}") \ No newline at end of file diff --git a/ry-ocr/tests/test_invoice_extractor.py b/ry-ocr/tests/test_invoice_extractor.py new file mode 100644 index 0000000..6071644 --- /dev/null +++ b/ry-ocr/tests/test_invoice_extractor.py @@ -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") diff --git a/ry-vue3/src/components/OssFileUploader.vue b/ry-vue3/src/components/OssFileUploader.vue index 50b68e5..7477baa 100644 --- a/ry-vue3/src/components/OssFileUploader.vue +++ b/ry-vue3/src/components/OssFileUploader.vue @@ -24,6 +24,14 @@ {{ ext.toUpperCase() }} · {{ fileSizeText }} 移除 + +

import { ref, computed } from 'vue' import { ElMessage } from 'element-plus' +import { Document } from '@element-plus/icons-vue' import { uploadToOss } from '@/utils/oss' const props = defineProps({ @@ -48,7 +57,8 @@ const props = defineProps({ accept: { type: String, default: '.pdf,.png,.jpg,.jpeg' }, maxSize: { type: Number, default: 10 }, // MB 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']) @@ -69,6 +79,24 @@ const ext = 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() { if (props.readonly || uploading.value) return fileInput.value?.click() @@ -149,4 +177,30 @@ function onRemove() { .file-name:hover { color: #1890ff; text-decoration: underline; } .file-type { font-size: 12px; color: #909399; } .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; } \ No newline at end of file diff --git a/ry-vue3/src/router/index.js b/ry-vue3/src/router/index.js index c9b9a57..b09a8e2 100644 --- a/ry-vue3/src/router/index.js +++ b/ry-vue3/src/router/index.js @@ -63,6 +63,7 @@ const routes = [ { 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: '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: '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: '新建专家' } }, diff --git a/ry-vue3/src/views/auth/Login.vue b/ry-vue3/src/views/auth/Login.vue index b15b5bf..3b10435 100644 --- a/ry-vue3/src/views/auth/Login.vue +++ b/ry-vue3/src/views/auth/Login.vue @@ -243,7 +243,7 @@ async function onSubmit() { loading.value = true try { 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) { // 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只刷新验证码 loadCaptcha() @@ -255,14 +255,20 @@ async function onSubmit() { /** * 登录后置: 存 token → 调 /getInfo 拿真实 user → 跳角色首页 * 密码登录和短信登录共用 + * 角色单一可信源: sys_user.role_type (由 /getInfo 返回), 不再按用户名推断 */ -async function afterLogin(token, displayName, fallbackRole) { +async function afterLogin(token, displayName) { userStore.setToken(token) - let role = fallbackRole + let role = '' try { const info = await getInfo() const u = info.user || {} - role = u.roleType || fallbackRole + role = u.roleType + if (!role) { + // /getInfo 拿不到 roleType → 用户无业务角色, 不让进系统 + ElMessage.error('账号角色未配置, 请联系管理员') + return router.replace({ name: 'login' }) + } userStore.setUser({ userId: u.userId, userName: u.userName || displayName, @@ -273,20 +279,13 @@ async function afterLogin(token, displayName, fallbackRole) { role }) } catch { - // /getInfo 失败时回退: 只存基本信息 - userStore.setUser({ - userId: null, - userName: displayName, - nickName: displayName, - phonenumber: '', - accountType: 'MAIN', - parentUserId: null, - role: fallbackRole - }) + // /getInfo 失败: 不存残缺 user, 让用户重新登录 + ElMessage.error('获取用户信息失败, 请重新登录') + return router.replace({ name: 'login' }) } 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/守卫带过来的原页面), 否则跳角色首页 const redirect = route.query.redirect if (redirect) { @@ -345,24 +344,13 @@ async function onSmsLoginSubmit() { try { const res = await smsLogin({ phone: smsForm.phone, smsCode: smsForm.smsCode, uuid: smsForm.uuid }) // 短信登录后无 username, 用 phone 作为显示名; fallbackRole 留空, 让 /getInfo 决定 - await afterLogin(res.token, smsForm.phone, '') + await afterLogin(res.token, smsForm.phone) } catch (e) { // 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 = { admin: '/admin/workbench', manager: '/manager/workbench', diff --git a/ry-vue3/src/views/manager/MeetingDetail.vue b/ry-vue3/src/views/manager/MeetingDetail.vue new file mode 100644 index 0000000..1dd917b --- /dev/null +++ b/ry-vue3/src/views/manager/MeetingDetail.vue @@ -0,0 +1,760 @@ + + + + + \ No newline at end of file diff --git a/ry-vue3/src/views/manager/Meetings.vue b/ry-vue3/src/views/manager/Meetings.vue index 5471216..1f83425 100644 --- a/ry-vue3/src/views/manager/Meetings.vue +++ b/ry-vue3/src/views/manager/Meetings.vue @@ -4,14 +4,14 @@ - - - - + + + + - + - + @@ -29,7 +29,7 @@ - + 查找 重置 @@ -57,7 +57,7 @@ - +