feat: 项目分配通知支持方主账号 + 结题前置检查未结算会议

#8 项目分配 → 支持方主账号待办通知
- BizProjectController.edit 检测 sponsor_org_id 变更 (null→org / A→B)
  调 BizNotifyService.projectAssignedToSupportOrg 发待办给支持方主账号
  (biz_org.user_id, MAIN sponsor). 去掉支持方 (置 null) 不发.
- BizNotifyService 新增 projectAssignedToSupportOrg, 与既有的
  projectAssignedToSponsor (通知 SUB 监察员) 配套, 共 2 条.

结题前置检查 (manager)
- BizProjectController 新增 GET /business/project/meetingSettlement
  入参 projectIds 逗号分隔, 查还有未结算会议 (is_settled<>1)
  的项目, 仅返回 unsettledCount>0 项.
- BizProjectMapper.checkMeetingSettled (LinkedHashMap, HAVING > 0).
- manager/Projects.vue openClose 先调该端点, 有未结算会议时弹警告
  ElMessageBox.confirm (允许强制结题). 后端失败兜底空数组不阻塞.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-09-14 22:37:18 +08:00
co-authored by Claude Code
parent 91bf0fa3b9
commit 0fb98311ce
18 changed files with 288 additions and 55 deletions
@@ -7,7 +7,7 @@ spring:
basename: i18n/messages
profiles:
# 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test)
active: test
active: prod
group:
test: druid,test
prod: druid,prod
@@ -6,6 +6,7 @@ import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.impl.InvoiceOcrService;
import com.ruoyi.common.utils.SecurityUtils;
/**
* 会议发票识别 Controller (v3)
@@ -44,7 +45,8 @@ public class BizMeetingInvoiceController extends BaseController
body.getMaterialId(),
body.getMeetingId(),
body.getOssUrl(),
body.getOldMaterialId());
body.getOldMaterialId(),
SecurityUtils.getUserId());
return success(r);
}
@@ -233,21 +233,30 @@ public class BizProjectController extends BaseController
@PutMapping
public AjaxResult edit(@RequestBody BizProject bizProject)
{
BizProject old = bizProject.getProjectId() != null ? bizProjectService.getById(bizProject.getProjectId()) : null;
// submit_deadline_days 变更 → 级联重算该项目存量「从未退回、且仍有轨未提交」会议的提交截止时间.
// 建会时 deadline 一次性落库, 改项目天数本不影响已有会议; 这里补上级联, 避免"改了天数会议却不按新天数冻结".
Integer newDays = bizProject.getSubmitDeadlineDays();
boolean daysChanged = false;
if (bizProject.getProjectId() != null && newDays != null)
{
BizProject old = bizProjectService.getById(bizProject.getProjectId());
Integer oldDays = old == null ? null : old.getSubmitDeadlineDays();
daysChanged = !Objects.equals(oldDays, newDays);
}
boolean daysChanged = bizProject.getProjectId() != null && newDays != null
&& !Objects.equals(old == null ? null : old.getSubmitDeadlineDays(), newDays);
// sponsor_org_id 变更 (合规把项目分配给支持方) → 通知新支持方主账号 (待办: 去查看并分配监察员)
Long newSponsorOrgId = bizProject.getSponsorOrgId();
boolean sponsorChanged = bizProject.getProjectId() != null && newSponsorOrgId != null
&& !Objects.equals(old == null ? null : old.getSponsorOrgId(), newSponsorOrgId);
int rows = bizProjectService.updateByPrimaryKey(bizProject);
if (daysChanged)
{
bizMeetingService.recomputeSubmitDeadlinesByProject(bizProject.getProjectId(), newDays);
}
if (sponsorChanged)
{
Long sponsorMainUserId = resolveMainUserIdByOrgId(newSponsorOrgId);
bizNotifyService.projectAssignedToSupportOrg(sponsorMainUserId, bizProject.getProjectId(),
old == null ? null : old.getProjectName());
}
return toAjax(rows);
}
@Log(title = "项目", businessType = BusinessType.DELETE)
@@ -595,6 +604,29 @@ public class BizProjectController extends BaseController
return getDataTable(list);
}
/**
* 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目.
* GET /business/project/meetingSettlement?projectIds=1,2,3
* 返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0
* (0 会议或全部已结算不返回). 前端结题弹警告据此判定.
* 接收逗号分隔字符串 (跨 axios 数组序列化版本安全), 内部 split 转 List<Long>.
*/
@GetMapping("/meetingSettlement")
public AjaxResult checkMeetingSettled(@RequestParam("projectIds") String projectIdsStr)
{
if (projectIdsStr == null || projectIdsStr.trim().isEmpty()) {
return success(new ArrayList<>());
}
List<Long> ids = new ArrayList<>();
for (String s : projectIdsStr.split(",")) {
String t = s.trim();
if (!t.isEmpty()) {
try { ids.add(Long.parseLong(t)); } catch (NumberFormatException ignore) {}
}
}
return success(bizProjectService.checkMeetingSettled(ids));
}
/**
* 支持方批量分配监察员 (多个项目, 同一个监察员 + 同一份说明)
* POST /business/project/sponsorAssignBatch
@@ -1,5 +1,6 @@
package com.ruoyi.business.mapper;
import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizProject;
/**
@@ -36,4 +37,6 @@ public interface BizProjectMapper
int clearAnnouncement(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
int closeExpiredOpenStatus();
/** 结题前置检查: 返回还有未结算会议(is_settled<>1)的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */
List<Map<String, Object>> checkMeetingSettled(@org.apache.ibatis.annotations.Param("projectIds") List<Long> projectIds);
}
@@ -378,4 +378,36 @@ public class BizNotifyService
bizMessageService.insert(msg);
log.info("[notify] projectAssignedToSponsor 已发 uid={} projectId={}", monitorUserId, projectId);
}
/**
* #8 项目分配给支持方 → 通知支持方主账号 (待办: 去查看项目并分配监察员).
*
* <p>调用方: {@link com.ruoyi.business.controller.BizProjectController#edit},
* 检测到 biz_project.sponsor_org_id 真正变了 (null→org / A→B) 才调. 去掉支持方 (置 null) 不发.
*
* <p>与 {@link #projectAssignedToSponsor} 的区别: 那条是通知被分配的监察员 (SUB sponsor),
* 本条是通知支持单位的主账号 (biz_org.user_id, MAIN sponsor).
*
* @param sponsorUserId 支持方主账号 sys_user.user_id (biz_org.user_id, nullable, 跳过)
* @param projectId biz_project.project_id (Long, 用于 bizId)
* @param projectName 项目名 (可空, 兜底)
*/
public void projectAssignedToSupportOrg(Long sponsorUserId, Long projectId, String projectName)
{
if (sponsorUserId == null) {
log.warn("[notify] projectAssignedToSupportOrg: sponsorUserId 为空, 跳过 (projectId={})", projectId);
return;
}
String name = projectName != null ? projectName : ("项目 #" + projectId);
BizMessage msg = new BizMessage();
msg.setReceiverUserId(sponsorUserId);
msg.setMsgType(TYPE_TODO);
msg.setTitle("项目分配: " + name);
msg.setContent("您单位被分配为项目【" + name + "】的支持方,请登录系统查看并分配监察员。");
msg.setBizType(BIZ_PROJECT);
msg.setBizId(projectId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] projectAssignedToSupportOrg 已发 uid={} projectId={}", sponsorUserId, projectId);
}
}
@@ -1,6 +1,7 @@
package com.ruoyi.business.service;
import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizProject;
/**
@@ -47,4 +48,6 @@ public interface IBizProjectService
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
int closeExpiredOpenStatus();
/** 结题前置检查: 返回还有未结算会议的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */
List<Map<String, Object>> checkMeetingSettled(List<Long> projectIds);
}
@@ -1031,18 +1031,40 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return folders;
}
/** 从 zip 条目路径里解析父目录段 {序号}_姓名 → [序号数字, 姓名段]; 文件不在该结构下返回 null.
* 父目录 = 文件名前最后一段目录 (路径倒数第二段), 与模板生成的 {序号}_姓名/ 一致. */
/** 从 zip 条目路径里定位参会人 → [序号数字, 姓名段]; 无法定位返回 null.
* 兼容多种形态:
* - 目录: {序号}_姓名/ 作为路径中任意一层目录 (套 1~N 层均可), 且文件可在该目录下的子目录里 → 取最靠近文件的匹配段
* - 单文件: 无目录时, 文件名本身为 {序号}_姓名[.扩展名] (如 1_张三.pdf) → 姓名剥掉最后一个扩展名
* 优先目录段, 找不到再认文件名. */
private static String[] parseAgreementDir(String path) {
if (path == null || path.isEmpty()) return null;
String[] segs = path.split("/");
if (segs.length < 2) return null;
String dir = segs[segs.length - 2];
int underscore = dir.indexOf('_');
if (segs.length == 0) return null;
// 1) 目录段优先: 从倒数第 2 段向根方向扫, 取最深匹配 {序号}_姓名 的目录段
for (int i = segs.length - 2; i >= 0; i--) {
String[] t = attendeeTokenOf(segs[i], false);
if (t != null) return t;
}
// 2) 文件名兜底: 最后一段 (文件名) 匹配 {序号}_姓名[.扩展名]
return attendeeTokenOf(segs[segs.length - 1], true);
}
/** 从单个路径段解析 {序号}_姓名 → [序号数字, 姓名段]; 不匹配返回 null.
* stripExt=true 时把姓名段末尾的最后一个 .扩展名 剥掉 (仅文件名兜底用). */
private static String[] attendeeTokenOf(String s, boolean stripExt) {
if (s == null || s.isEmpty()) return null;
int underscore = s.indexOf('_');
if (underscore <= 0) return null;
String numPart = dir.substring(0, underscore);
String numPart = s.substring(0, underscore);
if (!numPart.matches("\\d+")) return null;
return new String[] { numPart, dir.substring(underscore + 1) };
String name = s.substring(underscore + 1);
if (stripExt) {
int dot = name.lastIndexOf('.');
if (dot > 0) name = name.substring(0, dot);
}
return new String[] { numPart, name };
}
/** 严格匹配: 序号 (order by id 后的 1-based 下标) 必须落位, 且该位姓名与目录姓名一致才回填.
@@ -551,9 +551,9 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{
if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue;
if (m.getMaterialType() == null || !LABOR_MATERIAL_TYPES.contains(m.getMaterialType())) continue;
// 签到表连拍多张: oss_url 是 JSON 数组字符串, 逐个 copy (签到表-1.jpg ...); 老数据单 URL 由 copyJsonUrls 兜底
if ("L_SIGN_IN".equals(m.getSubType()) && m.getOssUrl().trim().startsWith("[")) {
copied += copyJsonUrls(m.getOssUrl(), prefix + folderName + "/" + safeName(laborFolderName("L_SIGN_IN")) + "/", laborFileLabel("L_SIGN_IN"));
// oss_url 是 JSON 数组字符串 (签到表连拍 / 电子签到表 / 企业权益多文件): 逐个 copy (XX-1.ext ...); 老数据单 URL 由 copyJsonUrls 兜底
if (m.getOssUrl().trim().startsWith("[")) {
copied += copyJsonUrls(m.getOssUrl(), prefix + folderName + "/" + safeName(laborFolderName(m.getSubType())) + "/", laborFileLabel(m.getSubType()));
continue;
}
String srcKey = ossZipService.extractKey(m.getOssUrl());
@@ -924,7 +924,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
}
// 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总)
if (ocrNeeded) {
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId);
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId, userId);
}
updated++;
uploadProgressRegistry.update(jobId, processed);
@@ -1,6 +1,8 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -154,4 +156,9 @@ public class BizProjectServiceImpl implements IBizProjectService
public int closeExpiredOpenStatus() {
return bizProjectMapper.closeExpiredOpenStatus();
}
@Override
public List<Map<String, Object>> checkMeetingSettled(List<Long> projectIds) {
if (projectIds == null || projectIds.isEmpty()) return new ArrayList<>();
return bizProjectMapper.checkMeetingSettled(projectIds);
}
}
@@ -21,7 +21,6 @@ import com.ruoyi.business.ocr.InvoiceResult;
import com.ruoyi.business.ocr.LocalInvoiceRecognizer;
import com.ruoyi.business.ocr.ZipExtractor;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.common.utils.SecurityUtils;
import cn.hutool.core.io.FileUtil;
/**
@@ -76,11 +75,12 @@ public class InvoiceOcrService
* @param ossUrl OSS URL (单文件: 原图;ZIP: zip 包)
* @param oldMaterialId 替换场景携带, 后端先 DELETE invoice WHERE material_id=old + material.amount=0;
* null → 新增场景, 不做清理
* @param creatorId 操作人 ID (由调用方在 HTTP 线程取好传入, 后台 OCR 线程无 SecurityContext)
* @return 提交摘要 (立即返回, 不等 OCR 完成)
* <p>是否 zip 不再由前端传, 后端按 ossUrl 后缀 (转小写) 判断, 避免前后端不一致.
*/
public RecognizeResult submitRecognition(Long materialId, Long meetingId, String ossUrl,
Long oldMaterialId)
Long oldMaterialId, Long creatorId)
{
RecognizeResult out = new RecognizeResult();
if (materialId == null || meetingId == null || ossUrl == null || ossUrl.isEmpty())
@@ -92,9 +92,6 @@ public class InvoiceOcrService
// 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参
boolean isZip = ossUrl.toLowerCase().endsWith(".zip");
// 提前取登录用户 ID: OCR 在后台线程池跑, 无 SecurityContext, 必须在此 (HTTP 线程) 取好
final Long creatorId = SecurityUtils.getUserId();
// 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0)
if (oldMaterialId != null)
{
@@ -600,4 +600,24 @@
and b.staff_user_id = #{userId}
) t
</select>
<!-- 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目.
返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0 (HAVING).
0 会议或全部已结算的项目不返回, 前端据此判定是否弹警告.
与项目金额子查询 (m.is_settled=1) 口径一致; 0 会议视为已全部结算. -->
<select id="checkMeetingSettled" resultType="java.util.LinkedHashMap">
SELECT m.project_id AS projectId,
p.project_no AS projectNo,
COUNT(*) AS unsettledCount
FROM biz_meeting m
INNER JOIN biz_project p ON p.project_id = m.project_id
WHERE m.project_id IN
<foreach collection="projectIds" item="pid" open="(" close=")" separator=",">
#{pid}
</foreach>
AND m.is_settled &lt;&gt; 1
AND m.is_deleted = 0
GROUP BY m.project_id, p.project_no
HAVING COUNT(*) > 0
</select>
</mapper>
@@ -84,6 +84,7 @@
<if test="submitterId != null">submitter_id,</if>
<if test="createBy != null">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="submitTime != null">submit_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="planId != null and planId != ''">#{planId},</if>
@@ -101,6 +102,7 @@
<if test="submitterId != null">#{submitterId},</if>
<if test="createBy != null">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="submitTime != null">#{submitTime},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizProjectPlan">
@@ -72,6 +72,8 @@ public class SecurityConfig
.requestMatchers(HttpMethod.POST, "/business/meetingMaterial/cameraUpload").permitAll()
// 业务公开门户与注册入口可匿名访问; 字典 active/单查公开, 写操作需登录+权限
.requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll()
// 医院字典远程搜索 (医生注册下拉"边输入边查询", 未登录可访问)
.requestMatchers(HttpMethod.GET, "/business/hospital/search").permitAll()
// 字典 active (只读) + 按 ID 查 公开给前端拉下拉数据
.requestMatchers(HttpMethod.GET, "/business/dict/department/active", "/business/dict/title/active",
"/business/dict/department/*", "/business/dict/title/*").permitAll()