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:
@@ -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
|
||||
|
||||
+3
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+39
-7
@@ -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);
|
||||
}
|
||||
|
||||
+29
-7
@@ -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 下标) 必须落位, 且该位姓名与目录姓名一致才回填.
|
||||
|
||||
+4
-4
@@ -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);
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -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 <> 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()
|
||||
|
||||
Reference in New Issue
Block a user