diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..8c681fc Binary files /dev/null and b/favicon.ico differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..6b95ee1 Binary files /dev/null and b/logo.png differ diff --git a/qrcode_1.png b/qrcode_1.png new file mode 100644 index 0000000..77d52db Binary files /dev/null and b/qrcode_1.png differ diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CameraController.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CameraController.java new file mode 100644 index 0000000..1bb3722 --- /dev/null +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CameraController.java @@ -0,0 +1,36 @@ +package com.ruoyi.web.controller.common; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.core.domain.AjaxResult; + +/** + * 扫码拍照相机配置服务 + * + *

前端生成二维码前调用, 拿到相机 H5 网页基础地址 (ruoyi.camera.base-url), + * 再拼接 ry-h5 页面路由 + meetingId + subType. + * + *

公开访问, 无需 token (SecurityConfig 已对 /common/camera/** 公开). + */ +@RestController +@RequestMapping("/common/camera") +public class CameraController +{ + @org.springframework.beans.factory.annotation.Autowired + private RuoYiConfig ruoyiConfig; + + /** + * 返回相机网页基础地址 + * @return {baseUrl: "http://localhost:8090/camera/"} + */ + @GetMapping("/config") + public AjaxResult config() + { + AjaxResult ajax = AjaxResult.success(); + String baseUrl = ruoyiConfig.getCamera() != null ? ruoyiConfig.getCamera().getBaseUrl() : null; + ajax.put("baseUrl", baseUrl); + return ajax; + } +} diff --git a/ry-api/ruoyi-admin/src/main/resources/application-druid.yml b/ry-api/ruoyi-admin/src/main/resources/application-druid.yml index 95480d3..2398376 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application-druid.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application-druid.yml @@ -5,9 +5,9 @@ spring: driverClassName: com.mysql.cj.jdbc.Driver druid: master: - url: jdbc:mysql://127.0.0.1:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + url: jdbc:mysql://mmos.uvwcloud.com:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 username: root - password: cu2oh2co3 + password: Qljl1rh_ slave: enabled: false url: diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index 5cfef2a..df90b96 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -30,12 +30,16 @@ ruoyi: accessKeySecret: UIkwjMpmlYjgX5IMLjPj8FQNPthdlR signName: 北京仙仁掌医学科技发展 template: SMS_321560247 - inviteTemplate: SMS_492460505 + esignTemplate: SMS_492460505 + esignBaseUrl: https://ringdoctor.com/hg 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 + # 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL) + camera: + base-url: https://ringdoctor.com/camera/ # 开发环境配置 server: @@ -58,9 +62,9 @@ server: # 日志配置 logging: level: - com.ruoyi: debug - org.springframework: debug - com.ruoyi.business: debug + com.ruoyi: info + org.springframework: info + com.ruoyi.business: info # 用户配置 user: diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java index 749a2d2..97b31e4 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/config/OcrExecutorConfig.java @@ -27,4 +27,15 @@ public class OcrExecutorConfig { return Executors.newFixedThreadPool(16); } + + /** + * 费用汇总执行器 (FeeCalcScheduler 用): 8 线程并行汇总各会议. + *

+ * 汇总本身纯 SUM 幂等, 无锁; 线程数 8 足够 (每会议 3 次轻量查询 + 1 次回写). + */ + @Bean(name = "feeCalcExecutor", destroyMethod = "shutdown") + public ExecutorService feeCalcExecutor() + { + return Executors.newFixedThreadPool(8); + } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDashboardController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDashboardController.java index bf04930..bcbd07e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDashboardController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDashboardController.java @@ -36,9 +36,17 @@ public class BizDashboardController extends BaseController { map.put("totalProjects", projects.size()); map.put("totalMeetings", meetings.size()); map.put("totalExperts", experts.size()); - map.put("todoMeetings", meetings.stream().filter(m -> "未执行".equals(m.getCurrentStage())).count()); - map.put("doingMeetings", meetings.stream().filter(m -> "待监管".equals(m.getCurrentStage()) || "待整改".equals(m.getCurrentStage())).count()); - map.put("doneMeetings", meetings.stream().filter(m -> "已结算".equals(m.getCurrentStage()) || "已结题".equals(m.getCurrentStage())).count()); + // current_stage 是 10 值物理阶段 code (BizMeetingStageEnum), 不是中文 label (旧代码比对中文永远为 0). + map.put("todoMeetings", meetings.stream().filter(m -> "NOT_STARTED".equals(m.getCurrentStage())).count()); + map.put("doingMeetings", meetings.stream().filter(m -> { + String s = m.getCurrentStage(); + return "RUNNING".equals(s) || "AWAITING_COMPLIANCE".equals(s) + || "AWAITING_SUPERVISION".equals(s) || "RECTIFYING".equals(s); + }).count()); + map.put("doneMeetings", meetings.stream().filter(m -> { + String s = m.getCurrentStage(); + return "SETTLED".equals(s) || "FINISHED".equals(s); + }).count()); return success(map); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java index ca014f9..8f95910 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java @@ -1,8 +1,7 @@ package com.ruoyi.business.controller; -import java.util.HashSet; +import java.util.ArrayList; import java.util.List; -import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -48,7 +47,7 @@ public class BizMeetingAttendeeController extends BaseController { private BizNotifyService bizNotifyService; /** - * 当前登录用户的"待签署"列表 (handsign 或 labor_protocol 任一为空) + * 当前登录用户的"待签署协议"列表 (已推送电子签 is_esigned=1 且 任一未签) * 用于 /doctor/home 工作台 */ @GetMapping("/unsigned") @@ -58,6 +57,17 @@ public class BizMeetingAttendeeController extends BaseController { return success(rows); } + /** + * 当前登录用户的"待参加"会议列表 (已邀请参会 is_invited=1) + * 用于 /doctor/home 工作台 + */ + @GetMapping("/invited") + public AjaxResult listInvited() { + Long userId = SecurityUtils.getUserId(); + List rows = attendeeService.selectInvitedByUserId(userId); + return success(rows); + } + /** * 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用). * 返回全字段, 前端按需展示. @@ -71,7 +81,7 @@ public class BizMeetingAttendeeController extends BaseController { * 管理端: 按手机号新增参会人. * *

后端流程: 按 body.phone 查 sys_user → 查到用之, 查不到新建 (用户名=密码=phone, role_type='doctor') - * → 写完整档案行 (含 name/work_unit/fee...) → 触发 #5 会议邀请通知. + * → 写完整档案行 (含 name/work_unit/fee...). 邀请参会已改为手动, 此处不再自动推. * *

请求体示例: *

@@ -84,11 +94,9 @@ public class BizMeetingAttendeeController extends BaseController {
     @PostMapping
     public AjaxResult add(@RequestBody BizMeetingAttendee body) {
         Long attendeeId = attendeeService.insertByPhoneWithProfile(body);
-        // #5 触发: 新参会人发邀请 (走 BizMeetingController.edit 同一路径, 不需要 dedup — 这是新行)
-        BizMeeting m = bizMeetingService.getById(body.getMeetingId());
-        bizNotifyService.meetingInvitation(body.getUserId(), body.getMeetingId(),
-                m != null ? m.getMeetingName() : null,
-                m != null ? m.getStartTime() : null);
+        // 人员变化 → 会议费用待重算
+        bizMeetingService.markFeeCalcPending(body.getMeetingId());
+        // 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发
         return success(attendeeId);
     }
 
@@ -102,8 +110,14 @@ public class BizMeetingAttendeeController extends BaseController {
         if (body.getId() == null) {
             return error("id 不能为空");
         }
+        BizMeetingAttendee before = attendeeService.selectById(body.getId());
         body.setUpdateBy(SecurityUtils.getUsername());
-        return toAjax(attendeeService.updateProfile(body));
+        int rows = attendeeService.updateProfile(body);
+        // 人员变化 → 会议费用待重算
+        if (before != null) {
+            bizMeetingService.markFeeCalcPending(before.getMeetingId());
+        }
+        return toAjax(rows);
     }
 
     /**
@@ -113,7 +127,33 @@ public class BizMeetingAttendeeController extends BaseController {
     @Log(title = "参会人", businessType = BusinessType.DELETE)
     @DeleteMapping("/{id}")
     public AjaxResult remove(@PathVariable("id") Long id) {
-        return toAjax(attendeeService.deleteByPrimaryKey(id));
+        BizMeetingAttendee before = attendeeService.selectById(id);
+        int rows = attendeeService.deleteByPrimaryKey(id);
+        // 人员变化 → 会议费用待重算
+        if (before != null) {
+            bizMeetingService.markFeeCalcPending(before.getMeetingId());
+        }
+        return toAjax(rows);
+    }
+
+    /**
+     * 推送电子签: body = attendee.id 数组 [1,2,3] (批量=勾选后传选中 id, 每行=传 [row.id]).
+     * 逐个发短信 + 推站内信 + 置 is_esigned=1, 返回成功条数.
+     */
+    @Log(title = "推送电子签", businessType = BusinessType.UPDATE)
+    @PostMapping("/esign")
+    public AjaxResult esign(@RequestBody List attendeeIds) {
+        return success(attendeeService.pushEsign(attendeeIds));
+    }
+
+    /**
+     * 邀请参会: body = attendee.id 数组 [1,2,3] (批量=勾选后传选中 id, 每行=传 [row.id]).
+     * 逐个推"会议邀请"站内信 (不发短信) + 置 is_invited=1, 返回成功条数.
+     */
+    @Log(title = "邀请参会", businessType = BusinessType.UPDATE)
+    @PostMapping("/invite")
+    public AjaxResult invite(@RequestBody List attendeeIds) {
+        return success(attendeeService.invite(attendeeIds));
     }
 
     /** 更新手写签名 (Base64 字符串, 直接存 DB longtext) */
@@ -139,14 +179,10 @@ public class BizMeetingAttendeeController extends BaseController {
     }
 
     /**
-     * 批量导入参会人 — 上传 Excel + 解析入库 + #5 会议邀请差集推送.
+     * 批量导入参会人 — 上传 Excel + 解析入库.
      *
-     * 

三步: - *

    - *
  1. 查"导入前"该会议已存在的参会人 userIds (Set)
  2. - *
  3. 调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList
  4. - *
  5. 查"导入后"该会议 userIds, 与"前"做差集 → 仅给"新加入"的 userId 推 #5 会议邀请
  6. - *
+ *

调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList. + * 邀请参会已改为手动, 此处不再自动推"会议邀请". * *

返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染. * @@ -156,27 +192,50 @@ public class BizMeetingAttendeeController extends BaseController { @PostMapping("/importData") public AjaxResult importData(@RequestParam("file") MultipartFile file, @RequestParam("meetingId") Long meetingId) throws Exception { - // 1. 导入前快照 - Set preUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId)); - - // 2. 解析 + 入库 + // 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发) ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername()); - - // 3. 差集 → 仅对"新加入" userId 推 #5 邀请 - Set postUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId)); - postUserIds.removeAll(preUserIds); - if (!postUserIds.isEmpty()) { - BizMeeting m = bizMeetingService.getById(meetingId); - String meetingName = m != null ? m.getMeetingName() : null; - java.util.Date startTime = m != null ? m.getStartTime() : null; - for (Long uid : postUserIds) { - bizNotifyService.meetingInvitation(uid, meetingId, meetingName, startTime); - } + // 人员变化 → 会议费用待重算 (有成功导入才需重算, 但幂等, 直接标记) + if (result != null && result.getOkNum() > 0) { + bizMeetingService.markFeeCalcPending(meetingId); } - return success(result); } + /** + * 导出某会议的参会人档案 (Excel, 复用导入 VO 的 @Excel 列头, 含账户名称/开户行地址/银行详细地址/身份证附件). + */ + @Log(title = "参会人导出", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, @RequestParam("meetingId") Long meetingId) { + List list = attendeeService.selectByMeetingId(meetingId); + List exportList = new ArrayList<>(list.size()); + for (BizMeetingAttendee a : list) { + BizMeetingAttendeeImportVo v = new BizMeetingAttendeeImportVo(); + v.setName(a.getName()); + v.setPhone(a.getPhone()); + v.setWorkUnit(a.getWorkUnit()); + v.setDepartment(a.getDepartment()); + v.setTitle(a.getTitle()); + v.setIdCard(a.getIdCard()); + v.setBankName(a.getBankName()); + v.setBankCard(a.getBankCard()); + v.setBankBranch(a.getBankBranch()); + v.setAccountName(a.getAccountName()); + v.setBankRegion(a.getBankRegion()); + v.setBankAddress(a.getBankAddress()); + v.setIdCardAttachments(a.getIdCardAttachments()); + v.setLaborForm(a.getLaborForm()); + v.setFeePreTax(a.getFeePreTax()); + v.setTax(a.getTax()); + v.setVatAndSurcharge(a.getVatAndSurcharge()); + v.setFee(a.getFee()); + v.setSummary(a.getSummary()); + exportList.add(v); + } + ExcelUtil util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class); + util.exportExcel(response, exportList, "参会人"); + } + /** * 更新劳务协议 URL (OSS 上传后调本接口). * diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java index b5a48f9..04df21b 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java @@ -2,6 +2,7 @@ package com.ruoyi.business.controller; import java.util.Date; import java.util.List; +import java.util.Map; import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.domain.BizMeetingAuditLog; import com.ruoyi.business.domain.BizMeetingSupervisor; @@ -11,6 +12,7 @@ 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.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; @@ -20,9 +22,15 @@ 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.domain.BizProject; import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.service.IBizMeetingService; +import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizMeetingAttendeeService; +import com.ruoyi.business.service.StageDeriver; +import com.ruoyi.business.service.PosterService; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.system.mapper.SysUserMapper; /** * 会议Controller @@ -45,13 +53,42 @@ public class BizMeetingController extends BaseController { private IBizMeetingExecutorService bizMeetingExecutorService; @Autowired private BizNotifyService bizNotifyService; + @Autowired + private SysUserMapper sysUserMapper; + @Autowired + private IBizProjectService bizProjectService; + @Autowired + private StageDeriver stageDeriver; + @Autowired + private PosterService posterService; @GetMapping("/list") public TableDataInfo list(BizMeeting bizMeeting) { - // 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表) + Long uid = SecurityUtils.getUserId(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); + // 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表) if ("doctor".equals(roleType) || "expert".equals(roleType)) { - bizMeeting.setUserId(SecurityUtils.getUserId()); + bizMeeting.setUserId(uid); + } + // sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_admin_user_id, SUB 走 sponsor_assign.monitor_user_id). + // 与项目列表 selectSponsorList 的 MAIN/SUB 判定平行, 但刻意不带 biz_publicity_support_intent 关联. + else if ("sponsor".equals(roleType)) { + SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid); + if (current != null && "SUB".equals(current.getAccountType())) { + bizMeeting.getParams().put("monitorUserId", uid); + } else { + bizMeeting.getParams().put("sponsorAdminUserId", uid); + } + } + // executor 数据权限: 只看"我的项目"下的会议 (与项目列表 selectExecutorList/selectExecutorStaffList 同源). + // MAIN 走 biz_project_assign (exec_user_id / execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id. + else if ("executor".equals(roleType)) { + SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid); + if (current != null && "SUB".equals(current.getAccountType())) { + bizMeeting.getParams().put("executorStaffUserId", uid); + } else { + bizMeeting.getParams().put("executorUserId", uid); + } } startPage(); List list = bizMeetingService.selectList(bizMeeting); @@ -66,17 +103,37 @@ public class BizMeetingController extends BaseController { @Log(title = "会议", businessType = BusinessType.INSERT) @PostMapping public AjaxResult add(@RequestBody BizMeeting bizMeeting) { + // executor 建会限额: 执行机构人员 (MAIN/SUB 只要能看到项目) 都可建会, 但该项目的会议数不得超过分配给本公司的场次. + // 场次是公司维度: SUB 执行人反查主账号 parent_user_id 聚合 (与项目列表 assigned_sessions 口径一致). + // 注意: 会议数按"该项目下全部未软删会议"计数 (biz_meeting 无执行方归属列, 无法区分是哪个执行方建的) — 见 memory [[ry-executor-staff-project-visibility]]. + String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); + if ("executor".equals(roleType)) { + Long uid = SecurityUtils.getUserId(); + Long projectId = bizMeeting.getProjectId(); + if (projectId == null) { + throw new ServiceException("建会必须指定 projectId"); + } + Long aggUid = uid; + SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid); + if (current != null && "SUB".equals(current.getAccountType()) && current.getParentUserId() != null) { + aggUid = current.getParentUserId(); + } + int assigned = bizProjectService.countAssignedSessions(projectId, aggUid); + int existing = bizMeetingService.countByProjectId(projectId); + if (existing >= assigned) { + throw new ServiceException("本项目分配给本公司的场次为 " + assigned + " 场, 已建 " + existing + " 场, 已达上限"); + } + } + // 创建人/时间: DB 列无默认值, 需代码显式落库 (否则详情页 createBy/createTime 为空) + bizMeeting.setCreateBy(SecurityUtils.getUsername()); + bizMeeting.setCreateTime(new Date()); + bizMeeting.setUpdateBy(SecurityUtils.getUsername()); + bizMeeting.setUpdateTime(new Date()); int rows = bizMeetingService.insert(bizMeeting); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); if (attendeeUserIds != null && attendeeUserIds.length > 0) { attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds); - // #5 会议邀请通知 (新增会议, 全部新参会人都要通知) - String meetingName = bizMeeting.getMeetingName(); - Date startTime = bizMeeting.getStartTime(); - for (Long uid : attendeeUserIds) { - if (uid == null) continue; - bizNotifyService.meetingInvitation(uid, bizMeeting.getMeetingId(), meetingName, startTime); - } + // 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发 } return toAjax(rows); } @@ -84,23 +141,32 @@ public class BizMeetingController extends BaseController { @Log(title = "会议", businessType = BusinessType.UPDATE) @PutMapping public AjaxResult edit(@RequestBody BizMeeting bizMeeting) { + bizMeeting.setUpdateBy(SecurityUtils.getUsername()); + bizMeeting.setUpdateTime(new Date()); int rows = bizMeetingService.updateByPrimaryKey(bizMeeting); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); if (attendeeUserIds != null && attendeeUserIds.length > 0) { Long meetingId = bizMeeting.getMeetingId(); - // #5 dedup: 先拿已有的 userId 集合, 仅给"新增"的 userId 发通知, 避免重复打扰已参会医生 - java.util.Set existingUids = new java.util.HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId)); attendeeService.insertBatch(meetingId, attendeeUserIds); - String meetingName = bizMeeting.getMeetingName(); - Date startTime = bizMeeting.getStartTime(); - for (Long uid : attendeeUserIds) { - if (uid == null || existingUids.contains(uid)) continue; - bizNotifyService.meetingInvitation(uid, meetingId, meetingName, startTime); - } + // 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发 } return toAjax(rows); } + /** + * 生成海报: 下载日程海报 (width=1200) → Java2D 叠加会议信息 → 上传 OSS → 回写 poster_url. + * 返回生成海报的 OSS URL. + */ + @Log(title = "生成海报", businessType = BusinessType.UPDATE) + @PostMapping("/{meetingId}/generate-poster") + public AjaxResult generatePoster(@PathVariable("meetingId") Long meetingId, + @RequestBody(required = false) Map body) { + boolean addText = body != null && Boolean.TRUE.equals(body.get("addText")); + String textColor = body != null && body.get("textColor") != null ? body.get("textColor").toString() : "#FFFFFF"; + String url = posterService.generatePoster(meetingId, addText, textColor); + return AjaxResult.success("海报生成成功", url); + } + /** * 软删除会议 (admin/manager 会议管理用, 后端强校验 role_type) * 级联置 biz_meeting + 5 张子表 is_deleted=1, 数据保留审计追溯 @@ -117,17 +183,18 @@ public class BizMeetingController extends BaseController { } // =================================================================== - // 审核流程端点 (5 个) + // 审核流程端点 (事实模型) // =================================================================== /** * 执行人员提交材料 *

    - *
  • 校验 1: 当前用户是该会议执行人员 (强校验)
  • - *
  • 校验 2: material_audit_stage = INIT
  • - *
  • 校验 3: biz_meeting_material 至少 1 条 L_* + 至少 1 条 M_*
  • + *
  • 校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)
  • + *
  • 校验 2: 已执行 (is_executed=1) 且未冻结
  • + *
  • 校验 3: material_audit_stage ∈ {NOT_SUBMITTED, REJECTED}
  • + *
  • 校验 4: biz_meeting_material 劳务(L_*)与会务(M_*)各至少 1 条, 不必全部子类型填满
  • *
- * 通过后 material_audit_stage INIT → SUBMITTED, 记 audit_log. + * 通过后 material → SUBMITTED (compliance_approved=0), 记 audit_log. */ @Log(title = "会议审核", businessType = BusinessType.UPDATE) @PostMapping("/{meetingId}/submit-material") @@ -136,24 +203,29 @@ public class BizMeetingController extends BaseController { 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() + ") 不允许提交材料"); + if (!bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) { + throw new ServiceException("您不是该项目的执行方, 无法提交材料"); + } + if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交材料"); + if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交材料"); + String stage = m.getMaterialAuditStage(); + if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) { + throw new ServiceException("当前阶段 (" + stage + ") 不允许提交材料"); } List mats = bizMeetingMaterialService.selectByMeetingId(meetingId); boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_")); boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_")); if (!hasLabor || !hasService) { - throw new ServiceException("请同时上传劳务材料和会务材料"); + throw new ServiceException("劳务材料和会务材料各至少上传一条"); } m.setMaterialAuditStage("SUBMITTED"); + m.setMaterialComplianceApproved(0); + m.setMaterialAuditTime(new Date()); + m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); bizMeetingService.updateByPrimaryKey(m); - appendAuditLog(meetingId, "MATERIAL", "SUBMITTED", "APPROVED", "执行人员提交材料"); + appendAuditLog(m, "MATERIAL", "SUBMITTED", "执行人员提交材料"); return success("SUBMITTED"); } @@ -167,12 +239,14 @@ public class BizMeetingController extends BaseController { 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() + ") 不允许提交凭证"); + if (!bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) { + throw new ServiceException("您不是该项目的执行方, 无法提交凭证"); + } + if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交凭证"); + if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交凭证"); + String stage = m.getVoucherAuditStage(); + if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) { + throw new ServiceException("当前阶段 (" + stage + ") 不允许提交凭证"); } List mats = bizMeetingMaterialService.selectByMeetingId(meetingId); @@ -183,14 +257,19 @@ public class BizMeetingController extends BaseController { } m.setVoucherAuditStage("SUBMITTED"); + m.setVoucherComplianceApproved(0); + m.setVoucherAuditTime(new Date()); bizMeetingService.updateByPrimaryKey(m); - appendAuditLog(meetingId, "VOUCHER", "SUBMITTED", "APPROVED", "执行人员提交凭证"); + appendAuditLog(m, "VOUCHER", "SUBMITTED", "执行人员提交凭证"); return success("SUBMITTED"); } /** - * 合规审核 (role_type=manager) - * body: { "auditType": "MATERIAL"|"VOUCHER", "approved": true|false, "opinion": "..." } + * 合规审核 (role_type=manager), 两级审核中的第一级. + *

+ * body: { "auditType": "MATERIAL"|"VOUCHER"|"BOTH", "approved": true|false, "opinion": "..." } + *

合规审中判据: stage=SUBMITTED 且 compliance_approved=0. + * 通过 → compliance_approved=1 (转入支持方审), 拒绝 → REJECTED (退回执行方). */ @Log(title = "会议审核", businessType = BusinessType.UPDATE) @PostMapping("/{meetingId}/audit-compliance") @@ -201,67 +280,149 @@ public class BizMeetingController extends BaseController { 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())) { + String[] types = resolveTypes(body.getAuditType()); + boolean approved = Boolean.TRUE.equals(body.getApproved()); + if (!approved && (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); + String result = approved ? "APPROVED" : "REJECTED"; + for (String type : types) { + String stage = stageOf(m, type); + boolean complianceDone = complianceApprovedOf(m, type); + if (!"SUBMITTED".equals(stage) || complianceDone) { + throw new ServiceException(type + " 当前阶段不允许合规审核"); + } + if (approved) { + setComplianceApproved(m, type, 1); + } else { + setStage(m, type, "REJECTED"); + } + setAuditTime(m, type, new Date()); } + m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); bizMeetingService.updateByPrimaryKey(m); - appendAuditLog(meetingId, auditType, newStage, result, body.getOpinion()); - return success(newStage); + for (String type : types) { + appendAuditLog(m, type, result, body.getOpinion()); + } + return success(result); } /** - * 监察审核 (强校验: 当前用户必须是该会议监察员) - * body: { "auditType": "MATERIAL"|"VOUCHER", "approved": true|false, "opinion": "..." } + * 支持方(监察员) 审核, 两级审核中的第二级. + *

+ * body: { "auditType": "MATERIAL"|"VOUCHER"|"BOTH", "approved": true|false, "opinion": "..." } + *

支持方审中判据: stage=SUBMITTED 且 compliance_approved=1. + * 通过 → APPROVED (材料通过时一并写监管意见), 拒绝 → REJECTED (退回执行方). */ @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"); + // 授权: 监察员 (biz_meeting_supervisor) 或 支持方 MAIN 账号 (biz_project.sponsor_admin_user_id) 均可审 + boolean isSupervisor = bizMeetingSupervisorService.selectByMeetingId(meetingId).stream() + .anyMatch(s -> userId.equals(s.getUserId())); + BizProject project = m.getProjectId() == null ? null : bizProjectService.getById(m.getProjectId()); + boolean isSponsorMain = project != null && userId.equals(project.getSponsorAdminUserId()); + if (!isSupervisor && !isSponsorMain) { + throw new ServiceException("您不是该会议监察员, 无权监察"); } - 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())) { + + String[] types = resolveTypes(body.getAuditType()); + boolean approved = Boolean.TRUE.equals(body.getApproved()); + if (!approved && (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); + String result = approved ? "APPROVED" : "REJECTED"; + for (String type : types) { + String stage = stageOf(m, type); + boolean complianceDone = complianceApprovedOf(m, type); + if (!"SUBMITTED".equals(stage) || !complianceDone) { + throw new ServiceException(type + " 当前阶段不允许监察审核"); + } + setStage(m, type, approved ? "APPROVED" : "REJECTED"); + setAuditTime(m, type, new Date()); } + // 材料通过 → 写监管意见 (支持方的书面意见) + if (approved && java.util.Arrays.asList(types).contains("MATERIAL")) { + m.setSupervisionOpinion(body.getOpinion()); + m.setSupervisionBy(SecurityUtils.getUsername()); + m.setSupervisionTime(new Date()); + } + m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); bizMeetingService.updateByPrimaryKey(m); - appendAuditLog(meetingId, auditType, newStage, result, body.getOpinion()); - return success(newStage); + for (String type : types) { + appendAuditLog(m, type, result, body.getOpinion()); + } + + // 退回 → 通知执行方 (待整改 + 说明) + if (!approved) { + for (BizMeetingExecutor e : bizMeetingExecutorService.selectByMeetingId(meetingId)) { + bizNotifyService.meetingSupervisionRejected(e.getUserId(), meetingId, m.getMeetingName(), body.getOpinion()); + } + } + return success(result); + } + + /** + * 结算 (合规/管理员 手动点击). 前置: material+voucher 都 APPROVED. + */ + @Log(title = "会议审核", businessType = BusinessType.UPDATE) + @PostMapping("/{meetingId}/settle") + @Transactional(rollbackFor = Exception.class) + public AjaxResult settle(@PathVariable("meetingId") Long meetingId) { + String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); + if (!"manager".equals(roleType) && !"admin".equals(roleType)) { + throw new ServiceException("只有合规或管理员可结算"); + } + BizMeeting m = bizMeetingService.getById(meetingId); + if (m == null) throw new ServiceException("会议不存在"); + if (!"APPROVED".equals(m.getMaterialAuditStage()) || !"APPROVED".equals(m.getVoucherAuditStage())) { + throw new ServiceException("材料与凭证均审核通过后才能结算"); + } + if (isSettled(m)) throw new ServiceException("会议已结算"); + // 费用未汇总完 (fee_calc_status=0) 禁止结算: 此时 labor_fee/meeting_fee 可能为旧值/0, 直接回写会污染项目金额. + // 无发票的材料 fee_status 恒为 1, 调度器只会因"存在待 OCR 发票"而停在 0, 故无发票的会议天然不会被此校验误挡. + if (m.getFeeCalcStatus() == null || m.getFeeCalcStatus() != 1) { + throw new ServiceException("会议费用尚未汇总完成,无法结算"); + } + m.setIsSettled(1); + m.setSettleTime(new Date()); + m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); + bizMeetingService.updateByPrimaryKey(m); + // 结算成功 → 触发项目金额重算 (全量 SUM 已结算会议, 幂等) + if (m.getProjectId() != null) { + bizProjectService.recomputeSettledAmounts(m.getProjectId()); + } + appendAuditLog(m, "SETTLE", "APPROVED", "会议结算"); + return success("SETTLED"); + } + + /** + * 完结 (合规/管理员 手动点击). 前置: 已结算 (is_settled=1). + */ + @Log(title = "会议审核", businessType = BusinessType.UPDATE) + @PostMapping("/{meetingId}/finish") + public AjaxResult finish(@PathVariable("meetingId") Long meetingId) { + String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); + if (!"manager".equals(roleType) && !"admin".equals(roleType)) { + throw new ServiceException("只有合规或管理员可完结"); + } + BizMeeting m = bizMeetingService.getById(meetingId); + if (m == null) throw new ServiceException("会议不存在"); + if (!isSettled(m)) throw new ServiceException("会议尚未结算, 不能完结"); + if (isFinished(m)) throw new ServiceException("会议已完结"); + m.setIsFinished(1); + m.setFinishTime(new Date()); + m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); + bizMeetingService.updateByPrimaryKey(m); + appendAuditLog(m, "FINISH", "APPROVED", "会议完结"); + return success("FINISHED"); } /** @@ -275,25 +436,65 @@ public class BizMeetingController extends BaseController { return success(list); } + // =================================================================== + // 事实字段辅助 (null 安全) + // =================================================================== + + private static boolean isExecuted(BizMeeting m) { return m.getIsExecuted() != null && m.getIsExecuted() == 1; } + private static boolean isFrozen(BizMeeting m) { return m.getIsFrozen() != null && m.getIsFrozen() == 1; } + private static boolean isSettled(BizMeeting m) { return m.getIsSettled() != null && m.getIsSettled() == 1; } + private static boolean isFinished(BizMeeting m) { return m.getIsFinished() != null && m.getIsFinished() == 1; } + + /** 材料/凭证 子状态读取 */ + private static String stageOf(BizMeeting m, String type) { + return "MATERIAL".equals(type) ? m.getMaterialAuditStage() : m.getVoucherAuditStage(); + } + private static boolean complianceApprovedOf(BizMeeting m, String type) { + Integer v = "MATERIAL".equals(type) ? m.getMaterialComplianceApproved() : m.getVoucherComplianceApproved(); + return v != null && v == 1; + } + private static void setStage(BizMeeting m, String type, String stage) { + if ("MATERIAL".equals(type)) m.setMaterialAuditStage(stage); + else m.setVoucherAuditStage(stage); + } + private static void setComplianceApproved(BizMeeting m, String type, int v) { + if ("MATERIAL".equals(type)) m.setMaterialComplianceApproved(v); + else m.setVoucherComplianceApproved(v); + } + private static void setAuditTime(BizMeeting m, String type, Date t) { + if ("MATERIAL".equals(type)) m.setMaterialAuditTime(t); + else m.setVoucherAuditTime(t); + } + + /** auditType: MATERIAL / VOUCHER / BOTH → 处理类型数组 */ + private static String[] resolveTypes(String auditType) { + if ("BOTH".equals(auditType)) return new String[] { "MATERIAL", "VOUCHER" }; + if ("MATERIAL".equals(auditType) || "VOUCHER".equals(auditType)) return new String[] { auditType }; + throw new ServiceException("auditType 必须是 MATERIAL / VOUCHER / BOTH"); + } + /** - * 内部: 写一条 audit_log + * 内部: 写一条 audit_log (4 列角色展示状态由 post-transition 事实推导). */ - private void appendAuditLog(Long meetingId, String auditType, String stage, String result, String opinion) { + private void appendAuditLog(BizMeeting m, String auditType, String result, String opinion) { BizMeetingAuditLog log = new BizMeetingAuditLog(); - log.setMeetingId(meetingId); + log.setMeetingId(m.getMeetingId()); log.setAuditor(SecurityUtils.getUsername()); log.setAuditType(auditType); - log.setCurrentStage(stage); log.setAuditResult(result); log.setOpinion(opinion); log.setCreateTime(new Date()); log.setAuditTime(new Date()); + log.setExecutorStage(stageDeriver.deriveDisplay("executor", m)); + log.setSponsorStage(stageDeriver.deriveDisplay("sponsor", m)); + log.setManagerStage(stageDeriver.deriveDisplay("manager", m)); + log.setAdminStage(stageDeriver.deriveDisplay("admin", m)); bizMeetingAuditLogService.insert(log); } /** request body for audit endpoints */ public static class AuditBody { - private String auditType; // MATERIAL / VOUCHER + private String auditType; // MATERIAL / VOUCHER / BOTH private Boolean approved; // true=通过 false=拒绝 private String opinion; // 意见 public String getAuditType() { return auditType; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java index 7ac0d01..2cd7d1e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java @@ -10,6 +10,7 @@ import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.service.IBizMeetingMaterialService; +import com.ruoyi.business.service.IBizMeetingService; /** * 会议材料 Controller @@ -22,6 +23,8 @@ public class BizMeetingMaterialController extends BaseController { @Autowired private IBizMeetingMaterialService bizMeetingMaterialService; + @Autowired + private IBizMeetingService bizMeetingService; /** * 查该会议的所有材料记录 @@ -50,6 +53,20 @@ public class BizMeetingMaterialController extends BaseController { } } List saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list); + // 材料变化 → 会议费用待重算 (FeeCalcScheduler 汇总回写) + bizMeetingService.markFeeCalcPending(meetingId); return success(saved); } + + /** + * 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL). + *

