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

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

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

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

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

_self/
  - manager_meetings.md / manager_meeting_detail.md: 文档
This commit is contained in:
郭庆泰
2026-08-22 00:23:22 +08:00
parent edfaf4e7f5
commit c3eb8ed9c3
88 changed files with 6710 additions and 240 deletions
@@ -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)
@@ -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:
+29
View File
@@ -39,12 +39,41 @@
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.4</version>
</dependency>
<!-- 阿里云 OSS SDK (服务端上传: zip 解压 → OCR → 重传 invoice 文件, copy 自 hwt-serve ruoyi-common AliOssService) -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<!-- HTML 转 PDF (iText 7 html2pdf, 宽容 HTML 解析, 兼容非 XHTML 的 <img> 等) -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>html2pdf</artifactId>
<version>3.0.2</version>
</dependency>
<!-- ry-ocr Java 调用客户端依赖: hutool-http / hutool-json / hutool-core -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-http</artifactId>
<version>5.8.27</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-json</artifactId>
<version>5.8.27</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>5.8.27</version>
</dependency>
<!-- Lombok (OcrClient 等用了 @Slf4j) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,23 @@
package com.ruoyi.business.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.ruoyi.business.ocr.OcrClient;
/**
* ry-ocr 微服务集成配置
* <p>
* yml 配置: ruoyi.ocr.base-url (默认 http://127.0.0.1:8801)
*/
@Configuration
public class OcrConfig {
@Value("${ruoyi.ocr.base-url:http://127.0.0.1:8801}")
private String ocrBaseUrl;
@Bean
public OcrClient ocrClient() {
return new OcrClient(ocrBaseUrl);
}
}
@@ -0,0 +1,30 @@
package com.ruoyi.business.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* OCR 后台执行器配置
* <p>
* 16 个固定线程, 用于:
* - 单文件上传后, 后台异步 OCR (前端立即返回 SUBMITTED)
* - ZIP 解压后, 后台逐张识别 + 重传 OSS
* - 兜底调度 InvoiceOcrScheduler 重试 UNRECOGNIZED 超过 5 分钟的记录
*/
@Configuration
public class OcrExecutorConfig
{
/**
* 16 线程 FixedThreadPool
* <p>
* 线程数选 16: 与阿里云 OSS 默认下载并发限速对齐, 兼顾单台机器 ry-ocr 服务能力
* (单张发票 OCR 平均 1-3s, 16 线程 ≈ 5-15 张/秒)
*/
@Bean(name = "ocrExecutor", destroyMethod = "shutdown")
public ExecutorService ocrExecutor()
{
return Executors.newFixedThreadPool(16);
}
}
@@ -0,0 +1,53 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.business.domain.BizMeetingAuditLog;
import com.ruoyi.business.service.IBizMeetingAuditLogService;
/**
* 会议审核流程日志 Controller
*/
@RestController
@RequestMapping("/business/meetingAuditLog")
public class BizMeetingAuditLogController extends BaseController {
@Autowired
private IBizMeetingAuditLogService bizMeetingAuditLogService;
@GetMapping("/list")
public TableDataInfo list(BizMeetingAuditLog bizMeetingAuditLog) {
startPage();
List<BizMeetingAuditLog> list = bizMeetingAuditLogService.selectList(bizMeetingAuditLog);
return getDataTable(list);
}
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id) {
return success(bizMeetingAuditLogService.getById(id));
}
@Log(title = "会议审核日志", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizMeetingAuditLog bizMeetingAuditLog) {
return toAjax(bizMeetingAuditLogService.insert(bizMeetingAuditLog));
}
@Log(title = "会议审核日志", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizMeetingAuditLog bizMeetingAuditLog) {
return toAjax(bizMeetingAuditLogService.updateByPrimaryKey(bizMeetingAuditLog));
}
@Log(title = "会议审核日志", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) {
return toAjax(bizMeetingAuditLogService.deleteByPrimaryKeys(ids));
}
}
@@ -1,8 +1,15 @@
package com.ruoyi.business.controller;
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<BizMeeting> 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 个)
// ===================================================================
/**
* 执行人员提交材料
* <ul>
* <li>校验 1: 当前用户是该会议执行人员 (强校验)</li>
* <li>校验 2: material_audit_stage = INIT</li>
* <li>校验 3: biz_meeting_material 至少 1 条 L_* + 至少 1 条 M_*</li>
* </ul>
* 通过后 material_audit_stage INIT → SUBMITTED, 记 audit_log.
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/submit-material")
public AjaxResult submitMaterial(@PathVariable("meetingId") Long meetingId) {
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId();
boolean isExec = bizMeetingExecutorService.selectByMeetingId(meetingId).stream()
.anyMatch(e -> userId.equals(e.getUserId()));
if (!isExec) throw new ServiceException("您不是该会议执行人员, 无法提交材料");
if (!"INIT".equals(m.getMaterialAuditStage())) {
throw new ServiceException("当前阶段 (" + m.getMaterialAuditStage() + ") 不允许提交材料");
}
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_"));
boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_"));
if (!hasLabor || !hasService) {
throw new ServiceException("请同时上传劳务材料和会务材料");
}
m.setMaterialAuditStage("SUBMITTED");
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, "MATERIAL", "SUBMITTED", "APPROVED", "执行人员提交材料");
return success("SUBMITTED");
}
/**
* 执行人员提交凭证 (校验 LV_PAYMENT + SV_PAYMENT)
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/submit-voucher")
public AjaxResult submitVoucher(@PathVariable("meetingId") Long meetingId) {
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId();
boolean isExec = bizMeetingExecutorService.selectByMeetingId(meetingId).stream()
.anyMatch(e -> userId.equals(e.getUserId()));
if (!isExec) throw new ServiceException("您不是该会议执行人员, 无法提交凭证");
if (!"INIT".equals(m.getVoucherAuditStage())) {
throw new ServiceException("当前阶段 (" + m.getVoucherAuditStage() + ") 不允许提交凭证");
}
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLv = mats.stream().anyMatch(x -> "LV_PAYMENT".equals(x.getSubType()));
boolean hasSv = mats.stream().anyMatch(x -> "SV_PAYMENT".equals(x.getSubType()));
if (!hasLv || !hasSv) {
throw new ServiceException("请同时上传劳务凭证和会务凭证");
}
m.setVoucherAuditStage("SUBMITTED");
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, "VOUCHER", "SUBMITTED", "APPROVED", "执行人员提交凭证");
return success("SUBMITTED");
}
/**
* 合规审核 (role_type=manager)
* body: { "auditType": "MATERIAL"|"VOUCHER", "approved": true|false, "opinion": "..." }
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-compliance")
public AjaxResult auditCompliance(@PathVariable("meetingId") Long meetingId, @RequestBody AuditBody body) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType)) throw new ServiceException("仅合规人员可操作");
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
String auditType = body.getAuditType();
if (!"MATERIAL".equals(auditType) && !"VOUCHER".equals(auditType)) {
throw new ServiceException("auditType 必须是 MATERIAL 或 VOUCHER");
}
String currentStage = "MATERIAL".equals(auditType) ? m.getMaterialAuditStage() : m.getVoucherAuditStage();
if (!"SUBMITTED".equals(currentStage)) {
throw new ServiceException("当前阶段 (" + currentStage + ") 不允许合规审核");
}
if (Boolean.FALSE.equals(body.getApproved()) && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空");
}
String result = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "REJECTED";
String newStage = Boolean.TRUE.equals(body.getApproved()) ? "COMPLIANCE_APPROVED" : "SUBMITTED";
if ("MATERIAL".equals(auditType)) {
m.setMaterialAuditStage(newStage);
} else {
m.setVoucherAuditStage(newStage);
}
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, auditType, newStage, result, body.getOpinion());
return success(newStage);
}
/**
* 监察审核 (强校验: 当前用户必须是该会议监察员)
* body: { "auditType": "MATERIAL"|"VOUCHER", "approved": true|false, "opinion": "..." }
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-supervision")
public AjaxResult auditSupervision(@PathVariable("meetingId") Long meetingId, @RequestBody AuditBody body) {
Long userId = SecurityUtils.getUserId();
boolean isSupervisor = bizMeetingSupervisorService.selectByMeetingId(meetingId).stream()
.anyMatch(s -> userId.equals(s.getUserId()));
if (!isSupervisor) throw new ServiceException("您不是该会议监察员, 无权监察");
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
String auditType = body.getAuditType();
if (!"MATERIAL".equals(auditType) && !"VOUCHER".equals(auditType)) {
throw new ServiceException("auditType 必须是 MATERIAL 或 VOUCHER");
}
String currentStage = "MATERIAL".equals(auditType) ? m.getMaterialAuditStage() : m.getVoucherAuditStage();
if (!"COMPLIANCE_APPROVED".equals(currentStage)) {
throw new ServiceException("当前阶段 (" + currentStage + ") 不允许监察");
}
if (Boolean.FALSE.equals(body.getApproved()) && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空");
}
String result = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "REJECTED";
String newStage = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "SUBMITTED";
if ("MATERIAL".equals(auditType)) {
m.setMaterialAuditStage(newStage);
} else {
m.setVoucherAuditStage(newStage);
}
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, auditType, newStage, result, body.getOpinion());
return success(newStage);
}
/**
* 审核轨迹 (audit_log 列表, 按时间排序)
*/
@GetMapping("/{meetingId}/audit-trail")
public AjaxResult auditTrail(@PathVariable("meetingId") Long meetingId) {
BizMeetingAuditLog q = new BizMeetingAuditLog();
q.setMeetingId(meetingId);
List<BizMeetingAuditLog> list = bizMeetingAuditLogService.selectList(q);
return success(list);
}
/**
* 内部: 写一条 audit_log
*/
private void appendAuditLog(Long meetingId, String auditType, String stage, String result, String opinion) {
BizMeetingAuditLog log = new BizMeetingAuditLog();
log.setMeetingId(meetingId);
log.setAuditor(SecurityUtils.getUsername());
log.setAuditType(auditType);
log.setCurrentStage(stage);
log.setAuditResult(result);
log.setOpinion(opinion);
log.setCreateTime(new Date());
log.setAuditTime(new Date());
bizMeetingAuditLogService.insert(log);
}
/** request body for audit endpoints */
public static class AuditBody {
private String auditType; // MATERIAL / VOUCHER
private Boolean approved; // true=通过 false=拒绝
private String opinion; // 意见
public String getAuditType() { return auditType; }
public void setAuditType(String auditType) { this.auditType = auditType; }
public Boolean getApproved() { return approved; }
public void setApproved(Boolean approved) { this.approved = approved; }
public String getOpinion() { return opinion; }
public void setOpinion(String opinion) { this.opinion = opinion; }
}
}
@@ -0,0 +1,48 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingExecutor;
import com.ruoyi.business.service.IBizMeetingExecutorService;
/**
* 会议-执行人员 Controller
*/
@RestController
@RequestMapping("/business/meeting/executor")
public class BizMeetingExecutorController extends BaseController {
@Autowired
private IBizMeetingExecutorService bizMeetingExecutorService;
/** 查该会议的所有执行人员 */
@GetMapping("/list/{meetingId}")
public AjaxResult list(@PathVariable("meetingId") Long meetingId) {
return success(bizMeetingExecutorService.selectByMeetingId(meetingId));
}
/**
* 分配执行人员 (全删全插)
* body: { "userIds": [1, 2, 3] }
*/
@Log(title = "会议-执行人员", businessType = BusinessType.UPDATE)
@PutMapping("/{meetingId}")
public AjaxResult assign(@PathVariable("meetingId") Long meetingId, @RequestBody AssignBody body) {
Long assignedBy = SecurityUtils.getUserId();
int n = bizMeetingExecutorService.replaceByMeetingId(meetingId, body.getUserIds(), assignedBy);
return success(n);
}
/** request body wrapper */
public static class AssignBody {
private List<Long> userIds;
public List<Long> getUserIds() { return userIds; }
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
}
}
@@ -0,0 +1,71 @@
package com.ruoyi.business.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.service.impl.InvoiceOcrService;
/**
* 会议发票识别 Controller (v3)
* <p>
* v3 变更:
* - 端点改为"提交即返回 SUBMITTED", OCR 在后台 ExecutorService 跑
* - 新增 isZip 字段: true → zip 路径(解压 → 重传 OSS → 写多行 invoice)
* - 新增 oldMaterialId 字段: 替换场景, 后端先清旧 invoice + material.amount=0
* - 不再走旧的同步 recognizeAndSave, 已被 submitRecognition 取代
*/
@RestController
@RequestMapping("/business/meeting/invoice")
public class BizMeetingInvoiceController extends BaseController
{
@Autowired
private InvoiceOcrService invoiceOcrService;
/**
* 提交发票识别 (后台执行, 立即返回)
* <p>
* body: {
* "materialId": 123,
* "meetingId": 456,
* "ossUrl": "https://...",
* "isZip": false, // 新增
* "oldMaterialId": null // 新增, 替换时携带原 materialId
* }
*/
@PostMapping("/recognize")
public AjaxResult recognize(@RequestBody RecognizeBody body)
{
InvoiceOcrService.RecognizeResult r = invoiceOcrService.submitRecognition(
body.getMaterialId(),
body.getMeetingId(),
body.getOssUrl(),
body.isZip(),
body.getOldMaterialId());
return success(r);
}
/** request body wrapper */
public static class RecognizeBody
{
private Long materialId;
private Long meetingId;
private String ossUrl;
/** v3 新增: true=zip 路径(解压遍历), false=单文件路径 */
private boolean isZip;
/** v3 新增: 替换场景携带的旧 materialId, 后端先 DELETE invoice WHERE material_id=old + material.amount=0 */
private Long oldMaterialId;
public Long getMaterialId() { return materialId; }
public void setMaterialId(Long materialId) { this.materialId = materialId; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public boolean isZip() { return isZip; }
public void setZip(boolean zip) { isZip = zip; }
public Long getOldMaterialId() { return oldMaterialId; }
public void setOldMaterialId(Long oldMaterialId) { this.oldMaterialId = oldMaterialId; }
}
}
@@ -0,0 +1,55 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.service.IBizMeetingMaterialService;
/**
* 会议材料 Controller
* <p>
* 单表设计: GET 查 / PUT 全删全插 (替代 4 张分表 + 4 套端点)
*/
@RestController
@RequestMapping("/business/meetingMaterial")
public class BizMeetingMaterialController extends BaseController {
@Autowired
private IBizMeetingMaterialService bizMeetingMaterialService;
/**
* 查该会议的所有材料记录
*/
@GetMapping("/{meetingId}")
public AjaxResult list(@PathVariable("meetingId") Long meetingId) {
return success(bizMeetingMaterialService.selectByMeetingId(meetingId));
}
/**
* 保存 (全删全插)
* <p>
* body 是该会议当前的所有材料记录. 前端按 rows 过滤 url 非空后整体 PUT.
* creator_id 后端兜底, 防止前端伪造.
* 返回值是插入后带 id 的 list (前端用于触发 OCR 识别).
*/
@Log(title = "会议材料", businessType = BusinessType.UPDATE)
@PutMapping("/{meetingId}")
public AjaxResult save(@PathVariable("meetingId") Long meetingId, @RequestBody List<BizMeetingMaterial> list) {
Long userId = SecurityUtils.getUserId();
if (list != null) {
for (BizMeetingMaterial m : list) {
if (m.getCreatorId() == null) {
m.setCreatorId(userId);
}
}
}
List<BizMeetingMaterial> saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list);
return success(saved);
}
}
@@ -0,0 +1,48 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingSupervisor;
import com.ruoyi.business.service.IBizMeetingSupervisorService;
/**
* 会议-监察员 Controller
*/
@RestController
@RequestMapping("/business/meeting/supervisor")
public class BizMeetingSupervisorController extends BaseController {
@Autowired
private IBizMeetingSupervisorService bizMeetingSupervisorService;
/** 查该会议的所有监察员 */
@GetMapping("/list/{meetingId}")
public AjaxResult list(@PathVariable("meetingId") Long meetingId) {
return success(bizMeetingSupervisorService.selectByMeetingId(meetingId));
}
/**
* 分配监察员 (全删全插)
* body: { "userIds": [1, 2, 3] }
*/
@Log(title = "会议-监察员", businessType = BusinessType.UPDATE)
@PutMapping("/{meetingId}")
public AjaxResult assign(@PathVariable("meetingId") Long meetingId, @RequestBody AssignBody body) {
Long assignedBy = SecurityUtils.getUserId();
int n = bizMeetingSupervisorService.replaceByMeetingId(meetingId, body.getUserIds(), assignedBy);
return success(n);
}
/** request body wrapper */
public static class AssignBody {
private List<Long> userIds;
public List<Long> getUserIds() { return userIds; }
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
}
}
@@ -220,7 +220,7 @@ public class BizProjectController extends BaseController
BigDecimal sum = BigDecimal.ZERO;
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++; }
}
@@ -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; }
@@ -0,0 +1,71 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议审核流程日志对象 biz_meeting_audit_log
* <p>
* 记录会议审核的每一次流转: 谁、什么时间、什么意见、当前阶段.
* 与 biz_meeting.material_audit_stage / voucher_audit_stage 配合, 还原审核轨迹.
*/
public class BizMeetingAuditLog {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** 审核人 (username 或人工填入) */
private String auditor;
/** 审核意见 */
private String opinion;
/** 当前阶段 (INIT / ... 后续开发中定) */
private String currentStage;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** 审核时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date auditTime;
/** 审核类型 (MATERIAL / VOUCHER) */
private String auditType;
/** 审核结果 (APPROVED / REJECTED) */
private String auditResult;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getAuditor() { return auditor; }
public void setAuditor(String auditor) { this.auditor = auditor; }
public String getOpinion() { return opinion; }
public void setOpinion(String opinion) { this.opinion = opinion; }
public String getCurrentStage() { return currentStage; }
public void setCurrentStage(String currentStage) { this.currentStage = currentStage; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public Date getAuditTime() { return auditTime; }
public void setAuditTime(Date auditTime) { this.auditTime = auditTime; }
public String getAuditType() { return auditType; }
public void setAuditType(String auditType) { this.auditType = auditType; }
public String getAuditResult() { return auditResult; }
public void setAuditResult(String auditResult) { this.auditResult = auditResult; }
}
@@ -0,0 +1,45 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议-执行人员 关联对象 biz_meeting_executor (1:N)
* <p>
* 当前阶段: 仅取 executor 主账号 (parent_user_id IS NULL), 后续表结构预留支持子账号.
*/
public class BizMeetingExecutor {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** sys_user.user_id (executor 主账号, 后续支持子账号) */
private Long userId;
/** 分配人 user_id (审计) */
private Long assignedBy;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getAssignedBy() { return assignedBy; }
public void setAssignedBy(Long assignedBy) { this.assignedBy = assignedBy; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -0,0 +1,96 @@
package com.ruoyi.business.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议发票识别对象 biz_meeting_invoice
* <p>
* 一张会议材料 (biz_meeting_material.id) 对应一条发票记录.
* 仅当 OCR 识别为发票时插入, 不是发票则不创建任何行.
* 金额 amount 同时回写到 biz_meeting_material.amount.
*/
public class BizMeetingInvoice {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** 关联材料ID (FK -> biz_meeting_material.id) */
private Long materialId;
/** OSS URL */
private String ossUrl;
/** 发票类型 (MAIN=主发票, SUB=子发票) */
private String invoiceType;
/** 价税合计 (OCR 识别金额, 同时回写到 material) */
private BigDecimal amount;
/** 提交人 user_id */
private Long creatorId;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** 更新时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
/**
* 识别状态:
* UNRECOGNIZED=已插入待 OCR (单文件路径)
* RECOGNIZED=已识别完成 (默认, ZIP 路径直接走识别完成)
* FAILED=识别失败
*/
private String recognizeStatus;
/** 识别失败原因 */
private String errorMsg;
/** 原始文件名 (zip 解压时记录, 单文件路径可空) */
private String sourceFilename;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getMaterialId() { return materialId; }
public void setMaterialId(Long materialId) { this.materialId = materialId; }
public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public String getInvoiceType() { return invoiceType; }
public void setInvoiceType(String invoiceType) { this.invoiceType = invoiceType; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Long getCreatorId() { return creatorId; }
public void setCreatorId(Long creatorId) { this.creatorId = creatorId; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public String getRecognizeStatus() { return recognizeStatus; }
public void setRecognizeStatus(String recognizeStatus) { this.recognizeStatus = recognizeStatus; }
public String getErrorMsg() { return errorMsg; }
public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; }
public String getSourceFilename() { return sourceFilename; }
public void setSourceFilename(String sourceFilename) { this.sourceFilename = sourceFilename; }
}
@@ -0,0 +1,77 @@
package com.ruoyi.business.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议材料对象 biz_meeting_material (单表)
* <p>
* 包含 4 大类 13 子类:
* <ul>
* <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li>
* <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / SV_PAYMENT / LV_PAYMENT</li>
* </ul>
* <p>
* 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段.
* 提交人用 creator_id (user_id), 创建时间 create_time.
*/
public class BizMeetingMaterial {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** 资料类型 (4 种): SERVICE / LABOR / SERVICE_VOUCHER / LABOR_VOUCHER */
private String materialType;
/** 子分类 (13 种): M_MATERIAL / M_HOTEL / ... / SV_PAYMENT / LV_PAYMENT */
private String subType;
/** 文件名称 */
private String fileName;
/** OSS URL */
private String ossUrl;
/** 金额 (发票专用, 其他类型 = 0) */
private BigDecimal amount;
/** 提交人 user_id (后端从 SecurityUtils 自动取) */
private Long creatorId;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getMaterialType() { return materialType; }
public void setMaterialType(String materialType) { this.materialType = materialType; }
public String getSubType() { return subType; }
public void setSubType(String subType) { this.subType = subType; }
public String getFileName() { return fileName; }
public void setFileName(String fileName) { this.fileName = fileName; }
public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Long getCreatorId() { return creatorId; }
public void setCreatorId(Long creatorId) { this.creatorId = creatorId; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -0,0 +1,45 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议-监察员 关联对象 biz_meeting_supervisor (1:N)
* <p>
* 当前阶段: 仅取 sponsor 主账号 (parent_user_id IS NULL), 后续表结构预留支持子账号.
*/
public class BizMeetingSupervisor {
private static final long serialVersionUID = 1L;
/** 记录ID */
private Long id;
/** 会议ID */
private Long meetingId;
/** sys_user.user_id (sponsor 主账号, 后续支持子账号) */
private Long userId;
/** 分配人 user_id (审计) */
private Long assignedBy;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getAssignedBy() { return assignedBy; }
public void setAssignedBy(Long assignedBy) { this.assignedBy = assignedBy; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -0,0 +1,28 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingAuditLog;
/**
* 会议审核流程日志 Mapper 接口
*/
public interface BizMeetingAuditLogMapper {
/** 按主键查 */
BizMeetingAuditLog selectByPrimaryKey(Long id);
/** 条件查询 (meetingId 可选过滤) */
List<BizMeetingAuditLog> selectList(BizMeetingAuditLog entity);
/** 插入 (id 走 AUTO_INCREMENT) */
int insert(BizMeetingAuditLog entity);
/** 按主键更新 */
int updateByPrimaryKey(BizMeetingAuditLog entity);
/** 按主键删除单条 */
int deleteByPrimaryKey(Long id);
/** 按主键批量删除 */
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,37 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingExecutor;
/**
* 会议-执行人员 Mapper 接口
*/
public interface BizMeetingExecutorMapper {
/** 按主键查 */
BizMeetingExecutor selectByPrimaryKey(Long id);
/** 按会议ID查该会议的所有执行人员 */
List<BizMeetingExecutor> selectByMeetingId(Long meetingId);
/** 按 userId 查该执行人员被分配到哪些会议 */
List<BizMeetingExecutor> selectByUserId(Long userId);
/** 条件查询 */
List<BizMeetingExecutor> selectList(BizMeetingExecutor entity);
/** 插入 (id AUTO_INCREMENT) */
int insert(BizMeetingExecutor entity);
/** 按主键更新 */
int updateByPrimaryKey(BizMeetingExecutor entity);
/** 按主键删除 */
int deleteByPrimaryKey(Long id);
/** 按会议ID全删 (分配时全删全插) */
int deleteByMeetingId(Long meetingId);
/** 批量插入 */
int insertBatch(List<BizMeetingExecutor> list);
}
@@ -0,0 +1,61 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingInvoice;
/**
* 会议发票 Mapper (单表 biz_meeting_invoice)
*/
public interface BizMeetingInvoiceMapper {
BizMeetingInvoice selectByPrimaryKey(Long id);
List<BizMeetingInvoice> selectList(BizMeetingInvoice query);
/** 按 material_id 查 (UK 唯一) */
BizMeetingInvoice selectByMaterialId(Long materialId);
/** 按 meeting_id 查 (用于展示某个会议下所有发票) */
List<BizMeetingInvoice> selectByMeetingId(Long meetingId);
int insert(BizMeetingInvoice record);
int updateByPrimaryKey(BizMeetingInvoice record);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
/** 按 material_id 删除 (用于重传时清掉旧记录) */
int deleteByMaterialId(Long materialId);
/**
* 兜底扫描: 查询 UNRECOGNIZED 状态且 create_time 早于 N 分钟前的行
* <p>
* 用于 InvoiceOcrScheduler 每 60s 扫一次, 处理程序重启/OCR 服务临时挂掉导致的遗漏
*
* @param minutes 分钟阈值
*/
List<BizMeetingInvoice> selectUnrecognizedOlderThanMinutes(int minutes);
/**
* 单文件后台 OCR 完成: 按 material_id 更新状态 (前提是 UNRECOGNIZED → RECOGNIZED/FAILED)
*/
int updateStatusByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
@org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus,
@org.apache.ibatis.annotations.Param("errorMsg") String errorMsg);
/**
* 单文件后台 OCR 完成: 按 material_id 更新金额 (与状态更新分开调用)
*/
int updateAmountByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount);
/**
* 兜底 OCR: 按主键更新 (状态 + 金额 + 错误信息)
*/
int updateStatusAndAmountByPrimaryKey(@org.apache.ibatis.annotations.Param("id") Long id,
@org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
@org.apache.ibatis.annotations.Param("errorMsg") String errorMsg);
}
@@ -0,0 +1,34 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingMaterial;
/**
* 会议材料 Mapper 接口 (单表 biz_meeting_material)
*/
public interface BizMeetingMaterialMapper {
/** 按主键查 */
BizMeetingMaterial selectByPrimaryKey(Long id);
/** 按会议ID查该会议所有材料记录 */
List<BizMeetingMaterial> selectByMeetingId(Long meetingId);
/** 插入 (id 走 DB AUTO_INCREMENT, 不接受前端传入的 id) */
int insert(BizMeetingMaterial entity);
/** 按主键更新 (一般不用, 全删全插代替) */
int updateByPrimaryKey(BizMeetingMaterial entity);
/** 按主键删除单条 */
int deleteByPrimaryKey(Long id);
/** 按会议ID删除该会议所有材料记录 (save 时先全删) */
int deleteByMeetingId(Long meetingId);
/** 批量插入 (单会议替换 save 专用) */
int insertBatch(List<BizMeetingMaterial> list);
/** 单条更新 amount (OCR 识别为发票后回写, 不动其他字段) */
int updateAmount(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount);
}
@@ -0,0 +1,37 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingSupervisor;
/**
* 会议-监察员 Mapper 接口
*/
public interface BizMeetingSupervisorMapper {
/** 按主键查 */
BizMeetingSupervisor selectByPrimaryKey(Long id);
/** 按会议ID查该会议的所有监察员 */
List<BizMeetingSupervisor> selectByMeetingId(Long meetingId);
/** 按 userId 查该监察员被分配到哪些会议 */
List<BizMeetingSupervisor> selectByUserId(Long userId);
/** 条件查询 */
List<BizMeetingSupervisor> selectList(BizMeetingSupervisor entity);
/** 插入 (id AUTO_INCREMENT) */
int insert(BizMeetingSupervisor entity);
/** 按主键更新 */
int updateByPrimaryKey(BizMeetingSupervisor entity);
/** 按主键删除 */
int deleteByPrimaryKey(Long id);
/** 按会议ID全删 (分配时全删全插) */
int deleteByMeetingId(Long meetingId);
/** 批量插入 */
int insertBatch(List<BizMeetingSupervisor> list);
}
@@ -0,0 +1,37 @@
package com.ruoyi.business.ocr;
import lombok.Data;
/** 结构化发票字段 */
@Data
public class InvoiceFields {
/** 发票类型:增值税电子普通发票 / 增值税专用发票 / ... */
private String invoiceType;
/** 发票号码 */
private String invoiceNo;
/** 发票代码 */
private String invoiceCode;
/** 开票日期 YYYY-MM-DD */
private String invoiceDate;
/** 价税合计(小写) */
private Double amount;
/** 价税合计(大写中文) */
private String amountCn;
/** 不含税金额 */
private Double amountPretax;
/** 税额 */
private Double taxAmount;
/** 销售方名称 */
private String sellerName;
/** 销售方纳税人识别号 */
private String sellerTaxNo;
/** 购买方名称 */
private String buyerName;
/** 购买方纳税人识别号 */
private String buyerTaxNo;
/** 大写金额 vs 小写金额是否一致(null=未能比对) */
private Boolean amountMatch;
}
@@ -0,0 +1,67 @@
package com.ruoyi.business.ocr;
import com.ruoyi.business.domain.BizMeetingInvoice;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.service.impl.InvoiceOcrService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.ExecutorService;
/**
* OCR 兜底调度器
* <p>
* 每 60 秒扫描一次, 重新识别 5 分钟前插入但仍未识别的 invoice 行
* (处理: 程序重启导致后台任务丢失 / OCR 服务临时挂掉 / OSS 下载超时 等情况)
* <p>
* 需要启动类加 {@code @EnableScheduling} 才会生效 (RuoyiApplication 已有)
*/
@Slf4j
@Component
public class InvoiceOcrScheduler
{
/** 阈值: UNRECOGNIZED 行超过这个分钟数才重试 (避免抢正在跑的 OCR 任务) */
private static final int STALE_MINUTES = 5;
@Autowired
private BizMeetingInvoiceMapper invoiceMapper;
@Autowired
private InvoiceOcrService ocrService;
@Autowired
@Qualifier("ocrExecutor")
private ExecutorService ocrExecutor;
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void scanStaleUnrecognized()
{
try
{
List<BizMeetingInvoice> stale = invoiceMapper.selectUnrecognizedOlderThanMinutes(STALE_MINUTES);
if (stale == null || stale.isEmpty()) return;
log.info("兜底扫描: {} 条 UNRECOGNIZED 超过 {} 分钟, 重新提交 OCR", stale.size(), STALE_MINUTES);
for (BizMeetingInvoice inv : stale)
{
ocrExecutor.submit(() ->
{
try
{
ocrService.recognizeOneInvoice(inv);
}
catch (Exception e)
{
log.warn("兜底 OCR 失败 id={} url={}", inv.getId(), inv.getOssUrl(), e);
}
});
}
}
catch (Exception e)
{
log.warn("兜底扫描异常 (本次跳过, 下分钟再试)", e);
}
}
}
@@ -0,0 +1,18 @@
package com.ruoyi.business.ocr;
import lombok.Data;
import java.util.List;
/** OCR 识别结果(与 ry-ocr 的 InvoiceResult JSON 对应) */
@Data
public class InvoiceResult {
private Boolean success;
private String rawText;
private String engine;
private Integer pageCount;
private Integer elapsedMs;
private String error;
private InvoiceFields fields;
private List<OcrLine> lines;
}
@@ -0,0 +1,120 @@
package com.ruoyi.business.ocr;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* ry-ocr Java 调用客户端
*
* 依赖:hutool-http, hutool-json, hutool-core, lombok
*
* 用法:
* OcrClient client = new OcrClient("http://127.0.0.1:8801");
* InvoiceResult r = client.recognize(new File("d:/发票.pdf"));
* InvoiceResult r2 = client.recognizeByUrl("https://oss.example.com/xxx.png");
* System.out.println(r.getFields().getAmount());
*/
@Slf4j
public class OcrClient {
private final String baseUrl;
public OcrClient(String baseUrl) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
/** 健康检查 */
public boolean ping() {
try (HttpResponse resp = HttpRequest.get(baseUrl + "/health").timeout(3000).execute()) {
return resp.getStatus() == 200 && "ok".equals(JSONUtil.parseObj(resp.body()).getStr("status"));
} catch (Exception e) {
log.warn("ocr ping failed: {}", e.getMessage());
return false;
}
}
/** 识别发票(图片或 PDF) */
public InvoiceResult recognize(File file) {
try (HttpResponse resp = HttpRequest.post(baseUrl + "/recognize/invoice")
.form("file", file)
.timeout(60_000)
.execute()) {
String body = resp.body();
JSONObject json = JSONUtil.parseObj(body);
if (resp.getStatus() != 200) {
throw new RuntimeException("OCR 调用失败: " + resp.getStatus() + " " + body);
}
return parse(json);
}
}
/**
* 从 URL 识别发票: 后端下载 OSS URL 到临时文件 → recognize → 清理临时文件.
* 临时文件目录: System.getProperty("java.io.tmpdir")/ry-ocr/
*
* @param url OSS 可访问 URL
* @return 识别结果
*/
public InvoiceResult recognizeByUrl(String url) {
if (url == null || url.isEmpty()) {
throw new IllegalArgumentException("ossUrl 不能为空");
}
File tmpDir = new File(System.getProperty("java.io.tmpdir"), "ry-ocr");
if (!tmpDir.exists() && !tmpDir.mkdirs()) {
throw new RuntimeException("无法创建临时目录: " + tmpDir.getAbsolutePath());
}
// 从 URL 截取文件名, 保留后缀 (用于 ry-ocr 推断图片/PDF)
String name = url.substring(url.lastIndexOf('/') + 1);
if (name.indexOf('?') >= 0) name = name.substring(0, name.indexOf('?'));
if (name.indexOf('.') < 0) name = name + ".png";
File tmp = new File(tmpDir, System.currentTimeMillis() + "_" + name);
try {
long size = HttpUtil.downloadFile(url, tmp);
if (size <= 0) {
throw new RuntimeException("OSS 文件下载失败或为空: " + url);
}
log.info("OCR 下载: url={} size={}B tmp={}", url, size, tmp.getAbsolutePath());
return recognize(tmp);
} finally {
FileUtil.del(tmp);
}
}
private InvoiceResult parse(JSONObject json) {
InvoiceResult r = new InvoiceResult();
r.setSuccess(json.getBool("success", false));
r.setRawText(json.getStr("rawText", ""));
r.setEngine(json.getStr("engine", ""));
r.setPageCount(json.getInt("pageCount", 1));
r.setElapsedMs(json.getInt("elapsedMs", 0));
r.setError(json.getStr("error"));
JSONObject f = json.getJSONObject("fields");
if (f != null) {
InvoiceFields fields = new InvoiceFields();
fields.setInvoiceType(f.getStr("invoiceType"));
fields.setInvoiceNo(f.getStr("invoiceNo"));
fields.setInvoiceCode(f.getStr("invoiceCode"));
fields.setInvoiceDate(f.getStr("invoiceDate"));
fields.setAmount(f.getDouble("amount"));
fields.setAmountCn(f.getStr("amountCn"));
fields.setAmountPretax(f.getDouble("amount_pretax"));
fields.setTaxAmount(f.getDouble("taxAmount"));
fields.setSellerName(f.getStr("sellerName"));
fields.setSellerTaxNo(f.getStr("sellerTaxNo"));
fields.setBuyerName(f.getStr("buyerName"));
fields.setBuyerTaxNo(f.getStr("buyerTaxNo"));
fields.setAmountMatch(f.getBool("amountMatch"));
r.setFields(fields);
}
return r;
}
}
@@ -0,0 +1,13 @@
package com.ruoyi.business.ocr;
import lombok.Data;
import java.util.List;
/** 单行 OCR 识别结果 */
@Data
public class OcrLine {
private String text;
private Double confidence;
private List<List<Double>> box;
}
@@ -0,0 +1,84 @@
package com.ruoyi.business.ocr;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 下载 zip 到临时目录, 解压, 返回所有图片/PDF 文件
* <p>
* 临时目录由调用方识别完成后调用 {@link #cleanup(File)} 清理
* <p>
* 依赖: JDK 自带 {@link ZipInputStream} (不引 commons-compress / zip4j)
*/
@Slf4j
public class ZipExtractor
{
/**
* @param zipUrl zip 的 OSS URL
* @return 解压后的 File 列表 (仅 .png/.jpg/.jpeg/.pdf, 子目录展平, 用 _ 拼接)
* @throws IOException 下载失败 / IO 异常
*/
public static List<File> extract(String zipUrl) throws IOException
{
File tmpRoot = new File(System.getProperty("java.io.tmpdir"),
"ry-ocr-zip/" + System.currentTimeMillis());
if (!tmpRoot.mkdirs()) throw new IOException("无法创建临时目录: " + tmpRoot);
File zipFile = new File(tmpRoot, "input.zip");
try
{
long size = HttpUtil.downloadFile(zipUrl, zipFile);
if (size <= 0) throw new IOException("OSS zip 下载失败: " + zipUrl);
log.info("zip 下载: url={} size={}B tmp={}", zipUrl, size, zipFile.getAbsolutePath());
List<File> out = new ArrayList<>();
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile)))
{
ZipEntry e;
while ((e = zin.getNextEntry()) != null)
{
if (e.isDirectory()) continue;
String name = e.getName();
String lower = name.toLowerCase();
if (!(lower.endsWith(".png") || lower.endsWith(".jpg")
|| lower.endsWith(".jpeg") || lower.endsWith(".pdf")))
{
continue;
}
// 子目录展平: a/b/c.png → tmpRoot/a_b_c.png
File outFile = new File(tmpRoot, name.replace("/", "_"));
try (FileOutputStream fos = new FileOutputStream(outFile))
{
zin.transferTo(fos);
}
out.add(outFile);
}
}
log.info("zip 解压完成: 文件数={}", out.size());
return out;
}
finally
{
// zip 压缩包本身删掉;解压产物在 tmpRoot 下,等识别完由 cleanup 删
if (zipFile.exists()) zipFile.delete();
}
}
/** 清理整个解压临时目录 */
public static void cleanup(File tmpRoot)
{
if (tmpRoot != null && tmpRoot.exists())
{
FileUtil.del(tmpRoot);
log.info("清理 zip 临时目录: {}", tmpRoot.getAbsolutePath());
}
}
}
@@ -0,0 +1,57 @@
package com.ruoyi.business.oss;
import org.springframework.stereotype.Component;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.config.RuoYiConfig.OssProperties;
/**
* OSS 连接配置 (copy 自 hwt-serve/ruoyi-common-base/.../third/meta/OssConfMeta)
* <p>
* 改造点:
* <ul>
* <li>去掉了 hwt-serve 的 @Value("${ali.*}") 硬编码配置,改读本项目 {@link RuoYiConfig.OssProperties}</li>
* <li>endpoint 在 application.yml 里带 https:// 前缀 (例: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com),
* 但 {@code OSSClient} 构造时只吃裸 host → {@link #stripScheme(String)} 剥前缀</li>
* <li>不创建 bucket (hwt-serve 的 getOSSClient() 会自动建,本项目 bucket 已存在,无需建)</li>
* </ul>
*/
@Component
public class OssConfMeta
{
private final String endpoint;
private final String bucket;
private final String accessKeyId;
private final String accessKeySecret;
public OssConfMeta(RuoYiConfig cfg)
{
OssProperties p = cfg.getOss();
if (p == null)
{
throw new IllegalStateException("OSS 未配置 (application.yml 缺 ruoyi.oss.*)");
}
this.endpoint = stripScheme(p.getEndpoint());
this.bucket = p.getBucket();
this.accessKeyId = p.getAccessKeyId();
this.accessKeySecret = p.getAccessKeySecret();
}
/**
* 剥协议头与可能的 path
* 例: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com → hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com
*/
private static String stripScheme(String url)
{
if (url == null) return null;
String s = url;
if (s.startsWith("https://")) s = s.substring("https://".length());
else if (s.startsWith("http://")) s = s.substring("http://".length());
int slash = s.indexOf('/');
return slash >= 0 ? s.substring(0, slash) : s;
}
public String getEndpoint() { return endpoint; }
public String getBucket() { return bucket; }
public String getAccessKeyId() { return accessKeyId; }
public String getAccessKeySecret() { return accessKeySecret; }
}
@@ -0,0 +1,102 @@
package com.ruoyi.business.oss;
import com.aliyun.oss.OSSClient;
import com.aliyun.oss.model.ObjectMetadata;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
/**
* 服务端 OSS 上传器 (copy 自 hwt-serve/ruoyi-common-base/.../third/AliOssService,精简)
* <p>
* 用于: ZIP 解压 → OCR 识别为发票 → 重新上传到 OSS,拿新 URL 写入 invoice.oss_url
* (不传 zip 的 oss_url, 因为那是压缩包不是发票本体)
* <p>
* 与 hwt-serve 的差异:
* <ul>
* <li>只保留 {@code upload} (服务端上传) 一个公开方法;删除 deleteFile / extractKey (本场景不需要)</li>
* <li>key 前缀由调用方通过 {@code subDir} 传入 (例 "meeting/invoice"),不再硬编码 "files/yyyy/MM/dd/"</li>
* <li>Content-Type / Content-Disposition 复用 hwt-serve 的 guessContentType 实现 (含 bmp/jpg/doc/ppt/pdf)</li>
* </ul>
*/
@Slf4j
@Component
public class OssUploader
{
@Autowired
private OssConfMeta ossConfMeta;
/**
* 上传字节到 OSS
*
* @param data 文件字节
* @param originalFilename 原始文件名 (用于决定 Content-Type 和 URL 末尾,不是 OSS key)
* @param subDir 业务子目录,直接拼在 dirPrefix 后 (例 "meeting/invoice")
* @return 完整 URL: {@code https://{bucket}.{endpoint}/{key}}
*/
public String upload(byte[] data, String originalFilename, String subDir)
{
if (data == null || data.length == 0) throw new IllegalArgumentException("上传字节为空");
if (originalFilename == null) throw new IllegalArgumentException("originalFilename 不能为空");
log.info("OSS 上传开始: name={} size={}B subDir={}", originalFilename, data.length, subDir);
String ext = originalFilename.contains(".")
? originalFilename.substring(originalFilename.lastIndexOf('.'))
: "";
String date = new SimpleDateFormat("yyyyMM").format(new Date());
String uuid = UUID.randomUUID().toString().replace("-", "");
String key = (subDir == null ? "" : subDir + "/") + date + "/" + uuid + ext;
OSSClient client = new OSSClient(
ossConfMeta.getEndpoint(),
ossConfMeta.getAccessKeyId(),
ossConfMeta.getAccessKeySecret());
try
{
ObjectMetadata meta = buildObjectMeta(originalFilename, ext);
client.putObject(ossConfMeta.getBucket(), key, new ByteArrayInputStream(data), meta);
}
finally
{
client.shutdown();
}
String url = "https://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/" + key;
log.info("OSS 上传完成: {}", url);
return url;
}
private static ObjectMetadata buildObjectMeta(String filename, String ext)
{
ObjectMetadata meta = new ObjectMetadata();
meta.setCacheControl("no-cache");
meta.setHeader("Pragma", "no-cache");
meta.setContentType(guessContentType(ext));
meta.setContentDisposition("inline;filename=" + filename);
return meta;
}
/**
* 复制自 hwt-serve AliOssService.guessContentType
* 注: hwt-serve 把所有图片 (bmp/jpg/png) 都映射为 image/jpg,与命名不太严谨
* 本实现按标准 mime 区分 png / jpg / jpeg
*/
private static String guessContentType(String ext)
{
if (ext == null) return "application/octet-stream";
String lower = ext.toLowerCase();
if (".bmp".equals(lower)) return "image/bmp";
if (".png".equals(lower)) return "image/png";
if (".jpg".equals(lower) || ".jpeg".equals(lower)) return "image/jpeg";
if (".gif".equals(lower)) return "image/gif";
if (".webp".equals(lower)) return "image/webp";
if (".pdf".equals(lower)) return "application/pdf";
if (".doc".equals(lower) || ".docx".equals(lower)) return "application/msword";
if (".ppt".equals(lower) || ".pptx".equals(lower)) return "application/vnd.ms-powerpoint";
return "application/octet-stream";
}
}
@@ -0,0 +1,22 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingAuditLog;
/**
* 会议审核流程日志 Service 接口
*/
public interface IBizMeetingAuditLogService {
BizMeetingAuditLog getById(Long id);
List<BizMeetingAuditLog> selectList(BizMeetingAuditLog entity);
int insert(BizMeetingAuditLog entity);
int updateByPrimaryKey(BizMeetingAuditLog entity);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,22 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingExecutor;
/**
* 会议-执行人员 Service 接口
*/
public interface IBizMeetingExecutorService {
BizMeetingExecutor getById(Long id);
List<BizMeetingExecutor> selectByMeetingId(Long meetingId);
List<BizMeetingExecutor> selectByUserId(Long userId);
/**
* 替换该会议的执行人员 (全删全插, 一个事务)
* @param assignedBy 分配人 user_id
*/
int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy);
}
@@ -0,0 +1,24 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingInvoice;
/**
* 会议发票 Service 接口
*/
public interface IBizMeetingInvoiceService {
BizMeetingInvoice getById(Long id);
BizMeetingInvoice selectByMaterialId(Long materialId);
List<BizMeetingInvoice> selectByMeetingId(Long meetingId);
/**
* Upsert: 按 material_id 唯一 (uk_material), 存在则更新金额, 不存在则插入.
* 仅在 OCR 识别为发票时调用, 不是发票不调此方法 (不入库).
*/
int upsertByMaterialId(BizMeetingInvoice record);
int deleteByMaterialId(Long materialId);
}
@@ -0,0 +1,35 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingMaterial;
/**
* 会议材料 Service 接口
*/
public interface IBizMeetingMaterialService {
/** 按主键查单条 */
BizMeetingMaterial getById(Long id);
/** 按会议ID查该会议所有材料记录 */
List<BizMeetingMaterial> selectByMeetingId(Long meetingId);
/**
* 替换该会议的所有材料记录 (一个事务, 全删全插)
* <p>
* 用于"保存"按钮: 前端传当前 UI 上传的文件列表, 后端先删后插.
* <ul>
* <li>list 为空或 null → 仅删除该会议的全部记录, 不插入</li>
* <li>list 非空 → 先 deleteByMeetingId, 再 insertBatch</li>
* </ul>
*
* @return 插入后带 id 的 list (前端可借此触发 OCR 识别, 拿到 materialId 关联 invoice 表)
*/
List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list);
/**
* 单条更新 amount (OCR 识别为发票后回写).
* 不动其他字段, 不抛异常 (失败仅 log).
*/
int updateAmount(Long materialId, java.math.BigDecimal amount);
}
@@ -0,0 +1,22 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingSupervisor;
/**
* 会议-监察员 Service 接口
*/
public interface IBizMeetingSupervisorService {
BizMeetingSupervisor getById(Long id);
List<BizMeetingSupervisor> selectByMeetingId(Long meetingId);
List<BizMeetingSupervisor> selectByUserId(Long userId);
/**
* 替换该会议的监察员 (全删全插, 一个事务)
* @param assignedBy 分配人 user_id (前端 / manager 自己)
*/
int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy);
}
@@ -0,0 +1,45 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizMeetingAuditLog;
import com.ruoyi.business.mapper.BizMeetingAuditLogMapper;
import com.ruoyi.business.service.IBizMeetingAuditLogService;
@Service
public class BizMeetingAuditLogServiceImpl implements IBizMeetingAuditLogService {
@Autowired
private BizMeetingAuditLogMapper bizMeetingAuditLogMapper;
@Override
public BizMeetingAuditLog getById(Long id) {
return bizMeetingAuditLogMapper.selectByPrimaryKey(id);
}
@Override
public List<BizMeetingAuditLog> selectList(BizMeetingAuditLog entity) {
return bizMeetingAuditLogMapper.selectList(entity);
}
@Override
public int insert(BizMeetingAuditLog entity) {
return bizMeetingAuditLogMapper.insert(entity);
}
@Override
public int updateByPrimaryKey(BizMeetingAuditLog entity) {
return bizMeetingAuditLogMapper.updateByPrimaryKey(entity);
}
@Override
public int deleteByPrimaryKey(Long id) {
return bizMeetingAuditLogMapper.deleteByPrimaryKey(id);
}
@Override
public int deleteByPrimaryKeys(Long[] ids) {
return bizMeetingAuditLogMapper.deleteByPrimaryKeys(ids);
}
}
@@ -0,0 +1,50 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizMeetingExecutor;
import com.ruoyi.business.mapper.BizMeetingExecutorMapper;
import com.ruoyi.business.service.IBizMeetingExecutorService;
@Service
public class BizMeetingExecutorServiceImpl implements IBizMeetingExecutorService {
@Autowired
private BizMeetingExecutorMapper bizMeetingExecutorMapper;
@Override
public BizMeetingExecutor getById(Long id) {
return bizMeetingExecutorMapper.selectByPrimaryKey(id);
}
@Override
public List<BizMeetingExecutor> selectByMeetingId(Long meetingId) {
return bizMeetingExecutorMapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingExecutor> selectByUserId(Long userId) {
return bizMeetingExecutorMapper.selectByUserId(userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy) {
bizMeetingExecutorMapper.deleteByMeetingId(meetingId);
if (userIds == null || userIds.isEmpty()) {
return 0;
}
List<BizMeetingExecutor> list = new ArrayList<>(userIds.size());
for (Long uid : userIds) {
BizMeetingExecutor m = new BizMeetingExecutor();
m.setMeetingId(meetingId);
m.setUserId(uid);
m.setAssignedBy(assignedBy);
list.add(m);
}
return bizMeetingExecutorMapper.insertBatch(list);
}
}
@@ -0,0 +1,62 @@
package com.ruoyi.business.service.impl;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizMeetingInvoice;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
@Service
public class BizMeetingInvoiceServiceImpl implements IBizMeetingInvoiceService {
@Autowired
private BizMeetingInvoiceMapper bizMeetingInvoiceMapper;
@Override
public BizMeetingInvoice getById(Long id) {
return bizMeetingInvoiceMapper.selectByPrimaryKey(id);
}
@Override
public BizMeetingInvoice selectByMaterialId(Long materialId) {
return bizMeetingInvoiceMapper.selectByMaterialId(materialId);
}
@Override
public List<BizMeetingInvoice> selectByMeetingId(Long meetingId) {
return bizMeetingInvoiceMapper.selectByMeetingId(meetingId);
}
/**
* Upsert: 存在则按 material_id 更新 (amount + url + 时间), 不存在则插入新行.
* <p>
* 注意: UK uk_material 保证一个 material_id 只对应一条 invoice, 自然幂等.
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int upsertByMaterialId(BizMeetingInvoice record) {
if (record == null || record.getMaterialId() == null) return 0;
Date now = new Date();
BizMeetingInvoice exist = bizMeetingInvoiceMapper.selectByMaterialId(record.getMaterialId());
if (exist == null) {
record.setId(null);
if (record.getCreateTime() == null) record.setCreateTime(now);
record.setUpdateTime(now);
return bizMeetingInvoiceMapper.insert(record);
} else {
// 更新金额/url, 时间戳
if (record.getAmount() != null) exist.setAmount(record.getAmount());
if (record.getOssUrl() != null && !record.getOssUrl().isEmpty()) exist.setOssUrl(record.getOssUrl());
exist.setUpdateTime(now);
return bizMeetingInvoiceMapper.updateByPrimaryKey(exist);
}
}
@Override
public int deleteByMaterialId(Long materialId) {
return bizMeetingInvoiceMapper.deleteByMaterialId(materialId);
}
}
@@ -0,0 +1,63 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import com.ruoyi.business.service.IBizMeetingMaterialService;
@Service
public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService {
@Autowired
private BizMeetingMaterialMapper bizMeetingMaterialMapper;
@Override
public BizMeetingMaterial getById(Long id) {
return bizMeetingMaterialMapper.selectByPrimaryKey(id);
}
@Override
public List<BizMeetingMaterial> selectByMeetingId(Long meetingId) {
return bizMeetingMaterialMapper.selectByMeetingId(meetingId);
}
/**
* 全删全插, 一个事务. list 为空则只删不插.
* <p>
* 防御性捕获 DuplicateKeyException: 正常流程 (delete → insert) 不会触发,
* 但并发或前端传重复 (meetingId, materialType, subType) 会触发 UK uk_meeting_type_sub.
* 翻译成友好中文提示, 避免暴露 SQL 堆栈.
* <p>
* 返回插入后的 list (各元素 id 字段被 useGeneratedKeys 回填), 前端可借此触发 OCR.
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list) {
bizMeetingMaterialMapper.deleteByMeetingId(meetingId);
if (list == null || list.isEmpty()) {
return list;
}
// 不接受前端传的 id, 走 AUTO_INCREMENT; meetingId 兜底由路径提供
for (BizMeetingMaterial m : list) {
m.setId(null);
m.setMeetingId(meetingId);
}
try {
bizMeetingMaterialMapper.insertBatch(list);
} catch (DuplicateKeyException e) {
throw new ServiceException("材料上传重复, 请检查 (同一会议下同一资料类型同一子分类只能有一条记录)");
}
return list;
}
@Override
public int updateAmount(Long materialId, java.math.BigDecimal amount) {
if (materialId == null || amount == null) return 0;
return bizMeetingMaterialMapper.updateAmount(materialId, amount);
}
}
@@ -0,0 +1,50 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizMeetingSupervisor;
import com.ruoyi.business.mapper.BizMeetingSupervisorMapper;
import com.ruoyi.business.service.IBizMeetingSupervisorService;
@Service
public class BizMeetingSupervisorServiceImpl implements IBizMeetingSupervisorService {
@Autowired
private BizMeetingSupervisorMapper bizMeetingSupervisorMapper;
@Override
public BizMeetingSupervisor getById(Long id) {
return bizMeetingSupervisorMapper.selectByPrimaryKey(id);
}
@Override
public List<BizMeetingSupervisor> selectByMeetingId(Long meetingId) {
return bizMeetingSupervisorMapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingSupervisor> selectByUserId(Long userId) {
return bizMeetingSupervisorMapper.selectByUserId(userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int replaceByMeetingId(Long meetingId, List<Long> userIds, Long assignedBy) {
bizMeetingSupervisorMapper.deleteByMeetingId(meetingId);
if (userIds == null || userIds.isEmpty()) {
return 0;
}
List<BizMeetingSupervisor> list = new ArrayList<>(userIds.size());
for (Long uid : userIds) {
BizMeetingSupervisor m = new BizMeetingSupervisor();
m.setMeetingId(meetingId);
m.setUserId(uid);
m.setAssignedBy(assignedBy);
list.add(m);
}
return bizMeetingSupervisorMapper.insertBatch(list);
}
}
@@ -0,0 +1,333 @@
package com.ruoyi.business.service.impl;
import java.io.File;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.IBizMeetingMaterialService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutorService;
import com.ruoyi.business.domain.BizMeetingInvoice;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.ocr.InvoiceFields;
import com.ruoyi.business.ocr.InvoiceResult;
import com.ruoyi.business.ocr.OcrClient;
import com.ruoyi.business.ocr.ZipExtractor;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.common.utils.SecurityUtils;
import cn.hutool.core.io.FileUtil;
/**
* 发票识别协调服务 (v3 重写)
* <p>
* 设计要点 (v2 → v3 变更):
* <ul>
* <li><b>后台执行</b>: 单文件 OCR 改为 {@code ExecutorService.submit}, 立即返回 SUBMITTED
* (原 v2 是同步阻塞, 大文件/慢网络时拖慢保存接口)</li>
* <li><b>ZIP 支持</b>: zipUrl → 下载 → 解压 → 遍历 .png/.jpg/.jpeg/.pdf →
* 是发票 → <b>重新上传到 OSS</b> (key 是新生成的, 不是 zip 的 url) → 写 invoice 行</li>
* <li><b>is_invoice 列</b>: <b>不加</b>. 不是发票的图直接不入 invoice 表</li>
* <li><b>替换场景</b>: 前端传 {@code oldMaterialId}, 后端先 DELETE invoice WHERE material_id=old
* + material.amount=0, 再走 OCR</li>
* <li><b>状态机</b>: UNRECOGNIZED → RECOGNIZED / FAILED, 兜底 scheduler 每 60s 重试 > 5min 未识别行</li>
* </ul>
*/
@Service
public class InvoiceOcrService
{
private static final Logger log = LoggerFactory.getLogger(InvoiceOcrService.class);
@Autowired
private OcrClient ocrClient;
@Autowired
private OssUploader ossUploader;
@Autowired
private BizMeetingInvoiceMapper invoiceMapper;
@Autowired
private IBizMeetingInvoiceService invoiceService;
@Autowired
private IBizMeetingMaterialService materialService;
@Autowired
@Qualifier("ocrExecutor")
private ExecutorService ocrExecutor;
// ==================== 对外入口 ====================
/**
* 提交识别 (后台执行, 立即返回 SUBMITTED)
*
* @param materialId biz_meeting_material.id
* @param meetingId 会议 ID
* @param ossUrl OSS URL (单文件: 原图;ZIP: zip 包)
* @param isZip true → ZIP 路径;false → 单文件路径
* @param oldMaterialId 替换场景携带, 后端先 DELETE invoice WHERE material_id=old + material.amount=0;
* null → 新增场景, 不做清理
* @return 提交摘要 (立即返回, 不等 OCR 完成)
*/
public RecognizeResult submitRecognition(Long materialId, Long meetingId, String ossUrl,
boolean isZip, Long oldMaterialId)
{
RecognizeResult out = new RecognizeResult();
if (materialId == null || meetingId == null || ossUrl == null || ossUrl.isEmpty())
{
out.setSubmitted(false);
out.setErrorMsg("参数缺失");
return out;
}
// 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0)
if (oldMaterialId != null)
{
log.info("替换场景: 清旧 invoice + material.amount=0, oldMaterialId={}", oldMaterialId);
invoiceMapper.deleteByMaterialId(oldMaterialId);
materialService.updateAmount(oldMaterialId, BigDecimal.ZERO);
}
// 2. 单文件: 插 UNRECOGNIZED 占位行 (zip 路径跳过 — 解压才知道有几张)
if (!isZip)
{
BizMeetingInvoice placeholder = new BizMeetingInvoice();
placeholder.setMeetingId(meetingId);
placeholder.setMaterialId(materialId);
placeholder.setOssUrl(ossUrl);
placeholder.setInvoiceType("SUB");
placeholder.setRecognizeStatus("UNRECOGNIZED");
placeholder.setCreatorId(SecurityUtils.getUserId());
placeholder.setCreateTime(new Date());
placeholder.setUpdateTime(new Date());
invoiceMapper.insert(placeholder);
}
// 3. 后台执行 OCR (单文件或 zip)
final String url = ossUrl;
ocrExecutor.submit(() ->
{
try
{
if (isZip)
{
recognizeZip(materialId, meetingId, url);
}
else
{
recognizeSingle(materialId, url);
}
}
catch (Exception e)
{
log.warn("OCR 后台任务失败 materialId={} isZip={} err={}", materialId, isZip, e.getMessage(), e);
if (!isZip)
{
// 单文件: 占位行标 FAILED
invoiceMapper.updateStatusByMaterial(materialId, "FAILED",
e.getMessage() == null ? "OCR 异常" : e.getMessage());
}
}
});
out.setSubmitted(true);
out.setIsZip(isZip);
out.setMaterialId(materialId);
out.setStatus("SUBMITTED");
return out;
}
// ==================== 后台执行方法 ====================
/**
* 单文件 OCR:
* 下载 → 识别 → 是发票 → updateStatus=RECOGNIZED + 回写 amount + material.amount
* 不是发票 → 删除占位行 (material 表不动, amount 保持原值)
* <p>
* material 表语义是"会议上传的文件", 不是发票也照样是上传文件, 不能动它
*/
void recognizeSingle(Long materialId, String ossUrl)
{
InvoiceResult ir = ocrClient.recognizeByUrl(ossUrl);
if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields()))
{
BigDecimal amount = ir.getFields().getAmount() != null
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
invoiceMapper.updateStatusByMaterial(materialId, "RECOGNIZED", null);
invoiceMapper.updateAmountByMaterial(materialId, amount);
materialService.updateAmount(materialId, amount);
log.info("单文件识别成功: materialId={} amount={} elapsed={}ms",
materialId, amount, ir.getElapsedMs());
}
else
{
// 不是发票: 仅删占位行, material 表不动 (material 仍代表上传的文件本身)
invoiceMapper.deleteByMaterialId(materialId);
log.info("单文件识别非发票: materialId={} (invoiceType={}, amount={}) - material 表保持原状",
materialId,
ir.getFields() != null ? ir.getFields().getInvoiceType() : null,
ir.getFields() != null ? ir.getFields().getAmount() : null);
}
}
/**
* ZIP OCR:
* 下载 zip → 解压 → 遍历图片/PDF → 是发票 → 重传 OSS (新 URL) → 写 invoice 行 →
* 累加 amount → material.amount = 总和
* 不是发票 → 跳过 (不入 invoice 表, material 表不动)
* <p>
* material 表只动一次 (末尾的 updateAmount), 即使 0 张发票也照写; 非发票文件不进任何统计
*/
void recognizeZip(Long materialId, Long meetingId, String zipUrl) throws Exception
{
File tmpRoot = null;
try
{
List<File> files = ZipExtractor.extract(zipUrl);
if (!files.isEmpty())
{
tmpRoot = files.get(0).getParentFile();
}
BigDecimal total = BigDecimal.ZERO;
int invoiceCount = 0;
int skipCount = 0;
int failCount = 0;
for (File f : files)
{
try
{
InvoiceResult ir = ocrClient.recognize(f);
if (!Boolean.TRUE.equals(ir.getSuccess()) || ir.getFields() == null
|| !isRecognizedAsInvoice(ir.getFields()))
{
// 不是发票: 跳过, 不入库 (不入 invoice 表, material 表不动)
skipCount++;
continue;
}
// ★ 修正点2: 是发票 → 重传 OSS, 用新 URL 写入 invoice 行 (不传 zipUrl)
byte[] bytes = FileUtil.readBytes(f);
String newUrl = ossUploader.upload(bytes, f.getName(), "meeting/invoice");
BigDecimal amount = ir.getFields().getAmount() != null
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
BizMeetingInvoice inv = new BizMeetingInvoice();
inv.setMeetingId(meetingId);
inv.setMaterialId(materialId);
inv.setOssUrl(newUrl);
inv.setInvoiceType("SUB");
inv.setAmount(amount);
inv.setRecognizeStatus("RECOGNIZED");
inv.setSourceFilename(f.getName());
inv.setCreatorId(SecurityUtils.getUserId());
inv.setCreateTime(new Date());
inv.setUpdateTime(new Date());
invoiceMapper.insert(inv);
total = total.add(amount);
invoiceCount++;
}
catch (Exception e)
{
log.warn("zip 子文件识别失败 file={} err={}", f.getName(), e.getMessage());
failCount++;
}
}
// material.amount 只在"识别出至少 1 张发票"时回写求和; 0 张时不动 material
// (用户上传了文件, 即使全是合同照片, material 也应保留, amount 字段保持原值)
if (invoiceCount > 0)
{
materialService.updateAmount(materialId, total);
}
log.info("zip OCR 完成 materialId={} invoice={} skip={} fail={} total={}",
materialId, invoiceCount, skipCount, failCount, total);
}
finally
{
ZipExtractor.cleanup(tmpRoot);
}
}
/**
* 兜底 OCR (InvoiceOcrScheduler 调用): 已知 invoice 行 (UNRECOGNIZED), 重新识别
* <p>
* 不是发票 → 仅删占位行, material 表不动 (与单文件/ZIP 路径一致)
*/
public void recognizeOneInvoice(BizMeetingInvoice inv)
{
if (inv == null || inv.getOssUrl() == null) return;
try
{
InvoiceResult ir = ocrClient.recognizeByUrl(inv.getOssUrl());
if (Boolean.TRUE.equals(ir.getSuccess()) && ir.getFields() != null && isRecognizedAsInvoice(ir.getFields()))
{
BigDecimal amount = ir.getFields().getAmount() != null
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
invoiceMapper.updateStatusAndAmountByPrimaryKey(inv.getId(), "RECOGNIZED", amount, null);
materialService.updateAmount(inv.getMaterialId(), amount);
log.info("兜底识别成功: invoiceId={} amount={}", inv.getId(), amount);
}
else
{
// 不是发票: 仅删占位行, material 表保持原状
invoiceMapper.deleteByPrimaryKey(inv.getId());
log.info("兜底识别非发票: invoiceId={} 已删除 (material 表不动)", inv.getId());
}
}
catch (Exception e)
{
invoiceMapper.updateStatusAndAmountByPrimaryKey(inv.getId(), "FAILED", null,
e.getMessage() == null ? "OCR 异常" : e.getMessage());
log.warn("兜底识别失败 invoiceId={} err={}", inv.getId(), e.getMessage());
}
}
// ==================== 工具方法 ====================
/**
* 是否识别为发票: 必须有 invoiceType + amount>0
* (ry-ocr 对非发票图片也可能返回 fields, 但 invoiceType/amount 通常为空/0)
*/
private boolean isRecognizedAsInvoice(InvoiceFields f)
{
if (f.getInvoiceType() == null || f.getInvoiceType().isEmpty()) return false;
if (f.getAmount() == null || f.getAmount() <= 0) return false;
return true;
}
// ==================== DTO ====================
/** 提交结果 (立即返回, 不等 OCR 完成) */
public static class RecognizeResult
{
private boolean submitted;
private boolean isZip;
private Long materialId;
private String status;
private String errorMsg;
public boolean isSubmitted() { return submitted; }
public void setSubmitted(boolean submitted) { this.submitted = submitted; }
public boolean isZip() { return isZip; }
public void setIsZip(boolean zip) { isZip = zip; }
public Long getMaterialId() { return materialId; }
public void setMaterialId(Long materialId) { this.materialId = materialId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getErrorMsg() { return errorMsg; }
public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; }
}
}
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingAuditLogMapper">
<resultMap type="BizMeetingAuditLog" id="BizMeetingAuditLogResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="auditor" column="auditor" />
<result property="opinion" column="opinion" />
<result property="currentStage" column="current_stage" />
<result property="createTime" column="create_time" />
<result property="auditTime" column="audit_time" />
<result property="auditType" column="audit_type" />
<result property="auditResult" column="audit_result" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, auditor, opinion, current_stage, create_time, audit_time, audit_type, audit_result
from biz_meeting_audit_log
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingAuditLogResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectList" resultMap="BizMeetingAuditLogResult" parameterType="BizMeetingAuditLog">
<include refid="selectFields"/>
<where>
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if>
<if test="auditor != null and auditor != ''">and auditor = #{auditor}</if>
</where>
order by id desc
</select>
<insert id="insert" parameterType="BizMeetingAuditLog" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_audit_log
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="auditor != null and auditor != ''">auditor,</if>
<if test="opinion != null and opinion != ''">opinion,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if>
<if test="createTime != null">create_time,</if>
<if test="auditTime != null">audit_time,</if>
<if test="auditType != null and auditType != ''">audit_type,</if>
<if test="auditResult != null and auditResult != ''">audit_result,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="auditor != null and auditor != ''">#{auditor},</if>
<if test="opinion != null and opinion != ''">#{opinion},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if>
<if test="createTime != null">#{createTime},</if>
<if test="auditTime != null">#{auditTime},</if>
<if test="auditType != null and auditType != ''">#{auditType},</if>
<if test="auditResult != null and auditResult != ''">#{auditResult},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingAuditLog">
update biz_meeting_audit_log
<trim prefix="SET" suffixOverrides=",">
<if test="auditor != null and auditor != ''">auditor = #{auditor},</if>
<if test="opinion != null and opinion != ''">opinion = #{opinion},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="auditTime != null">audit_time = #{auditTime},</if>
<if test="auditType != null and auditType != ''">audit_type = #{auditType},</if>
<if test="auditResult != null and auditResult != ''">audit_result = #{auditResult},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_audit_log where id = #{id}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="Long">
delete from biz_meeting_audit_log where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingExecutorMapper">
<resultMap type="BizMeetingExecutor" id="BizMeetingExecutorResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="assignedBy" column="assigned_by" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, user_id, assigned_by, create_time
from biz_meeting_executor
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingExecutorResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectByMeetingId" resultMap="BizMeetingExecutorResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id asc
</select>
<select id="selectByUserId" resultMap="BizMeetingExecutorResult" parameterType="Long">
<include refid="selectFields"/>
where user_id = #{userId}
order by id desc
</select>
<select id="selectList" resultMap="BizMeetingExecutorResult" parameterType="BizMeetingExecutor">
<include refid="selectFields"/>
<where>
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="userId != null">and user_id = #{userId}</if>
</where>
order by id asc
</select>
<insert id="insert" parameterType="BizMeetingExecutor" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_executor
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="userId != null">user_id,</if>
<if test="assignedBy != null">assigned_by,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="userId != null">#{userId},</if>
<if test="assignedBy != null">#{assignedBy},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_executor (meeting_id, user_id, assigned_by, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.userId}, #{item.assignedBy}, #{item.createTime})
</foreach>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingExecutor">
update biz_meeting_executor
<trim prefix="SET" suffixOverrides=",">
<if test="userId != null">user_id = #{userId},</if>
<if test="assignedBy != null">assigned_by = #{assignedBy},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_executor where id = #{id}
</delete>
<delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_executor where meeting_id = #{meetingId}
</delete>
</mapper>
@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingInvoiceMapper">
<resultMap type="BizMeetingInvoice" id="BizMeetingInvoiceResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="materialId" column="material_id" />
<result property="ossUrl" column="oss_url" />
<result property="invoiceType" column="invoice_type" />
<result property="amount" column="amount" />
<result property="creatorId" column="creator_id" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
<result property="recognizeStatus" column="recognize_status" />
<result property="errorMsg" column="error_msg" />
<result property="sourceFilename" column="source_filename" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, material_id, oss_url, invoice_type, amount, creator_id, create_time, update_time,
recognize_status, error_msg, source_filename
from biz_meeting_invoice
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingInvoiceResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectList" resultMap="BizMeetingInvoiceResult" parameterType="BizMeetingInvoice">
<include refid="selectFields"/>
<where>
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="materialId != null">and material_id = #{materialId}</if>
<if test="invoiceType != null and invoiceType != ''">and invoice_type = #{invoiceType}</if>
</where>
order by id desc
</select>
<select id="selectByMaterialId" resultMap="BizMeetingInvoiceResult" parameterType="Long">
<include refid="selectFields"/>
where material_id = #{materialId}
limit 1
</select>
<select id="selectByMeetingId" resultMap="BizMeetingInvoiceResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id desc
</select>
<insert id="insert" parameterType="BizMeetingInvoice" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_invoice
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="materialId != null">material_id,</if>
<if test="ossUrl != null and ossUrl != ''">oss_url,</if>
<if test="invoiceType != null and invoiceType != ''">invoice_type,</if>
<if test="amount != null">amount,</if>
<if test="creatorId != null">creator_id,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
<if test="recognizeStatus != null and recognizeStatus != ''">recognize_status,</if>
<if test="errorMsg != null">error_msg,</if>
<if test="sourceFilename != null">source_filename,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="materialId != null">#{materialId},</if>
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
<if test="invoiceType != null and invoiceType != ''">#{invoiceType},</if>
<if test="amount != null">#{amount},</if>
<if test="creatorId != null">#{creatorId},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="recognizeStatus != null and recognizeStatus != ''">#{recognizeStatus},</if>
<if test="errorMsg != null">#{errorMsg},</if>
<if test="sourceFilename != null">#{sourceFilename},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingInvoice">
update biz_meeting_invoice
<trim prefix="SET" suffixOverrides=",">
<if test="meetingId != null">meeting_id = #{meetingId},</if>
<if test="materialId != null">material_id = #{materialId},</if>
<if test="ossUrl != null and ossUrl != ''">oss_url = #{ossUrl},</if>
<if test="invoiceType != null and invoiceType != ''">invoice_type = #{invoiceType},</if>
<if test="amount != null">amount = #{amount},</if>
<if test="creatorId != null">creator_id = #{creatorId},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="recognizeStatus != null and recognizeStatus != ''">recognize_status = #{recognizeStatus},</if>
<if test="errorMsg != null">error_msg = #{errorMsg},</if>
<if test="sourceFilename != null">source_filename = #{sourceFilename},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_invoice where id = #{id}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="Long">
delete from biz_meeting_invoice where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
<delete id="deleteByMaterialId" parameterType="Long">
delete from biz_meeting_invoice where material_id = #{materialId}
</delete>
<!-- 兜底扫描: UNRECOGNIZED 状态且 create_time 早于 N 分钟前的行 (处理程序重启 / OCR 临时挂掉导致的遗漏) -->
<select id="selectUnrecognizedOlderThanMinutes" resultMap="BizMeetingInvoiceResult" parameterType="int">
<include refid="selectFields"/>
where recognize_status = 'UNRECOGNIZED'
and create_time is not null
and create_time &lt; date_sub(now(), INTERVAL #{minutes} MINUTE)
order by create_time ASC
limit 100
</select>
<!-- 单文件后台 OCR 完成: 状态+金额 一起更新 (按 material_id) -->
<update id="updateStatusByMaterial">
update biz_meeting_invoice
set recognize_status = #{recognizeStatus},
<if test="errorMsg != null">error_msg = #{errorMsg},</if>
update_time = now()
where material_id = #{materialId}
and recognize_status = 'UNRECOGNIZED'
</update>
<!-- 单文件后台 OCR 完成: 更新金额 (前提: 状态已是 RECOGNIZED) -->
<update id="updateAmountByMaterial">
update biz_meeting_invoice
set amount = #{amount},
update_time = now()
where material_id = #{materialId}
</update>
<!-- 兜底 OCR 用: 同时更新状态 + 金额 + 错误信息 (按主键) -->
<update id="updateStatusAndAmountByPrimaryKey">
update biz_meeting_invoice
set recognize_status = #{recognizeStatus},
amount = #{amount},
error_msg = #{errorMsg},
update_time = now()
where id = #{id}
</update>
</mapper>
@@ -14,10 +14,13 @@
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="orgName" column="org_name" />
<result property="address" column="address" />
<result property="currentStage" column="current_stage" />
<result property="supervisionOpinion" column="supervision_opinion" />
<result property="supervisionBy" column="supervision_by" />
<result property="supervisionTime" column="supervision_time" />
<result property="materialAuditStage" column="material_audit_stage" />
<result property="voucherAuditStage" column="voucher_audit_stage" />
<result property="invitationUrl" column="invitation_url" />
<result property="scheduleUrl" column="schedule_url" />
<result property="laborSigned" column="labor_signed" />
@@ -27,7 +30,7 @@
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectFields">
select meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, current_stage, supervision_opinion, supervision_by, supervision_time, invitation_url, schedule_url, labor_signed, create_by, create_time, update_by, update_time
select meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, address, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, voucher_audit_stage, invitation_url, schedule_url, labor_signed, create_by, create_time, update_by, update_time
from biz_meeting
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long">
@@ -65,10 +68,13 @@
<if test="startTime != null">start_time,</if>
<if test="endTime != null">end_time,</if>
<if test="orgName != null and orgName != ''">org_name,</if>
<if test="address != null and address != ''">address,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion,</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by,</if>
<if test="supervisionTime != null">supervision_time,</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage,</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage,</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if>
<if test="laborSigned != null and laborSigned != ''">labor_signed,</if>
@@ -86,10 +92,13 @@
<if test="startTime != null">#{startTime},</if>
<if test="endTime != null">#{endTime},</if>
<if test="orgName != null and orgName != ''">#{orgName},</if>
<if test="address != null and address != ''">#{address},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">#{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">#{supervisionBy},</if>
<if test="supervisionTime != null">#{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">#{materialAuditStage},</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">#{voucherAuditStage},</if>
<if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if>
<if test="laborSigned != null and laborSigned != ''">#{laborSigned},</if>
@@ -109,10 +118,13 @@
<if test="startTime != null and startTime != ''">start_time = #{startTime},</if>
<if test="endTime != null and endTime != ''">end_time = #{endTime},</if>
<if test="orgName != null and orgName != ''">org_name = #{orgName},</if>
<if test="address != null and address != ''">address = #{address},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if>
<if test="supervisionTime != null and supervisionTime != ''">supervision_time = #{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage = #{materialAuditStage},</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage = #{voucherAuditStage},</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if>
<if test="laborSigned != null and laborSigned != ''">labor_signed = #{laborSigned},</if>
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingMaterialMapper">
<resultMap type="BizMeetingMaterial" id="BizMeetingMaterialResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="materialType" column="material_type" />
<result property="subType" column="sub_type" />
<result property="fileName" column="file_name" />
<result property="ossUrl" column="oss_url" />
<result property="amount" column="amount" />
<result property="creatorId" column="creator_id" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time
from biz_meeting_material
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingMaterialResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectByMeetingId" resultMap="BizMeetingMaterialResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id asc
</select>
<insert id="insert" parameterType="BizMeetingMaterial" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_material
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="materialType != null and materialType != ''">material_type,</if>
<if test="subType != null and subType != ''">sub_type,</if>
<if test="fileName != null and fileName != ''">file_name,</if>
<if test="ossUrl != null and ossUrl != ''">oss_url,</if>
<if test="amount != null">amount,</if>
<if test="creatorId != null">creator_id,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="materialType != null and materialType != ''">#{materialType},</if>
<if test="subType != null and subType != ''">#{subType},</if>
<if test="fileName != null and fileName != ''">#{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
<if test="amount != null">#{amount},</if>
<if test="creatorId != null">#{creatorId},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl},
#{item.amount}, #{item.creatorId}, #{item.createTime})
</foreach>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingMaterial">
update biz_meeting_material
<trim prefix="SET" suffixOverrides=",">
<if test="materialType != null and materialType != ''">material_type = #{materialType},</if>
<if test="subType != null and subType != ''">sub_type = #{subType},</if>
<if test="fileName != null and fileName != ''">file_name = #{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">oss_url = #{ossUrl},</if>
<if test="amount != null">amount = #{amount},</if>
</trim>
where id = #{id}
</update>
<update id="updateAmount">
update biz_meeting_material set amount = #{amount} where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_material where id = #{id}
</delete>
<delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_material where meeting_id = #{meetingId}
</delete>
</mapper>
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingSupervisorMapper">
<resultMap type="BizMeetingSupervisor" id="BizMeetingSupervisorResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="assignedBy" column="assigned_by" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, user_id, assigned_by, create_time
from biz_meeting_supervisor
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingSupervisorResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectByMeetingId" resultMap="BizMeetingSupervisorResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id asc
</select>
<select id="selectByUserId" resultMap="BizMeetingSupervisorResult" parameterType="Long">
<include refid="selectFields"/>
where user_id = #{userId}
order by id desc
</select>
<select id="selectList" resultMap="BizMeetingSupervisorResult" parameterType="BizMeetingSupervisor">
<include refid="selectFields"/>
<where>
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="userId != null">and user_id = #{userId}</if>
</where>
order by id asc
</select>
<insert id="insert" parameterType="BizMeetingSupervisor" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_supervisor
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="meetingId != null">meeting_id,</if>
<if test="userId != null">user_id,</if>
<if test="assignedBy != null">assigned_by,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
<if test="userId != null">#{userId},</if>
<if test="assignedBy != null">#{assignedBy},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_supervisor (meeting_id, user_id, assigned_by, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.userId}, #{item.assignedBy}, #{item.createTime})
</foreach>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeetingSupervisor">
update biz_meeting_supervisor
<trim prefix="SET" suffixOverrides=",">
<if test="userId != null">user_id = #{userId},</if>
<if test="assignedBy != null">assigned_by = #{assignedBy},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_supervisor where id = #{id}
</delete>
<delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_supervisor where meeting_id = #{meetingId}
</delete>
</mapper>