`; `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