feat: 生产部署 + 会议全链路 (执行方分配/费用结算/签到脱敏/H5相机)

- 生产 nginx 三路径: ry-vue3 /hg/, ry-h5 /camera/, API /hg-api, .env 分环境
- 签约链接/摄像头 base-url 走 yml, 不再硬编码 /hg 与 localhost
- 新增 biz_project_executor_assign (镜像 sponsor_assign) 执行方项目级分配
- 会议费用后台汇总 FeeCalcScheduler + 结算回写项目金额 + 状态流转调度
- 签到表拍照高斯模糊脱敏 extra_oss_url, 新增 PosterService/StageDeriver
- 清理 biz_support_intent 旧表 (6 Java/XML 删除)
- ry-h5 相机黑屏修复: 显式 video.play() + 动态 apiBase 上传
- 资源: simhei 字体 / logo.png / qrcode_1.png / favicon.ico

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-24 01:00:09 +08:00
co-authored by Claude
parent 80a276e661
commit 904e28710f
123 changed files with 6008 additions and 1706 deletions
@@ -27,4 +27,15 @@ public class OcrExecutorConfig
{
return Executors.newFixedThreadPool(16);
}
/**
* 费用汇总执行器 (FeeCalcScheduler 用): 8 线程并行汇总各会议.
* <p>
* 汇总本身纯 SUM 幂等, 无锁; 线程数 8 足够 (每会议 3 次轻量查询 + 1 次回写).
*/
@Bean(name = "feeCalcExecutor", destroyMethod = "shutdown")
public ExecutorService feeCalcExecutor()
{
return Executors.newFixedThreadPool(8);
}
}
@@ -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);
}
@@ -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<BizMeetingAttendee> rows = attendeeService.selectInvitedByUserId(userId);
return success(rows);
}
/**
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
* 返回全字段, 前端按需展示.
@@ -71,7 +81,7 @@ public class BizMeetingAttendeeController extends BaseController {
* 管理端: 按手机号新增参会人.
*
* <p>后端流程: 按 body.phone 查 sys_user → 查到用之, 查不到新建 (用户名=密码=phone, role_type='doctor')
* → 写完整档案行 (含 name/work_unit/fee...) → 触发 #5 会议邀请通知.
* → 写完整档案行 (含 name/work_unit/fee...). 邀请参会已改为手动, 此处不再自动推.
*
* <p>请求体示例:
* <pre>
@@ -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<Long> 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<Long> attendeeIds) {
return success(attendeeService.invite(attendeeIds));
}
/** 更新手写签名 (Base64 字符串, 直接存 DB longtext) */
@@ -139,14 +179,10 @@ public class BizMeetingAttendeeController extends BaseController {
}
/**
* 批量导入参会人 — 上传 Excel + 解析入库 + #5 会议邀请差集推送.
* 批量导入参会人 — 上传 Excel + 解析入库.
*
* <p>三步:
* <ol>
* <li>查"导入前"该会议已存在的参会人 userIds (Set)</li>
* <li>调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList</li>
* <li>查"导入后"该会议 userIds, 与"前"做差集 → 仅给"新加入"的 userId 推 #5 会议邀请</li>
* </ol>
* <p>调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList.
* 邀请参会已改为手动, 此处不再自动推"会议邀请".
*
* <p>返回 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<Long> preUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId));
// 2. 解析 + 入库
// 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发)
ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
// 3. 差集 → 仅对"新加入" userId 推 #5 邀请
Set<Long> 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<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
List<BizMeetingAttendeeImportVo> 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<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
util.exportExcel(response, exportList, "参会人");
}
/**
* 更新劳务协议 URL (OSS 上传后调本接口).
*
@@ -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<BizMeeting> 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<Long> 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<String, Object> 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 个)
// 审核流程端点 (事实模型)
// ===================================================================
/**
* 执行人员提交材料
* <ul>
* <li>校验 1: 当前用户是该会议执行人员 (强校验)</li>
* <li>校验 2: material_audit_stage = INIT</li>
* <li>校验 3: biz_meeting_material 至少 1 条 L_* + 至少 1 条 M_*</li>
* <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li>
* <li>校验 2: 已执行 (is_executed=1) 且未冻结</li>
* <li>校验 3: material_audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li>
* <li>校验 4: biz_meeting_material 劳务(L_*)与会务(M_*)各至少 1 条, 不必全部子类型填满</li>
* </ul>
* 通过后 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<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_"));
boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_"));
if (!hasLabor || !hasService) {
throw new ServiceException("请同时上传劳务材料和会务材料");
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<BizMeetingMaterial> 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), 两级审核中的第一级.
* <p>
* body: { "auditType": "MATERIAL"|"VOUCHER"|"BOTH", "approved": true|false, "opinion": "..." }
* <p>合规审中判据: 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": "..." }
* 支持方(监察员) 审核, 两级审核中的第二级.
* <p>
* body: { "auditType": "MATERIAL"|"VOUCHER"|"BOTH", "approved": true|false, "opinion": "..." }
* <p>支持方审中判据: 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; }
@@ -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<BizMeetingMaterial> saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list);
// 材料变化 → 会议费用待重算 (FeeCalcScheduler 汇总回写)
bizMeetingService.markFeeCalcPending(meetingId);
return success(saved);
}
/**
* 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL).
* <p>
* 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();
}
}
@@ -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 的子账号所属主账号))
* <ul>
* <li>MAIN 主账号: 看自己 sponsor_admin_user_id 下的项目 (用 biz_project.sponsor_admin_user_id)</li>
* <li>SUB 子账号: 看自己被分配 (作为监察员 monitor) 的项目 (走 biz_project_sponsor_assign.monitor_user_id)</li>
* </ul>
* 注: 支持方不能创建项目, 这里只看分配结果. 子账号不再回退到 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<BizProject> 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<BizProject> list = bizProjectService.selectExecutorStaffList(bizProject);
return getDataTable(list);
}
// 主账号视角 (原逻辑): biz_project_assign.exec_user_id / execution_unit_id
bizProject.getParams().put("executorUserId", uid);
startPage();
List<BizProject> 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<Long> 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<BizProjectSponsorAssign> oldList = bizProjectSponsorAssignService.listByProjectId(body.getProjectId());
Map<Long, BizProjectSponsorAssign> 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<Long> 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<BizProjectSponsorAssign> 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());
@@ -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,
@@ -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<BizSupportIntent> 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));
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
@@ -7,10 +7,10 @@ import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 会议材料对象 biz_meeting_material (单表)
* <p>
* 包含 4 大类 13 子类:
* 包含 4 大类 17 子类:
* <ul>
* <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li>
* <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / SV_PAYMENT / LV_PAYMENT</li>
* <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / L_ENTERPRISE_BENEFIT / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT</li>
* </ul>
* <p>
* 注意: 不继承 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; }
}
@@ -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; }
@@ -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; }
@@ -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<Long> 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<Long> getStaffUserIds() { return staffUserIds; }
public void setStaffUserIds(java.util.List<Long> 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; }
}
@@ -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<Long> 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<Long> getMonitorUserIds() { return monitorUserIds; }
public void setMonitorUserIds(java.util.List<Long> monitorUserIds) { this.monitorUserIds = monitorUserIds; }
public String getAssignDesc() { return assignDesc; }
public void setAssignDesc(String assignDesc) { this.assignDesc = assignDesc; }
public String getAssignPoints() { return assignPoints; }
@@ -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;
@@ -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; }
}
@@ -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; }
}
@@ -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<BizMeetingAttendee> 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<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */
List<BizMeetingAttendee> selectInvitedByUserId(Long userId);
/**
* 拿某会议已存在的参会人 userId 列表 (用于 add/edit 时 diff 新加入的人, 仅通知增量)
* 性能: 只查 user_id 一列, 走 meeting_id 索引; meeting 参会人通常 < 100, 无压力.
*/
List<Long> selectUserIdsByMeetingId(Long meetingId);
/**
* 费用汇总用: 某会议所有参会人应发金额 (fee_pre_tax) 之和 (is_deleted=0).
* 空/无参会人 → 返回 0 (SQL ifnull 兜底).
*/
java.math.BigDecimal sumFeePreTaxByMeetingId(Long meetingId);
}
@@ -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<Long> selectIdListByProjectId(Long projectId);
/** 建会限额用: 统计某项目下未软删的会议数 (executor 建会不得超过分配的场次) */
int countByProjectId(Long projectId);
/**
* 自动流转: start_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING.
* <p>由 MeetingStageScheduler 每分钟触发. 事实 + current_stage 缓存一起写.
*/
int markExecuted();
/**
* 自动流转: material 未提交 且 end_time + biz_project.submit_deadline_days 已过 → 置 is_frozen=1 并转 FROZEN.
* <p>由 MeetingStageScheduler 每分钟触发.
*/
int markFrozen();
/**
* 自动流转: material/voucher 都 APPROVED 且 最晚审核时间已过 1 自然日 且 current_stage 仍为 SUPERVISION_APPROVED → AWAITING_SETTLEMENT.
* <p>由 MeetingStageScheduler 每分钟触发 (待结算的 24h 慢路径).
*/
int markSettlementReady();
/**
* 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表.
*/
List<Long> 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);
}
@@ -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);
}
@@ -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<BizProjectExecutorAssign> selectByProjectId(String projectId);
/** 按 project_id 全删 (执行方分配: 先删后插策略) */
int deleteByProjectId(String projectId);
/** 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 */
int softDeleteByProjectId(String projectId);
}
@@ -13,6 +13,8 @@ public interface BizProjectMapper
List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
List<BizProject> selectExecutorList(BizProject entity);
/** executor 执行人 (SUB 子账号) 专属: 反查 biz_project_executor_assign.staff_user_id, 只看自己被派到的项目 */
List<BizProject> 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);
}
@@ -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<BizSupportIntent> selectList(BizSupportIntent entity);
int insert(BizSupportIntent entity);
int updateByPrimaryKey(BizSupportIntent entity);
int deleteByPrimaryKey(String intentId);
int deleteByPrimaryKeys(String[] intentIds);
}
@@ -195,6 +195,40 @@ public class BizNotifyService
log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId);
}
/**
* 支持方(监察员) 审批退回 → 通知执行方 (待整改 + 说明).
*
* <p>调用方: {@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 推送电子签 → 通知参会人劳务协议待签署 (带签署链接).
*
* <p>调用方: {@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/监察员 (待办: 去查看项目).
*
* <p>调用方: {@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);
}
}
@@ -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; }
@@ -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;
/**
* 会议费用汇总调度器 (每分钟一次, 多线程无锁).
* <p>
* 两态设计 (不用抢占锁): 会议 fee_calc_status (0未汇总/1已汇总) + 材料 fee_status (0未计算/1已计算).
* <pre>
* 每分钟: 查 fee_calc_status=0 的会议
* → 任一材料 fee_status=0 (发票还没 OCR 完) → 跳过, 等下轮
* → 全部 fee_status=1 → SUM 汇总 labor_fee/meeting_fee/total_fee → fee_calc_status=1
* </pre>
* 为什么不需要锁: 汇总前已检查"材料全算完", 天然防半成品; 汇总纯 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<Long> 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<BizMeetingMaterial> 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<BizMeetingMaterial> 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;
}
}
@@ -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;
/**
* 会议事实/阶段 自动流转调度器 (每分钟一次).
* <p>
* 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实
* (is_executed / is_frozen / material_audit_stage / voucher_audit_stage / 审核时间 …),
* 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的三类事实落地:
* <pre>
* 1) start_time 到 → is_executed=1 (执行中)
* 2) end_time + submit_deadline_days 到 且 material 未提交 → is_frozen=1 (冻结)
* 3) material+voucher 都通过 且 最晚审核时间过 24h → 待结算 (current_stage 缓存翻 AWAITING_SETTLEMENT)
* </pre>
* 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围.
* <p>
* 需要启动类加 {@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);
}
}
}
@@ -11,6 +11,8 @@ import java.util.Map;
public interface BizSignService {
/** 医生填写页 GET /info?attendeeId=X: 返回默认值 (biz_expert 预填) + 已存 attendee 字段 + 选项 */
Map<String, Object> getSignInfo(Long attendeeId);
/** 扫码直登: 只有 meetingId (无 attendeeId) 时, 校验当前用户是否在会议人员列表, 返回 {meetingName, periodNo, totalPeriods, attendeeId} */
Map<String, Object> resolveByMeeting(Long meetingId);
/** 医生填写页 POST /saveProfile: 批量 UPDATE attendee 字段 (不含签名) */
void saveProfile(Long attendeeId, BizMeetingAttendee form);
/** 签署页 GET /contract?attendeeId=X: 渲染完整 HTML (占位符替换 + 身份证附件 + 手写签名) */
@@ -42,6 +42,8 @@ public interface IBizMeetingAttendeeService {
List<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1) */
List<BizMeetingAttendee> 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.
*
* <p>单条失败 (手机号空/短信异常) 不中断其它人, 返回成功推送条数.
*
* @param attendeeIds biz_meeting_attendee.id 列表
* @return 成功推送 (短信+站内信+标记) 的人数
*/
int pushEsign(List<Long> attendeeIds);
/**
* 邀请参会 (批量/单条通用): 对给定 attendee.id 列表逐个
* 推"会议邀请"站内信 (不发短信) + 置 is_invited=1.
*
* <p>单条失败不中断其它人, 返回成功邀请条数.
*
* @param attendeeIds biz_meeting_attendee.id 列表
* @return 成功邀请 (站内信+标记) 的人数
*/
int invite(List<Long> attendeeIds);
}
@@ -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);
}
@@ -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);
}
@@ -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<Long> staffUserIds);
List<BizProjectExecutorAssign> listByProjectId(String projectId);
int deleteByProjectId(String projectId);
}
@@ -14,6 +14,8 @@ public interface IBizProjectService
List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
List<BizProject> selectExecutorList(BizProject entity);
/** executor 执行人 (SUB 子账号) 专属: 反查 biz_project_executor_assign.staff_user_id, 只看自己被派到的项目 */
List<BizProject> 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);
}
@@ -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<Long> monitorUserIds);
List<BizProjectSponsorAssign> listByProjectId(String projectId);
int deleteByProjectId(String projectId);
}
@@ -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<BizSupportIntent> selectList(BizSupportIntent entity);
int insert(BizSupportIntent entity);
int updateByPrimaryKey(BizSupportIntent entity);
int deleteByPrimaryKey(String intentId);
int deleteByPrimaryKeys(String[] intentId);
}
@@ -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 内容 (如 <img> 未自闭合), 不会报 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);
}
/**
@@ -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).
*
* <p>流程 (前端"生成海报"按钮触发):
* <ol>
* <li>按 {@code schedule_url} 下载源海报, OSS 图片处理参数 width 固定 1200</li>
* <li>Java2D 在海报底部画半透明信息条: 会议名称 / 期数 / 起止时间 / 支持单位</li>
* <li>编码 PNG → {@link OssUploader} 上传 OSS</li>
* <li>回写 {@code biz_meeting.poster_url}, 前端"预览海报"用该 URL 弹 dialog</li>
* </ol>
*/
@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<String> 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));
}
}
}
@@ -0,0 +1,125 @@
package com.ruoyi.business.service;
import java.util.Date;
import org.springframework.stereotype.Component;
import com.ruoyi.business.domain.BizMeeting;
/**
* 会议阶段推导器 (单一可信源).
* <p>
* 事实与展示分离: {@code biz_meeting} 只存事实 (is_executed/is_settled/is_finished/is_frozen
* + material_audit_stage/voucher_audit_stage + 审核时间 + compliance_approved), 各角色看到的
* 「阶段名称」由本类实时计算.
* <ul>
* <li>{@link #derivePhysicalStage(BizMeeting)}: 10 值物理阶段 (current_stage 缓存 + 列表筛选).</li>
* <li>{@link #deriveDisplay(String, BizMeeting)}: 各角色展示名 (audit_log 4 列 + 前端镜像).</li>
* </ul>
*
* @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 中性.
* <p>
* 优先级自上而下命中即返回:
* <pre>
* 冻结 → 完结 → 已结算 → (材料) 审核驳回 → 审核通过 → (材料) APPROVED → SUBMITTED → NOT_SUBMITTED
* </pre>
*/
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 "未执行";
}
}
@@ -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<BizMeetingAttendee> 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<BizMeetingAttendee> selectInvitedByUserId(Long userId) {
return mapper.selectInvitedByUserId(userId);
}
@Override
public List<Long> 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<Long> attendeeIds) {
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
int sent = 0;
Map<Long, BizMeeting> 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<Long> attendeeIds) {
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
int sent = 0;
Map<Long, BizMeeting> 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;
}
}
@@ -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<String> 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<String> 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 堆栈.
* <p>
* 返回插入后的 list (各元素 id 字段被 useGeneratedKeys 回填), 前端可借此触发 OCR.
* <p>
* <b>金额保留</b>: 前端全删全插只传 ossUrl 不传 amount. 为避免"重新上传一张发票导致其余未变发票金额被清零",
* 先快照旧材料, 对 (subType + ossUrl) 未变的材料回填旧 amount 并置 fee_status=1 (无需重算);
* 新增/替换的会 OCR 发票置 fee_status=0 (等 OCR 回写金额后置 1).
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list) {
// 快照旧材料: subType -> 旧材料 (用于回填 amount + 判未变)
List<BizMeetingMaterial> oldList = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
Map<String, BizMeetingMaterial> 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);
}
}
@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<BizMeetingMaterial> 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();
}
}
@@ -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);
}
}
}
@@ -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 执行人).
* <p>
* 策略: 先按 project_id 物理删除旧分配, 再逐个插入.
* 注意 ⚠️ 不能直接复用 insertAssign() — insertAssign() 内部会 deleteByProjectId, 循环里第二次 delete 会把刚 insert 的清掉.
*/
@Override
public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List<Long> 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<BizProjectExecutorAssign> listByProjectId(String projectId) {
return mapper.selectByProjectId(projectId);
}
@Override
public int deleteByProjectId(String projectId) {
return mapper.deleteByProjectId(projectId);
}
}
@@ -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<BizProject> selectExecutorList(BizProject entity)
{ return bizProjectMapper.selectExecutorList(entity); }
@Override
public List<BizProject> 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);
}
}
@@ -19,6 +19,32 @@ public class BizProjectSponsorAssignServiceImpl implements IBizProjectSponsorAss
return mapper.insertAssign(entity);
}
/**
* 多监察员分配 (一个项目 ↔ N 监察员).
* <p>
* 策略: 先按 project_id 物理删除旧分配, 再逐个插入 (事务内由 Service 默认单 insert 即可, 失败单条不影响其它).
* 注意 ⚠️ 不能直接复用 insertAssign() — insertAssign() 内部会 deleteByProjectId, 循环里第二次 delete 会把刚 insert 的清掉, 提交后只剩最后 1 条.
*/
@Override
public int assignMonitorsForProject(BizProjectSponsorAssign body, java.util.List<Long> 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<BizProjectSponsorAssign> listByProjectId(String projectId) {
return mapper.selectByProjectId(projectId);
@@ -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<String, Object> getSignInfo(Long attendeeId) {
@@ -79,10 +88,37 @@ public class BizSignServiceImpl implements BizSignService {
current.put("tax", attendee.getTax());
current.put("fee", attendee.getFee());
List<Map<String, String>> laborFormOptions = Arrays.asList(
map("讲课", "讲课"), map("讨论", "讨论"),
map("主持", "主持"), map("主席", "主席"),
map("__other__", "其他"));
// 会议信息 (会议名称 + 期数), 用于签署页大标题; 也用于取 projectId 拉项目角色
BizMeeting meeting = meetingMapper.selectByPrimaryKey(attendee.getMeetingId());
// 劳务形式选项: 从项目角色 (biz_project.role_labor) 读, 末尾追加"其他"逃生口
List<Map<String, String>> 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<String> 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<String, Object> 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<String, Object> result = new HashMap<>();
result.put("meetingName", meeting.getMeetingName());
result.put("periodNo", meeting.getPeriodNo());
result.put("totalPeriods", meeting.getTotalPeriods());
result.put("attendeeId", attendeeId);
return result;
}
@@ -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<BizSupportIntent> 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); }
}
@@ -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);
}
}
// ==================== 工具方法 ====================
@@ -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<String, String> 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
*/
@@ -39,34 +39,35 @@
<result property="projectName" column="project_name" />
<result property="projectNo" column="project_no" />
<result property="isDeleted" column="is_deleted" />
<result property="isEsigned" column="is_esigned" />
<result property="isInvited" column="is_invited" />
</resultMap>
<insert id="insert" parameterType="BizMeetingAttendee">
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>
<!--
管理端新增参会人 (MeetingDetail 参会人 CRUD 用):
一次性写入 meeting_id+user_id+档案字段 (name/phone/work_unit/...).
若档案字段为 NULL 则不写 (COALESCE 在调用方给空字符串兜底).
useGeneratedKeys 让调用方能拿到新 attendee.id (用于 #5 触发邀请通知).
一次性写入 id+meeting_id+user_id+档案字段 (name/phone/work_unit/...).
id 由调用方用雪花 ID (IdGenerator) 生成, 不走 DB 自增.
-->
<insert id="insertWithProfile" parameterType="BizMeetingAttendee" useGeneratedKeys="true" keyProperty="id">
insert into biz_meeting_attendee(meeting_id, user_id, name, phone, work_unit, department, title,
<insert id="insertWithProfile" parameterType="BizMeetingAttendee">
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>
<!-- 批量插入参会人 (BizMeetingController.add 调用) -->
<!-- 批量插入参会人 (BizMeetingController.add/edit 调用), id 由调用方雪花 ID 填好 -->
<insert id="insertBatch">
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
<foreach collection="userIds" item="userId" separator=",">
(#{meetingId}, #{userId}, #{createBy}, sysdate())
<foreach collection="list" item="a" separator=",">
(#{a.id}, #{a.meetingId}, #{a.userId}, #{a.createBy}, sysdate())
</foreach>
</insert>
<!-- 医生填写信息保存草稿: 更新所有签字字段 + 劳务信息 (不含签名/PDF) -->
@@ -124,6 +125,18 @@
update_time = sysdate()
where id = #{id}
</update>
<!-- 推送电子签后置 is_esigned=1 -->
<update id="markEsignedById" parameterType="Long">
update biz_meeting_attendee
set is_esigned = 1, update_time = sysdate()
where id = #{id}
</update>
<!-- 邀请参会后置 is_invited=1 -->
<update id="markInvitedById" parameterType="Long">
update biz_meeting_attendee
set is_invited = 1, update_time = sysdate()
where id = #{id}
</update>
<delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_attendee where meeting_id = #{meetingId}
</delete>
@@ -139,34 +152,51 @@
delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId}
</delete>
<select id="selectByMeetingId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select 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, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted
select 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, signed_at, signed_ip, handsign, labor_protocol, labor_protocol_masked, create_by, create_time, is_deleted, is_esigned, is_invited
from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
</select>
<select id="selectByUserId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select 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, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted
select 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, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted, is_esigned, is_invited
from biz_meeting_attendee where user_id = #{userId} and is_deleted = 0
</select>
<select id="selectById" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select 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, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted
select 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, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted, is_esigned, is_invited
from biz_meeting_attendee where id = #{id} and is_deleted = 0
</select>
<!--
当前用户的"待签署"会议列表 (任一未签: handsign 或 labor_protocol 为 NULL)
当前用户的"待签署协议"列表: 已推送电子签 (is_esigned=1) 且 任一未签 (handsign 或 labor_protocol 为)
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
字段别名 + resultMap 上面的 transient property 接收
两表都需 is_deleted=0 过滤: 删除会议后, 参会人的待签署列表也不显示
-->
<select id="selectUnsignedByUserId" resultType="BizMeetingAttendee" parameterType="Long">
select a.id, a.meeting_id, a.user_id, a.handsign, a.labor_protocol, a.create_by, a.create_time,
a.is_esigned as isEsigned,
m.meeting_name as meetingName, m.start_time as startTime,
m.end_time as endTime, m.project_name as projectName, m.project_no as projectNo
from biz_meeting_attendee a
inner join biz_meeting m on m.meeting_id = a.meeting_id
where a.user_id = #{userId}
and a.is_deleted = 0 and m.is_deleted = 0
and a.is_esigned = 1
and (a.handsign is null or a.handsign = '' or a.labor_protocol is null or a.labor_protocol = '')
order by m.start_time asc
</select>
<!--
当前用户的"待参加"会议列表: 已邀请参会 (is_invited=1)
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
-->
<select id="selectInvitedByUserId" resultType="BizMeetingAttendee" parameterType="Long">
select a.id, a.meeting_id, a.user_id, a.create_by, a.create_time,
m.meeting_name as meetingName, m.start_time as startTime,
m.end_time as endTime, m.project_name as projectName, m.project_no as projectNo
from biz_meeting_attendee a
inner join biz_meeting m on m.meeting_id = a.meeting_id
where a.user_id = #{userId}
and a.is_deleted = 0 and m.is_deleted = 0
and a.is_invited = 1
order by m.start_time asc
</select>
<!--
给 BizMeetingController.edit 做差集用: 查该会议已存在的参会人 userId 列表.
用于 #5 会议邀请: 仅给"新加入"的 userId 发通知, 已存在的用户不重发.
@@ -175,4 +205,10 @@
<select id="selectUserIdsByMeetingId" resultType="java.lang.Long" parameterType="Long">
select user_id from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
</select>
<!-- 费用汇总用: 某会议应发金额 (fee_pre_tax) 合计 -->
<select id="sumFeePreTaxByMeetingId" resultType="java.math.BigDecimal" parameterType="Long">
select ifnull(sum(fee_pre_tax), 0)
from biz_meeting_attendee
where meeting_id = #{meetingId} and is_deleted = 0
</select>
</mapper>
@@ -7,7 +7,10 @@
<result property="meetingId" column="meeting_id" />
<result property="auditor" column="auditor" />
<result property="opinion" column="opinion" />
<result property="currentStage" column="current_stage" />
<result property="executorStage" column="executor_stage" />
<result property="sponsorStage" column="sponsor_stage" />
<result property="managerStage" column="manager_stage" />
<result property="adminStage" column="admin_stage" />
<result property="createTime" column="create_time" />
<result property="auditTime" column="audit_time" />
<result property="auditType" column="audit_type" />
@@ -16,7 +19,7 @@
</resultMap>
<sql id="selectFields">
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
</sql>
@@ -30,7 +33,6 @@
<where>
is_deleted = 0
<if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if>
<if test="auditor != null and auditor != ''">and auditor = #{auditor}</if>
</where>
order by id desc
@@ -42,7 +44,10 @@
<if test="meetingId != null">meeting_id,</if>
<if test="auditor != null and auditor != ''">auditor,</if>
<if test="opinion != null and opinion != ''">opinion,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if>
<if test="executorStage != null and executorStage != ''">executor_stage,</if>
<if test="sponsorStage != null and sponsorStage != ''">sponsor_stage,</if>
<if test="managerStage != null and managerStage != ''">manager_stage,</if>
<if test="adminStage != null and adminStage != ''">admin_stage,</if>
<if test="createTime != null">create_time,</if>
<if test="auditTime != null">audit_time,</if>
<if test="auditType != null and auditType != ''">audit_type,</if>
@@ -52,7 +57,10 @@
<if test="meetingId != null">#{meetingId},</if>
<if test="auditor != null and auditor != ''">#{auditor},</if>
<if test="opinion != null and opinion != ''">#{opinion},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if>
<if test="executorStage != null and executorStage != ''">#{executorStage},</if>
<if test="sponsorStage != null and sponsorStage != ''">#{sponsorStage},</if>
<if test="managerStage != null and managerStage != ''">#{managerStage},</if>
<if test="adminStage != null and adminStage != ''">#{adminStage},</if>
<if test="createTime != null">#{createTime},</if>
<if test="auditTime != null">#{auditTime},</if>
<if test="auditType != null and auditType != ''">#{auditType},</if>
@@ -65,7 +73,10 @@
<trim prefix="SET" suffixOverrides=",">
<if test="auditor != null and auditor != ''">auditor = #{auditor},</if>
<if test="opinion != null and opinion != ''">opinion = #{opinion},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="executorStage != null and executorStage != ''">executor_stage = #{executorStage},</if>
<if test="sponsorStage != null and sponsorStage != ''">sponsor_stage = #{sponsorStage},</if>
<if test="managerStage != null and managerStage != ''">manager_stage = #{managerStage},</if>
<if test="adminStage != null and adminStage != ''">admin_stage = #{adminStage},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="auditTime != null">audit_time = #{auditTime},</if>
<if test="auditType != null and auditType != ''">audit_type = #{auditType},</if>
@@ -21,9 +21,28 @@
<result property="supervisionTime" column="supervision_time" />
<result property="materialAuditStage" column="material_audit_stage" />
<result property="voucherAuditStage" column="voucher_audit_stage" />
<result property="isExecuted" column="is_executed" />
<result property="executeTime" column="execute_time" />
<result property="isSettled" column="is_settled" />
<result property="settleTime" column="settle_time" />
<result property="isFinished" column="is_finished" />
<result property="finishTime" column="finish_time" />
<result property="isFrozen" column="is_frozen" />
<result property="freezeTime" column="freeze_time" />
<result property="materialAuditTime" column="material_audit_time" />
<result property="voucherAuditTime" column="voucher_audit_time" />
<result property="materialComplianceApproved" column="material_compliance_approved" />
<result property="voucherComplianceApproved" column="voucher_compliance_approved" />
<result property="invitationUrl" column="invitation_url" />
<result property="scheduleUrl" column="schedule_url" />
<result property="posterUrl" column="poster_url" />
<result property="laborSigned" column="labor_signed" />
<result property="laborFee" column="labor_fee" />
<result property="meetingFee" column="meeting_fee" />
<result property="totalFee" column="total_fee" />
<result property="feeCalcStatus" column="fee_calc_status" />
<result property="attendeeId" column="attendee_id" />
<result property="attendeeLaborProtocol" column="attendee_labor_protocol" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -31,15 +50,19 @@
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
select meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, 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
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long">
<include refid="selectFields"/>
select <include refid="selectFields"/>
from biz_meeting
where meeting_id = #{meetingId} and is_deleted = 0
</select>
<select id="selectList" resultMap="BizMeetingResult" parameterType="BizMeeting">
select
<if test="userId != null">(select a.id from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0 limit 1) as attendee_id,</if>
<if test="userId != null">(select a.labor_protocol from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0 limit 1) as attendee_labor_protocol,</if>
<include refid="selectFields"/>
from biz_meeting
<where>
is_deleted = 0
<if test="projectNo != null and projectNo != ''">and project_no like concat('%', #{projectNo}, '%')</if>
@@ -52,6 +75,21 @@
<if test="endTime != null">and end_time &lt;= #{endTime}</if>
<!-- doctor 角色按 user_id 过滤 (走 biz_meeting_attendee 中间表, 同时 attendee 也需 is_deleted=0) -->
<if test="userId != null">and exists (select 1 from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0)</if>
<!-- sponsor 数据权限: 只看"我的项目"下的会议 (project_id ∈ 我的项目). MAIN 走 sponsor_admin_user_id, SUB 走 sponsor_assign.monitor_user_id.
刻意不带 biz_publicity_support_intent 关联 (与项目列表 selectSponsorList 的区别点) -->
<if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_admin_user_id = #{params.sponsorAdminUserId} and is_deleted = 0)</if>
<if test="params.monitorUserId != null">and project_id in (select distinct project_id from biz_project_sponsor_assign where monitor_user_id = #{params.monitorUserId} and is_deleted = 0)</if>
<!-- executor 数据权限: 只看"我的项目"下的会议. MAIN 走 biz_project_assign (exec_user_id / execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id -->
<if test="params.executorUserId != null">and project_id in (
select distinct a.project_id from biz_project_assign a
where 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'))
)</if>
<if test="params.executorStaffUserId != null">and project_id in (
select distinct a.project_id from biz_project_executor_assign a
where a.is_deleted = 0 and a.staff_user_id = #{params.executorStaffUserId}
)</if>
</where>
order by meeting_id desc
</select>
@@ -79,7 +117,12 @@
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage,</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if>
<if test="posterUrl != null and posterUrl != ''">poster_url,</if>
<if test="laborSigned != null and laborSigned != ''">labor_signed,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
@@ -103,33 +146,54 @@
<if test="voucherAuditStage != null and voucherAuditStage != ''">#{voucherAuditStage},</if>
<if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if>
<if test="posterUrl != null and posterUrl != ''">#{posterUrl},</if>
<if test="laborSigned != null and laborSigned != ''">#{laborSigned},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizMeeting">
update biz_meeting
<trim prefix="SET" suffixOverrides=",">
<if test="businessId != null and businessId != ''">business_id = #{businessId},</if>
<if test="projectId != null and projectId != ''">project_id = #{projectId},</if>
<if test="projectId != null">project_id = #{projectId},</if>
<if test="projectNo != null and projectNo != ''">project_no = #{projectNo},</if>
<if test="projectName != null and projectName != ''">project_name = #{projectName},</if>
<if test="meetingName != null and meetingName != ''">meeting_name = #{meetingName},</if>
<if test="periodNo != null and periodNo != ''">period_no = #{periodNo},</if>
<if test="totalPeriods != null and totalPeriods != ''">total_periods = #{totalPeriods},</if>
<if test="periodNo != null">period_no = #{periodNo},</if>
<if test="totalPeriods != null">total_periods = #{totalPeriods},</if>
<if test="projectForm != null and projectForm != ''">project_form = #{projectForm},</if>
<if test="startTime != null and startTime != ''">start_time = #{startTime},</if>
<if test="endTime != null and endTime != ''">end_time = #{endTime},</if>
<!-- Date/Long 字段不能用 != '' (OGNL 会把 Date 和 String 做非法比较), 只判 null -->
<if test="startTime != null">start_time = #{startTime},</if>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="orgName != null and orgName != ''">org_name = #{orgName},</if>
<if test="address != null and address != ''">address = #{address},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if>
<if test="supervisionTime != null and supervisionTime != ''">supervision_time = #{supervisionTime},</if>
<if test="supervisionTime != null">supervision_time = #{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage = #{materialAuditStage},</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage = #{voucherAuditStage},</if>
<if test="isExecuted != null">is_executed = #{isExecuted},</if>
<if test="executeTime != null">execute_time = #{executeTime},</if>
<if test="isSettled != null">is_settled = #{isSettled},</if>
<if test="settleTime != null">settle_time = #{settleTime},</if>
<if test="isFinished != null">is_finished = #{isFinished},</if>
<if test="finishTime != null">finish_time = #{finishTime},</if>
<if test="isFrozen != null">is_frozen = #{isFrozen},</if>
<if test="freezeTime != null">freeze_time = #{freezeTime},</if>
<if test="materialAuditTime != null">material_audit_time = #{materialAuditTime},</if>
<if test="voucherAuditTime != null">voucher_audit_time = #{voucherAuditTime},</if>
<if test="materialComplianceApproved != null">material_compliance_approved = #{materialComplianceApproved},</if>
<if test="voucherComplianceApproved != null">voucher_compliance_approved = #{voucherComplianceApproved},</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if>
<if test="posterUrl != null and posterUrl != ''">poster_url = #{posterUrl},</if>
<if test="laborSigned != null and laborSigned != ''">labor_signed = #{laborSigned},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</trim>
where meeting_id = #{meetingId}
</update>
@@ -150,4 +214,66 @@
<select id="selectIdListByProjectId" resultType="Long" parameterType="Long">
select meeting_id from biz_meeting where project_id = #{projectId}
</select>
<!-- 建会限额用: 某项目下未软删的会议数 -->
<select id="countByProjectId" resultType="int" parameterType="Long">
select count(*) from biz_meeting where project_id = #{projectId} and is_deleted = 0
</select>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): start_time 已过 且 material 未提交 且未执行的会议 → is_executed=1 + RUNNING. 已软删/已冻结/已提交材料的不动. -->
<update id="markExecuted">
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 &lt;= NOW()
and material_audit_stage = 'NOT_SUBMITTED'
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): material 未提交 且 end_time + submit_deadline_days 已过 → 冻结 -->
<update id="markFrozen">
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) &lt;= NOW()
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): material+voucher 都 APPROVED 且 最晚审核时间已过 1 自然日 → AWAITING_SETTLEMENT (待结算 24h 慢路径) -->
<update id="markSettlementReady">
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) &lt;= (NOW() - INTERVAL 1 DAY)
</update>
<!-- 费用汇总调度器: 查 fee_calc_status=0 且未软删的会议 id -->
<select id="selectPendingFeeCalcIds" resultType="Long">
select meeting_id from biz_meeting where fee_calc_status = 0 and is_deleted = 0
</select>
<!-- 置未汇总 (人员/材料变化触发, 幂等) -->
<update id="markFeeCalcPending" parameterType="Long">
update biz_meeting set fee_calc_status = 0 where meeting_id = #{meetingId}
</update>
<!-- 汇总回写 3 个费用字段 + 置已汇总 -->
<update id="updateFeeSummary">
update biz_meeting
set labor_fee = #{laborFee},
meeting_fee = #{meetingFee},
total_fee = #{totalFee},
fee_calc_status = 1
where meeting_id = #{meetingId}
</update>
</mapper>
@@ -9,14 +9,16 @@
<result property="subType" column="sub_type" />
<result property="fileName" column="file_name" />
<result property="ossUrl" column="oss_url" />
<result property="extraOssUrl" column="extra_oss_url" />
<result property="amount" column="amount" />
<result property="creatorId" column="creator_id" />
<result property="createTime" column="create_time" />
<result property="isDeleted" column="is_deleted" />
<result property="feeStatus" column="fee_status" />
</resultMap>
<sql id="selectFields">
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
</sql>
@@ -39,6 +41,7 @@
<if test="subType != null and subType != ''">sub_type,</if>
<if test="fileName != null and fileName != ''">file_name,</if>
<if test="ossUrl != null and ossUrl != ''">oss_url,</if>
<if test="extraOssUrl != null and extraOssUrl != ''">extra_oss_url,</if>
<if test="amount != null">amount,</if>
<if test="creatorId != null">creator_id,</if>
<if test="createTime != null">create_time,</if>
@@ -49,6 +52,7 @@
<if test="subType != null and subType != ''">#{subType},</if>
<if test="fileName != null and fileName != ''">#{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
<if test="extraOssUrl != null and extraOssUrl != ''">#{extraOssUrl},</if>
<if test="amount != null">#{amount},</if>
<if test="creatorId != null">#{creatorId},</if>
<if test="createTime != null">#{createTime},</if>
@@ -56,11 +60,11 @@
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time)
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
<foreach collection="list" item="item" separator=",">
(#{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})
</foreach>
</insert>
@@ -71,6 +75,7 @@
<if test="subType != null and subType != ''">sub_type = #{subType},</if>
<if test="fileName != null and fileName != ''">file_name = #{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">oss_url = #{ossUrl},</if>
<if test="extraOssUrl != null and extraOssUrl != ''">extra_oss_url = #{extraOssUrl},</if>
<if test="amount != null">amount = #{amount},</if>
</trim>
where id = #{id}
@@ -80,6 +85,10 @@
update biz_meeting_material set amount = #{amount} where id = #{id}
</update>
<update id="updateFeeStatus">
update biz_meeting_material set fee_status = #{feeStatus} where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_material where id = #{id}
</delete>
@@ -15,6 +15,7 @@
<result property="unitType" column="unit_type" />
<result property="accountType" column="account_type" />
<result property="parentUserId" column="parent_user_id" />
<result property="account" column="user_name" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -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
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizProjectExecutorAssignMapper">
<resultMap id="BaseResultMap" type="BizProjectExecutorAssign">
<id property="id" column="id" />
<result property="projectId" column="project_id" />
<result property="executorUserId" column="executor_user_id" />
<result property="staffUserId" column="staff_user_id" />
<result property="assignDesc" column="assign_desc" />
<result property="assignPoints" column="assign_points" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="executorUserName" column="executor_user_name" />
<result property="staffUserName" column="staff_user_name" />
<result property="isDeleted" column="is_deleted" />
</resultMap>
<insert id="insertAssign" parameterType="BizProjectExecutorAssign" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
<!-- 按 project_id 全删 (执行方分配策略: 先删后插) -->
<delete id="deleteByProjectId" parameterType="String">
delete from biz_project_executor_assign where project_id = #{projectId}
</delete>
<!-- 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 -->
<update id="softDeleteByProjectId" parameterType="String">
update biz_project_executor_assign set is_deleted = 1 where project_id = #{projectId}
</update>
<select id="selectByProjectId" resultMap="BaseResultMap">
SELECT a.*,
e.user_name AS executor_user_name,
s.user_name AS staff_user_name
FROM biz_project_executor_assign a
LEFT JOIN sys_user e ON a.executor_user_id = e.user_id
LEFT JOIN sys_user s ON a.staff_user_id = s.user_id
WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC
</select>
</mapper>
@@ -8,6 +8,7 @@
<result property="totalSessions" column="total_sessions" />
<result property="assignedSessions" column="assigned_sessions" />
<result property="assignedAmount" column="assigned_amount" />
<result property="meetingCount" column="meeting_count" />
<result property="doneSessions" column="done_sessions" />
<result property="todoSessions" column="todo_sessions" />
<result property="totalAmount" column="total_amount" />
@@ -96,7 +97,32 @@
#{id}
</foreach>
</if>
<if test="params.sponsorAdminUserId != null">and p.sponsor_admin_user_id = #{params.sponsorAdminUserId}</if>
<!--
sponsor 视角: 可见项目 = MAIN/ADMIN 默认 (sponsor_admin_user_id) OR SUB/监察员 (sponsor_assign.monitor_user_id), 任一路径同时 UNION biz_publicity_support_intent
业务: sponsor (无论 MAIN/SUB) 提交过支持意向的项目, 同样要在 /sponsor/my-projects 看到, 跟被分配监察员同等地位
分页: PageHelper 加 LIMIT 到外层, 自动生成 COUNT(*) FROM biz_project WHERE ... — 子查询内 DISTINCT 避免重复 count
-->
<if test="params.sponsorAdminUserId != null">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}
)
)</if>
<if test="params.monitorUserId != null">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}
)
)</if>
<if test="projectNo != null and projectNo != ''">and p.project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''">and p.project_name like concat('%', #{projectName}, '%')</if>
<if test="startTime != null">and p.start_time &gt;= #{startTime}</if>
@@ -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: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
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
<where>
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'))
<if test="projectNo != null and projectNo != ''">and p.project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''">and p.project_name like concat('%', #{projectName}, '%')</if>
<if test="startTime != null">and p.start_time &gt;= #{startTime}</if>
<if test="endTime != null">and p.end_time &lt;= #{endTime}</if>
<if test="isFinished != null and isFinished != ''">and p.is_finished = #{isFinished}</if>
</where>
order by p.project_id desc
</select>
<!--
executor 执行人 (SUB 子账号) 专属列表: 反查 biz_project_executor_assign.staff_user_id
(主账号把执行人派到项目上后, 执行人登录看自己被派到的项目)
严格隔离: 不 JOIN biz_project_assign (避免主账号过滤), 不 UNION intent (executor 与 biz_*_intent 完全无关)
场次/金额列 assigned_sessions/assigned_amount 按 params.executorUserId (主账号/本公司) 聚合 — 执行人看到的是公司数据, 不是个人数据
-->
<select id="selectExecutorStaffList" resultMap="BizProjectResult" parameterType="BizProject">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled, p.sponsor_admin_user_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
o.org_name as sponsor_org_name,
lu.user_name as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa2
join biz_org o2 on o2.org_id = bpa2.execution_unit_id and o2.org_type = 'executor'
where bpa2.project_id = p.project_id and bpa2.is_deleted = 0) as exec_org_names,
(select coalesce(sum(bpa3.sessions), 0)
from biz_project_assign bpa3
where bpa3.project_id = p.project_id
and bpa3.is_deleted = 0
and (bpa3.exec_user_id = #{params.executorUserId}
or bpa3.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_sessions,
(select coalesce(sum(bpa4.amount), 0)
from biz_project_assign bpa4
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,
(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
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
<where>
p.is_deleted = 0
and p.project_id in (
select distinct a.project_id
from biz_project_executor_assign a
where a.is_deleted = 0 and a.staff_user_id = #{params.executorStaffUserId}
)
<if test="projectNo != null and projectNo != ''">and p.project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''">and p.project_name like concat('%', #{projectName}, '%')</if>
<if test="startTime != null">and p.start_time &gt;= #{startTime}</if>
@@ -324,4 +401,57 @@
<select id="selectProjectNoById" resultType="String" parameterType="Long">
select project_no from biz_project where project_id = #{projectId}
</select>
<!-- 建会限额用: 某项目分配给该执行方 (MAIN) 的总场次 = biz_project_assign.sessions 之和 (exec_user_id 或 其执行单位 org) -->
<select id="countAssignedSessions" resultType="int">
select coalesce(sum(a.sessions), 0)
from biz_project_assign a
where a.project_id = #{projectId}
and a.is_deleted = 0
and (a.exec_user_id = #{executorUserId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{executorUserId} and org_type = 'executor'))
</select>
<!-- 提交权限用: 判断 user 是否该项目的执行方.
MAIN: biz_project_assign (exec_user_id 或 其执行单位 org); SUB(执行人): biz_project_executor_assign.staff_user_id.
与会议列表 executor 可见性 (BizMeetingMapper.selectList) 同源, 替代原来的 biz_meeting_executor (会议级执行人员) 判定.
注: biz_project_executor_assign.project_id 是 varchar, 与 Long #{projectId} 比较走 MySQL 隐式转换 (与 selectExecutorStaffList 同款). -->
<select id="countExecutorOfProject" resultType="int">
select count(*) from (
select 1
from biz_project_assign a
where a.project_id = #{projectId}
and a.is_deleted = 0
and (a.exec_user_id = #{userId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{userId} and org_type = 'executor'))
union
select 1
from biz_project_executor_assign b
where b.project_id = #{projectId}
and b.is_deleted = 0
and b.staff_user_id = #{userId}
) t
</select>
<!-- 会议结算后重算项目金额: 全量 SUM 已结算会议 (is_settled=1) 的 labor_fee/meeting_fee,
幂等回写 paid_labor_amount / paid_meeting_amount, 并重算 available_amount = 总金额 - 管理费 - 已支付劳务 - 已支付会务.
单条 UPDATE 原子执行, 多会议并发结算同一项目时无丢失更新 (每次都是全量重算). -->
<update id="recomputeSettledAmounts" parameterType="Long">
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}
</update>
</mapper>
@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizSupportIntentMapper">
<resultMap type="BizSupportIntent" id="BizSupportIntentResult">
<id property="intentId" column="intent_id" />
<result property="projectNo" column="project_no" />
<result property="projectName" column="project_name" />
<result property="name" column="name" />
<result property="workUnit" column="work_unit" />
<result property="department" column="department" />
<result property="position" column="position" />
<result property="phone" column="phone" />
<result property="accountStatus" column="account_status" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
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
</sql>
<select id="selectByPrimaryKey" resultMap="BizSupportIntentResult" parameterType="String">
<include refid="selectFields"/>
where intent_id = #{intentId}
</select>
<select id="selectList" resultMap="BizSupportIntentResult" parameterType="BizSupportIntent">
<include refid="selectFields"/>
<where>
<if test="name != null and name != ''"> and name = #{name}</if>
<if test="department != null and department != ''"> and department = #{department}</if>
<if test="position != null and position != ''"> and position = #{position}</if>
<if test="phone != null and phone != ''"> and phone = #{phone}</if>
</where>
order by intent_id desc
</select>
<insert id="insert" parameterType="BizSupportIntent">
insert into biz_support_intent
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="intentId != null and intentId != ''">intent_id,</if>
<if test="name != null">name,</if>
<if test="department != null">department,</if>
<if test="position != null">position,</if>
<if test="phone != null">phone,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="intentId != null and intentId != ''">#{intentId},</if>
<if test="name != null">#{name},</if>
<if test="department != null">#{department},</if>
<if test="position != null">#{position},</if>
<if test="phone != null">#{phone},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizSupportIntent">
update biz_support_intent
<trim prefix="SET" suffixOverrides=",">
<if test="projectNo != null and projectNo != ''">project_no = #{projectNo},</if>
<if test="projectName != null and projectName != ''">project_name = #{projectName},</if>
<if test="workUnit != null and workUnit != ''">work_unit = #{workUnit},</if>
<if test="accountStatus != null and accountStatus != ''">account_status = #{accountStatus},</if>
<if test="name != null">name = #{name},</if>
<if test="department != null">department = #{department},</if>
<if test="position != null">position = #{position},</if>
<if test="phone != null">phone = #{phone},</if>
</trim>
where intent_id = #{intentId}
</update>
<delete id="deleteByPrimaryKey" parameterType="String">
delete from biz_support_intent where intent_id = #{intentId}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="String">
delete from biz_support_intent where intent_id in
<foreach collection="intentIds" item="intentId" open="(" separator="," close=")">
#{intentId}
</foreach>
</delete>
</mapper>