+ * body: { meetingId, subType, ossUrl }. 后端白名单 subType + 会议存在校验. + * 照片类 NON_OCR, 不影响会议费用. + */ + @PostMapping("/cameraUpload") + public AjaxResult cameraUpload(@RequestBody BizMeetingMaterial body) { + bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrl(), body.getExtraOssUrl()); + return success(); + } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java index 67023a5..0a58291 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java @@ -23,12 +23,16 @@ import com.ruoyi.business.domain.BizProjectRating; import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizExecutionIntentService; import com.ruoyi.business.domain.BizProjectSponsorAssign; +import com.ruoyi.business.domain.BizProjectExecutorAssign; import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.service.IBizProjectAssignService; import com.ruoyi.business.service.IBizProjectRatingService; import com.ruoyi.business.service.IBizProjectSponsorAssignService; +import com.ruoyi.business.service.IBizProjectExecutorAssignService; import com.ruoyi.system.domain.vo.SysUserExtendVo; import com.ruoyi.business.mapper.BizSysUserQueryMapper; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.system.mapper.SysUserMapper; /** * 项目Controller @@ -53,7 +57,11 @@ public class BizProjectController extends BaseController @Autowired private IBizProjectSponsorAssignService bizProjectSponsorAssignService; @Autowired + private IBizProjectExecutorAssignService bizProjectExecutorAssignService; + @Autowired private BizSysUserQueryMapper bizSysUserQueryMapper; + @Autowired + private SysUserMapper sysUserMapper; /** * 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目) @@ -116,15 +124,26 @@ public class BizProjectController extends BaseController } /** - * sponsor 专属项目列表 (按当前登录 sponsor 的 user_id 过滤) + * sponsor 专属项目列表 (按当前登录 sponsor 的账号类型分两种过滤) * GET /business/project/sponsorList - * 注: biz_project.sponsor_admin_user_id 永远是主账号 user_id, 子账号登录也应能看主账号的项目 — 简化: 直接用当前 user_id 过滤 - * 若需要子账号看主账号项目, 改 SQL 改为 (sponsor_admin_user_id = uid OR sponsor_admin_user_id IN (parent_user_id=uid 的子账号所属主账号)) + *

    + *
  • MAIN 主账号: 看自己 sponsor_admin_user_id 下的项目 (用 biz_project.sponsor_admin_user_id)
  • + *
  • SUB 子账号: 看自己被分配 (作为监察员 monitor) 的项目 (走 biz_project_sponsor_assign.monitor_user_id)
  • + *
+ * 注: 支持方不能创建项目, 这里只看分配结果. 子账号不再回退到 MAIN 路径, 严格隔离. */ @GetMapping("/sponsorList") public TableDataInfo sponsorList(BizProject bizProject) { - bizProject.getParams().put("sponsorAdminUserId", SecurityUtils.getUserId()); + Long uid = SecurityUtils.getUserId(); + SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid); + if (current != null && "SUB".equals(current.getAccountType())) { + // SUB 子账号视角: 走 sponsor_assign.monitor_user_id (监察员身份) + bizProject.getParams().put("monitorUserId", uid); + } else { + // MAIN 主账号视角 (含 admin / 其它兜底): 走 sponsor_admin_user_id + bizProject.getParams().put("sponsorAdminUserId", uid); + } startPage(); List list = bizProjectService.selectSponsorList(bizProject); return getDataTable(list); @@ -140,7 +159,20 @@ public class BizProjectController extends BaseController @GetMapping("/executorList") public TableDataInfo executorList(BizProject bizProject) { - bizProject.getParams().put("executorUserId", SecurityUtils.getUserId()); + Long uid = SecurityUtils.getUserId(); + SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid); + if (current != null && "SUB".equals(current.getAccountType())) { + // 执行人 (SUB 子账号) 视角: 反查 biz_project_executor_assign.staff_user_id, 严格隔离 + // (不 JOIN biz_project_assign, 不 UNION intent — executor 与 biz_*_intent 完全无关) + // 场次/金额列按"本公司"聚合: executorUserId 传 parent_user_id(主账号), 执行人看到的是公司数据而非个人数据 + bizProject.getParams().put("executorStaffUserId", uid); + bizProject.getParams().put("executorUserId", current.getParentUserId()); + startPage(); + List list = bizProjectService.selectExecutorStaffList(bizProject); + return getDataTable(list); + } + // 主账号视角 (原逻辑): biz_project_assign.exec_user_id / execution_unit_id + bizProject.getParams().put("executorUserId", uid); startPage(); List list = bizProjectService.selectExecutorList(bizProject); return getDataTable(list); @@ -232,6 +264,23 @@ public class BizProjectController extends BaseController return oa.compareTo(na) == 0; } + /** + * 比较两条 sponsor 分配是否对监察员而言"未变". + * 判定维度: assignDesc + assignPoints. (projectId 隐含相同, 旧数据就是本 projectId 的) + */ + private static boolean isSponsorAssignUnchanged(BizProjectSponsorAssign old, String assignDesc, String assignPoints) { + if (old == null) return false; // 新分配 → 算变化 + if (!Objects.equals(old.getAssignDesc(), assignDesc)) return false; + if (!Objects.equals(old.getAssignPoints(), assignPoints)) return false; + return true; + } + + /** String projectId → Long (sponsor_assign/executor_assign 表主键是 String, 查 biz_project 主表需 Long) */ + private static Long parseProjectId(String s) { + if (s == null || s.isEmpty()) return null; + try { return Long.parseLong(s); } catch (NumberFormatException e) { return null; } + } + @Log(title = "项目执行方分配", businessType = BusinessType.DELETE) @DeleteMapping("/{projectId}/assigns") public AjaxResult clearAssigns(@PathVariable("projectId") Long projectId) @@ -297,15 +346,101 @@ public class BizProjectController extends BaseController /** * 支持方分配监察员 (写 biz_project_sponsor_assign) * POST /business/project/sponsorAssign + * body: { projectId, monitorUserIds: [Long, ...] } — 多选 (前端 sponsor/my-projects 走这条) + * 兼容单值 { projectId, monitorUserId: Long } (前端 sponsor/Projects.vue 仍走单值) */ @Log(title = "支持方分配监察员", businessType = BusinessType.INSERT) @PostMapping("/sponsorAssign") public AjaxResult sponsorAssign(@RequestBody BizProjectSponsorAssign body) { + if (body.getProjectId() == null) { + return error("projectId 必填"); + } + java.util.List mids = body.getMonitorUserIds(); + if (mids == null || mids.isEmpty()) { + // 向后兼容: 单值 monitorUserId + if (body.getMonitorUserId() != null) { + mids = java.util.Collections.singletonList(body.getMonitorUserId()); + } else { + return error("monitorUserIds / monitorUserId 必填"); + } + } + // 通知去重: 拉旧监察员列表按 monitorUserId 索引, 同 (assignDesc, assignPoints) → 跳过 (修改不重复发) + List oldList = bizProjectSponsorAssignService.listByProjectId(body.getProjectId()); + Map oldByMonitor = new HashMap<>(); + if (oldList != null) { + for (BizProjectSponsorAssign o : oldList) { + if (o.getMonitorUserId() != null) oldByMonitor.put(o.getMonitorUserId(), o); + } + } body.setCreateBy(SecurityUtils.getUsername()); body.setSponsorUserId(SecurityUtils.getUserId()); - int rows = bizProjectSponsorAssignService.insertAssign(body); - return toAjax(rows); + // 一项目支持 N 监察员: service 内一次性 delete + 逐个 insert, 不会循环 delete + int inserted = bizProjectSponsorAssignService.assignMonitorsForProject(body, mids); + + // 通知被分配的监察员 (新增 / 说明或积分变化才发). projectId 在 sponsor_assign 是 String, 转 Long 查主表 + Long projectIdLong = parseProjectId(body.getProjectId()); + BizProject project = projectIdLong != null ? bizProjectService.getById(projectIdLong) : null; + String projectName = project != null ? project.getProjectName() : null; + for (Long mid : mids) { + if (mid == null) continue; + BizProjectSponsorAssign old = oldByMonitor.get(mid); + if (isSponsorAssignUnchanged(old, body.getAssignDesc(), body.getAssignPoints())) { + logger.debug("[sponsorAssign] monitorUserId={} (assignDesc, assignPoints) 未变, 跳过通知", mid); + continue; + } + bizNotifyService.projectAssignedToSponsor(mid, projectIdLong, projectName, body.getAssignDesc(), body.getAssignPoints()); + } + return toAjax(inserted); + } + + /** + * 查询项目已分配的监察员列表 (供前端 dialog 重开时回显) + * GET /business/project/{projectId}/sponsorAssigns + */ + @GetMapping("/{projectId}/sponsorAssigns") + public AjaxResult listSponsorAssigns(@PathVariable("projectId") String projectId) + { + return success(bizProjectSponsorAssignService.listByProjectId(projectId)); + } + + /** + * 执行方分配执行人 (写 biz_project_executor_assign) + * POST /business/project/executorAssign + * body: { projectId, staffUserIds: [Long, ...] } — 多选 (执行方给自己的项目分配执行人) + * 兼容单值 { projectId, staffUserId: Long } + */ + @Log(title = "执行方分配执行人", businessType = BusinessType.INSERT) + @PostMapping("/executorAssign") + public AjaxResult executorAssign(@RequestBody BizProjectExecutorAssign body) + { + if (body.getProjectId() == null) { + return error("projectId 必填"); + } + java.util.List sids = body.getStaffUserIds(); + if (sids == null || sids.isEmpty()) { + // 向后兼容: 单值 staffUserId + if (body.getStaffUserId() != null) { + sids = java.util.Collections.singletonList(body.getStaffUserId()); + } else { + return error("staffUserIds / staffUserId 必填"); + } + } + body.setCreateBy(SecurityUtils.getUsername()); + body.setExecutorUserId(SecurityUtils.getUserId()); + // 一项目支持 N 执行人: service 内一次性 delete + 逐个 insert, 不会循环 delete + int inserted = bizProjectExecutorAssignService.assignStaffForProject(body, sids); + return toAjax(inserted); + } + + /** + * 查询项目已分配的执行人列表 (供前端 dialog 重开时回显) + * GET /business/project/{projectId}/executorAssigns + */ + @GetMapping("/{projectId}/executorAssigns") + public AjaxResult listExecutorAssigns(@PathVariable("projectId") String projectId) + { + return success(bizProjectExecutorAssignService.listByProjectId(projectId)); } /** @@ -349,7 +484,22 @@ public class BizProjectController extends BaseController } body.setCreateBy(loginName); body.setSponsorUserId(loginUid); + // 通知去重: 拉旧分配, 找同 monitorUserId, 比较 assignDesc/assignPoints 是否变化 + BizProjectSponsorAssign old = null; + List oldList = bizProjectSponsorAssignService.listByProjectId(body.getProjectId()); + if (oldList != null) { + for (BizProjectSponsorAssign o : oldList) { + if (Objects.equals(o.getMonitorUserId(), body.getMonitorUserId())) { old = o; break; } + } + } + boolean changed = !isSponsorAssignUnchanged(old, body.getAssignDesc(), body.getAssignPoints()); bizProjectSponsorAssignService.insertAssign(body); + if (changed) { + Long pid = parseProjectId(body.getProjectId()); + BizProject project = pid != null ? bizProjectService.getById(pid) : null; + String projectName = project != null ? project.getProjectName() : null; + bizNotifyService.projectAssignedToSponsor(body.getMonitorUserId(), pid, projectName, body.getAssignDesc(), body.getAssignPoints()); + } ok++; } catch (Exception e) { errors.add("第" + (i + 1) + "条 (projectId=" + body.getProjectId() + "): " + e.getMessage()); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizSignController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizSignController.java index 64929f4..92508a7 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizSignController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizSignController.java @@ -32,6 +32,12 @@ public class BizSignController extends BaseController { return success(signService.getSignInfo(attendeeId)); } + /** 扫码入口: 只有 meetingId (无 attendeeId) 时, 返回 {meetingName, periodNo, totalPeriods, attendeeId} (不在邀请之列 attendeeId=null) */ + @GetMapping("/resolve") + public AjaxResult resolveByMeeting(@RequestParam("meetingId") Long meetingId) { + return success(signService.resolveByMeeting(meetingId)); + } + /** 医生点 "保存" / "下一步" 后调 */ @PostMapping("/saveProfile") public AjaxResult saveProfile(@RequestParam("attendeeId") Long attendeeId, diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizSupportIntentController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizSupportIntentController.java deleted file mode 100644 index 0512234..0000000 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizSupportIntentController.java +++ /dev/null @@ -1,53 +0,0 @@ -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.BizSupportIntent; -import com.ruoyi.business.service.IBizSupportIntentService; - -/** - * 支持意向Controller - */ -@RestController -@RequestMapping("/business/supportIntent") -public class BizSupportIntentController extends BaseController -{ - @Autowired - private IBizSupportIntentService bizSupportIntentService; - @GetMapping("/list") - public TableDataInfo list(BizSupportIntent bizSupportIntent) - { - startPage(); - List list = bizSupportIntentService.selectList(bizSupportIntent); - return getDataTable(list); - } - @GetMapping("/{intentId}") - public AjaxResult getInfo(@PathVariable("intentId") String intentId) - { - return success(bizSupportIntentService.getById(intentId)); - } - @Log(title = "支持意向", businessType = BusinessType.INSERT) - @PostMapping - public AjaxResult add(@RequestBody BizSupportIntent bizSupportIntent) - { - return toAjax(bizSupportIntentService.insert(bizSupportIntent)); - } - @Log(title = "支持意向", businessType = BusinessType.UPDATE) - @PutMapping - public AjaxResult edit(@RequestBody BizSupportIntent bizSupportIntent) - { - return toAjax(bizSupportIntentService.updateByPrimaryKey(bizSupportIntent)); - } - @Log(title = "支持意向", businessType = BusinessType.DELETE) - @DeleteMapping("/{ids}") - public AjaxResult remove(@PathVariable String[] ids) - { - return toAjax(bizSupportIntentService.deleteByPrimaryKeys(ids)); - } -} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java index a6c5fae..33c4381 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java @@ -40,7 +40,7 @@ public class BizMeeting extends BaseEntity { /** period_no */ @Excel(name = "period_no") private Long periodNo; - /** current_stage */ + /** current_stage: 10 值枚举, 见 com.ruoyi.common.enums.BizMeetingStageEnum (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN). 字段类型保持 String 是因为 MyBatis 默认 EnumTypeHandler 需要额外注册, 直接 String + Enum 常量更简单. */ @Excel(name = "current_stage") private String currentStage; /** create_by */ @@ -70,20 +70,64 @@ public class BizMeeting extends BaseEntity { /** 监察时间 (与 DB datetime 对齐) */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date supervisionTime; - /** 材料审核阶段 (INIT=待提交, 后续阶段开发中定) */ + /** 材料审核阶段 (NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED, 见 MeetingAuditStageEnum) */ private String materialAuditStage; - /** 凭证审核阶段 (INIT=待提交, 后续阶段开发中定) */ + /** 凭证审核阶段 (同上) */ private String voucherAuditStage; + /** 是否执行 0否1是 (会议开始时间到, scheduler 置1) */ + private Integer isExecuted; + /** 执行时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date executeTime; + /** 是否结算 0否1是 (合规点击结算置1) */ + private Integer isSettled; + /** 结算时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date settleTime; + /** 是否完结 0否1是 (合规/管理员点击完结置1) */ + private Integer isFinished; + /** 完结时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date finishTime; + /** 是否冻结 0否1是 (逾期未提交, scheduler 置1) */ + private Integer isFrozen; + /** 冻结时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date freezeTime; + /** 材料最近一次审核动作时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date materialAuditTime; + /** 凭证最近一次审核动作时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date voucherAuditTime; + /** 材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */ + private Integer materialComplianceApproved; + /** 凭证合规是否已通过 0否1是 */ + private Integer voucherComplianceApproved; /** 邀请函URL */ private String invitationUrl; /** 日程海报URL */ private String scheduleUrl; + /** 生成的海报URL (生成海报按钮产出) */ + private String posterUrl; /** 签署劳务 0未签 1已签 */ private String laborSigned; /** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */ private transient Long userId; + /** 当前登录医生/专家在本会议的参会人记录 id (非持久化, mapper 子查询填充; 用于 /doctor/meetings 签署劳务链接) */ + private transient Long attendeeId; + /** 当前登录医生/专家在本会议的已签劳务 PDF URL (非持久化, mapper 子查询填充; null=未签) */ + private transient String attendeeLaborProtocol; /** 新增/编辑会议时传入, 自动批量写入 biz_meeting_attendee 中间表 (非持久化) */ private Long[] attendeeUserIds; + /** 劳务费用 = 参会人应发金额 (fee_pre_tax) 合计 (后台定时任务汇总回写) */ + private BigDecimal laborFee; + /** 会务费用 = 总发票(M_INVOICE)覆盖 SUB 子类发票金额 (后台定时任务汇总回写) */ + private BigDecimal meetingFee; + /** 总费用 = 劳务费用 + 会务费用 */ + private BigDecimal totalFee; + /** 费用汇总状态 0未汇总 1已汇总 (经费变化置0, 定时任务汇总后置1) */ + private Integer feeCalcStatus; /** 软删除标记 0否1是 (admin 删除会议时置1, 查询需 WHERE is_deleted=0) */ private Integer isDeleted; public Long getMeetingId() { return meetingId; } @@ -133,16 +177,54 @@ public class BizMeeting extends BaseEntity { public void setInvitationUrl(String invitationUrl) { this.invitationUrl = invitationUrl; } public String getScheduleUrl() { return scheduleUrl; } public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; } + public String getPosterUrl() { return posterUrl; } + public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } 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 Integer getIsExecuted() { return isExecuted; } + public void setIsExecuted(Integer isExecuted) { this.isExecuted = isExecuted; } + public Date getExecuteTime() { return executeTime; } + public void setExecuteTime(Date executeTime) { this.executeTime = executeTime; } + public Integer getIsSettled() { return isSettled; } + public void setIsSettled(Integer isSettled) { this.isSettled = isSettled; } + public Date getSettleTime() { return settleTime; } + public void setSettleTime(Date settleTime) { this.settleTime = settleTime; } + public Integer getIsFinished() { return isFinished; } + public void setIsFinished(Integer isFinished) { this.isFinished = isFinished; } + public Date getFinishTime() { return finishTime; } + public void setFinishTime(Date finishTime) { this.finishTime = finishTime; } + public Integer getIsFrozen() { return isFrozen; } + public void setIsFrozen(Integer isFrozen) { this.isFrozen = isFrozen; } + public Date getFreezeTime() { return freezeTime; } + public void setFreezeTime(Date freezeTime) { this.freezeTime = freezeTime; } + public Date getMaterialAuditTime() { return materialAuditTime; } + public void setMaterialAuditTime(Date materialAuditTime) { this.materialAuditTime = materialAuditTime; } + public Date getVoucherAuditTime() { return voucherAuditTime; } + public void setVoucherAuditTime(Date voucherAuditTime) { this.voucherAuditTime = voucherAuditTime; } + public Integer getMaterialComplianceApproved() { return materialComplianceApproved; } + public void setMaterialComplianceApproved(Integer materialComplianceApproved) { this.materialComplianceApproved = materialComplianceApproved; } + public Integer getVoucherComplianceApproved() { return voucherComplianceApproved; } + public void setVoucherComplianceApproved(Integer voucherComplianceApproved) { this.voucherComplianceApproved = voucherComplianceApproved; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } + public Long getAttendeeId() { return attendeeId; } + public void setAttendeeId(Long attendeeId) { this.attendeeId = attendeeId; } + public String getAttendeeLaborProtocol() { return attendeeLaborProtocol; } + public void setAttendeeLaborProtocol(String attendeeLaborProtocol) { this.attendeeLaborProtocol = attendeeLaborProtocol; } public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public Long[] getAttendeeUserIds() { return attendeeUserIds; } public void setAttendeeUserIds(Long[] attendeeUserIds) { this.attendeeUserIds = attendeeUserIds; } + public BigDecimal getLaborFee() { return laborFee; } + public void setLaborFee(BigDecimal laborFee) { this.laborFee = laborFee; } + public BigDecimal getMeetingFee() { return meetingFee; } + public void setMeetingFee(BigDecimal meetingFee) { this.meetingFee = meetingFee; } + public BigDecimal getTotalFee() { return totalFee; } + public void setTotalFee(BigDecimal totalFee) { this.totalFee = totalFee; } + public Integer getFeeCalcStatus() { return feeCalcStatus; } + public void setFeeCalcStatus(Integer feeCalcStatus) { this.feeCalcStatus = feeCalcStatus; } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java index 5e92b0a..f843157 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java @@ -135,4 +135,14 @@ public class BizMeetingAttendee extends BaseEntity { private Integer isDeleted; public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } + + /** 是否已推送电子签 0否1是 (admin/manager 点"推送电子签"后置1) */ + private Integer isEsigned; + public Integer getIsEsigned() { return isEsigned; } + public void setIsEsigned(Integer isEsigned) { this.isEsigned = isEsigned; } + + /** 是否已邀请参会 0否1是 (点"邀请参会"后置1) */ + private Integer isInvited; + public Integer getIsInvited() { return isInvited; } + public void setIsInvited(Integer isInvited) { this.isInvited = isInvited; } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAuditLog.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAuditLog.java index 20ce7d1..96b0833 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAuditLog.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAuditLog.java @@ -25,8 +25,17 @@ public class BizMeetingAuditLog { /** 审核意见 */ private String opinion; - /** 当前阶段 (INIT / ... 后续开发中定) */ - private String currentStage; + /** 执行方当时展示状态 */ + private String executorStage; + + /** 支持方当时展示状态 */ + private String sponsorStage; + + /** 合规当时展示状态 */ + private String managerStage; + + /** 管理员当时展示状态 */ + private String adminStage; /** 创建时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @@ -57,8 +66,17 @@ public class BizMeetingAuditLog { 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 String getExecutorStage() { return executorStage; } + public void setExecutorStage(String executorStage) { this.executorStage = executorStage; } + + public String getSponsorStage() { return sponsorStage; } + public void setSponsorStage(String sponsorStage) { this.sponsorStage = sponsorStage; } + + public String getManagerStage() { return managerStage; } + public void setManagerStage(String managerStage) { this.managerStage = managerStage; } + + public String getAdminStage() { return adminStage; } + public void setAdminStage(String adminStage) { this.adminStage = adminStage; } public Date getCreateTime() { return createTime; } public void setCreateTime(Date createTime) { this.createTime = createTime; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java index a780d2b..f285fc8 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingMaterial.java @@ -7,10 +7,10 @@ import com.fasterxml.jackson.annotation.JsonFormat; /** * 会议材料对象 biz_meeting_material (单表) *

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

    *
  • material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证
  • - *
  • sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / SV_PAYMENT / LV_PAYMENT
  • + *
  • 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 / L_ENTERPRISE_BENEFIT / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT
  • *
*

* 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段. @@ -29,7 +29,7 @@ public class BizMeetingMaterial { /** 资料类型 (4 种): SERVICE / LABOR / SERVICE_VOUCHER / LABOR_VOUCHER */ private String materialType; - /** 子分类 (13 种): M_MATERIAL / M_HOTEL / ... / SV_PAYMENT / LV_PAYMENT */ + /** 子分类 (17 种): M_MATERIAL / M_HOTEL / ... / SV_PAYMENT / LV_PAYMENT */ private String subType; /** 文件名称 */ @@ -38,6 +38,9 @@ public class BizMeetingMaterial { /** OSS URL */ private String ossUrl; + /** 脱敏版 OSS URL (签到表拍照时额外生成的高斯模糊版, sponsor 只看这个以隐藏手机号/身份证号) */ + private String extraOssUrl; + /** 金额 (发票专用, 其他类型 = 0) */ private BigDecimal amount; @@ -51,6 +54,9 @@ public class BizMeetingMaterial { /** 软删除标记 0否1是 (admin 删除会议时级联置1, 查询需 WHERE is_deleted=0) */ private Integer isDeleted; + /** 该材料发票金额是否已计算 0未计算(待OCR) 1已计算(OCR完 或 本就不需OCR) */ + private Integer feeStatus; + public Long getId() { return id; } public void setId(Long id) { this.id = id; } @@ -69,6 +75,9 @@ public class BizMeetingMaterial { public String getOssUrl() { return ossUrl; } public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; } + public String getExtraOssUrl() { return extraOssUrl; } + public void setExtraOssUrl(String extraOssUrl) { this.extraOssUrl = extraOssUrl; } + public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal amount) { this.amount = amount; } @@ -80,4 +89,7 @@ public class BizMeetingMaterial { public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } + + public Integer getFeeStatus() { return feeStatus; } + public void setFeeStatus(Integer feeStatus) { this.feeStatus = feeStatus; } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPerson.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPerson.java index 6e4fad1..50d287e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPerson.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPerson.java @@ -57,6 +57,9 @@ public class BizPerson extends BaseEntity { private String accountType; /** 主账号ID (来自 sys_user.parent_user_id, 子账号指向其主账号) - 仅展示用 */ private Long parentUserId; + /** 登录账号 (来自 sys_user.user_name, 跟 accountType / parentUserId 一样仅展示, 不入库) */ + @com.fasterxml.jackson.annotation.JsonProperty("account") + private String account; /** 子账号登录账号 (前端传入, 用于创建 sys_user 子账号) - 非持久化字段 */ @com.fasterxml.jackson.annotation.JsonProperty("userName") private transient String loginUsername; @@ -102,6 +105,8 @@ public class BizPerson extends BaseEntity { public void setAccountType(String accountType) { this.accountType = accountType; } public Long getParentUserId() { return parentUserId; } public void setParentUserId(Long parentUserId) { this.parentUserId = parentUserId; } + public String getAccount() { return account; } + public void setAccount(String account) { this.account = account; } public String getLoginUsername() { return loginUsername; } public void setLoginUsername(String loginUsername) { this.loginUsername = loginUsername; } public String getLoginPassword() { return loginPassword; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java index 69fcf4e..a550186 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java @@ -25,6 +25,8 @@ public class BizProject extends BaseEntity { private Long assignedSessions; /** 分配给当前执行方的金额 (biz_project_assign.amount 之和, 仅 executor 端使用) */ private java.math.BigDecimal assignedAmount; + /** 该项目下已建的会议数 (biz_meeting 计数, 仅 executor 端建会限额用) */ + private Long meetingCount; /** done_sessions */ @Excel(name = "done_sessions") private Long doneSessions; @@ -152,6 +154,8 @@ public class BizProject extends BaseEntity { public void setAssignedSessions(Long assignedSessions) { this.assignedSessions = assignedSessions; } public java.math.BigDecimal getAssignedAmount() { return assignedAmount; } public void setAssignedAmount(java.math.BigDecimal assignedAmount) { this.assignedAmount = assignedAmount; } + public Long getMeetingCount() { return meetingCount; } + public void setMeetingCount(Long meetingCount) { this.meetingCount = meetingCount; } public Long getDoneSessions() { return doneSessions; } public void setDoneSessions(Long doneSessions) { this.doneSessions = doneSessions; } public Long getTodoSessions() { return todoSessions; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectExecutorAssign.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectExecutorAssign.java new file mode 100644 index 0000000..61a449c --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectExecutorAssign.java @@ -0,0 +1,54 @@ +package com.ruoyi.business.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** 项目-执行方-执行人分配记录 (biz_project_executor_assign) */ +public class BizProjectExecutorAssign extends BaseEntity { + private static final long serialVersionUID = 1L; + private Long id; + private String projectId; + /** 执行方主账号 user_id (分配人, 审计) */ + private Long executorUserId; + /** 执行人 user_id (被分配) */ + private Long staffUserId; + /** 多个执行人 userId (前端 multi-select 传入, 控制器循环 insert, 不入库) */ + @com.fasterxml.jackson.annotation.JsonProperty("staffUserIds") + private java.util.List staffUserIds; + private String assignDesc; + private String assignPoints; + + /** 关联展示字段 (非持久化) */ + private String executorUserName; + private String staffUserName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + /** 软删除标记 0否1是 (项目级联删除时按 project_id (String) 置1, 查询需 WHERE is_deleted=0) */ + private Integer isDeleted; + public Integer getIsDeleted() { return isDeleted; } + public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + public Long getExecutorUserId() { return executorUserId; } + public void setExecutorUserId(Long executorUserId) { this.executorUserId = executorUserId; } + public Long getStaffUserId() { return staffUserId; } + public void setStaffUserId(Long staffUserId) { this.staffUserId = staffUserId; } + public java.util.List getStaffUserIds() { return staffUserIds; } + public void setStaffUserIds(java.util.List staffUserIds) { this.staffUserIds = staffUserIds; } + public String getAssignDesc() { return assignDesc; } + public void setAssignDesc(String assignDesc) { this.assignDesc = assignDesc; } + public String getAssignPoints() { return assignPoints; } + public void setAssignPoints(String assignPoints) { this.assignPoints = assignPoints; } + public String getExecutorUserName() { return executorUserName; } + public void setExecutorUserName(String executorUserName) { this.executorUserName = executorUserName; } + public String getStaffUserName() { return staffUserName; } + public void setStaffUserName(String staffUserName) { this.staffUserName = staffUserName; } + public Date getCreateTime() { return createTime; } + public void setCreateTime(Date createTime) { this.createTime = createTime; } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectSponsorAssign.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectSponsorAssign.java index 76574a5..c0343d0 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectSponsorAssign.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectSponsorAssign.java @@ -11,6 +11,9 @@ public class BizProjectSponsorAssign extends BaseEntity { private String projectId; private Long sponsorUserId; private Long monitorUserId; + /** 多个监察员 userId (前端 multi-select 传入, 控制器循环 insert, 不入库) */ + @com.fasterxml.jackson.annotation.JsonProperty("monitorUserIds") + private java.util.List monitorUserIds; private String assignDesc; private String assignPoints; @@ -34,6 +37,8 @@ public class BizProjectSponsorAssign extends BaseEntity { public void setSponsorUserId(Long sponsorUserId) { this.sponsorUserId = sponsorUserId; } public Long getMonitorUserId() { return monitorUserId; } public void setMonitorUserId(Long monitorUserId) { this.monitorUserId = monitorUserId; } + public java.util.List getMonitorUserIds() { return monitorUserIds; } + public void setMonitorUserIds(java.util.List monitorUserIds) { this.monitorUserIds = monitorUserIds; } public String getAssignDesc() { return assignDesc; } public void setAssignDesc(String assignDesc) { this.assignDesc = assignDesc; } public String getAssignPoints() { return assignPoints; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPublicitySupportIntent.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPublicitySupportIntent.java index 4b7a43e..5fc0708 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPublicitySupportIntent.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPublicitySupportIntent.java @@ -8,7 +8,7 @@ import com.ruoyi.common.core.domain.BaseEntity; /** * 公示页-支持意向 (匿名快照, 与 sys_user 解耦) * 数据源: /publicity/:projectId 页面 "表达支持意向" 按钮 - * 与 biz_support_intent (旧表, 已登录用户流程) 语义不同, 物理表独立 + * 与旧 biz_support_intent (已删除) 语义不同: 本表允许 user_id=NULL (匿名), 旧表强绑已登录用户 */ public class BizPublicitySupportIntent extends BaseEntity { private static final long serialVersionUID = 1L; diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizSupportIntent.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizSupportIntent.java deleted file mode 100644 index 423b5ab..0000000 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizSupportIntent.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.ruoyi.business.domain; - -import java.math.BigDecimal; -import java.util.Date; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.ruoyi.common.annotation.Excel; -import com.ruoyi.common.core.domain.BaseEntity; - -/** 支持意向对象 BizSupportIntent */ -public class BizSupportIntent extends BaseEntity { - private static final long serialVersionUID = 1L; - /** intentId */ - private String intentId; - /** project_no */ - @Excel(name = "project_no") - private String projectNo; - /** project_name */ - @Excel(name = "project_name") - private String projectName; - /** name */ - @Excel(name = "name") - private String name; - /** work_unit */ - @Excel(name = "work_unit") - private String workUnit; - /** department */ - @Excel(name = "department") - private String department; - /** position */ - @Excel(name = "position") - private String position; - /** phone */ - @Excel(name = "phone") - private String phone; - /** account_status */ - @Excel(name = "account_status") - private String accountStatus; - /** create_by */ - @Excel(name = "create_by") - private String createBy; - /** create_time */ - @Excel(name = "create_time") - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private Date createTime; - /** update_by */ - @Excel(name = "update_by") - private String updateBy; - /** update_time */ - @Excel(name = "update_time") - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private Date updateTime; - public String getIntentId() { return intentId; } - public void setIntentId(String intentId) { this.intentId = intentId; } - public String getProjectNo() { return projectNo; } - public void setProjectNo(String projectNo) { this.projectNo = projectNo; } - public String getProjectName() { return projectName; } - public void setProjectName(String projectName) { this.projectName = projectName; } - public String getName() { return name; } - public void setName(String name) { this.name = name; } - public String getWorkUnit() { return workUnit; } - public void setWorkUnit(String workUnit) { this.workUnit = workUnit; } - public String getDepartment() { return department; } - public void setDepartment(String department) { this.department = department; } - public String getPosition() { return position; } - public void setPosition(String position) { this.position = position; } - public String getPhone() { return phone; } - public void setPhone(String phone) { this.phone = phone; } - public String getAccountStatus() { return accountStatus; } - public void setAccountStatus(String accountStatus) { this.accountStatus = accountStatus; } - public String getCreateBy() { return createBy; } - public void setCreateBy(String createBy) { this.createBy = createBy; } - public Date getCreateTime() { return createTime; } - public void setCreateTime(Date createTime) { this.createTime = createTime; } - public String getUpdateBy() { return updateBy; } - public void setUpdateBy(String updateBy) { this.updateBy = updateBy; } - public Date getUpdateTime() { return updateTime; } - public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } -} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java index ccba4f2..5dbe59a 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java @@ -80,6 +80,22 @@ public class BizMeetingAttendeeImportVo { @Excel(name = "摘要", sort = 15) private String summary; + /** 账户名称(持卡人姓名) */ + @Excel(name = "账户名称(持卡人姓名)", sort = 16) + private String accountName; + + /** 开户银行地址(省/市) */ + @Excel(name = "开户银行地址", sort = 17) + private String bankRegion; + + /** 银行详细地址 */ + @Excel(name = "银行详细地址", sort = 18) + private String bankAddress; + + /** 身份证附件(正反面 URL CSV) */ + @Excel(name = "身份证附件", sort = 19) + private String idCardAttachments; + public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public String getName() { return name; } @@ -110,4 +126,12 @@ public class BizMeetingAttendeeImportVo { public void setFee(BigDecimal fee) { this.fee = fee; } public String getSummary() { return summary; } public void setSummary(String summary) { this.summary = summary; } + public String getAccountName() { return accountName; } + public void setAccountName(String accountName) { this.accountName = accountName; } + public String getBankRegion() { return bankRegion; } + public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; } + public String getBankAddress() { return bankAddress; } + public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; } + public String getIdCardAttachments() { return idCardAttachments; } + public void setIdCardAttachments(String idCardAttachments) { this.idCardAttachments = idCardAttachments; } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java index a1ca660..63957fe 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingAttendeeMapper.java @@ -6,8 +6,8 @@ import com.ruoyi.business.domain.BizMeetingAttendee; public interface BizMeetingAttendeeMapper { int insert(BizMeetingAttendee entity); - /** 批量插入参会人 (BizMeetingController.add 调用) */ - int insertBatch(@Param("meetingId") Long meetingId, @Param("userIds") Long[] userIds, @Param("createBy") String createBy); + /** 批量插入参会人 (BizMeetingController.add/edit 调用), id 由调用方雪花 ID 填好 */ + int insertBatch(@Param("list") List list); /** * 管理端"新增参会人"用: 一次性插入完整档案 (含 name/phone/workUnit 等). * 与 {@link #insert} 区别: insert 只写 meeting_id+user_id+create_by (医生端"刚被加入"零信息行), @@ -20,6 +20,10 @@ public interface BizMeetingAttendeeMapper { /** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */ int updateSign(BizMeetingAttendee entity); int updateLaborProtocol(BizMeetingAttendee entity); + /** 推送电子签后置 is_esigned=1 (单条) */ + int markEsignedById(@Param("id") Long id); + /** 邀请参会后置 is_invited=1 (单条) */ + int markInvitedById(@Param("id") Long id); int deleteByMeetingId(Long meetingId); /** 管理端按 attendee.id 单删 (MeetingDetail 参会人 CRUD 用) */ int deleteByPrimaryKey(Long id); @@ -30,9 +34,16 @@ public interface BizMeetingAttendeeMapper { List selectByUserId(Long userId); BizMeetingAttendee selectById(Long id); List selectUnsignedByUserId(Long userId); + /** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */ + List selectInvitedByUserId(Long userId); /** * 拿某会议已存在的参会人 userId 列表 (用于 add/edit 时 diff 新加入的人, 仅通知增量) * 性能: 只查 user_id 一列, 走 meeting_id 索引; meeting 参会人通常 < 100, 无压力. */ List selectUserIdsByMeetingId(Long meetingId); + /** + * 费用汇总用: 某会议所有参会人应发金额 (fee_pre_tax) 之和 (is_deleted=0). + * 空/无参会人 → 返回 0 (SQL ifnull 兜底). + */ + java.math.BigDecimal sumFeePreTaxByMeetingId(Long meetingId); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java index 779f531..e9fb681 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java @@ -1,5 +1,7 @@ package com.ruoyi.business.mapper; +import java.math.BigDecimal; import java.util.List; +import org.apache.ibatis.annotations.Param; import com.ruoyi.business.domain.BizMeeting; /** @@ -17,4 +19,36 @@ public interface BizMeetingMapper int softDeleteByPrimaryKey(Long meetingId); /** 项目级联删除时用: 查项目下所有 meeting_id (不过滤 is_deleted, 软删 idempotent) */ List selectIdListByProjectId(Long projectId); + /** 建会限额用: 统计某项目下未软删的会议数 (executor 建会不得超过分配的场次) */ + int countByProjectId(Long projectId); + /** + * 自动流转: start_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING. + *

由 MeetingStageScheduler 每分钟触发. 事实 + current_stage 缓存一起写. + */ + int markExecuted(); + /** + * 自动流转: material 未提交 且 end_time + biz_project.submit_deadline_days 已过 → 置 is_frozen=1 并转 FROZEN. + *

由 MeetingStageScheduler 每分钟触发. + */ + int markFrozen(); + /** + * 自动流转: material/voucher 都 APPROVED 且 最晚审核时间已过 1 自然日 且 current_stage 仍为 SUPERVISION_APPROVED → AWAITING_SETTLEMENT. + *

由 MeetingStageScheduler 每分钟触发 (待结算的 24h 慢路径). + */ + int markSettlementReady(); + /** + * 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表. + */ + List selectPendingFeeCalcIds(); + /** + * 置未汇总 (人员/材料变化触发, 幂等). + */ + int markFeeCalcPending(Long meetingId); + /** + * 汇总回写 labor_fee/meeting_fee/total_fee 并置 fee_calc_status=1. + */ + int updateFeeSummary(@Param("meetingId") Long meetingId, + @Param("laborFee") BigDecimal laborFee, + @Param("meetingFee") BigDecimal meetingFee, + @Param("totalFee") BigDecimal totalFee); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMaterialMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMaterialMapper.java index 300d869..3c876c7 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMaterialMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMaterialMapper.java @@ -34,4 +34,7 @@ public interface BizMeetingMaterialMapper { /** 单条更新 amount (OCR 识别为发票后回写, 不动其他字段) */ int updateAmount(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount); + + /** 单条更新 fee_status (OCR 完成/材料保存时设置 0未算 1已算) */ + int updateFeeStatus(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("feeStatus") Integer feeStatus); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectExecutorAssignMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectExecutorAssignMapper.java new file mode 100644 index 0000000..48d41a1 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectExecutorAssignMapper.java @@ -0,0 +1,13 @@ +package com.ruoyi.business.mapper; + +import java.util.List; +import com.ruoyi.business.domain.BizProjectExecutorAssign; + +public interface BizProjectExecutorAssignMapper { + int insertAssign(BizProjectExecutorAssign entity); + List selectByProjectId(String projectId); + /** 按 project_id 全删 (执行方分配: 先删后插策略) */ + int deleteByProjectId(String projectId); + /** 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 */ + int softDeleteByProjectId(String projectId); +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java index 5144ddc..3e5431b 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java @@ -13,6 +13,8 @@ public interface BizProjectMapper List selectSponsorList(BizProject entity); /** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */ List selectExecutorList(BizProject entity); + /** executor 执行人 (SUB 子账号) 专属: 反查 biz_project_executor_assign.staff_user_id, 只看自己被派到的项目 */ + List selectExecutorStaffList(BizProject entity); int insert(BizProject entity); int updateByPrimaryKey(BizProject entity); int deleteByPrimaryKey(Long projectId); @@ -21,4 +23,15 @@ public interface BizProjectMapper int softDeleteByProjectId(Long projectId); /** 级联删除时用: 查 project_no (不过滤 is_deleted, 避免已软删项目查不到 projectNo) */ String selectProjectNoById(Long projectId); + /** 建会限额用: 统计某项目分配给该执行方 (MAIN) 的总场次 biz_project_assign.sessions 之和 */ + int countAssignedSessions(@org.apache.ibatis.annotations.Param("projectId") Long projectId, + @org.apache.ibatis.annotations.Param("executorUserId") Long executorUserId); + /** 提交权限用: 判断 user 是否该项目的执行方 (MAIN biz_project_assign 或 SUB biz_project_executor_assign), >0 即命中 */ + int countExecutorOfProject(@org.apache.ibatis.annotations.Param("projectId") Long projectId, + @org.apache.ibatis.annotations.Param("userId") Long userId); + /** + * 会议结算后重算项目金额: 按"所有已结算会议"全量 SUM 回写 paid_labor_amount / paid_meeting_amount, + * 并重算 available_amount = total_amount - manage_fee - 已支付劳务 - 已支付会务 (幂等, 无累计副作用). + */ + int recomputeSettledAmounts(@org.apache.ibatis.annotations.Param("projectId") Long projectId); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizSupportIntentMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizSupportIntentMapper.java deleted file mode 100644 index 6799f57..0000000 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizSupportIntentMapper.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.ruoyi.business.mapper; -import java.util.List; -import com.ruoyi.business.domain.BizSupportIntent; - -/** - * 支持意向Mapper接口 - */ -public interface BizSupportIntentMapper -{ - BizSupportIntent selectByPrimaryKey(String intentId); - List selectList(BizSupportIntent entity); - int insert(BizSupportIntent entity); - int updateByPrimaryKey(BizSupportIntent entity); - int deleteByPrimaryKey(String intentId); - int deleteByPrimaryKeys(String[] intentIds); -} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java index 09f0499..529a376 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java @@ -195,6 +195,40 @@ public class BizNotifyService log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId); } + /** + * 支持方(监察员) 审批退回 → 通知执行方 (待整改 + 说明). + * + *

调用方: {@link com.ruoyi.business.controller.BizMeetingController#supervisionOpinion} + * 退回分支内, 对每个 biz_meeting_executor 逐条调 (待办: 需要整改后重新提交). + * + * @param execUserId 执行方 sys_user.user_id (nullable, 跳过) + * @param meetingId biz_meeting.meeting_id + * @param meetingName 会议名 (可空, 兜底) + * @param opinion 退回意见 (可空) + */ + public void meetingSupervisionRejected(Long execUserId, Long meetingId, String meetingName, String opinion) + { + if (execUserId == null) { + log.warn("[notify] meetingSupervisionRejected: execUserId 为空, 跳过 (meetingId={})", meetingId); + return; + } + String name = meetingName != null ? meetingName : ("会议 #" + meetingId); + StringBuilder content = new StringBuilder("会议【").append(name).append("】监管未通过, 请整改后重新提交"); + if (opinion != null && !opinion.isEmpty()) content.append("。意见: ").append(opinion); + content.append("。"); + + BizMessage msg = new BizMessage(); + msg.setReceiverUserId(execUserId); + msg.setMsgType(TYPE_TODO); // 待办: 执行方需要整改后重新提交 + msg.setTitle("会议监管退回: " + name); + msg.setContent(content.toString()); + msg.setBizType(BIZ_MEETING); + msg.setBizId(meetingId); + msg.setCreateBy("system"); + bizMessageService.insert(msg); + log.info("[notify] meetingSupervisionRejected 已发 uid={} meetingId={}", execUserId, meetingId); + } + /** * #6 劳务协议待签 → 通知参会人协议已生成, 请手写签字. * @@ -225,6 +259,41 @@ public class BizNotifyService log.info("[notify] agreementAwaitingSign 已发 uid={} attendeeId={}", userId, attendeeId); } + /** + * #7 推送电子签 → 通知参会人劳务协议待签署 (带签署链接). + * + *

调用方: {@link com.ruoyi.business.service.impl.BizMeetingAttendeeServiceImpl#pushEsign}, + * 与短信同批推送, 站内信附带签署链接 (与短信同一链接). + * + * @param userId 被通知人 sys_user.user_id (参会人的 user_id) + * @param attendeeId biz_meeting_attendee.id (用于 bizId 跳转 + 拼链接) + * @param meetingId biz_meeting.meeting_id (会议 ID, 用于 title/兜底) + * @param meetingName 会议名 (可空, 兜底) + * @param link 签署链接 (可空, 空则不展示) + */ + public void esignPushed(Long userId, Long attendeeId, Long meetingId, String meetingName, String link) + { + if (userId == null) { + log.warn("[notify] esignPushed: userId 为空, 跳过 (attendeeId={})", attendeeId); + return; + } + String name = meetingName != null ? meetingName : ("会议 #" + meetingId); + StringBuilder content = new StringBuilder("会议【").append(name).append("】的劳务协议已生成, 请点击链接签署: "); + if (link != null && !link.isEmpty()) { + content.append(link); + } + BizMessage msg = new BizMessage(); + msg.setReceiverUserId(userId); + msg.setMsgType(TYPE_TODO); // 待办: 医生需要签署 + msg.setTitle("劳务协议待签署: " + name); + msg.setContent(content.toString()); + msg.setBizType(BIZ_AGREEMENT); + msg.setBizId(attendeeId); + msg.setCreateBy("system"); + bizMessageService.insert(msg); + log.info("[notify] esignPushed 已发 uid={} attendeeId={}", userId, attendeeId); + } + /** * #3 项目分配执行方 → 通知被分配的 executor (待办: 去承接). * @@ -264,4 +333,46 @@ public class BizNotifyService bizMessageService.insert(msg); log.info("[notify] projectAssignedToExecutor 已发 uid={} projectId={}", execUserId, projectId); } + + /** + * #3b 项目分配监察员 → 通知被分配的 sponsor/监察员 (待办: 去查看项目). + * + *

调用方: {@link com.ruoyi.business.controller.BizProjectController#sponsorAssign} / + * {@code sponsorAssignBatch}, 在 biz_project_sponsor_assign 写入后, 对"新增"或"内容变化"的 + * 监察员逐条调本方法. 去重由调用方保证 (旧数据差集 + assignDesc/assignPoints 未变跳过), 本方法不再判断. + * + * @param monitorUserId 被分配的监察员 sys_user.user_id (nullable, 跳过) + * @param projectId biz_project.project_id (Long, 用于 bizId; sponsor_assign 表里是 String, 调用方负责转) + * @param projectName 项目名 (可空, 兜底) + * @param assignDesc 分配说明 (可空) + * @param assignPoints 分配积分 (可空) + */ + public void projectAssignedToSponsor(Long monitorUserId, Long projectId, String projectName, + String assignDesc, String assignPoints) + { + if (monitorUserId == null) { + log.warn("[notify] projectAssignedToSponsor: monitorUserId 为空, 跳过 (projectId={})", projectId); + return; + } + String name = projectName != null ? projectName : ("项目 #" + projectId); + StringBuilder content = new StringBuilder("您被分配为【").append(name).append("】的监察员"); + if (assignDesc != null && !assignDesc.trim().isEmpty()) { + content.append(",说明: ").append(assignDesc.trim()); + } + if (assignPoints != null && !assignPoints.trim().isEmpty()) { + content.append(",积分: ").append(assignPoints.trim()); + } + content.append("。请登录系统查看。"); + + BizMessage msg = new BizMessage(); + msg.setReceiverUserId(monitorUserId); + msg.setMsgType(TYPE_TODO); + msg.setTitle("项目监察分配: " + name); + msg.setContent(content.toString()); + msg.setBizType(BIZ_PROJECT); + msg.setBizId(projectId); + msg.setCreateBy("system"); + bizMessageService.insert(msg); + log.info("[notify] projectAssignedToSponsor 已发 uid={} projectId={}", monitorUserId, projectId); + } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssConfMeta.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssConfMeta.java index 93ab9dd..3c229ea 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssConfMeta.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssConfMeta.java @@ -30,10 +30,11 @@ public class OssConfMeta { 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(); + // endpoint: 剥协议头 + 剥 bucket 前缀 → 纯 OSS endpoint (OSSClient 构造/URL 拼接都要求裸 endpoint, 不带 bucket) + this.endpoint = stripBucketPrefix(stripScheme(p.getEndpoint()), this.bucket); } /** @@ -50,6 +51,16 @@ public class OssConfMeta return slash >= 0 ? s.substring(0, slash) : s; } + /** + * 剥 bucket 前缀, 得到纯 OSS endpoint + * 例: hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com + bucket=hwtossbamlorgcn → oss-cn-beijing.aliyuncs.com + */ + private static String stripBucketPrefix(String endpoint, String bucket) + { + if (endpoint == null || bucket == null) return endpoint; + return endpoint.startsWith(bucket + ".") ? endpoint.substring(bucket.length() + 1) : endpoint; + } + public String getEndpoint() { return endpoint; } public String getBucket() { return bucket; } public String getAccessKeyId() { return accessKeyId; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/FeeCalcScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/FeeCalcScheduler.java new file mode 100644 index 0000000..6476ab8 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/FeeCalcScheduler.java @@ -0,0 +1,129 @@ +package com.ruoyi.business.scheduler; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.ExecutorService; +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 com.ruoyi.business.domain.BizMeetingMaterial; +import com.ruoyi.business.mapper.BizMeetingAttendeeMapper; +import com.ruoyi.business.mapper.BizMeetingMapper; +import com.ruoyi.business.mapper.BizMeetingMaterialMapper; +import lombok.extern.slf4j.Slf4j; + +/** + * 会议费用汇总调度器 (每分钟一次, 多线程无锁). + *

+ * 两态设计 (不用抢占锁): 会议 fee_calc_status (0未汇总/1已汇总) + 材料 fee_status (0未计算/1已计算). + *

+ *   每分钟: 查 fee_calc_status=0 的会议
+ *     → 任一材料 fee_status=0 (发票还没 OCR 完) → 跳过, 等下轮
+ *     → 全部 fee_status=1 → SUM 汇总 labor_fee/meeting_fee/total_fee → fee_calc_status=1
+ * 
+ * 为什么不需要锁: 汇总前已检查"材料全算完", 天然防半成品; 汇总纯 SUM 幂等, 并发重复无副作用; + * 置 1 后不再被扫到, 天然去重. 重算 = 只对 material + attendee 重新求和, 不碰 OCR. + */ +@Slf4j +@Component +public class FeeCalcScheduler +{ + @Autowired + private BizMeetingMapper meetingMapper; + @Autowired + private BizMeetingMaterialMapper materialMapper; + @Autowired + private BizMeetingAttendeeMapper attendeeMapper; + @Autowired + @Qualifier("feeCalcExecutor") + private ExecutorService feeCalcExecutor; + + /** + * 每分钟: 扫描 fee_calc_status=0 的会议, 并行汇总. + */ + @Scheduled(fixedRate = 60_000, initialDelay = 60_000) + public void calcFees() + { + try + { + List ids = meetingMapper.selectPendingFeeCalcIds(); + if (ids == null || ids.isEmpty()) + { + return; + } + log.info("[FeeCalcScheduler] 待汇总会议 {} 个", ids.size()); + for (Long meetingId : ids) + { + if (meetingId == null) continue; + feeCalcExecutor.submit(() -> processMeeting(meetingId)); + } + } + catch (Exception e) + { + log.warn("[FeeCalcScheduler] 扫描异常 (跳过, 下分钟再试)", e); + } + } + + private void processMeeting(Long meetingId) + { + try + { + List mats = materialMapper.selectByMeetingId(meetingId); + // 任一材料 fee_status=0 (发票待 OCR) → 跳过 + if (mats != null) + { + for (BizMeetingMaterial m : mats) + { + if (m.getFeeStatus() != null && m.getFeeStatus() == 0) + { + return; + } + } + } + + BigDecimal laborFee = attendeeMapper.sumFeePreTaxByMeetingId(meetingId); + if (laborFee == null) laborFee = BigDecimal.ZERO; + BigDecimal meetingFee = computeMeetingFee(mats); + BigDecimal totalFee = laborFee.add(meetingFee); + + meetingMapper.updateFeeSummary(meetingId, laborFee, meetingFee, totalFee); + log.info("[FeeCalcScheduler] 汇总完成 meetingId={} labor={} meeting={} total={}", + meetingId, laborFee, meetingFee, totalFee); + } + catch (Exception e) + { + log.warn("[FeeCalcScheduler] 汇总失败 meetingId={} err={}", meetingId, e.getMessage(), e); + } + } + + /** + * 会务费口径: 有总发票 (M_INVOICE 金额>0) 则只用总发票; 否则各 SUB 子类 (M_ 开头) 金额之和, + * 排除 M_INVOICE / M_SETTLEMENT (两者是汇总单据, 非子类发票). + */ + private BigDecimal computeMeetingFee(List mats) + { + if (mats == null || mats.isEmpty()) return BigDecimal.ZERO; + BigDecimal main = null; + for (BizMeetingMaterial m : mats) + { + if ("M_INVOICE".equals(m.getSubType())) + { + main = m.getAmount(); + break; + } + } + if (main != null && main.compareTo(BigDecimal.ZERO) > 0) + { + return main; + } + BigDecimal sum = BigDecimal.ZERO; + for (BizMeetingMaterial m : mats) + { + if (m.getSubType() == null || !m.getSubType().startsWith("M_")) continue; + if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType())) continue; + if (m.getAmount() != null) sum = sum.add(m.getAmount()); + } + return sum; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java new file mode 100644 index 0000000..ee4ca93 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java @@ -0,0 +1,90 @@ +package com.ruoyi.business.scheduler; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import com.ruoyi.business.mapper.BizMeetingMapper; +import lombok.extern.slf4j.Slf4j; + +/** + * 会议事实/阶段 自动流转调度器 (每分钟一次). + *

+ * 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实 + * (is_executed / is_frozen / material_audit_stage / voucher_audit_stage / 审核时间 …), + * 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的三类事实落地: + *

+ *   1) start_time 到 → is_executed=1 (执行中)
+ *   2) end_time + submit_deadline_days 到 且 material 未提交 → is_frozen=1 (冻结)
+ *   3) material+voucher 都通过 且 最晚审核时间过 24h → 待结算 (current_stage 缓存翻 AWAITING_SETTLEMENT)
+ * 
+ * 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围. + *

+ * 需要启动类加 {@code @EnableScheduling} (RuoyiApplication 已有). + */ +@Slf4j +@Component +public class MeetingStageScheduler +{ + @Autowired + private BizMeetingMapper meetingMapper; + + /** + * 每分钟: start_time 已过 且 material 未提交 且未执行 → 置执行中. + */ + @Scheduled(fixedRate = 60_000, initialDelay = 30_000) + public void markExecuted() + { + try + { + int affected = meetingMapper.markExecuted(); + if (affected > 0) + { + log.info("[MeetingStageScheduler] 自动置执行中: 本次更新 {} 行", affected); + } + } + catch (Exception e) + { + log.warn("[MeetingStageScheduler] 置执行中异常 (跳过, 下分钟再试)", e); + } + } + + /** + * 每分钟: material 未提交 且 提交截止时间 (end_time + submit_deadline_days) 已过 → 冻结. + */ + @Scheduled(fixedRate = 60_000, initialDelay = 30_000) + public void markFrozen() + { + try + { + int affected = meetingMapper.markFrozen(); + if (affected > 0) + { + log.info("[MeetingStageScheduler] 自动冻结: 本次更新 {} 行", affected); + } + } + catch (Exception e) + { + log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e); + } + } + + /** + * 每分钟: material+voucher 都 APPROVED 且最晚审核时间已过 1 自然日 → 待结算 (24h 慢路径). + */ + @Scheduled(fixedRate = 60_000, initialDelay = 30_000) + public void markSettlementReady() + { + try + { + int affected = meetingMapper.markSettlementReady(); + if (affected > 0) + { + log.info("[MeetingStageScheduler] 自动转待结算: 本次更新 {} 行", affected); + } + } + catch (Exception e) + { + log.warn("[MeetingStageScheduler] 转待结算异常 (跳过, 下分钟再试)", e); + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/BizSignService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/BizSignService.java index dc3fce1..a43395f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/BizSignService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/BizSignService.java @@ -11,6 +11,8 @@ import java.util.Map; public interface BizSignService { /** 医生填写页 GET /info?attendeeId=X: 返回默认值 (biz_expert 预填) + 已存 attendee 字段 + 选项 */ Map getSignInfo(Long attendeeId); + /** 扫码直登: 只有 meetingId (无 attendeeId) 时, 校验当前用户是否在会议人员列表, 返回 {meetingName, periodNo, totalPeriods, attendeeId} */ + Map resolveByMeeting(Long meetingId); /** 医生填写页 POST /saveProfile: 批量 UPDATE attendee 字段 (不含签名) */ void saveProfile(Long attendeeId, BizMeetingAttendee form); /** 签署页 GET /contract?attendeeId=X: 渲染完整 HTML (占位符替换 + 身份证附件 + 手写签名) */ diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java index 6af9ad8..d8366ef 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java @@ -42,6 +42,8 @@ public interface IBizMeetingAttendeeService { List selectByUserId(Long userId); BizMeetingAttendee selectById(Long id); List selectUnsignedByUserId(Long userId); + /** 当前用户的"待参加"会议 (已邀请参会 is_invited=1) */ + List selectInvitedByUserId(Long userId); /** * 拿某会议已存在的参会人 userId 列表 (#5 会议邀请 dedup 用). * 列表实现层直接返 mapper 结果; 业务方通常用 {@code new HashSet<>(service.selectUserIdsByMeetingId(mid))} 做 contains 判断. @@ -60,4 +62,26 @@ public interface IBizMeetingAttendeeService { * @return ImportResult { okNum, ngNum, ngList: [{rowNum, message}] } */ ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception; + + /** + * 推送电子签 (批量/单条通用): 对给定 attendee.id 列表逐个 + * 发短信 (电子签模板) + 推站内信 + 置 is_esigned=1. + * + *

单条失败 (手机号空/短信异常) 不中断其它人, 返回成功推送条数. + * + * @param attendeeIds biz_meeting_attendee.id 列表 + * @return 成功推送 (短信+站内信+标记) 的人数 + */ + int pushEsign(List attendeeIds); + + /** + * 邀请参会 (批量/单条通用): 对给定 attendee.id 列表逐个 + * 推"会议邀请"站内信 (不发短信) + 置 is_invited=1. + * + *

单条失败不中断其它人, 返回成功邀请条数. + * + * @param attendeeIds biz_meeting_attendee.id 列表 + * @return 成功邀请 (站内信+标记) 的人数 + */ + int invite(List attendeeIds); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java index ddf4ed8..8fb186e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java @@ -32,4 +32,18 @@ public interface IBizMeetingMaterialService { * 不动其他字段, 不抛异常 (失败仅 log). */ int updateAmount(Long materialId, java.math.BigDecimal amount); + + /** + * 单条更新 fee_status (0未计算 1已计算). + * OCR 完成后置 1; 材料保存时按需置 0/1. + */ + int updateFeeStatus(Long materialId, Integer feeStatus); + + /** + * 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后, 回传 URL 到此存库. + * 按 (meetingId, subType) upsert 单行 (存在改 ossUrl, 不存在 insert). + * extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL (sponsor 只看这个), 其他 subType 传空. + * 白名单 subType + 会议存在校验 (公开端点防滥用). + */ + void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java index 93b4a64..817bdb1 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java @@ -22,4 +22,8 @@ public interface IBizMeetingService void softDeleteCascade(Long meetingId); /** 批量软删 (admin 会议管理页一次选多个) */ void softDeleteCascadeBatch(Long[] meetingIds); + /** 建会限额用: 统计某项目下未软删的会议数 (executor 建会不得超过分配的场次) */ + int countByProjectId(Long projectId); + /** 标记会议费用待重算 (人员/材料变化触发, 幂等; 由 FeeCalcScheduler 汇总回写) */ + void markFeeCalcPending(Long meetingId); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectExecutorAssignService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectExecutorAssignService.java new file mode 100644 index 0000000..eab90a4 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectExecutorAssignService.java @@ -0,0 +1,13 @@ +package com.ruoyi.business.service; + +import java.util.List; +import com.ruoyi.business.domain.BizProjectExecutorAssign; + +public interface IBizProjectExecutorAssignService { + /** 执行方单条分配 (策略: 按 project_id 先删后插) */ + int insertAssign(BizProjectExecutorAssign entity); + /** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */ + int assignStaffForProject(BizProjectExecutorAssign body, List staffUserIds); + List listByProjectId(String projectId); + int deleteByProjectId(String projectId); +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java index f1abf23..a467e8f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java @@ -14,6 +14,8 @@ public interface IBizProjectService List selectSponsorList(BizProject entity); /** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */ List selectExecutorList(BizProject entity); + /** executor 执行人 (SUB 子账号) 专属: 反查 biz_project_executor_assign.staff_user_id, 只看自己被派到的项目 */ + List selectExecutorStaffList(BizProject entity); int insert(BizProject entity); int updateByPrimaryKey(BizProject entity); int deleteByPrimaryKey(Long projectId); @@ -28,4 +30,19 @@ public interface IBizProjectService /** 软删除项目 (批量): 逐条 cascade, 失败粒度细 */ void softDeleteCascadeBatch(Long[] projectIds); + + /** 建会限额用: 统计某项目分配给该执行方 (MAIN) 的总场次 biz_project_assign.sessions 之和 */ + int countAssignedSessions(Long projectId, Long executorUserId); + + /** + * 提交材料/凭证权限用: 判断当前 user 是否该项目的执行方 (MAIN 走 biz_project_assign, SUB 走 biz_project_executor_assign). + * 与会议列表 executor 可见性同源, 替代原来的 biz_meeting_executor (会议级执行人员) 判定. + */ + boolean isExecutorOfProject(Long projectId, Long userId); + + /** + * 会议结算后重算项目金额: 按"所有已结算会议"全量 SUM 回写 paid_labor_amount / paid_meeting_amount, + * 并重算 available_amount. 幂等 (每次全量重算), 无累计副作用. + */ + void recomputeSettledAmounts(Long projectId); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectSponsorAssignService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectSponsorAssignService.java index 4323e0d..c8bb511 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectSponsorAssignService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectSponsorAssignService.java @@ -4,8 +4,10 @@ import java.util.List; import com.ruoyi.business.domain.BizProjectSponsorAssign; public interface IBizProjectSponsorAssignService { - /** 支持方分配 (策略: 按 project_id 先删后插, 一个项目只分配一个 sponsor) */ + /** 支持方单条分配 (策略: 按 project_id 先删后插, 一个项目只分配一个 sponsor) */ int insertAssign(BizProjectSponsorAssign entity); + /** 支持方多条分配 (一个项目 ↔ N 监察员: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */ + int assignMonitorsForProject(BizProjectSponsorAssign body, List monitorUserIds); List listByProjectId(String projectId); int deleteByProjectId(String projectId); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizSupportIntentService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizSupportIntentService.java deleted file mode 100644 index 8a50cfc..0000000 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizSupportIntentService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ruoyi.business.service; - -import java.util.List; -import com.ruoyi.business.domain.BizSupportIntent; - -/** - * 支持意向Service接口 - */ -public interface IBizSupportIntentService -{ - BizSupportIntent getById(String intentId); - List selectList(BizSupportIntent entity); - int insert(BizSupportIntent entity); - int updateByPrimaryKey(BizSupportIntent entity); - int deleteByPrimaryKey(String intentId); - int deleteByPrimaryKeys(String[] intentId); -} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PdfService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PdfService.java index f380a35..5a41683 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PdfService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PdfService.java @@ -9,20 +9,16 @@ import com.itextpdf.kernel.geom.PageSize; import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfWriter; import com.itextpdf.layout.font.FontProvider; -import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.business.oss.OssUploader; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.text.SimpleDateFormat; -import java.util.Date; /** * HTML 转 PDF 服务 * 用 iText 7 html2pdf (HtmlConverter) + 内置宋体 (simsun.ttc) 渲染中文 - * 输入: HTML 字符串 → 输出: PDF 文件 (存到 ruoyi.profile 目录, 返回 URL) + * 输入: HTML 字符串 → 输出: PDF 字节 (上传 OSS, 返回完整 URL) * 说明: 相比 Flying Saucer (xhtmlrenderer 严格 XML 解析), html2pdf 走 jsoup HTML 解析, * 能容忍前端拼出的非 XHTML 内容 (如 未自闭合), 不会报 SAXParseException。 */ @@ -30,7 +26,7 @@ import java.util.Date; public class PdfService { @Autowired - private RuoYiConfig ruoyiConfig; + private OssUploader ossUploader; /** * HTML 字符串 → PDF 字节流 @@ -61,31 +57,17 @@ public class PdfService { } /** - * HTML → PDF 文件 (存到本地) - * @return 完整 URL (前端可直接打开) + * HTML → PDF 文件 (上传 OSS) + * @return 完整 OSS URL (前端可直接打开, 与身份证附件/现场照片等字段一致) */ public String htmlToPdfFile(String htmlContent, String bizPath) { byte[] pdfBytes = htmlToPdf(htmlContent); - // 按 RuoYi 风格分目录: profile/labor/{date}/{filename} - SimpleDateFormat dateDir = new SimpleDateFormat("yyyy-MM-dd"); - String today = dateDir.format(new Date()); - String datePath = (bizPath == null || bizPath.isEmpty() ? "labor" : bizPath) + "/" + today; - String filename = System.currentTimeMillis() + "_" + (int)(Math.random() * 1000) + ".pdf"; - - String profilePath = ruoyiConfig.getProfile(); - File dir = new File(profilePath + File.separator + datePath); - if (!dir.exists()) { - dir.mkdirs(); - } - File pdfFile = new File(dir, filename); - try (FileOutputStream fos = new FileOutputStream(pdfFile)) { - fos.write(pdfBytes); - } catch (Exception e) { - throw new RuntimeException("保存 PDF 失败: " + e.getMessage(), e); - } - // 返回 URL 路径 (前端拼 origin) - String url = "/profile/" + datePath + "/" + filename; - return url; + String filename = System.currentTimeMillis() + "_" + (int) (Math.random() * 1000) + ".pdf"; + // bizPath 直接作为 OSS key 前缀 (例 "labor/123", 去掉首尾斜杠) + String subDir = (bizPath == null || bizPath.trim().isEmpty()) + ? "labor" + : bizPath.replaceAll("^/+|/+$", ""); + return ossUploader.upload(pdfBytes, filename, subDir); } /** diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PosterService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PosterService.java new file mode 100644 index 0000000..a4bfc8f --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/PosterService.java @@ -0,0 +1,191 @@ +package com.ruoyi.business.service; + +import cn.hutool.http.HttpUtil; +import com.ruoyi.business.domain.BizMeeting; +import com.ruoyi.business.oss.OssUploader; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * 会议海报生成服务 (日程海报 → 叠加会议信息 → 上传 OSS). + * + *

流程 (前端"生成海报"按钮触发): + *

    + *
  1. 按 {@code schedule_url} 下载源海报, OSS 图片处理参数 width 固定 1200
  2. + *
  3. Java2D 在海报底部画半透明信息条: 会议名称 / 期数 / 起止时间 / 支持单位
  4. + *
  5. 编码 PNG → {@link OssUploader} 上传 OSS
  6. + *
  7. 回写 {@code biz_meeting.poster_url}, 前端"预览海报"用该 URL 弹 dialog
  8. + *
+ */ +@Slf4j +@Service +public class PosterService { + + @Autowired + private IBizMeetingService bizMeetingService; + @Autowired + private OssUploader ossUploader; + + /** + * 生成海报并回写 poster_url. + * + * @return 生成海报的 OSS URL + */ + public String generatePoster(Long meetingId, boolean addText, String textColor) { + BizMeeting m = bizMeetingService.getById(meetingId); + if (m == null) throw new RuntimeException("会议不存在"); + if (m.getScheduleUrl() == null || m.getScheduleUrl().trim().isEmpty()) { + throw new RuntimeException("请先上传日程海报"); + } + + // 1. 下载源海报 (OSS 图片处理: width=1200) + byte[] imgBytes = download(m.getScheduleUrl().trim(), 1200); + + // 2. 解析图片 + BufferedImage img; + try { + img = ImageIO.read(new ByteArrayInputStream(imgBytes)); + } catch (Exception e) { + throw new RuntimeException("海报图片解析失败: " + e.getMessage(), e); + } + if (img == null) throw new RuntimeException("海报图片解析失败 (不支持的格式)"); + + // 3. (可选) 画会议信息 (addText=false 时只输出处理后的背景海报) + if (addText) { + drawInfo(img, m, textColor); + } + + // 4. 编码 PNG + byte[] out; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(img, "png", baos); + out = baos.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("海报编码失败: " + e.getMessage(), e); + } + + // 5. 上传 OSS + String url = ossUploader.upload(out, "poster.png", "meeting/poster"); + + // 6. 回写 poster_url + BizMeeting upd = new BizMeeting(); + upd.setMeetingId(meetingId); + upd.setPosterUrl(url); + bizMeetingService.updateByPrimaryKey(upd); + + log.info("[poster] 生成海报完成 meetingId={} url={}", meetingId, url); + return url; + } + + /** 下载海报 (OSS 图片处理: 缩宽 + 压质量 + 转 JPEG, 减小体积) */ + private byte[] download(String url, int width) { + String sep = url.contains("?") ? "&" : "?"; + // resize,w_1200 只缩宽且默认不放大、保持原格式 (PNG 无损, 体积不缩); + // 加 quality,q_80 + format,jpg 强制转 JPEG 并压质量, 显著减小体积 (海报无透明需求) + String dl = url + sep + "x-oss-process=image/resize,w_" + width + "/quality,q_80/format,jpg"; + log.info("[poster] 下载海报 width={}: {}", width, dl); + try { + return HttpUtil.downloadBytes(dl); + } catch (Exception e) { + throw new RuntimeException("下载海报失败: " + e.getMessage(), e); + } + } + + /** 在海报顶部 (距顶 200px) 居中打印会议信息 (会议名称/期数/起止时间/支持单位) */ + private void drawInfo(BufferedImage img, BizMeeting m, String textColor) { + int w = img.getWidth(); + Graphics2D g = img.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + // 字号按宽度 1200 等比缩放 (下载时已 resize 到 1200, 但留个余量) + float scale = w / 1200.0f; + float titleSize = 44 * scale; + float infoSize = 28 * scale; + + String title = (m.getMeetingName() == null || m.getMeetingName().trim().isEmpty()) + ? "会议" : m.getMeetingName().trim(); + String period = m.getPeriodNo() != null ? "第 " + m.getPeriodNo() + " 期" : null; + String time = buildTime(m.getStartTime(), m.getEndTime()); + String org = (m.getOrgName() != null && !m.getOrgName().trim().isEmpty()) + ? "支持单位:" + m.getOrgName().trim() : null; + + List lines = new ArrayList<>(); + if (period != null) lines.add(period); + if (time != null) lines.add(time); + if (org != null) lines.add(org); + + // 文字距顶端 200px (等比缩放), 每行水平居中, 无背景条 + int lineGap = Math.round(6 * scale); + int infoLineH = Math.round(infoSize * 1.5f); + + int y = Math.round(200 * scale); + + Color fg = parseColor(textColor, Color.WHITE); + g.setColor(fg); + g.setFont(loadFont(titleSize, Font.BOLD)); + g.drawString(title, centerX(g, title, w), y + titleSize); + y += titleSize + lineGap; + + g.setFont(loadFont(infoSize, Font.PLAIN)); + for (String line : lines) { + y += infoLineH + lineGap; + g.drawString(line, centerX(g, line, w), y); + } + } finally { + g.dispose(); + } + } + + /** 文字水平居中: 用 FontMetrics 测宽算 x */ + private int centerX(Graphics2D g, String text, int w) { + int textW = g.getFontMetrics().stringWidth(text); + return (w - textW) / 2; + } + + /** 解析前端传来的 hex 文字颜色 (如 #FFFFFF), 非法则回退 fallback */ + private Color parseColor(String hex, Color fallback) { + if (hex == null || hex.trim().isEmpty()) return fallback; + try { + return Color.decode(hex.trim()); + } catch (Exception e) { + log.warn("[poster] 非法文字颜色 {}: {}", hex, e.getMessage()); + return fallback; + } + } + + private String buildTime(Date start, Date end) { + SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + if (start != null && end != null) return fmt.format(start) + " ~ " + fmt.format(end); + if (start != null) return fmt.format(start); + if (end != null) return fmt.format(end); + return null; + } + + /** 加载中文字体 (优先项目内 simhei.ttc 黑体; 失败降级系统黑体) */ + private Font loadFont(float size, int style) { + try { + Font base = Font.createFont(Font.TRUETYPE_FONT, new File("simhei.ttc")); + return base.deriveFont(style, size); + } catch (Exception e) { + log.warn("[poster] 加载 simhei.ttc 失败, 降级系统字体: {}", e.getMessage()); + return new Font("黑体", style, Math.round(size)); + } + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java new file mode 100644 index 0000000..103a518 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java @@ -0,0 +1,125 @@ +package com.ruoyi.business.service; + +import java.util.Date; +import org.springframework.stereotype.Component; +import com.ruoyi.business.domain.BizMeeting; + +/** + * 会议阶段推导器 (单一可信源). + *

+ * 事实与展示分离: {@code biz_meeting} 只存事实 (is_executed/is_settled/is_finished/is_frozen + * + material_audit_stage/voucher_audit_stage + 审核时间 + compliance_approved), 各角色看到的 + * 「阶段名称」由本类实时计算. + *

    + *
  • {@link #derivePhysicalStage(BizMeeting)}: 10 值物理阶段 (current_stage 缓存 + 列表筛选).
  • + *
  • {@link #deriveDisplay(String, BizMeeting)}: 各角色展示名 (audit_log 4 列 + 前端镜像).
  • + *
+ * + * @author guoju + */ +@Component +public class StageDeriver +{ + private static final long H24 = 24L * 3600 * 1000; + + /** 待结算: 材料+凭证都审核通过 且 最晚通过时间已超 24h (用户拍板口径) */ + private boolean settlementReady(BizMeeting m) + { + if (!"APPROVED".equals(m.getVoucherAuditStage())) return false; + Date later = later(m.getMaterialAuditTime(), m.getVoucherAuditTime()); + if (later == null) return false; + return System.currentTimeMillis() - later.getTime() >= H24; + } + + private Date later(Date a, Date b) + { + if (a == null) return b; + if (b == null) return a; + return a.after(b) ? a : b; + } + + private static boolean t(Integer v) + { + return v != null && v == 1; + } + + /** + * 10 值物理阶段 (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/ + * SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN), 由事实推导. + * 用于 current_stage 缓存 (列表筛选仍按物理态精确匹配). + */ + public String derivePhysicalStage(BizMeeting m) + { + if (t(m.getIsFrozen())) return "FROZEN"; + if (t(m.getIsFinished())) return "FINISHED"; + if (t(m.getIsSettled())) return "SETTLED"; + + String material = m.getMaterialAuditStage(); + if ("REJECTED".equals(material)) return "RECTIFYING"; + if ("APPROVED".equals(material)) + { + return settlementReady(m) ? "AWAITING_SETTLEMENT" : "SUPERVISION_APPROVED"; + } + if ("SUBMITTED".equals(material)) + { + return t(m.getMaterialComplianceApproved()) ? "AWAITING_SUPERVISION" : "AWAITING_COMPLIANCE"; + } + // NOT_SUBMITTED (或 null 兜底) + return t(m.getIsExecuted()) ? "RUNNING" : "NOT_STARTED"; + } + + /** + * 各角色展示阶段名. role ∈ {executor, sponsor, manager, admin}; doctor/expert 等非流程角色按 admin 中性. + *

+ * 优先级自上而下命中即返回: + *

+     *   冻结 → 完结 → 已结算 → (材料) 审核驳回 → 审核通过 → (材料) APPROVED → SUBMITTED → NOT_SUBMITTED
+     * 
+ */ + public String deriveDisplay(String role, BizMeeting m) + { + if (t(m.getIsFrozen())) return "冻结中"; + if (t(m.getIsFinished())) return "已完结"; + if (t(m.getIsSettled())) return "已结算"; + + String material = m.getMaterialAuditStage(); + + // 退回: 执行方看「已退回」, 其他方看「待整改」 + if ("REJECTED".equals(material)) + { + return "executor".equals(role) ? "已退回" : "待整改"; + } + + // 材料已支持方通过 + if ("APPROVED".equals(material)) + { + return settlementReady(m) ? "待结算" : "审核通过"; + } + + // 材料在审 (SUBMITTED) + if ("SUBMITTED".equals(material)) + { + if (t(m.getMaterialComplianceApproved())) + { + // 支持方审中 + if ("manager".equals(role)) return "审核通过"; + if ("sponsor".equals(role)) return "待审核"; + return "待审核"; // executor / admin + } + else + { + // 合规审中 + if ("sponsor".equals(role)) return "已执行未传材料"; // 只读 + if ("manager".equals(role)) return "待审核"; + return "待审核"; // executor / admin + } + } + + // 未提交 + if (t(m.getIsExecuted())) + { + return "executor".equals(role) ? "执行中" : "已执行未传材料"; + } + return "未执行"; + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java index f735be5..3c3d506 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java @@ -1,20 +1,36 @@ package com.ruoyi.business.service.impl; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; import java.util.Collections; +import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeetingAttendee; +import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.domain.dto.ImportResult; import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo; import com.ruoyi.business.mapper.BizMeetingAttendeeMapper; +import com.ruoyi.business.mapper.BizMeetingMapper; +import com.ruoyi.business.mapper.BizProjectMapper; +import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.service.IBizMeetingAttendeeService; +import com.ruoyi.business.service.IBizMeetingService; +import com.ruoyi.business.sms.AliyunSmsSender; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.id.IdGenerator; import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.system.mapper.SysUserMapper; import com.ruoyi.system.service.ISysUserService; @@ -27,19 +43,46 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService @Autowired private BizMeetingAttendeeMapper mapper; @Autowired + private BizMeetingMapper meetingMapper; + @Autowired + private BizProjectMapper projectMapper; + @Autowired private SysUserMapper sysUserMapper; @Autowired private ISysUserService sysUserService; + /** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */ + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private AliyunSmsSender aliyunSmsSender; + @Autowired + private BizNotifyService bizNotifyService; + @Autowired + private IBizMeetingService bizMeetingService; + @Override public int insert(BizMeetingAttendee entity) { + if (entity.getId() == null) { + entity.setId(IdGenerator.generateId()); + } return mapper.insert(entity); } @Override public int insertBatch(Long meetingId, Long[] userIds) { if (userIds == null || userIds.length == 0) return 0; - return mapper.insertBatch(meetingId, userIds, SecurityUtils.getUsername()); + String createBy = SecurityUtils.getUsername(); + List list = new ArrayList<>(userIds.length); + for (Long uid : userIds) { + BizMeetingAttendee a = new BizMeetingAttendee(); + a.setId(IdGenerator.generateId()); + a.setMeetingId(meetingId); + a.setUserId(uid); + a.setCreateBy(createBy); + list.add(a); + } + return mapper.insertBatch(list); } /** @@ -96,9 +139,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService throw new ServiceException("该手机号参会人已在会议中, 无需重复添加"); } - // 4. 写完整档案行 + // 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增) body.setUserId(userId); body.setCreateBy(SecurityUtils.getUsername()); + body.setId(IdGenerator.generateId()); mapper.insertWithProfile(body); Long newId = body.getId(); log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId); @@ -160,11 +204,123 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService return mapper.selectUnsignedByUserId(userId); } + @Override + public List selectInvitedByUserId(Long userId) { + return mapper.selectInvitedByUserId(userId); + } + @Override public List selectUserIdsByMeetingId(Long meetingId) { return mapper.selectUserIdsByMeetingId(meetingId); } + /** + * 劳务报酬个税累进计算 (照搬 hwt BizActGatherService.calcLaborTax). + * 阈值 800/3360/21000/49500 是"实发(税后)"分界点, 对应税前 800/4000/25000/62500. + * + * @param fee 实发金额(税后), ≥ 0 + * @return 个税税金, 保留 2 位小数 (HALF_UP) + */ + private BigDecimal calcLaborTax(BigDecimal fee) { + if (fee == null || fee.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + BigDecimal tax; + if (fee.compareTo(new BigDecimal("800")) <= 0) { + tax = BigDecimal.ZERO; + } else if (fee.compareTo(new BigDecimal("3360")) <= 0) { + tax = fee.subtract(new BigDecimal("800")) + .divide(new BigDecimal("4"), 2, RoundingMode.HALF_UP); + } else if (fee.compareTo(new BigDecimal("21000")) <= 0) { + tax = fee.multiply(new BigDecimal("0.16")) + .divide(new BigDecimal("0.84"), 2, RoundingMode.HALF_UP); + } else if (fee.compareTo(new BigDecimal("49500")) <= 0) { + tax = fee.multiply(new BigDecimal("0.24")).subtract(new BigDecimal("2000")) + .divide(new BigDecimal("0.76"), 2, RoundingMode.HALF_UP); + } else { + tax = fee.multiply(new BigDecimal("0.32")).subtract(new BigDecimal("7000")) + .divide(new BigDecimal("0.68"), 2, RoundingMode.HALF_UP); + } + return tax.setScale(2, RoundingMode.HALF_UP); + } + + /** + * 由应发金额(feePreTax)反推实发金额(fee), 按劳务报酬个税累进公式分段求解 + * (照搬 hwt BizActGatherService.reverseCalcFeeFromFee2). + * 关系式: feePreTax = (fee + tax) * 1.0151, 其中 1.0151 = 1 + 1.51%(增值税及附加). + * + * @param fee2 应发金额(税前) + * @return 反推的实发金额(税后); fee2 为空/负时返回 null + */ + private BigDecimal reverseCalcFeeFromFee2(BigDecimal fee2) { + if (fee2 == null || fee2.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + BigDecimal r10151 = new BigDecimal("1.0151"); + // 段1: fee ≤ 800, tax=0, fee2 = fee * 1.0151 + BigDecimal fee = fee2.divide(r10151, 6, RoundingMode.HALF_UP); + if (fee.compareTo(new BigDecimal("800")) <= 0) { + return fee; + } + // 段2: 800 < fee ≤ 3360, fee = (4*fee2/1.0151 + 800)/5 + fee = fee2.multiply(new BigDecimal("4")) + .divide(r10151, 6, RoundingMode.HALF_UP) + .add(new BigDecimal("800")) + .divide(new BigDecimal("5"), 6, RoundingMode.HALF_UP); + if (fee.compareTo(new BigDecimal("800")) > 0 && fee.compareTo(new BigDecimal("3360")) <= 0) { + return fee; + } + // 段3: 3360 < fee ≤ 21000, fee = fee2*84/(100*1.0151) + fee = fee2.multiply(new BigDecimal("84")) + .divide(new BigDecimal("101.51"), 6, RoundingMode.HALF_UP); + if (fee.compareTo(new BigDecimal("3360")) > 0 && fee.compareTo(new BigDecimal("21000")) <= 0) { + return fee; + } + // 段4: 21000 < fee ≤ 49500, fee = 0.76*fee2/1.0151 + 2000 + fee = fee2.multiply(new BigDecimal("0.76")) + .divide(r10151, 6, RoundingMode.HALF_UP) + .add(new BigDecimal("2000")); + if (fee.compareTo(new BigDecimal("21000")) > 0 && fee.compareTo(new BigDecimal("49500")) <= 0) { + return fee; + } + // 段5: fee > 49500, fee = 0.68*fee2/1.0151 + 7000 + fee = fee2.multiply(new BigDecimal("0.68")) + .divide(r10151, 6, RoundingMode.HALF_UP) + .add(new BigDecimal("7000")); + return fee; + } + + /** + * 在项目角色劳务 JSON 数组 [{role, customName, amount}] 里按角色名匹配劳务金额. + * 匹配规则与前端 ProjectRoleSelect 一致: role === '其他' 时用 customName 作 label, 否则用 role. + * + * @param nodes 已解析的 role_labor JSON (可为 null/非数组) + * @param laborForm 参会人填的角色名 (可为 null/空) + * @return 匹配到的 amount (BigDecimal); 没匹配到或 amount 非法 → null + */ + private BigDecimal findRoleAmount(JsonNode nodes, String laborForm) { + if (nodes == null || !nodes.isArray() || laborForm == null || laborForm.trim().isEmpty()) { + return null; + } + String target = laborForm.trim(); + for (JsonNode n : nodes) { + if (n == null || n.isNull()) continue; + String role = n.path("role").asText(""); + String customName = n.path("customName").asText(""); + String label = "其他".equals(role) ? customName.trim() : role; + if (!target.equals(label)) continue; + JsonNode amountNode = n.get("amount"); + if (amountNode == null || amountNode.isNull()) return null; + try { + if (amountNode.isNumber()) return amountNode.decimalValue(); + return new BigDecimal(amountNode.asText()); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + /** * 批量导入参会人 (Excel → biz_meeting_attendee). * @@ -184,6 +340,20 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService throw new ServiceException("导入数据不能为空"); } + // 项目角色劳务 (只解析一次): 导入行只填角色、没填任何金额时, 按角色带出 amount 补算 + JsonNode roleLaborNodes = null; + try { + BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId); + if (meeting != null && meeting.getProjectId() != null) { + BizProject project = projectMapper.selectByPrimaryKey(meeting.getProjectId()); + if (project != null && project.getRoleLabor() != null && !project.getRoleLabor().trim().isEmpty()) { + roleLaborNodes = objectMapper.readTree(project.getRoleLabor()); + } + } + } catch (Exception e) { + log.warn("[attendee] 解析项目角色劳务失败, 跳过按角色补算金额 meetingId={}", meetingId, e); + } + ImportResult result = new ImportResult(); for (int i = 0; i < rows.size(); i++) { BizMeetingAttendeeImportVo vo = rows.get(i); @@ -195,7 +365,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService continue; } String phone = vo.getPhone().trim(); - if (!phone.matches("^1[3-9]\\d{9}$")) { + if (!phone.matches("^1\\d{10}$")) { result.fail(rowNo, "手机号格式不正确: " + phone); continue; } @@ -212,11 +382,43 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService body.setBankName(vo.getBankName()); body.setBankCard(vo.getBankCard()); body.setBankBranch(vo.getBankBranch()); + body.setAccountName(vo.getAccountName()); + body.setBankRegion(vo.getBankRegion()); + body.setBankAddress(vo.getBankAddress()); + body.setIdCardAttachments(vo.getIdCardAttachments()); body.setLaborForm(vo.getLaborForm()); - body.setFeePreTax(vo.getFeePreTax()); - body.setTax(vo.getTax()); - body.setVatAndSurcharge(vo.getVatAndSurcharge()); - body.setFee(vo.getFee()); + // 金额联动补算 (照搬 hwt importLaborData): 已有值优先, 空白才按链补算, 避免覆盖人工填写 + BigDecimal fee = vo.getFee(); + BigDecimal tax = vo.getTax(); + BigDecimal vat = vo.getVatAndSurcharge(); + BigDecimal feePreTax = vo.getFeePreTax(); + if (fee != null) { + // 用户填了实发金额 → 正向: fee → tax → vat → feePreTax + if (tax == null) tax = calcLaborTax(fee); + if (vat == null) vat = fee.add(tax).multiply(new BigDecimal("0.0151")).setScale(2, RoundingMode.HALF_UP); + if (feePreTax == null) feePreTax = fee.add(tax).add(vat).setScale(2, RoundingMode.HALF_UP); + } else if (feePreTax != null) { + // 用户只填了应发金额 → 反推实发, 再正向补 tax/vat (保留用户原填 feePreTax) + BigDecimal feeBd = reverseCalcFeeFromFee2(feePreTax); + if (feeBd != null) { + fee = feeBd; + if (tax == null) tax = calcLaborTax(fee); + if (vat == null) vat = fee.add(tax).multiply(new BigDecimal("0.0151")).setScale(2, RoundingMode.HALF_UP); + } + } else if (vo.getLaborForm() != null && !vo.getLaborForm().trim().isEmpty() && roleLaborNodes != null) { + // 什么金额都没填, 只填了角色 → 按项目角色劳务 amount 带出实发, 再正向补 tax/vat/feePreTax + BigDecimal amount = findRoleAmount(roleLaborNodes, vo.getLaborForm()); + if (amount != null) { + fee = amount; + tax = calcLaborTax(fee); + vat = fee.add(tax).multiply(new BigDecimal("0.0151")).setScale(2, RoundingMode.HALF_UP); + feePreTax = fee.add(tax).add(vat).setScale(2, RoundingMode.HALF_UP); + } + } + body.setFeePreTax(feePreTax); + body.setTax(tax); + body.setVatAndSurcharge(vat); + body.setFee(fee); body.setSummary(vo.getSummary()); insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch @@ -231,4 +433,78 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService return result; } + @Override + public int pushEsign(List attendeeIds) { + if (attendeeIds == null || attendeeIds.isEmpty()) return 0; + int sent = 0; + Map meetingCache = new HashMap<>(); + for (Long id : attendeeIds) { + if (id == null) continue; + try { + BizMeetingAttendee a = mapper.selectById(id); + if (a == null) continue; + Long mid = a.getMeetingId(); + BizMeeting m = null; + if (mid != null) { + m = meetingCache.get(mid); + if (m == null) { + m = bizMeetingService.getById(mid); + meetingCache.put(mid, m); + } + } + String meetingName = m != null ? m.getMeetingName() : null; + Date startTime = m != null ? m.getStartTime() : null; + + // 1. 短信 (电子签模板: name=参会人姓名, date=会议日期, link=签署链接) + String phone = a.getPhone(); + if (phone != null && !phone.trim().isEmpty()) { + aliyunSmsSender.sendEsign(phone.trim(), a.getName(), startTime, aliyunSmsSender.esignLink(id)); + } + // 2. 站内信 (劳务协议待签署 + 签署链接, 与短信同一链接) + bizNotifyService.esignPushed(a.getUserId(), id, mid, meetingName, aliyunSmsSender.esignLink(id)); + // 3. 标记已推送电子签 + mapper.markEsignedById(id); + sent++; + } catch (Exception e) { + log.warn("[attendee] 推送电子签失败 attendeeId={}: {}", id, e.getMessage()); + } + } + log.info("[attendee] 推送电子签完成 共{}条, 成功{}", attendeeIds.size(), sent); + return sent; + } + + @Override + public int invite(List attendeeIds) { + if (attendeeIds == null || attendeeIds.isEmpty()) return 0; + int sent = 0; + Map meetingCache = new HashMap<>(); + for (Long id : attendeeIds) { + if (id == null) continue; + try { + BizMeetingAttendee a = mapper.selectById(id); + if (a == null) continue; + Long mid = a.getMeetingId(); + BizMeeting m = null; + if (mid != null) { + m = meetingCache.get(mid); + if (m == null) { + m = bizMeetingService.getById(mid); + meetingCache.put(mid, m); + } + } + String meetingName = m != null ? m.getMeetingName() : null; + Date startTime = m != null ? m.getStartTime() : null; + + // 推"会议邀请"站内信 (不发短信) + 置 is_invited=1 + bizNotifyService.meetingInvitation(a.getUserId(), mid, meetingName, startTime); + mapper.markInvitedById(id); + sent++; + } catch (Exception e) { + log.warn("[attendee] 邀请参会失败 attendeeId={}: {}", id, e.getMessage()); + } + } + log.info("[attendee] 邀请参会完成 共{}条, 成功{}", attendeeIds.size(), sent); + return sent; + } + } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java index 59c71ae..0b8fc24 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java @@ -1,20 +1,44 @@ package com.ruoyi.business.service.impl; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; 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.BizMeeting; import com.ruoyi.business.domain.BizMeetingMaterial; +import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingMaterialMapper; import com.ruoyi.business.service.IBizMeetingMaterialService; @Service public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService { + /** 非发票 subType (与前端 MeetingDetail.NON_OCR_SUBTYPES 对齐): 现场照片/签到表等不 OCR, 避免误识别脏 amount */ + private static final Set NON_OCR_SUBTYPES = new HashSet<>(Arrays.asList( + "L_ENTERPRISE_BENEFIT", "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK", "L_EXPERT_PHOTO")); + + /** 扫码拍照白名单 subType (公开端点只允许这三类照片回传) */ + private static final Set CAMERA_SUBTYPES = new HashSet<>(Arrays.asList( + "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK")); + + /** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF 才触发 OCR */ + private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf)$", Pattern.CASE_INSENSITIVE); + @Autowired private BizMeetingMaterialMapper bizMeetingMaterialMapper; + @Autowired + private BizMeetingMapper bizMeetingMapper; @Override public BizMeetingMaterial getById(Long id) { @@ -34,10 +58,25 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService * 翻译成友好中文提示, 避免暴露 SQL 堆栈. *

* 返回插入后的 list (各元素 id 字段被 useGeneratedKeys 回填), 前端可借此触发 OCR. + *

+ * 金额保留: 前端全删全插只传 ossUrl 不传 amount. 为避免"重新上传一张发票导致其余未变发票金额被清零", + * 先快照旧材料, 对 (subType + ossUrl) 未变的材料回填旧 amount 并置 fee_status=1 (无需重算); + * 新增/替换的会 OCR 发票置 fee_status=0 (等 OCR 回写金额后置 1). */ @Override @Transactional(rollbackFor = Exception.class) public List replaceByMeetingId(Long meetingId, List list) { + // 快照旧材料: subType -> 旧材料 (用于回填 amount + 判未变) + List oldList = bizMeetingMaterialMapper.selectByMeetingId(meetingId); + Map oldBySubType = new HashMap<>(); + if (oldList != null) { + for (BizMeetingMaterial o : oldList) { + if (o.getSubType() != null) { + oldBySubType.put(o.getSubType(), o); + } + } + } + bizMeetingMaterialMapper.deleteByMeetingId(meetingId); if (list == null || list.isEmpty()) { return list; @@ -46,6 +85,18 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService for (BizMeetingMaterial m : list) { m.setId(null); m.setMeetingId(meetingId); + BizMeetingMaterial old = oldBySubType.get(m.getSubType()); + boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl()); + if (unchanged) { + // 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版, 前端全删全插不传 extraOssUrl) + m.setAmount(old.getAmount()); + m.setFeeStatus(1); + m.setExtraOssUrl(old.getExtraOssUrl()); + } else { + // 新增/替换: 金额清零, 会 OCR 的发票标记待计算 + m.setAmount(null); + m.setFeeStatus(needsOcr(m) ? 0 : 1); + } } try { bizMeetingMaterialMapper.insertBatch(list); @@ -56,8 +107,68 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService } @Override - public int updateAmount(Long materialId, java.math.BigDecimal amount) { + public int updateAmount(Long materialId, BigDecimal amount) { if (materialId == null || amount == null) return 0; return bizMeetingMaterialMapper.updateAmount(materialId, amount); } -} \ No newline at end of file + + @Override + public int updateFeeStatus(Long materialId, Integer feeStatus) { + if (materialId == null || feeStatus == null) return 0; + return bizMeetingMaterialMapper.updateFeeStatus(materialId, feeStatus); + } + + /** + * 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行. + * 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底. + * 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不 markFeeCalcPending. + * extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空. + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl) { + if (meetingId == null) throw new ServiceException("缺少 meetingId"); + if (subType == null || !CAMERA_SUBTYPES.contains(subType)) throw new ServiceException("非法的拍照类型"); + if (ossUrl == null || ossUrl.isEmpty()) throw new ServiceException("缺少照片 URL"); + + BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId); + if (meeting == null) throw new ServiceException("会议不存在"); + + // 找该 subType 现有行 (同会议同 subType 唯一) + BizMeetingMaterial row = null; + List existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId); + if (existing != null) { + for (BizMeetingMaterial m : existing) { + if (subType.equals(m.getSubType())) { row = m; break; } + } + } + + if (row != null) { + // 更新 ossUrl (updateByPrimaryKey 只改非空字段, 不动 amount/fee_status) + BizMeetingMaterial upd = new BizMeetingMaterial(); + upd.setId(row.getId()); + upd.setOssUrl(ossUrl); + upd.setExtraOssUrl(extraOssUrl); + bizMeetingMaterialMapper.updateByPrimaryKey(upd); + } else { + BizMeetingMaterial ins = new BizMeetingMaterial(); + ins.setMeetingId(meetingId); + ins.setMaterialType(subType.startsWith("L_") ? "LABOR" : "SERVICE"); + ins.setSubType(subType); + ins.setOssUrl(ossUrl); + ins.setExtraOssUrl(extraOssUrl); + ins.setCreateTime(new Date()); + bizMeetingMaterialMapper.insert(ins); + } + } + + /** + * 是否会被前端提交 OCR (与 MeetingDetail.saveMaterials 门控一致): + * 文件可识别 (jpg/jpeg/png/pdf) 且 不在非发票 subType 集合里. + */ + private boolean needsOcr(BizMeetingMaterial m) { + if (m == null || m.getOssUrl() == null || m.getOssUrl().isEmpty()) return false; + if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false; + return RECOGNIZABLE.matcher(m.getOssUrl()).find(); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java index 658e4b3..076cbd9 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java @@ -12,6 +12,7 @@ import com.ruoyi.business.mapper.BizMeetingExecutorMapper; import com.ruoyi.business.mapper.BizMeetingMaterialMapper; import com.ruoyi.business.mapper.BizMeetingAuditLogMapper; import com.ruoyi.business.service.IBizMeetingService; +import com.ruoyi.common.enums.BizMeetingStageEnum; import com.ruoyi.common.utils.id.IdGenerator; @Service @@ -43,6 +44,18 @@ public class BizMeetingServiceImpl implements IBizMeetingService if (entity.getBusinessId() == null || entity.getBusinessId().isEmpty()) { entity.setBusinessId(String.valueOf(IdGenerator.generateId())); } + // 新建会议初始状态: DB 列默认值 '0' 会让前端 stageLabel 显示成 0 而不是 enum 项, 这里显式兜底成 enum. + // current_stage = NOT_STARTED (未执行, 等 scheduler 过 startTime 置 is_executed 转 RUNNING) + // 材料/凭证审核子状态 = NOT_SUBMITTED (未提交, 供执行方 submit-material/submit-voucher 校验) + if (entity.getCurrentStage() == null || entity.getCurrentStage().isEmpty()) { + entity.setCurrentStage(BizMeetingStageEnum.NOT_STARTED.getCode()); + } + if (entity.getMaterialAuditStage() == null || entity.getMaterialAuditStage().isEmpty()) { + entity.setMaterialAuditStage("NOT_SUBMITTED"); + } + if (entity.getVoucherAuditStage() == null || entity.getVoucherAuditStage().isEmpty()) { + entity.setVoucherAuditStage("NOT_SUBMITTED"); + } return bizMeetingMapper.insert(entity); } @Override @@ -79,4 +92,15 @@ public class BizMeetingServiceImpl implements IBizMeetingService if (id != null) softDeleteCascade(id); } } + + @Override + public int countByProjectId(Long projectId) + { return bizMeetingMapper.countByProjectId(projectId); } + + @Override + public void markFeeCalcPending(Long meetingId) { + if (meetingId != null) { + bizMeetingMapper.markFeeCalcPending(meetingId); + } + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectExecutorAssignServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectExecutorAssignServiceImpl.java new file mode 100644 index 0000000..e8970da --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectExecutorAssignServiceImpl.java @@ -0,0 +1,57 @@ +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.BizProjectExecutorAssign; +import com.ruoyi.business.mapper.BizProjectExecutorAssignMapper; +import com.ruoyi.business.service.IBizProjectExecutorAssignService; + +@Service +public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorAssignService { + @Autowired + private BizProjectExecutorAssignMapper mapper; + + @Override + public int insertAssign(BizProjectExecutorAssign entity) { + // 执行方分配策略: 先按 project_id 删, 再插 + mapper.deleteByProjectId(entity.getProjectId()); + return mapper.insertAssign(entity); + } + + /** + * 多执行人分配 (一个项目 ↔ N 执行人). + *

+ * 策略: 先按 project_id 物理删除旧分配, 再逐个插入. + * 注意 ⚠️ 不能直接复用 insertAssign() — insertAssign() 内部会 deleteByProjectId, 循环里第二次 delete 会把刚 insert 的清掉. + */ + @Override + public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List staffUserIds) { + if (body == null || body.getProjectId() == null) return 0; + if (staffUserIds == null || staffUserIds.isEmpty()) return 0; + // 一次性清旧, 不在循环里清 + mapper.deleteByProjectId(body.getProjectId()); + int inserted = 0; + for (Long sid : staffUserIds) { + BizProjectExecutorAssign item = new BizProjectExecutorAssign(); + item.setProjectId(body.getProjectId()); + item.setStaffUserId(sid); + item.setAssignDesc(body.getAssignDesc()); + item.setAssignPoints(body.getAssignPoints()); + item.setCreateBy(body.getCreateBy()); + item.setExecutorUserId(body.getExecutorUserId()); + inserted += mapper.insertAssign(item); + } + return inserted; + } + + @Override + public List listByProjectId(String projectId) { + return mapper.selectByProjectId(projectId); + } + + @Override + public int deleteByProjectId(String projectId) { + return mapper.deleteByProjectId(projectId); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java index 1d8a537..0203834 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java @@ -9,6 +9,7 @@ import com.ruoyi.business.mapper.BizProjectMapper; import com.ruoyi.business.mapper.BizProjectPlanMapper; import com.ruoyi.business.mapper.BizProjectAssignMapper; import com.ruoyi.business.mapper.BizProjectSponsorAssignMapper; +import com.ruoyi.business.mapper.BizProjectExecutorAssignMapper; import com.ruoyi.business.mapper.BizProjectRatingMapper; import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.service.IBizProjectService; @@ -27,6 +28,8 @@ public class BizProjectServiceImpl implements IBizProjectService @Autowired private BizProjectSponsorAssignMapper bizProjectSponsorAssignMapper; @Autowired + private BizProjectExecutorAssignMapper bizProjectExecutorAssignMapper; + @Autowired private BizProjectRatingMapper bizProjectRatingMapper; @Autowired private BizMeetingMapper bizMeetingMapper; @@ -46,6 +49,9 @@ public class BizProjectServiceImpl implements IBizProjectService public List selectExecutorList(BizProject entity) { return bizProjectMapper.selectExecutorList(entity); } @Override + public List selectExecutorStaffList(BizProject entity) + { return bizProjectMapper.selectExecutorStaffList(entity); } + @Override // 注: biz_project.project_id 用 DB AUTO_INCREMENT, 不需要 SnowflakeId 注入; // 项目 ID 用 Long 后, SnowflakeId.injectIfEmpty 反射 setProjectId(String) 会 NoSuchMethodException 被吞掉 (SnowflakeId.java:30-31), 行为安全. // create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制) @@ -97,6 +103,8 @@ public class BizProjectServiceImpl implements IBizProjectService bizProjectAssignMapper.softDeleteByProjectId(projectId); // 5) sponsor assign (String) bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId)); + // 5.5) executor assign (String) + bizProjectExecutorAssignMapper.softDeleteByProjectId(String.valueOf(projectId)); // 6) rating bizProjectRatingMapper.softDeleteByProjectId(projectId); // 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch @@ -115,4 +123,19 @@ public class BizProjectServiceImpl implements IBizProjectService if (id != null) softDeleteCascade(id); } } + + @Override + public int countAssignedSessions(Long projectId, Long executorUserId) + { return bizProjectMapper.countAssignedSessions(projectId, executorUserId); } + + @Override + public boolean isExecutorOfProject(Long projectId, Long userId) + { return bizProjectMapper.countExecutorOfProject(projectId, userId) > 0; } + + /** 会议结算后重算项目金额 (全量 SUM 已结算会议, 幂等) */ + @Override + public void recomputeSettledAmounts(Long projectId) { + if (projectId == null) return; + bizProjectMapper.recomputeSettledAmounts(projectId); + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectSponsorAssignServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectSponsorAssignServiceImpl.java index c47456f..2b547f2 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectSponsorAssignServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectSponsorAssignServiceImpl.java @@ -19,6 +19,32 @@ public class BizProjectSponsorAssignServiceImpl implements IBizProjectSponsorAss return mapper.insertAssign(entity); } + /** + * 多监察员分配 (一个项目 ↔ N 监察员). + *

+ * 策略: 先按 project_id 物理删除旧分配, 再逐个插入 (事务内由 Service 默认单 insert 即可, 失败单条不影响其它). + * 注意 ⚠️ 不能直接复用 insertAssign() — insertAssign() 内部会 deleteByProjectId, 循环里第二次 delete 会把刚 insert 的清掉, 提交后只剩最后 1 条. + */ + @Override + public int assignMonitorsForProject(BizProjectSponsorAssign body, java.util.List monitorUserIds) { + if (body == null || body.getProjectId() == null) return 0; + if (monitorUserIds == null || monitorUserIds.isEmpty()) return 0; + // 一次性清旧, 不在循环里清 + mapper.deleteByProjectId(body.getProjectId()); + int inserted = 0; + for (Long mid : monitorUserIds) { + BizProjectSponsorAssign item = new BizProjectSponsorAssign(); + item.setProjectId(body.getProjectId()); + item.setMonitorUserId(mid); + item.setAssignDesc(body.getAssignDesc()); + item.setAssignPoints(body.getAssignPoints()); + item.setCreateBy(body.getCreateBy()); + item.setSponsorUserId(body.getSponsorUserId()); + inserted += mapper.insertAssign(item); + } + return inserted; + } + @Override public List listByProjectId(String projectId) { return mapper.selectByProjectId(projectId); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java index 13a14e7..168d290 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java @@ -1,6 +1,11 @@ package com.ruoyi.business.service.impl; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; + import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -11,9 +16,11 @@ import com.ruoyi.business.domain.BizExpert; import com.ruoyi.business.domain.BizLaborProtocolTemplate; import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeetingAttendee; +import com.ruoyi.business.domain.BizProject; import com.ruoyi.business.mapper.BizExpertMapper; import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper; import com.ruoyi.business.mapper.BizMeetingMapper; +import com.ruoyi.business.mapper.BizProjectMapper; import com.ruoyi.business.service.BizSignService; import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.PdfService; @@ -33,6 +40,8 @@ public class BizSignServiceImpl implements BizSignService { private BizLaborProtocolTemplateMapper templateMapper; @Autowired private PdfService pdfService; + @Autowired + private BizProjectMapper projectMapper; @Override public Map getSignInfo(Long attendeeId) { @@ -79,10 +88,37 @@ public class BizSignServiceImpl implements BizSignService { current.put("tax", attendee.getTax()); current.put("fee", attendee.getFee()); - List> laborFormOptions = Arrays.asList( - map("讲课", "讲课"), map("讨论", "讨论"), - map("主持", "主持"), map("主席", "主席"), - map("__other__", "其他")); + // 会议信息 (会议名称 + 期数), 用于签署页大标题; 也用于取 projectId 拉项目角色 + BizMeeting meeting = meetingMapper.selectByPrimaryKey(attendee.getMeetingId()); + + // 劳务形式选项: 从项目角色 (biz_project.role_labor) 读, 末尾追加"其他"逃生口 + List> laborFormOptions = new ArrayList<>(); + Long projectId = meeting != null ? meeting.getProjectId() : null; + if (projectId != null) { + BizProject project = projectMapper.selectByPrimaryKey(projectId); + String roleLabor = project != null ? project.getRoleLabor() : null; + if (roleLabor != null && !roleLabor.trim().isEmpty()) { + try { + JSONArray arr = JSONUtil.parseArray(roleLabor); + for (int i = 0; i < arr.size(); i++) { + JSONObject node = arr.getJSONObject(i); + if (node == null) continue; + String role = node.getStr("role"); + if (role == null || role.trim().isEmpty()) continue; + // role === '其他' → label = customName; 否则 label = role + String customName = node.getStr("customName"); + String label = "其他".equals(role) + ? (customName != null && !customName.trim().isEmpty() ? customName.trim() : "其他") + : role; + laborFormOptions.add(map(label, label)); + } + } catch (Exception e) { + // role_labor JSON 解析失败 → 空列表兜底 (仍保留"其他"逃生口) + } + } + } + laborFormOptions.add(map("__other__", "其他")); + List titleOptions = Arrays.asList( "主任医师", "副主任医师", "主治(主管)医师", "医士", "主任药师", "药师", "药士", @@ -96,6 +132,35 @@ public class BizSignServiceImpl implements BizSignService { result.put("laborFormOptions", laborFormOptions); result.put("titleOptions", titleOptions); result.put("attendeeId", attendeeId); + result.put("meetingName", meeting != null ? meeting.getMeetingName() : ""); + result.put("periodNo", meeting != null ? meeting.getPeriodNo() : null); + result.put("totalPeriods", meeting != null ? meeting.getTotalPeriods() : null); + return result; + } + + @Override + public Map resolveByMeeting(Long meetingId) { + // 有 meetingId: 直接按 meetingId 查会议 (名称 + 期数), 再查当前用户是否在会议人员列表里 + Long userId = SecurityUtils.getUserId(); + BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId); + if (meeting == null) { + throw new ServiceException("会议不存在"); + } + Long attendeeId = null; + if (userId != null) { + // 受邀判定: 只要在 biz_meeting_attendee 人员列表里 (is_deleted=0), 不管有没有点邀请/电子签, 都能签 + for (BizMeetingAttendee a : attendeeService.selectByMeetingId(meetingId)) { + if (a != null && userId.equals(a.getUserId())) { + attendeeId = a.getId(); + break; + } + } + } + Map result = new HashMap<>(); + result.put("meetingName", meeting.getMeetingName()); + result.put("periodNo", meeting.getPeriodNo()); + result.put("totalPeriods", meeting.getTotalPeriods()); + result.put("attendeeId", attendeeId); return result; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSupportIntentServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSupportIntentServiceImpl.java deleted file mode 100644 index c74850b..0000000 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSupportIntentServiceImpl.java +++ /dev/null @@ -1,33 +0,0 @@ -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.BizSupportIntent; -import com.ruoyi.business.mapper.BizSupportIntentMapper; -import com.ruoyi.business.service.IBizSupportIntentService; - -@Service -public class BizSupportIntentServiceImpl implements IBizSupportIntentService -{ - @Autowired - private BizSupportIntentMapper bizSupportIntentMapper; - - @Override - public BizSupportIntent getById(String intentId) - { return bizSupportIntentMapper.selectByPrimaryKey(intentId); } - @Override - public List selectList(BizSupportIntent entity) - { return bizSupportIntentMapper.selectList(entity); } - @Override - public int insert(BizSupportIntent entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "intentId"); return bizSupportIntentMapper.insert(entity); } - @Override - public int updateByPrimaryKey(BizSupportIntent entity) - { return bizSupportIntentMapper.updateByPrimaryKey(entity); } - @Override - public int deleteByPrimaryKey(String intentId) - { return bizSupportIntentMapper.deleteByPrimaryKey(intentId); } - @Override - public int deleteByPrimaryKeys(String[] intentId) - { return bizSupportIntentMapper.deleteByPrimaryKeys(intentId); } -} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java index 3ead7af..d79933f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java @@ -134,6 +134,11 @@ public class InvoiceOcrService e.getMessage() == null ? "OCR 异常" : e.getMessage()); } } + finally + { + // OCR 处理完毕 (成功/非发票/失败), 该材料金额最终确定 → fee_status=1 + materialService.updateFeeStatus(materialId, 1); + } }); out.setSubmitted(true); @@ -293,6 +298,11 @@ public class InvoiceOcrService e.getMessage() == null ? "OCR 异常" : e.getMessage()); log.warn("兜底识别失败 invoiceId={} err={}", inv.getId(), e.getMessage()); } + finally + { + // 兜底 OCR 处理完毕, 金额最终确定 → fee_status=1 + materialService.updateFeeStatus(inv.getMaterialId(), 1); + } } // ==================== 工具方法 ==================== diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java index 10c5ea7..30d002f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java @@ -5,6 +5,11 @@ import com.aliyuncs.IAcsClient; import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest; import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse; import com.aliyuncs.profile.DefaultProfile; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.text.SimpleDateFormat; +import cn.hutool.json.JSONUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -37,6 +42,12 @@ public class AliyunSmsSender { @Value("${ruoyi.sms.template}") private String template; + @Value("${ruoyi.sms.esignTemplate}") + private String esignTemplate; + + @Value("${ruoyi.sms.esignBaseUrl:https://ringdoctor.com/hg}") + private String esignBaseUrl; + @Value("${ruoyi.sms.regionId:cn-hangzhou}") private String regionId; @@ -63,6 +74,45 @@ public class AliyunSmsSender { return client; } + /** + * 发送电子签短信 (模板占位符 name=姓名, date=日期 MM月dd日, link=签署链接). + * 与验证码模板 (ruoyi.sms.template) 分离, 成功返回 true. + */ + public boolean sendEsign(String phone, String name, Date date, String link) { + try { + SendSmsRequest req = new SendSmsRequest(); + req.setPhoneNumbers(phone); + req.setSignName(signName); + req.setTemplateCode(esignTemplate); + Map params = new LinkedHashMap<>(); + params.put("name", name != null ? name : ""); + params.put("date", date != null ? new SimpleDateFormat("MM月dd日").format(date) : ""); + params.put("link", link != null ? link : ""); + req.setTemplateParam(JSONUtil.toJsonStr(params)); + SendSmsResponse resp = getClient().getAcsResponse(req); + if ("OK".equalsIgnoreCase(resp.getCode())) { + log.info("[SMS] 电子签短信发送成功 phone={}, bizId={}", phone, resp.getBizId()); + return true; + } + log.error("[SMS] 电子签短信发送失败 phone={}, code={}, msg={}, requestId={}", + phone, resp.getCode(), resp.getMessage(), resp.getRequestId()); + return false; + } catch (Exception e) { + log.error("[SMS] 电子签短信异常 phone={}", phone, e); + return false; + } + } + + /** + * 拼电子签签署链接: {esignBaseUrl}/#/doctor/sign-fill?attendeeId={attendeeId} + * 例: https://ringdoctor.com/hg/#/doctor/sign-fill?attendeeId=123 + * (nginx 子路径 /hg 已配在 ruoyi.sms.esignBaseUrl 里, 前端 Vue Router 是 hash 模式, + * 所以 Java 只拼 #/doctor/sign-fill 路由 + attendeeId) + */ + public String esignLink(Long attendeeId) { + return esignBaseUrl + "/#/doctor/sign-fill?attendeeId=" + attendeeId; + } + /** * 真发送短信验证码, 成功返回 true */ diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml index 3db6332..bb66187 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAttendeeMapper.xml @@ -39,34 +39,35 @@ + + - insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time) - values(#{meetingId}, #{userId}, #{createBy}, sysdate()) + insert into biz_meeting_attendee(id, meeting_id, user_id, create_by, create_time) + values(#{id}, #{meetingId}, #{userId}, #{createBy}, sysdate()) - - insert into biz_meeting_attendee(meeting_id, user_id, name, phone, work_unit, department, title, + + insert into biz_meeting_attendee(id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, create_by, create_time) - values(#{meetingId}, #{userId}, #{name}, #{phone}, #{workUnit}, #{department}, #{title}, + values(#{id}, #{meetingId}, #{userId}, #{name}, #{phone}, #{workUnit}, #{department}, #{title}, #{idCard}, #{bankCard}, #{bankName}, #{bankBranch}, #{bankRegion}, #{bankAddress}, #{accountName}, #{idCardAttachments}, #{laborForm}, #{feePreTax}, #{tax}, #{fee}, #{vatAndSurcharge}, #{summary}, #{onSitePhotos}, #{createBy}, sysdate()) - + - insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time) + insert into biz_meeting_attendee(id, meeting_id, user_id, create_by, create_time) values - - (#{meetingId}, #{userId}, #{createBy}, sysdate()) + + (#{a.id}, #{a.meetingId}, #{a.userId}, #{a.createBy}, sysdate()) @@ -124,6 +125,18 @@ update_time = sysdate() where id = #{id} + + + update biz_meeting_attendee + set is_esigned = 1, update_time = sysdate() + where id = #{id} + + + + update biz_meeting_attendee + set is_invited = 1, update_time = sysdate() + where id = #{id} + delete from biz_meeting_attendee where meeting_id = #{meetingId} @@ -139,34 +152,51 @@ delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId} + + + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAuditLogMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAuditLogMapper.xml index e0a34ac..d8528e7 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAuditLogMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingAuditLogMapper.xml @@ -7,7 +7,10 @@ - + + + + @@ -16,7 +19,7 @@ - select id, meeting_id, auditor, opinion, current_stage, create_time, audit_time, audit_type, audit_result, is_deleted + select id, meeting_id, auditor, opinion, executor_stage, sponsor_stage, manager_stage, admin_stage, create_time, audit_time, audit_type, audit_result, is_deleted from biz_meeting_audit_log @@ -30,7 +33,6 @@ is_deleted = 0 and meeting_id = #{meetingId} - and current_stage = #{currentStage} and auditor = #{auditor} order by id desc @@ -42,7 +44,10 @@ meeting_id, auditor, opinion, - current_stage, + executor_stage, + sponsor_stage, + manager_stage, + admin_stage, create_time, audit_time, audit_type, @@ -52,7 +57,10 @@ #{meetingId}, #{auditor}, #{opinion}, - #{currentStage}, + #{executorStage}, + #{sponsorStage}, + #{managerStage}, + #{adminStage}, #{createTime}, #{auditTime}, #{auditType}, @@ -65,7 +73,10 @@ auditor = #{auditor}, opinion = #{opinion}, - current_stage = #{currentStage}, + executor_stage = #{executorStage}, + sponsor_stage = #{sponsorStage}, + manager_stage = #{managerStage}, + admin_stage = #{adminStage}, create_time = #{createTime}, audit_time = #{auditTime}, audit_type = #{auditType}, diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml index 76cdc92..faea603 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml @@ -21,9 +21,28 @@ + + + + + + + + + + + + + + + + + + + @@ -31,15 +50,19 @@ - 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, is_deleted - from biz_meeting + 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, is_executed, execute_time, is_settled, settle_time, is_finished, finish_time, is_frozen, freeze_time, material_audit_time, voucher_audit_time, material_compliance_approved, voucher_compliance_approved, invitation_url, schedule_url, poster_url, labor_signed, labor_fee, meeting_fee, total_fee, fee_calc_status, create_by, create_time, update_by, update_time, is_deleted @@ -79,7 +117,12 @@ voucher_audit_stage, invitation_url, schedule_url, + poster_url, labor_signed, + create_by, + create_time, + update_by, + update_time, #{meetingId}, @@ -103,33 +146,54 @@ #{voucherAuditStage}, #{invitationUrl}, #{scheduleUrl}, + #{posterUrl}, #{laborSigned}, + #{createBy}, + #{createTime}, + #{updateBy}, + #{updateTime}, update biz_meeting business_id = #{businessId}, - project_id = #{projectId}, + project_id = #{projectId}, project_no = #{projectNo}, project_name = #{projectName}, meeting_name = #{meetingName}, - period_no = #{periodNo}, - total_periods = #{totalPeriods}, + period_no = #{periodNo}, + total_periods = #{totalPeriods}, project_form = #{projectForm}, - start_time = #{startTime}, - end_time = #{endTime}, + + start_time = #{startTime}, + end_time = #{endTime}, org_name = #{orgName}, address = #{address}, current_stage = #{currentStage}, supervision_opinion = #{supervisionOpinion}, supervision_by = #{supervisionBy}, - supervision_time = #{supervisionTime}, + supervision_time = #{supervisionTime}, material_audit_stage = #{materialAuditStage}, voucher_audit_stage = #{voucherAuditStage}, + is_executed = #{isExecuted}, + execute_time = #{executeTime}, + is_settled = #{isSettled}, + settle_time = #{settleTime}, + is_finished = #{isFinished}, + finish_time = #{finishTime}, + is_frozen = #{isFrozen}, + freeze_time = #{freezeTime}, + material_audit_time = #{materialAuditTime}, + voucher_audit_time = #{voucherAuditTime}, + material_compliance_approved = #{materialComplianceApproved}, + voucher_compliance_approved = #{voucherComplianceApproved}, invitation_url = #{invitationUrl}, schedule_url = #{scheduleUrl}, + poster_url = #{posterUrl}, labor_signed = #{laborSigned}, + update_by = #{updateBy}, + update_time = #{updateTime}, where meeting_id = #{meetingId} @@ -150,4 +214,66 @@ + + + + + update biz_meeting + set is_executed = 1, + execute_time = NOW(), + current_stage = 'RUNNING' + where is_deleted = 0 + and is_executed = 0 + and is_frozen = 0 + and start_time is not null + and start_time <= NOW() + and material_audit_stage = 'NOT_SUBMITTED' + + + + update biz_meeting m + join biz_project p on p.project_id = m.project_id and p.is_deleted = 0 + set m.is_frozen = 1, + m.freeze_time = NOW(), + m.current_stage = 'FROZEN' + where m.is_deleted = 0 + and m.is_frozen = 0 + and m.material_audit_stage = 'NOT_SUBMITTED' + and m.end_time is not null + and p.submit_deadline_days is not null + and date_add(m.end_time, interval p.submit_deadline_days day) <= NOW() + + + + update biz_meeting + set current_stage = 'AWAITING_SETTLEMENT' + where is_deleted = 0 + and is_frozen = 0 + and is_settled = 0 + and is_finished = 0 + and material_audit_stage = 'APPROVED' + and voucher_audit_stage = 'APPROVED' + and current_stage = 'SUPERVISION_APPROVED' + and greatest(material_audit_time, voucher_audit_time) is not null + and greatest(material_audit_time, voucher_audit_time) <= (NOW() - INTERVAL 1 DAY) + + + + + + update biz_meeting set fee_calc_status = 0 where meeting_id = #{meetingId} + + + + update biz_meeting + set labor_fee = #{laborFee}, + meeting_fee = #{meetingFee}, + total_fee = #{totalFee}, + fee_calc_status = 1 + where meeting_id = #{meetingId} + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMaterialMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMaterialMapper.xml index 5559bce..ab78d4c 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMaterialMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMaterialMapper.xml @@ -9,14 +9,16 @@ + + - select id, meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time, is_deleted + select id, meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, amount, creator_id, create_time, is_deleted, fee_status from biz_meeting_material @@ -39,6 +41,7 @@ sub_type, file_name, oss_url, + extra_oss_url, amount, creator_id, create_time, @@ -49,6 +52,7 @@ #{subType}, #{fileName}, #{ossUrl}, + #{extraOssUrl}, #{amount}, #{creatorId}, #{createTime}, @@ -56,11 +60,11 @@ - insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time) + insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, amount, creator_id, create_time, fee_status) values (#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl}, - #{item.amount}, #{item.creatorId}, #{item.createTime}) + #{item.extraOssUrl}, #{item.amount}, #{item.creatorId}, #{item.createTime}, #{item.feeStatus}) @@ -71,6 +75,7 @@ sub_type = #{subType}, file_name = #{fileName}, oss_url = #{ossUrl}, + extra_oss_url = #{extraOssUrl}, amount = #{amount}, where id = #{id} @@ -80,6 +85,10 @@ update biz_meeting_material set amount = #{amount} where id = #{id} + + update biz_meeting_material set fee_status = #{feeStatus} where id = #{id} + + delete from biz_meeting_material where id = #{id} diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizPersonMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizPersonMapper.xml index b18164a..a29bbb5 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizPersonMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizPersonMapper.xml @@ -15,6 +15,7 @@ + @@ -31,7 +32,8 @@ select p.person_id, p.name, p.phone, p.org_id, o.org_name, o.org_type, p.department, p.position, p.role, p.unit_type, p.user_id, p.create_by, p.create_time, p.update_by, p.update_time, - u.account_type, u.parent_user_id, u.status, u.del_flag as user_del_flag + u.account_type, u.parent_user_id, u.status, u.del_flag as user_del_flag, + u.user_name as user_name from biz_person p left join biz_org o on p.org_id = o.org_id left join sys_user u on p.user_id = u.user_id diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml new file mode 100644 index 0000000..2bc7a9b --- /dev/null +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO biz_project_executor_assign + (project_id, executor_user_id, staff_user_id, assign_desc, assign_points, create_by, create_time) + VALUES + (#{projectId}, #{executorUserId}, #{staffUserId}, #{assignDesc}, #{assignPoints}, #{createBy}, sysdate()) + + + + + delete from biz_project_executor_assign where project_id = #{projectId} + + + + + update biz_project_executor_assign set is_deleted = 1 where project_id = #{projectId} + + + + diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml index e0cde4e..e6f8c32 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml @@ -8,6 +8,7 @@ + @@ -96,7 +97,32 @@ #{id} - and p.sponsor_admin_user_id = #{params.sponsorAdminUserId} + + and ( + p.sponsor_admin_user_id = #{params.sponsorAdminUserId} + or p.project_id in ( + select distinct i.project_id + from biz_publicity_support_intent i + where i.user_id = #{params.sponsorAdminUserId} + ) + ) + and ( + p.project_id in ( + select distinct a.project_id + from biz_project_sponsor_assign a + where a.is_deleted = 0 + and a.monitor_user_id = #{params.monitorUserId} + ) + or p.project_id in ( + select distinct i.project_id + from biz_publicity_support_intent i + where i.user_id = #{params.monitorUserId} + ) + ) and p.project_no like concat('%', #{projectNo}, '%') and p.project_name like concat('%', #{projectName}, '%') and p.start_time >= #{startTime} @@ -133,16 +159,67 @@ where bpa4.project_id = p.project_id and bpa4.is_deleted = 0 and (bpa4.exec_user_id = #{params.executorUserId} - or bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_amount + or bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_amount, + (select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0) as meeting_count from biz_project p - join biz_project_assign a on a.project_id = p.project_id and a.is_deleted = 0 + + join biz_project_assign a on a.project_id = p.project_id + and a.is_deleted = 0 + and (a.exec_user_id = #{params.executorUserId} + or a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) left join biz_org o on o.user_id = p.sponsor_admin_user_id and o.org_type = 'sponsor' left join sys_user lu on lu.user_id = p.lead_user_id left join biz_person bp on bp.user_id = p.create_user_id p.is_deleted = 0 - (a.exec_user_id = #{params.executorUserId} - or a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) + and p.project_no like concat('%', #{projectNo}, '%') + and p.project_name like concat('%', #{projectName}, '%') + and p.start_time >= #{startTime} + and p.end_time <= #{endTime} + and p.is_finished = #{isFinished} + + order by p.project_id desc + + + + select project_no from biz_project where project_id = #{projectId} + + + + + + + update biz_project p + set p.paid_labor_amount = ( + select ifnull(sum(m.labor_fee), 0) + from biz_meeting m + where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0 + ), + p.paid_meeting_amount = ( + select ifnull(sum(m.meeting_fee), 0) + from biz_meeting m + where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0 + ), + p.available_amount = p.total_amount - ifnull(p.manage_fee, 0) + - (select ifnull(sum(m.labor_fee), 0) + from biz_meeting m + where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) + - (select ifnull(sum(m.meeting_fee), 0) + from biz_meeting m + where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) + where p.project_id = #{projectId} + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizSupportIntentMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizSupportIntentMapper.xml deleted file mode 100644 index dad35db..0000000 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizSupportIntentMapper.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - select intent_id, project_no, project_name, name, work_unit, department, position, phone, account_status, create_by, create_time, update_by, update_time - from biz_support_intent - - - - - insert into biz_support_intent - - intent_id, - name, - department, - position, - phone, - - - #{intentId}, - #{name}, - #{department}, - #{position}, - #{phone}, - - - - update biz_support_intent - - project_no = #{projectNo}, - project_name = #{projectName}, - work_unit = #{workUnit}, - account_status = #{accountStatus}, - name = #{name}, - department = #{department}, - position = #{position}, - phone = #{phone}, - - where intent_id = #{intentId} - - - delete from biz_support_intent where intent_id = #{intentId} - - - delete from biz_support_intent where intent_id in - - #{intentId} - - - \ No newline at end of file diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java index 260c945..0b05a28 100644 --- a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java @@ -33,6 +33,9 @@ public class RuoYiConfig /** OSS 配置 (可选) */ private OssProperties oss = new OssProperties(); + /** 扫码拍照相机网页配置 (可选) */ + private CameraProperties camera = new CameraProperties(); + public String getName() { return name; @@ -133,6 +136,28 @@ public class RuoYiConfig this.oss = oss; } + public CameraProperties getCamera() + { + return camera; + } + + public void setCamera(CameraProperties camera) + { + this.camera = camera; + } + + /** + * 扫码拍照相机网页配置 (前端二维码目标 URL) + */ + public static class CameraProperties + { + /** 相机 H5 网页基础地址, 例如 http://localhost:8090/camera/ */ + private String baseUrl; + + public String getBaseUrl() { return baseUrl; } + public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } + } + /** * OSS 配置 (前端直传签名用) */ diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/BizMeetingStageEnum.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/BizMeetingStageEnum.java new file mode 100644 index 0000000..49ba38a --- /dev/null +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/BizMeetingStageEnum.java @@ -0,0 +1,109 @@ +package com.ruoyi.common.enums; + +/** + * 会议 当前阶段 (biz_meeting.current_stage) + *

+ * 10 值统一物理状态机 (单一可信源). 每个角色看到的是它的一个显示投影, 见前端 stageLabel(role, code): + *

+ *   NOT_STARTED             未执行          会议开始时间前
+ *       ↓ (过 startTime, scheduler)
+ *   RUNNING                 执行中          已开始, 执行方未提交材料
+ *       ↓ (执行方提交材料)
+ *   AWAITING_COMPLIANCE     待合规审核      执行方已提交, 等合规人员审核 (支持方此时只读, 不能审)
+ *       ↓ (合规审通过)          ↘ (合规退回)
+ *   AWAITING_SUPERVISION    待支持方审核    合规审通过, 等支持方审核(填监管意见)
+ *       ↓ (支持方审通过)         ↘ (支持方退回)
+ *   SUPERVISION_APPROVED    审核通过        支持方审通过, 等 1 自然日 → 待结算
+ *       ↓ (过 1 自然日, scheduler)
+ *   AWAITING_SETTLEMENT     待结算          支持方审通过 + 1 自然日, 等合规结算 + 上传付款凭证
+ *       ↓ (合规结算 + 上传凭证)
+ *   SETTLED                 已结算          材料+凭证均通过, 已结算
+ *       ↓ (合规/管理员手动完结)
+ *   FINISHED                已完结          流程终止
+ *
+ *   RECTIFYING              待整改          合规或支持方退回, 执行方整改后重新提交 → AWAITING_COMPLIANCE
+ *   FROZEN                  冻结中          过期未提交, 不可再提交
+ * 
+ * + *

code 用英文单词而非数字, 原因: + *

    + *
  1. B-tree 索引比较更快 (ASCII 7-19B vs UTF-8 中文 9B/字符)
  2. + *
  3. 日志/SQL 直接可读, 不需要 decode
  4. + *
  5. URL 查询参数无需 URL-encode (解决 ?currentStage=执行中 编码乱问题)
  6. + *
+ * + *

前端 dropdown / KPI 卡 / 列表筛选: value 用 code, 显示用 info (中文 label). + * + * @author guoju + */ +public enum BizMeetingStageEnum +{ + /** 会议开始时间前 */ + NOT_STARTED("NOT_STARTED", "未执行", "会议开始时间前"), + + /** 已开始, 执行方未提交材料 */ + RUNNING("RUNNING", "执行中", "已开始, 执行方未提交"), + + /** 执行方已提交, 等合规人员审核 (支持方此阶段只读, 不可审) */ + AWAITING_COMPLIANCE("AWAITING_COMPLIANCE", "待合规审核", "执行方提交, 等合规审"), + + /** 合规审通过, 等支持方审核 (填监管意见) */ + AWAITING_SUPERVISION("AWAITING_SUPERVISION", "待支持方审核", "合规审通过, 等支持方审"), + + /** 支持方审通过, 等 1 自然日 → 待结算 */ + SUPERVISION_APPROVED("SUPERVISION_APPROVED", "审核通过", "支持方审通过, 等结算"), + + /** 合规或支持方退回, 执行方整改后重新提交 */ + RECTIFYING("RECTIFYING", "待整改", "审核退回, 执行方整改"), + + /** 支持方审通过 + 1 自然日, 等合规结算 + 上传付款凭证 */ + AWAITING_SETTLEMENT("AWAITING_SETTLEMENT", "待结算", "支持方审通过, 等结算"), + + /** 合规点击结算 + 上传付款凭证, 全部完成 */ + SETTLED("SETTLED", "已结算", "已结算 + 凭证已上传"), + + /** 已结算后, 合规/管理员手动完结, 流程终止 */ + FINISHED("FINISHED", "已完结", "已结算后手动完结"), + + /** 会议结束后逾期未提交, 冻结 (不可再提交) */ + FROZEN("FROZEN", "冻结中", "过期未提交, 不可再提交"); + + private final String code; + private final String info; + private final String description; + + BizMeetingStageEnum(String code, String info, String description) + { + this.code = code; + this.info = info; + this.description = description; + } + + public String getCode() + { + return code; + } + + public String getInfo() + { + return info; + } + + public String getDescription() + { + return description; + } + + /** + * 根据 code 解析枚举 (找不到返回 null, 调用方自行 fallback) + */ + public static BizMeetingStageEnum of(String code) + { + if (code == null) return null; + for (BizMeetingStageEnum e : values()) + { + if (e.code.equals(code)) return e; + } + return null; + } +} diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/MeetingAuditStageEnum.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/MeetingAuditStageEnum.java new file mode 100644 index 0000000..b207a85 --- /dev/null +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/MeetingAuditStageEnum.java @@ -0,0 +1,59 @@ +package com.ruoyi.common.enums; + +/** + * 材料/凭证 审核阶段 (biz_meeting.material_audit_stage / voucher_audit_stage) + *

+ * 4 值 (用户拍板「已提交/待审核」合并): 提交后进入 SUBMITTED, 两级审核(合规先-支持方后) + * 用 material_compliance_approved / voucher_compliance_approved 布尔区分「合规审中 vs 支持方审中」. + *

+ *   NOT_SUBMITTED  未提交     执行方还没交
+ *       ↓ (执行方提交)
+ *   SUBMITTED      已提交     已提交, 在审 (合规审中=compliance_approved=0, 支持方审中=1)
+ *       ↓ (支持方审通过)
+ *   APPROVED       审核通过   支持方已通过
+ *
+ *   REJECTED       审核驳回   被合规/支持方退回 (执行方重提 → SUBMITTED)
+ * 
+ * + * @author guoju + */ +public enum MeetingAuditStageEnum +{ + /** 未提交 */ + NOT_SUBMITTED("NOT_SUBMITTED", "未提交"), + /** 已提交 (含两级审核中, 层级由 compliance_approved 区分) */ + SUBMITTED("SUBMITTED", "已提交"), + /** 审核通过 (支持方已通过) */ + APPROVED("APPROVED", "审核通过"), + /** 审核驳回 (退回) */ + REJECTED("REJECTED", "审核驳回"); + + private final String code; + private final String info; + + MeetingAuditStageEnum(String code, String info) + { + this.code = code; + this.info = info; + } + + public String getCode() + { + return code; + } + + public String getInfo() + { + return info; + } + + public static MeetingAuditStageEnum of(String code) + { + if (code == null) return null; + for (MeetingAuditStageEnum e : values()) + { + if (e.code.equals(code)) return e; + } + return null; + } +} diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index 056bcd2..ea33312 100644 --- a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -63,6 +63,10 @@ public class SecurityConfig // OSS 直传签名 (注册场景需匿名访问: 专家/执行方/支持方上传证书时还没 token) // 安全性: OssController 已用 policy 限定 dir 前缀 + 文件大小, key 含时间戳+随机串防覆盖 .requestMatchers(HttpMethod.GET, "/common/oss/sign").permitAll() + // 扫码拍照相机网页基础地址 (前端生成二维码用, 只读公开) + .requestMatchers(HttpMethod.GET, "/common/camera/config").permitAll() + // 扫码拍照回传 (ry-h5 手机端匿名上传照片 URL, 后端白名单 subType + 会议存在校验兜底) + .requestMatchers(HttpMethod.POST, "/business/meetingMaterial/cameraUpload").permitAll() // 业务公开门户与注册入口可匿名访问; 字典 active/单查公开, 写操作需登录+权限 .requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll() // 字典 active (只读) + 按 ID 查 公开给前端拉下拉数据 diff --git a/ry-api/simhei.ttf b/ry-api/simhei.ttf new file mode 100644 index 0000000..1f1bdf8 Binary files /dev/null and b/ry-api/simhei.ttf differ diff --git a/ry-h5/composables/useCamera.ts b/ry-h5/composables/useCamera.ts index 7031d9a..0d4b0a7 100644 --- a/ry-h5/composables/useCamera.ts +++ b/ry-h5/composables/useCamera.ts @@ -8,11 +8,27 @@ * - 返回 ref 和方法,业务页只需负责 UI 布局 */ import { ref, onMounted, onBeforeUnmount } from 'vue' -import { openCamera, stopCamera, captureFrame, describeCameraError, type Facing } from '@/utils/camera' +import { + openCamera, + stopCamera, + captureFrame, + captureFrameWithBlur, + describeCameraError, + type Facing, +} from '@/utils/camera' +import { uploadCameraPhoto } from '@/utils/upload' -export function useCamera(videoId: string) { +/** 条带高斯模糊配置 (签到表脱敏用): 对截图竖直 [start, end] 比例区间做高斯模糊 */ +export type BlurConfig = { + start: number + end: number + radius: number +} + +export function useCamera(videoId: string, blur?: BlurConfig) { const facing = ref('environment') const captured = ref('') + const blurred = ref('') const errorMsg = ref('') function getVideoEl(): HTMLVideoElement | null { @@ -43,7 +59,14 @@ export function useCamera(videoId: string) { const video = getVideoEl() if (!video) return try { - captured.value = captureFrame(video) + if (blur) { + const r = captureFrameWithBlur(video, blur.start, blur.end, blur.radius) + captured.value = r.sharp + blurred.value = r.blurred + } else { + captured.value = captureFrame(video) + blurred.value = '' + } } catch (err) { uni.showToast({ title: (err as Error).message || '截图失败', @@ -54,19 +77,32 @@ export function useCamera(videoId: string) { function retake() { captured.value = '' + blurred.value = '' } async function confirm() { - // POC: 假上传 + loading 给用户完整仪式感 + if (!captured.value) return uni.showLoading({ title: '上传中...' }) - await new Promise((resolve) => setTimeout(resolve, 800)) - uni.hideLoading() - uni.showToast({ - title: '已保存 (POC 未上传)', - icon: 'none', - duration: 1500, - }) - captured.value = '' + try { + // 直传 OSS + 回传 URL 到后端 (公开端点, 无需登录) + // 签到表额外上传一张高斯模糊版 (blurred) 作为 extraOssUrl, sponsor 只看这个 + await uploadCameraPhoto(captured.value, blurred.value || '') + uni.hideLoading() + uni.showToast({ + title: '已上传', + icon: 'success', + duration: 1500, + }) + captured.value = '' + blurred.value = '' + } catch (err) { + uni.hideLoading() + uni.showToast({ + title: (err as Error).message || '上传失败', + icon: 'none', + duration: 2500, + }) + } } onMounted(() => { diff --git a/ry-h5/pages/panorama/index.uvue b/ry-h5/pages/panorama/index.uvue index bf6dcc1..afa3f90 100644 --- a/ry-h5/pages/panorama/index.uvue +++ b/ry-h5/pages/panorama/index.uvue @@ -5,7 +5,7 @@ - 全景取景 (16:9) + {{ title }} @@ -19,6 +19,7 @@ playsinline /> import { useCamera } from '@/composables/useCamera' +import { getCameraParams } from '@/utils/upload' const videoId = 'panorama-video' const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } = useCamera(videoId) +// 前全景 (L_PANORAMA_FRONT) 带定位框, 后全景 (L_PANORAMA_BACK) 不带 +const subType = getCameraParams().subType +const isFront = subType === 'L_PANORAMA_FRONT' +const title = isFront ? '前全景取景 (带定位框)' : '后全景取景' + function goBack() { uni.navigateBack() } diff --git a/ry-h5/pages/signin/index.uvue b/ry-h5/pages/signin/index.uvue index f93bdb9..8d57088 100644 --- a/ry-h5/pages/signin/index.uvue +++ b/ry-h5/pages/signin/index.uvue @@ -67,8 +67,9 @@ import { useCamera } from '@/composables/useCamera' const videoId = 'signin-video' +// A4 纸 20%~50% 区间 (签到人手机号/身份证号所在的姓名栏) 做高斯模糊脱敏 const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } = - useCamera(videoId) + useCamera(videoId, { start: 0.2, end: 0.5, radius: 20 }) function goBack() { uni.navigateBack() diff --git a/ry-h5/utils/camera.ts b/ry-h5/utils/camera.ts index bcfaed7..578e4da 100644 --- a/ry-h5/utils/camera.ts +++ b/ry-h5/utils/camera.ts @@ -52,6 +52,11 @@ video.setAttribute('playsinline', 'true') // iOS 必须: 不静音黑屏 (否则 iOS 拒绝播放) video.muted = true +// 显式 play():