批量推送代码

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-29 17:52:05 +08:00
co-authored by Claude
parent 36528b6e0c
commit e47f76478b
130 changed files with 3972 additions and 2546 deletions
@@ -23,6 +23,9 @@ import com.ruoyi.framework.web.service.SysPermissionService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.system.service.ISysMenuService;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.service.IBizExpertService;
/**
* 登录验证
@@ -47,6 +50,12 @@ public class SysLoginController
@Autowired
private ISysConfigService configService;
@Autowired
private ISysUserService userService;
@Autowired
private IBizExpertService expertService;
/**
* 登录方法
*
@@ -56,6 +65,11 @@ public class SysLoginController
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
// 待审核专家拦截: role_type=doctor 且 biz_expert.audit_status='1' → 禁止登录
if (isPendingExpert(loginBody.getUsername()))
{
return AjaxResult.error("您的信息正在审核中");
}
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
@@ -64,6 +78,29 @@ public class SysLoginController
return ajax;
}
/**
* 判断用户是否为待审核专家 (role_type=doctor 且 biz_expert.audit_status='1')
*/
private boolean isPendingExpert(String username)
{
if (username == null || username.isEmpty())
{
return false;
}
SysUser probe = userService.selectUserByUserName(username);
// 邮箱登录兜底 (用户名查不到且含 @ 时按邮箱反查)
if (probe == null && username.contains("@"))
{
probe = userService.selectUserByEmail(username);
}
if (probe == null || probe.getUserId() == null || !"doctor".equals(probe.getRoleType()))
{
return false;
}
BizExpert expert = expertService.getByUserId(probe.getUserId());
return expert != null && "1".equals(expert.getAuditStatus());
}
/**
* 获取用户信息
*
@@ -28,12 +28,12 @@ ruoyi:
# 阿里云短信配置 (hwt-code ali.sms 模式)
# dev/prod 区分走代码: phone 以 "10" 开头视为 dev 测试 (固定码 1234), 其它走 aliyun 真发
sms:
accessKeyId: LTAI5t7S88DmdTxHPzPtJTwG
accessKeySecret: UIkwjMpmlYjgX5IMLjPj8FQNPthdlR
signName: 北京仙仁掌医学科技发展
template: SMS_321560247
esignTemplate: SMS_492460505
esignBaseUrl: https://risingdoctor.com/hg
accessKeyId: LTAI5tAgeAUviVdPSsxYCkPY
accessKeySecret: 556LX6mXnJIPZgf0vFXcl4mKCzzgKq
signName: 北京整合医学学会
template: SMS_291440833
esignTemplate: SMS_512040098
esignBaseUrl: https://hegui.bahim.org.cn
endpoint: dysmsapi.aliyuncs.com
regionId: cn-hangzhou
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
@@ -14,8 +14,10 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.dto.SmsValidForm;
import com.ruoyi.business.mapper.BizPersonMapper;
import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.SysSmsService;
import com.ruoyi.common.utils.id.SnowflakeId;
@@ -64,6 +66,9 @@ public class BizAuthController extends BaseController {
@Autowired
private IBizOrgService bizOrgService;
@Autowired
private IBizExpertService expertService;
@Autowired
private BizPersonMapper bizPersonMapper;
@@ -165,6 +170,14 @@ public class BizAuthController extends BaseController {
}
}
// 4.6 待审核专家拦截: role_type=doctor 且 biz_expert.audit_status='1' → 禁止登录
if ("doctor".equals(user.getRoleType())) {
BizExpert expert = expertService.getByUserId(user.getUserId());
if (expert != null && "1".equals(expert.getAuditStatus())) {
return error("您的信息正在审核中");
}
}
// 5. 构造 LoginUser 并设 SecurityContext (兼容后续 spring security 鉴权)
LoginUser loginUser = new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
Authentication authentication = new UsernamePasswordAuthenticationToken(
@@ -288,6 +301,7 @@ public class BizAuthController extends BaseController {
public AjaxResult registerSponsor(@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
String orgIdStr = body.get("orgId") == null ? null : body.get("orgId").toString();
String realName = (String) body.get("realName");
String phone = (String) body.get("phone");
String code = (String) body.get("smsCode");
String password = (String) body.get("password");
@@ -298,6 +312,7 @@ public class BizAuthController extends BaseController {
if (username == null || username.length() < 4 || username.length() > 20) return error("用户名长度 4-20 位");
if (!username.matches("^[A-Za-z0-9_]+$")) return error("用户名只能包含字母/数字/下划线");
if (orgIdStr == null || orgIdStr.isEmpty()) return error("请选择企业");
if (realName == null || realName.trim().isEmpty()) return error("请输入联系人姓名");
if (phone == null || !phone.matches("^1\\d{10}$")) return error("手机号格式错误");
if (code == null || code.isEmpty()) return error("请输入短信验证码");
if (password == null || password.length() < 6 || password.length() > 20) return error("密码长度 6-20 位");
@@ -340,7 +355,7 @@ public class BizAuthController extends BaseController {
// role_type 显式写 sponsor, 避免被 sys_user.role_type DB DEFAULT 'executor' 覆盖
SysUser user = new SysUser();
user.setUserName(username);
user.setNickName(org.getOrgName()); // 昵称用所选企业
user.setNickName(realName); // 昵称用联系人姓
user.setPhonenumber(phone);
user.setPassword(passwordEncoder.encode(password));
user.setStatus("0");
@@ -353,7 +368,7 @@ public class BizAuthController extends BaseController {
// 6. 写 biz_person (关联到所选企业, 不再新建 biz_org)
BizPerson self = new BizPerson();
SnowflakeId.injectIfEmpty(self, "personId");
self.setName(username); // 表单无姓名字段, 用登录用户名占位
self.setName(realName);
self.setPhone(phone);
self.setOrgId(orgId);
self.setDepartment("待分配");
@@ -68,7 +68,7 @@ public class BizExecutionIntentController extends BaseController
SysUser u = sysUserMapper.selectUserById(uid);
if (u != null) {
if (bizExecutionIntent.getName() == null || bizExecutionIntent.getName().isEmpty())
bizExecutionIntent.setName(u.getUserName());
bizExecutionIntent.setName(u.getNickName());
if (bizExecutionIntent.getPhone() == null || bizExecutionIntent.getPhone().isEmpty())
bizExecutionIntent.setPhone(u.getPhonenumber());
}
@@ -94,8 +94,8 @@ public class BizMeetingAttendeeController extends BaseController {
@PostMapping
public AjaxResult add(@RequestBody BizMeetingAttendee body) {
Long attendeeId = attendeeService.insertByPhoneWithProfile(body);
// 人员变化 → 会议费用待重算
bizMeetingService.markFeeCalcPending(body.getMeetingId());
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
bizMeetingService.recomputeLaborFee(body.getMeetingId());
// 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发
return success(attendeeId);
}
@@ -113,9 +113,9 @@ public class BizMeetingAttendeeController extends BaseController {
BizMeetingAttendee before = attendeeService.selectById(body.getId());
body.setUpdateBy(SecurityUtils.getUsername());
int rows = attendeeService.updateProfile(body);
// 人员变化 → 会议费用待重算
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
if (before != null) {
bizMeetingService.markFeeCalcPending(before.getMeetingId());
bizMeetingService.recomputeLaborFee(before.getMeetingId());
}
return toAjax(rows);
}
@@ -129,9 +129,9 @@ public class BizMeetingAttendeeController extends BaseController {
public AjaxResult remove(@PathVariable("id") Long id) {
BizMeetingAttendee before = attendeeService.selectById(id);
int rows = attendeeService.deleteByPrimaryKey(id);
// 人员变化 → 会议费用待重算
// 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
if (before != null) {
bizMeetingService.markFeeCalcPending(before.getMeetingId());
bizMeetingService.recomputeLaborFee(before.getMeetingId());
}
return toAjax(rows);
}
@@ -194,9 +194,9 @@ public class BizMeetingAttendeeController extends BaseController {
@RequestParam("meetingId") Long meetingId) throws Exception {
// 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发)
ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
// 人员变化 → 会议费用待重算 (有成功导入才需重算, 但幂等, 直接标记)
// 人员变化 → 立即重算劳务费 (有成功导入才需重算)
if (result != null && result.getOkNum() > 0) {
bizMeetingService.markFeeCalcPending(meetingId);
bizMeetingService.recomputeLaborFee(meetingId);
}
return success(result);
}
@@ -99,6 +99,12 @@ public class BizMeetingController extends BaseController {
} else {
bizMeeting.getParams().put("executorUserId", uid);
}
// assignedSessions 聚合用 (公司维度): MAIN 用自己, SUB 反查主账号 parent_user_id; 与过滤参数分离, 避免改变 SUB 可见会议范围
Long aggUid = uid;
if (current != null && "SUB".equals(current.getAccountType()) && current.getParentUserId() != null) {
aggUid = current.getParentUserId();
}
bizMeeting.getParams().put("assignedExecutorUserId", aggUid);
}
// 合规人员(manager) 数据权限: 只看"本人创建的项目"下的会议 (project_id ∈ create_user_id = 自己的项目)
else if ("manager".equals(roleType)) {
@@ -111,7 +117,19 @@ public class BizMeetingController extends BaseController {
@GetMapping("/{meetingId}")
public AjaxResult getInfo(@PathVariable("meetingId") Long meetingId) {
return success(bizMeetingService.getById(meetingId));
BizMeeting m = bizMeetingService.getById(meetingId);
// executor 详情: "总期数/期数分母" = 分配给本执行方的场次, 而非项目总场次 (total_periods 对执行方不可见)
if (m != null && m.getProjectId() != null
&& "executor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
Long uid = SecurityUtils.getUserId();
Long aggUid = uid;
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType()) && current.getParentUserId() != null) {
aggUid = current.getParentUserId();
}
m.setAssignedSessions((long) bizProjectService.countAssignedSessions(m.getProjectId(), aggUid));
}
return success(m);
}
@Log(title = "会议", businessType = BusinessType.INSERT)
@@ -183,6 +201,32 @@ public class BizMeetingController extends BaseController {
@Log(title = "会议", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizMeeting bizMeeting) {
// executor 修改会议同样校验 (与 add 一致, 只是期数冲突检测排除自身): 期数不得超过分配给本公司的场次 + 相同期数不重复.
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();
}
Long executionUnitId = bizOrgService.selectOrgIdByUserId(aggUid);
int assigned = bizProjectService.countAssignedSessions(projectId, aggUid);
Long periodNo = bizMeeting.getPeriodNo();
if (periodNo != null && periodNo > assigned) {
throw new ServiceException("期数不能超过分配给本公司的场次 (共 " + assigned + " 场)");
}
if (periodNo != null) {
int dup = bizMeetingService.countByProjectIdExecutionUnitPeriodExclude(projectId, executionUnitId, periodNo, bizMeeting.getMeetingId());
if (dup > 0) {
throw new ServiceException("本机构已创建第 " + periodNo + " 期会议, 请勿重复");
}
}
}
bizMeeting.setUpdateBy(SecurityUtils.getUsername());
bizMeeting.setUpdateTime(new Date());
// 修改会议时项目形式也从项目继承 (与 add 一致, 避免 project_form 残留为空)
@@ -534,6 +578,9 @@ public class BizMeetingController extends BaseController {
m.setIsSettled(1);
m.setSettleTime(new Date());
// 结算即终态: 直接落完结标记, 省去二次「完结」动作 (auto-finish)
m.setIsFinished(1);
m.setFinishTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "SETTLE", "APPROVED", "会议结算", null);
@@ -1,5 +1,6 @@
package com.ruoyi.business.controller;
import java.io.InputStream;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -10,7 +11,9 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.file.FileUtils;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.oss.OssZipService;
import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService;
@@ -29,6 +32,8 @@ public class BizMeetingMaterialController extends BaseController {
private IBizMeetingMaterialService bizMeetingMaterialService;
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired
private OssZipService ossZipService;
/**
* 查该会议的所有材料记录
@@ -56,9 +61,14 @@ public class BizMeetingMaterialController extends BaseController {
}
}
}
// 先判断材料是否实际变化 (必须在 replaceByMeetingId 之前, 后者会先删旧记录).
// 会务材料没变时不置"统计中", 避免无谓重算 + 前端"统计中"闪烁.
boolean changed = bizMeetingMaterialService.isMaterialSetChanged(meetingId, list);
List<BizMeetingMaterial> saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list);
// 材料变化 → 会议费用待重算 (FeeCalcScheduler 汇总回写)
bizMeetingService.markFeeCalcPending(meetingId);
if (changed) {
// 材料变化 → 立即重算会议费用 (发票未 OCR 完则回滚 0 走调度器兜底)
bizMeetingService.recomputeMeetingFee(meetingId);
}
return success(saved);
}
@@ -92,58 +102,79 @@ public class BizMeetingMaterialController extends BaseController {
}
/**
* 会务下载: 把该会议所有"会务"材料 (SERVICE + SERVICE_VOUCHER) 在 OSS 端打 zip, 返回下载 URL.
* 会务下载: 把该会议所有"会务"材料 (SERVICE + SERVICE_VOUCHER) 在 OSS 端打 zip, 后端代理改名下发.
* 仅 admin/manager 可触发 (前端 manager/meetings 列表操作栏按钮).
* 文件名: 项目编号_项目名称_第N期_会务.zip
*/
@GetMapping("/{meetingId}/downloadZip")
public AjaxResult downloadZip(@PathVariable("meetingId") Long meetingId) {
public void downloadZip(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载会务材料");
}
// 注意: 不能用 success(String), 否则 URL 会被塞进 msg 字段 (BaseController.success(String) 重载陷阱)
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildServiceZipUrl(meetingId));
String url = bizMeetingMaterialService.buildServiceZipUrl(meetingId);
streamZip(url, bizMeetingMaterialService.serviceZipFilename(meetingId), response);
}
/**
* 劳务下载: 把该会议所有"劳务"材料 (LABOR + LABOR_VOUCHER) + 参会人信息在 OSS 端打 zip, 返回下载 URL.
* 劳务下载: 把该会议所有"劳务"材料 (LABOR + LABOR_VOUCHER) + 参会人信息在 OSS 端打 zip, 后端代理改名下发.
* 仅 admin/manager 可触发 (前端 manager/meetings 列表操作栏按钮).
* 文件名: 项目编号_项目名称_第N期_劳务.zip
*/
@GetMapping("/{meetingId}/downloadLaborZip")
public AjaxResult downloadLaborZip(@PathVariable("meetingId") Long meetingId) {
public void downloadLaborZip(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载劳务材料");
}
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildLaborZipUrl(meetingId));
String url = bizMeetingMaterialService.buildLaborZipUrl(meetingId);
streamZip(url, bizMeetingMaterialService.laborZipFilename(meetingId), response);
}
/**
* 批量会务下载: 多会议会务材料合并打一个 zip, 返回下载 URL.
* 批量会务下载: 多会议会务材料合并打一个 zip, 后端代理改名下发.
* body: { "meetingIds": [1,2,3] }, 仅 admin/manager.
* 文件名: 会务_下载时间.zip
*/
@PostMapping("/batchDownloadZip")
public AjaxResult batchDownloadZip(@RequestBody(required = false) BatchDownloadBody body) {
public void batchDownloadZip(@RequestBody(required = false) BatchDownloadBody body, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载会务材料");
}
List<Long> meetingIds = body == null ? null : body.getMeetingIds();
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildBatchServiceZipUrl(meetingIds));
String url = bizMeetingMaterialService.buildBatchServiceZipUrl(meetingIds);
streamZip(url, bizMeetingMaterialService.batchServiceZipFilename(), response);
}
/**
* 批量劳务下载: 多会议劳务材料(含参会人信息)合并打一个 zip, 返回下载 URL.
* 批量劳务下载: 多会议劳务材料(含参会人信息)合并打一个 zip, 后端代理改名下发.
* body: { "meetingIds": [1,2,3] }, 仅 admin/manager.
* 文件名: 劳务_下载时间.zip
*/
@PostMapping("/batchDownloadLaborZip")
public AjaxResult batchDownloadLaborZip(@RequestBody(required = false) BatchDownloadBody body) {
public void batchDownloadLaborZip(@RequestBody(required = false) BatchDownloadBody body, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载劳务材料");
}
List<Long> meetingIds = body == null ? null : body.getMeetingIds();
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildBatchLaborZipUrl(meetingIds));
String url = bizMeetingMaterialService.buildBatchLaborZipUrl(meetingIds);
streamZip(url, bizMeetingMaterialService.batchLaborZipFilename(), response);
}
/** 从 FC 打包返回的签名 URL 拉取 zip 字节流, 以自定义文件名写回 response (Content-Disposition 走 UTF-8 编码) */
private void streamZip(String url, String filename, HttpServletResponse response) throws Exception {
response.setContentType("application/zip");
FileUtils.setAttachmentResponseHeader(response, filename);
try (InputStream in = ossZipService.openZipStream(url)) {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) {
response.getOutputStream().write(buf, 0, n);
}
response.getOutputStream().flush();
}
}
/**
@@ -167,7 +198,10 @@ public class BizMeetingMaterialController extends BaseController {
public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
int updated = bizMeetingMaterialService.uploadServiceMaterials(file, meetingId);
bizMeetingService.markFeeCalcPending(meetingId);
// 仅当真有材料被替换 (内容变化, updated>0) 才立即重算会务费; 全量未变不置"统计中"
if (updated > 0) {
bizMeetingService.recomputeMeetingFee(meetingId);
}
return success(updated);
}
@@ -0,0 +1,71 @@
package com.ruoyi.business.controller;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.mapper.SysUserMapper;
/**
* 会议阶段统计端点 — 独立 Controller (仅 sponsor/Home KPI 用).
*
* <p>独立成 Controller 的原因: BizMeetingController 有 `GET /{meetingId}` 详情端点,
* Spring 6.x 会把同 Controller 内的字面量 `/stageStats` 路由到 `/{meetingId}` 上
* (报 "参数类型不匹配"). 拆到独立 Controller 后两条路径不在同一个映射表竞争, 零冲突.
*/
@RestController
@RequestMapping("/business/meeting")
public class BizMeetingStageStatsController extends BaseController
{
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired
private SysUserMapper sysUserMapper;
/**
* 按 current_stage 分组计数, 只做 sponsor 数据权限隔离 (MAIN=本支持单位全部, SUB=自己负责的项目).
* 返回 { stage: cnt, ... }, 前端据此算: 未执行=NOT_STARTED, 已执行=排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN).
* 其他角色的统计端点单独实现, 此端点非 sponsor 调用返回空 map (不泄露全量数据).
*/
@GetMapping("/stageStats")
public AjaxResult stageStats() {
Map<String, Long> result = new HashMap<>();
if (!"sponsor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
return success(result);
}
BizMeeting q = new BizMeeting();
Long uid = SecurityUtils.getUserId();
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) {
q.getParams().put("monitorUserId", uid);
} else {
q.getParams().put("sponsorAdminUserId", uid);
}
for (Map<String, Object> r : bizMeetingService.selectStageStats(q)) {
// resultType=map 的 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0"
String stage = null;
long cnt = 0L;
for (Map.Entry<String, Object> e : r.entrySet()) {
String k = e.getKey();
if (k == null) continue;
if (k.equalsIgnoreCase("currentStage") || k.equalsIgnoreCase("stage")) {
stage = e.getValue() == null ? null : String.valueOf(e.getValue());
} else if (k.equalsIgnoreCase("cnt") || k.equalsIgnoreCase("count")) {
cnt = e.getValue() == null ? 0L : ((Number) e.getValue()).longValue();
}
}
if (stage != null) {
result.put(stage, cnt);
}
}
return success(result);
}
}
@@ -40,20 +40,34 @@ public class BizMessageController extends BaseController
/**
* 当前登录用户的最近消息 (含未读统计)
* GET /business/message/my?limit=50
* 返回 {rows: [...], unread: 12}
* unread 用 countUnread 跟 limit 解耦, SSE 推的也是真值
* 两种模式:
* - 分页: GET /business/message/my?pageNum=1&pageSize=20 (返回真实 total)
* - 快捷: GET /business/message/my?limit=50 (嵌入式列表, total=rows.size)
* 返回 {rows: [...], unread: 12, total: n}
* unread 用 countUnread 跟分页解耦, SSE 推的也是真值
*/
@GetMapping("/my")
public AjaxResult my(@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit)
public AjaxResult my(@RequestParam(value = "pageNum", required = false) Integer pageNum,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "limit", required = false) Integer limit)
{
Long uid = SecurityUtils.getUserId();
List<BizMessage> rows = bizMessageService.selectMyRecent(uid, limit);
List<BizMessage> rows;
int total;
if (pageNum != null && pageSize != null && pageNum > 0 && pageSize > 0) {
int offset = (pageNum - 1) * pageSize;
rows = bizMessageService.selectMyPage(uid, offset, pageSize);
total = bizMessageService.countMy(uid);
} else {
int lim = (limit == null || limit <= 0) ? 50 : limit;
rows = bizMessageService.selectMyRecent(uid, lim);
total = rows.size();
}
int unread = bizMessageService.countUnread(uid);
Map<String, Object> data = new HashMap<>();
data.put("rows", rows);
data.put("unread", unread);
data.put("total", rows.size());
data.put("total", total);
return success(data);
}
@@ -15,6 +15,7 @@ import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.domain.BizPersonExecutorImportVO;
import com.ruoyi.business.domain.BizPersonImportVO;
import com.ruoyi.business.mapper.BizOrgMapper;
import com.ruoyi.business.service.IBizPersonService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
@@ -33,6 +34,9 @@ public class BizPersonController extends BaseController
@Autowired
private SysUserMapper sysUserMapper;
@Autowired
private BizOrgMapper bizOrgMapper;
/**
* 业务角色白名单: 这些 roleType 的 MAIN 账号调 /list 会自动按"本机构子账号"隔离
* (sys_user.parent_user_id → sys_user.user_id → biz_person.user_id 链路过滤)
@@ -69,8 +73,9 @@ public class BizPersonController extends BaseController
@GetMapping("/sponsorList")
public TableDataInfo sponsorList(BizPerson bizPerson)
{
Long mainUid = getUserId();
bizPerson.getParams().put("sponsorOwnerUid", mainUid);
// 严格按 org_id 圈本机构所有用户 (MAIN + SUB), 而非 parent_user_id 归属
Long orgId = bizOrgMapper.selectOrgIdByUserId(getUserId());
bizPerson.getParams().put("sponsorOrgId", orgId);
startPage();
List<BizPerson> list = bizPersonService.selectSponsorList(bizPerson);
return getDataTable(list);
@@ -82,8 +87,9 @@ public class BizPersonController extends BaseController
@GetMapping("/executorList")
public TableDataInfo executorList(BizPerson bizPerson)
{
Long mainUid = getUserId();
bizPerson.getParams().put("executorOwnerUid", mainUid);
// 严格按 org_id 圈本机构所有用户 (MAIN + SUB), 而非 parent_user_id 归属
Long orgId = bizOrgMapper.selectOrgIdByUserId(getUserId());
bizPerson.getParams().put("executorOrgId", orgId);
startPage();
List<BizPerson> list = bizPersonService.selectExecutorList(bizPerson);
return getDataTable(list);
@@ -114,6 +120,17 @@ public class BizPersonController extends BaseController
bizPerson.setUpdateBy(getUsername());
return toAjax(bizPersonService.updateByPrimaryKey(bizPerson));
}
/**
* 个人资料编辑 (sponsor/executor 账号管理页): 只改当前登录人自己的姓名/手机号
* 按 user_id 反查 personId, 同步写 biz_person.name + sys_user.nick_name (单一可信源)
*/
@Log(title = "个人资料", businessType = BusinessType.UPDATE)
@PutMapping("/profile")
public AjaxResult updateProfile(@RequestBody BizPerson bizPerson)
{
bizPerson.setUserId(getUserId());
return toAjax(bizPersonService.updateProfileByUserId(bizPerson));
}
/**
* 更换机构管理员 (admin/sponsor-people 管理员 switch)
* body: { personId } — 把该人员晋升为机构 MAIN, 原管理员降为 SUB
@@ -252,7 +252,15 @@ public class BizProjectController extends BaseController
@GetMapping("/{projectId}/assigns")
public AjaxResult getAssigns(@PathVariable("projectId") Long projectId)
{
return success(bizProjectAssignService.selectByProjectId(projectId));
List<BizProjectAssign> list = bizProjectAssignService.selectByProjectId(projectId);
// 执行方只看本单位 (严格隔离, 不依赖前端): 过滤掉其它执行单位
if ("executor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
Long myOrgId = bizOrgService.selectOrgIdByUserId(SecurityUtils.getUserId());
if (myOrgId != null) {
list.removeIf(a -> a.getExecutionUnitId() == null || !a.getExecutionUnitId().equals(myOrgId));
}
}
return success(list);
}
@Log(title = "项目执行方分配", businessType = BusinessType.INSERT)
@@ -350,46 +358,77 @@ public class BizProjectController extends BaseController
}
/**
* 通用评分 upsert (任何角色: sponsor / executor / compliance)
* 评分 upsert
* POST /business/project/rate
* 不做可见性校验 — 评分公开
* 评分写入 biz_project_rating 后, 同步回写 biz_project.manager_score
* = 该项目所有 compliance 角色评分的 4 维度总分之平均 (decimal(3,1) 1 位小数)
* 评分公开可读; 写入仅限 admin/manager → 'manager', sponsor → 'sponsor'
* raterRole 由后端按登录人 role_type 派生, 不信任前端 (杜绝医生/执行方伪造评分)
* 评分写入 biz_project_rating 后, 同步回写聚合分:
* manager → biz_project.manager_score
* sponsor → biz_project.sponsor_score
* 聚合口径 = 该角色所有评分记录 4 个维度值的平均 (decimal(3,1) 1 位小数), 多人评分取平均而非最后一次覆盖
*/
@Log(title = "项目评分", businessType = BusinessType.UPDATE)
@PostMapping("/rate")
public AjaxResult rate(@RequestBody BizProjectRating body)
{
if (body.getProjectId() == null || body.getRaterRole() == null) {
return error("projectId / raterRole 必填");
if (body.getProjectId() == null) {
return error("projectId 必填");
}
// 评分人以当前登录用户为准, 不信任前端传入的 raterId
body.setRaterId(SecurityUtils.getUserId());
body.setCreateBy(SecurityUtils.getUsername());
body.setUpdateBy(SecurityUtils.getUsername());
// raterRole 由登录人真实角色派生 (role_type 单一可信源), 杜绝医生/执行方伪造 manager/sponsor 评分
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
String raterRole;
if ("admin".equals(roleType) || "manager".equals(roleType)) {
raterRole = "manager";
}
else if ("sponsor".equals(roleType)) {
raterRole = "sponsor";
}
else {
return error("当前角色不可评分");
}
body.setRaterRole(raterRole);
int rows = bizProjectRatingService.upsertRating(body);
// 同步回写 biz_project.manager_score (仅 compliance 角色聚合)
if ("compliance".equals(body.getRaterRole())) {
BizProjectRating q = new BizProjectRating();
q.setProjectId(body.getProjectId());
q.setRaterRole("compliance");
List<BizProjectRating> all = bizProjectRatingService.selectList(q);
BigDecimal sum = BigDecimal.ZERO;
int cnt = 0;
for (BizProjectRating r : all) {
long s = safeLong(r.getQualityScore()) + safeLong(r.getResponseScore())
+ safeLong(r.getCooperationScore()) + safeLong(r.getComplianceScore());
if (s > 0) { sum = sum.add(BigDecimal.valueOf(s)); cnt++; }
}
BizProject p = new BizProject();
p.setProjectId(body.getProjectId());
p.setManagerScore(cnt == 0 ? null : sum.divide(BigDecimal.valueOf(cnt), 1, RoundingMode.HALF_UP));
bizProjectService.updateByPrimaryKey(p); // mapper `<if test="managerScore != null">` 仅更新这一列
}
// 写明细后重算聚合分 (聚合分只由后端从明细算, 前端不再手写单值)
recomputeScore(body.getProjectId(), body.getRaterRole());
return toAjax(rows);
}
/**
* 从 biz_project_rating 实时重算某项目某角色的聚合分 (4 维度平均, 覆盖该角色所有评分人)
* 聚合 = 该角色所有评分记录 4 维度值之和 / (记录数 × 4)
*/
private void recomputeScore(Long projectId, String raterRole)
{
BizProjectRating q = new BizProjectRating();
q.setProjectId(projectId);
q.setRaterRole(raterRole);
List<BizProjectRating> all = bizProjectRatingService.selectList(q);
BigDecimal sum = BigDecimal.ZERO;
int cnt = 0;
for (BizProjectRating r : all) {
long s = safeLong(r.getQualityScore()) + safeLong(r.getResponseScore())
+ safeLong(r.getCooperationScore()) + safeLong(r.getComplianceScore());
if (s > 0) { sum = sum.add(BigDecimal.valueOf(s)); cnt++; }
}
if (cnt == 0) return; // 无有效评分, 不动聚合分
BigDecimal avg = sum.divide(BigDecimal.valueOf(cnt * 4L), 1, RoundingMode.HALF_UP);
BizProject p = new BizProject();
p.setProjectId(projectId);
if ("sponsor".equals(raterRole)) {
p.setSponsorScore(avg);
} else {
p.setManagerScore(avg);
}
bizProjectService.updateByPrimaryKey(p); // mapper `<if test="...Score != null">` 仅更新对应列
}
private static long safeLong(Long v) { return v == null ? 0 : v; }
/**
@@ -17,7 +17,7 @@ import com.ruoyi.business.service.IBizProjectPlanService;
*
* 角色权限:
* - 投稿角色 (doctor/executor/sponsor): 只看自己投的稿 (submitter_id = 当前用户); 新建/编辑强制 submitter_id 写自己, status 默认 '0'
* - 管理角色 (admin/manager): 全部可见, 不强制 submitter (用于审核/结算)
* - 管理角色 (admin/manager): 排除未提交草稿 (status='0'), 不强制 submitter (用于审核/结算)
*/
@RestController
@RequestMapping("/business/projectPlan")
@@ -32,6 +32,9 @@ public class BizProjectPlanController extends BaseController
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (isSubmitterRole(roleType)) {
BizProjectPlan.setSubmitterId(SecurityUtils.getUserId());
} else {
// 管理角色 (admin/manager): 未提交的草稿 (status='0') 不进列表, 只审已提交的稿
BizProjectPlan.getParams().put("excludeDraft", true);
}
startPage();
List<BizProjectPlan> list = BizProjectPlanService.selectList(BizProjectPlan);
@@ -232,7 +232,6 @@ public class BizPublicityIntentController extends BaseController {
v.setPosition(e.getPosition());
v.setPhone(e.getPhone());
v.setUserStatus(e.getUserId() != null ? "存在" : "不存在");
v.setIntentStatus(e.getIntentStatus() == null || e.getIntentStatus().isEmpty() ? "待审核" : e.getIntentStatus());
v.setCreateTime(e.getCreateTime());
exportList.add(v);
}
@@ -259,7 +258,6 @@ public class BizPublicityIntentController extends BaseController {
v.setPosition(e.getPosition());
v.setPhone(e.getPhone());
v.setUserStatus(e.getUserId() != null ? "存在" : "不存在");
v.setIntentStatus(e.getIntentStatus() == null || e.getIntentStatus().isEmpty() ? "待审核" : e.getIntentStatus());
v.setCreateTime(e.getCreateTime());
exportList.add(v);
}
@@ -66,6 +66,8 @@ public class BizExpert extends BaseEntity {
private String bankCard;
/** 银行名称 */
private String bankName;
/** 开户行 (支行) */
private String bankBranch;
/** 开户行省/市 (1-3 段, / 分隔, 直辖市省=市) */
private String bankRegion;
/** 开户行地址 */
@@ -120,6 +122,8 @@ public class BizExpert extends BaseEntity {
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankBranch() { return bankBranch; }
public void setBankBranch(String bankBranch) { this.bankBranch = bankBranch; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
@@ -118,10 +118,14 @@ public class BizMeeting extends BaseEntity {
private String laborSigned;
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
private transient Long userId;
/** 会议列表筛选: current_stage NOT IN (非持久化, 逗号分隔; 前端 sponsor Home "已执行会议"跳转传 'NOT_STARTED,IN_PROGRESS') */
private transient String currentStageNotIn;
/** 当前登录医生/专家在本会议的参会人记录 id (非持久化, mapper 子查询填充; 用于 /doctor/meetings 签署劳务链接) */
private transient Long attendeeId;
/** 当前登录医生/专家在本会议的已签劳务 PDF URL (非持久化, mapper 子查询填充; null=未签) */
private transient String attendeeLaborProtocol;
/** 分配给本执行方的场次 (非持久化; executor 会议详情用, controller 按登录执行方反查 biz_project_assign.sessions 之和, 作为"期数"分母/总期数) */
private transient Long assignedSessions;
/** 新增/编辑会议时传入, 自动批量写入 biz_meeting_attendee 中间表 (非持久化) */
private Long[] attendeeUserIds;
/** 劳务费用 = 参会人应发金额 (fee_pre_tax) 合计 (后台定时任务汇总回写) */
@@ -223,10 +227,14 @@ public class BizMeeting extends BaseEntity {
public void setSubmitDeadline(Date submitDeadline) { this.submitDeadline = submitDeadline; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getCurrentStageNotIn() { return currentStageNotIn; }
public void setCurrentStageNotIn(String currentStageNotIn) { this.currentStageNotIn = currentStageNotIn; }
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 Long getAssignedSessions() { return assignedSessions; }
public void setAssignedSessions(Long assignedSessions) { this.assignedSessions = assignedSessions; }
public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
public Long[] getAttendeeUserIds() { return attendeeUserIds; }
@@ -62,6 +62,8 @@ public class BizMeetingAttendee extends BaseEntity {
private transient Date endTime;
private transient String projectName;
private transient String projectNo;
/** 该参会人是否已报名该项目 (biz_execution_intent 存在 user_id + project_no): 1已报名 0未报名; 会议详情参会人列表用, 未报名姓名标红 */
private transient Integer hasIntent;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@@ -131,6 +133,8 @@ public class BizMeetingAttendee extends BaseEntity {
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public Integer getHasIntent() { return hasIntent; }
public void setHasIntent(Integer hasIntent) { this.hasIntent = hasIntent; }
/** 软删除标记 0否1是 (admin 删除会议时级联置1, 查询需 WHERE is_deleted=0) */
private Integer isDeleted;
public Integer getIsDeleted() { return isDeleted; }
@@ -45,6 +45,8 @@ public class BizPerson extends BaseEntity {
private Date updateTime;
/** 关联系统用户ID */
private Long userId;
/** 是否供应商同步过来的用户 (1=是, 0=否) */
private Integer isSynced;
/** 启停状态 '0'/'1' (前端 toggle 用, BizPersonServiceImpl 同步到 sys_user.status) - 非持久化字段 */
@com.fasterxml.jackson.annotation.JsonProperty("status")
private transient String status;
@@ -94,6 +96,8 @@ public class BizPerson extends BaseEntity {
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Integer getIsSynced() { return isSynced; }
public void setIsSynced(Integer isSynced) { this.isSynced = isSynced; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getUnitType() { return unitType; }
@@ -63,6 +63,9 @@ public class BizProjectPlan extends BaseEntity {
@Excel(name = "update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
/** 提交时间 (状态→待审核 '1' 时写入, 列表按此倒序) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date submitTime;
/** 审核意见 */
private String auditOpinion;
/** 审核人 */
@@ -115,6 +118,8 @@ public class BizProjectPlan extends BaseEntity {
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public Date getSubmitTime() { return submitTime; }
public void setSubmitTime(Date submitTime) { this.submitTime = submitTime; }
public String getAuditOpinion() { return auditOpinion; }
public void setAuditOpinion(String auditOpinion) { this.auditOpinion = auditOpinion; }
public String getAuditBy() { return auditBy; }
@@ -38,10 +38,7 @@ public class BizPublicityExecutionIntentExportVo {
@Excel(name = "账号状态", sort = 8)
private String userStatus;
@Excel(name = "审核状态", sort = 9)
private String intentStatus;
@Excel(name = "创建时间", sort = 10, dateFormat = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "创建时间", sort = 9, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@@ -61,8 +58,6 @@ public class BizPublicityExecutionIntentExportVo {
public void setPhone(String phone) { this.phone = phone; }
public String getUserStatus() { return userStatus; }
public void setUserStatus(String userStatus) { this.userStatus = userStatus; }
public String getIntentStatus() { return intentStatus; }
public void setIntentStatus(String intentStatus) { this.intentStatus = intentStatus; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -38,10 +38,7 @@ public class BizPublicitySupportIntentExportVo {
@Excel(name = "账号状态", sort = 8)
private String userStatus;
@Excel(name = "审核状态", sort = 9)
private String intentStatus;
@Excel(name = "创建时间", sort = 10, dateFormat = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "创建时间", sort = 9, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@@ -61,8 +58,6 @@ public class BizPublicitySupportIntentExportVo {
public void setPhone(String phone) { this.phone = phone; }
public String getUserStatus() { return userStatus; }
public void setUserStatus(String userStatus) { this.userStatus = userStatus; }
public String getIntentStatus() { return intentStatus; }
public void setIntentStatus(String intentStatus) { this.intentStatus = intentStatus; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -1,6 +1,7 @@
package com.ruoyi.business.mapper;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizMeeting;
@@ -11,6 +12,11 @@ public interface BizMeetingMapper
{
BizMeeting selectByPrimaryKey(Long meetingId);
List<BizMeeting> selectList(BizMeeting entity);
/**
* 会议阶段统计 (仅 sponsor/Home KPI 用): 按 current_stage 分组计数, 只套 sponsor 数据权限 (不参与分页).
* 返回 [{currentStage: 'RUNNING', cnt: 10}, ...]. 其他角色的统计另行实现, 不在此复用多角色过滤.
*/
List<Map<String, Object>> selectStageStats(BizMeeting entity);
int insert(BizMeeting entity);
int updateByPrimaryKey(BizMeeting entity);
int deleteByPrimaryKey(Long meetingId);
@@ -25,8 +31,15 @@ public interface BizMeetingMapper
int countByProjectIdAndExecutionUnit(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId);
/** 建会校验用 (执行方隔离): 统计某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) */
int countByProjectIdExecutionUnitPeriod(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId, @Param("periodNo") Long periodNo);
/** 修改校验用 (执行方隔离): 同上, 但排除指定 meetingId (修改自身会议不触发相同期数误报) */
int countByProjectIdExecutionUnitPeriodExclude(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId, @Param("periodNo") Long periodNo, @Param("excludeMeetingId") Long excludeMeetingId);
/**
* 自动流转: start_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING.
* 自动流转: start_time 已过 且 end_time 未到 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 current_stage = IN_PROGRESS (执行中).
* <p>由 MeetingStageScheduler 每分钟触发. 只写 current_stage 缓存, 不动 is_executed 事实.
*/
int markInProgress();
/**
* 自动流转: end_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING (已执行).
* <p>由 MeetingStageScheduler 每分钟触发. 事实 + current_stage 缓存一起写.
*/
int markExecuted();
@@ -40,9 +53,10 @@ public interface BizMeetingMapper
*/
List<Long> selectPendingFeeCalcIds();
/**
* 置未汇总 (人员/材料变化触发, 幂等).
* 费用重算状态机用: 直接置 fee_calc_status = #{status} (-1 计算中 / 0 待算兜底).
* <p>成功(1) 由 {@link #updateFeeSummary} 一并写入, 不单独置.
*/
int markFeeCalcPending(Long meetingId);
int updateFeeCalcStatus(@Param("meetingId") Long meetingId, @Param("status") int status);
/**
* 汇总回写 labor_fee/meeting_fee/total_fee 并置 fee_calc_status=1.
*/
@@ -50,4 +64,11 @@ public interface BizMeetingMapper
@Param("laborFee") BigDecimal laborFee,
@Param("meetingFee") BigDecimal meetingFee,
@Param("totalFee") BigDecimal totalFee);
/**
* 同步重算劳务费 (参会人增删改后立即调用): 只回写 labor_fee + total_fee,
* 不动 meeting_fee / fee_calc_status (参会人变化不影响会务费, 也不触发整体重算).
*/
int updateLaborFee(@Param("meetingId") Long meetingId,
@Param("laborFee") BigDecimal laborFee,
@Param("totalFee") BigDecimal totalFee);
}
@@ -18,6 +18,9 @@ public interface BizMessageMapper
/** 收件人未读总数 (SSE 推送用, 跟 limit 解耦) */
int countUnread(Long receiverUserId);
/** 收件人的全部消息总数 (分页 total 用, 跟未读解耦) */
int countMy(Long receiverUserId);
int insert(BizMessage entity);
int updateByPrimaryKey(BizMessage entity);
@@ -31,4 +31,6 @@ public interface BizOrgMapper {
/** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id)
* MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */
Long selectOrgIdByUserId(Long userId);
/** 单位名称查重: 同 orgType 下 org_name 精确匹配的条数 (新增单位前判重) */
int countByOrgName(BizOrg entity);
}
@@ -13,6 +13,10 @@ public interface BizPersonMapper
List<BizPerson> selectSponsorList(BizPerson entity);
/** executor 专属: 同 sponsor, SQL 硬编码 unit_type='executor' (前端绕不开) */
List<BizPerson> selectExecutorList(BizPerson entity);
/** 按 user_id 反查人员档案 (个人资料编辑: 拿 personId 再走 updateByPrimaryKey) */
BizPerson selectByUserId(Long userId);
/** 按 org_id 反查该机构主账号 user_id (sys_user.account_type='MAIN' + biz_person.org_id 绑定), 不走 biz_org.user_id (可能脏) */
Long selectMainUserIdByOrgId(Long orgId);
int insert(BizPerson entity);
int updateByPrimaryKey(BizPerson entity);
int deleteByPrimaryKey(String personId);
@@ -1,6 +1,10 @@
package com.ruoyi.business.oss;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -199,4 +203,18 @@ public class OssZipService
}
return location.replaceFirst("^http://", "https://");
}
/**
* 下载已打包好的 zip 字节流 (从 FC 返回的签名 URL 拉取).
* 后端代理改名下发用: 先拿到 zip 字节流, 再以自定义文件名写回 response (见 BizMeetingMaterialController),
* 绕开 FC 固定命名 output_1-xxx.zip 的问题.
*/
public InputStream openZipStream(String signedUrl) throws IOException
{
URI uri = URI.create(signedUrl);
URLConnection conn = uri.toURL().openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(120000);
return conn.getInputStream();
}
}
@@ -1,29 +1,25 @@
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 com.ruoyi.business.service.IBizMeetingService;
import lombok.extern.slf4j.Slf4j;
/**
* 会议费用汇总调度器 (每分钟一次, 多线程无锁).
* 会议费用汇总调度器 (每分钟一次, 多线程无锁) — 兜底通道.
* <p>
* 两态设计 (不用抢占锁): 会议 fee_calc_status (0未汇总/1已汇总) + 材料 fee_status (0未计算/1已计算).
* 主通道: 材料保存/上传、发票 OCR 完成后由 {@link IBizMeetingService#recomputeMeetingFee} 立即同步重算,
* 状态机 -1(计算中) → 1(成功) / 0(失败回滚). 本调度器只兜底扫描 fee_calc_status=0 的会议重试,
* 覆盖"立即算时发票还没 OCR 完 / 算错"留下的 0 态.
* <pre>
* 每分钟: 查 fee_calc_status=0 的会议
* → 任一材料 fee_status=0 (发票还没 OCR 完) → 跳过, 等下轮
* → 全部 fee_status=1 → SUM 汇总 labor_fee/meeting_fee/total_fee → fee_calc_status=1
* 每分钟: 查 fee_calc_status=0 的会议 → 逐个交给 recomputeMeetingFee 重算
* </pre>
* 为什么不需要锁: 汇总前已检查"材料全算完", 天然防半成品; 汇总纯 SUM 幂等, 并发重复无副作用;
* 置 1 后不再被扫到, 天然去重. 重算 = 只对 material + attendee 重新求和, 不碰 OCR.
* 为什么不需要锁: 立即算/兜底都是纯 SUM 幂等, 置 1 后不再被扫到, 天然去重; -1 期间也不会被扫 (=0 才扫).
*/
@Slf4j
@Component
@@ -32,15 +28,13 @@ public class FeeCalcScheduler
@Autowired
private BizMeetingMapper meetingMapper;
@Autowired
private BizMeetingMaterialMapper materialMapper;
@Autowired
private BizMeetingAttendeeMapper attendeeMapper;
private IBizMeetingService bizMeetingService;
@Autowired
@Qualifier("feeCalcExecutor")
private ExecutorService feeCalcExecutor;
/**
* 每分钟: 扫描 fee_calc_status=0 的会议, 并行汇总.
* 每分钟: 扫描 fee_calc_status=0 的会议, 并行兜底重算.
*/
@Scheduled(fixedRate = 60_000, initialDelay = 60_000)
public void calcFees()
@@ -52,11 +46,11 @@ public class FeeCalcScheduler
{
return;
}
log.info("[FeeCalcScheduler] 待汇总会议 {} 个", ids.size());
log.info("[FeeCalcScheduler] 待兜底汇总会议 {} 个", ids.size());
for (Long meetingId : ids)
{
if (meetingId == null) continue;
feeCalcExecutor.submit(() -> processMeeting(meetingId));
feeCalcExecutor.submit(() -> bizMeetingService.recomputeMeetingFee(meetingId));
}
}
catch (Exception e)
@@ -64,66 +58,4 @@ public class FeeCalcScheduler
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;
}
}
@@ -11,10 +11,11 @@ import lombok.extern.slf4j.Slf4j;
* <p>
* 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实
* (is_executed / is_frozen / 劳务·会务两轨 audit_stage / 审核时间 …),
* 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的类事实落地:
* 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的类事实落地:
* <pre>
* 1) start_time 到 → is_executed=1 (执行中)
* 2) submit_deadline 到 且 任一轨未提交/已退回 → is_frozen=1 (冻结)
* 1) start_time 到 → current_stage = IN_PROGRESS (执行中)
* 2) end_time 到 → is_executed=1 (已执行)
* 3) submit_deadline 到 且 任一轨未提交/已退回 → is_frozen=1 (冻结)
* </pre>
* 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围.
* <p>
@@ -28,7 +29,27 @@ public class MeetingStageScheduler
private BizMeetingMapper meetingMapper;
/**
* 每分钟: start_time 已过 且 劳务·会务两轨均未提交 且未执行 → 置执行中.
* 每分钟: start_time 已过 且 end_time 未到 且 劳务·会务两轨均未提交 且未执行 → 置执行中 (IN_PROGRESS).
*/
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markInProgress()
{
try
{
int affected = meetingMapper.markInProgress();
if (affected > 0)
{
log.info("[MeetingStageScheduler] 自动置执行中: 本次更新 {} 行", affected);
}
}
catch (Exception e)
{
log.warn("[MeetingStageScheduler] 置执行中异常 (跳过, 下分钟再试)", e);
}
}
/**
* 每分钟: end_time 已过 且 劳务·会务两轨均未提交 且未执行 → 置已执行 (is_executed=1).
*/
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markExecuted()
@@ -38,12 +59,12 @@ public class MeetingStageScheduler
int affected = meetingMapper.markExecuted();
if (affected > 0)
{
log.info("[MeetingStageScheduler] 自动置执行: 本次更新 {} 行", affected);
log.info("[MeetingStageScheduler] 自动置执行: 本次更新 {} 行", affected);
}
}
catch (Exception e)
{
log.warn("[MeetingStageScheduler] 置执行异常 (跳过, 下分钟再试)", e);
log.warn("[MeetingStageScheduler] 置执行异常 (跳过, 下分钟再试)", e);
}
}
@@ -3,27 +3,31 @@ package com.ruoyi.business.scheduler;
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.business.supplier.SupplierAccount;
import com.ruoyi.business.supplier.SupplierAccountApiCodec;
import com.ruoyi.business.supplier.SupplierAccountSyncService;
import com.ruoyi.common.utils.http.HttpUtils;
import lombok.extern.slf4j.Slf4j;
/**
* 供应商账号数据拉取调度器: 每分钟拉取最近 5 分钟更新的账号, 解密后打印.
* 供应商账号数据拉取调度器: 每分钟拉取最近 N 分钟更新的账号, 解密后同步到执行方侧.
* <p>
* 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts}
* 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回.
* 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密.
* <p>
* 说明: 只打印不落库 (后续需要持久化时再扩展).
* 解密后按邮箱 upsert 到 sys_user / biz_org / biz_person (见 {@link SupplierAccountSyncService}).
*/
@Slf4j
@Component
@@ -44,6 +48,9 @@ public class SupplierAccountPullScheduler
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private SupplierAccountSyncService supplierAccountSyncService;
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void pullAccounts()
{
@@ -87,8 +94,14 @@ public class SupplierAccountPullScheduler
int size = rows.isArray() ? rows.size() : 0;
fetched += size;
// 只打印即可: 整页明文 JSON 打出来 (供观察/后续落库)
log.info("[SupplierAccountPull] page={} total={} 本页={} 明文: {}", pageNum, total, size, plain);
// 同步到执行方 sys_user / biz_org / biz_person (按邮箱 upsert)
if (size > 0)
{
List<SupplierAccount> accounts =
objectMapper.convertValue(rows, new TypeReference<List<SupplierAccount>>() {});
supplierAccountSyncService.sync(accounts);
}
log.info("[SupplierAccountPull] page={} total={} 本页={} 同步完成", pageNum, total, size);
if (size == 0 || fetched >= total)
{
@@ -28,6 +28,15 @@ public interface IBizMeetingMaterialService {
*/
List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list);
/**
* 判断保存的材料集合相对库里是否有实际变化 (增/删 subType, 或同 subType 的 ossUrl 变化).
* <p>
* 用于"保存"按钮: 会务材料没变时不应把会议费用置"统计中" (fee_calc_status=0),
* 避免无谓的汇总重算与前端"统计中"闪烁.
* 必须在 {@link #replaceByMeetingId} 之前调用 (后者会先删旧记录).
*/
boolean isMaterialSetChanged(Long meetingId, List<BizMeetingMaterial> list);
/**
* 单条更新 amount (OCR 识别为发票后回写).
* 不动其他字段, 不抛异常 (失败仅 log).
@@ -81,6 +90,18 @@ public interface IBizMeetingMaterialService {
*/
String buildBatchLaborZipUrl(List<Long> meetingIds);
/** 单个会务下载文件名 (项目编号_项目名称_第N期_会务.zip) */
String serviceZipFilename(Long meetingId);
/** 单个劳务下载文件名 (项目编号_项目名称_第N期_劳务.zip) */
String laborZipFilename(Long meetingId);
/** 批量会务下载文件名 (会务_下载时间.zip) */
String batchServiceZipFilename();
/** 批量劳务下载文件名 (劳务_下载时间.zip) */
String batchLaborZipFilename();
/**
* 生成"会务材料"空目录模板 zip (会务材料 tab "打包上传" 的"下载目录"按钮).
* <p>
@@ -1,6 +1,7 @@
package com.ruoyi.business.service;
import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizMeeting;
/**
@@ -10,6 +11,11 @@ public interface IBizMeetingService
{
BizMeeting getById(Long meetingId);
List<BizMeeting> selectList(BizMeeting entity);
/**
* 会议阶段统计 (仅 sponsor/Home KPI 用): 按 current_stage 分组计数, 只套 sponsor 数据权限.
* 返回 [{currentStage: 'RUNNING', cnt: 10}, ...]. 其他角色的统计另行实现.
*/
List<Map<String, Object>> selectStageStats(BizMeeting entity);
int insert(BizMeeting entity);
int updateByPrimaryKey(BizMeeting entity);
int deleteByPrimaryKey(Long meetingId);
@@ -28,6 +34,19 @@ public interface IBizMeetingService
int countByProjectIdAndExecutionUnit(Long projectId, Long executionUnitId);
/** 建会校验用 (执行方隔离): 统计某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) */
int countByProjectIdExecutionUnitPeriod(Long projectId, Long executionUnitId, Long periodNo);
/** 标记会议费用待重算 (人员/材料变化触发, 幂等; 由 FeeCalcScheduler 汇总回写) */
void markFeeCalcPending(Long meetingId);
/** 修改校验用 (执行方隔离): 同上, 但排除指定 meetingId (修改自身会议不触发相同期数误报) */
int countByProjectIdExecutionUnitPeriodExclude(Long projectId, Long executionUnitId, Long periodNo, Long excludeMeetingId);
/**
* 立即重算会议费用 (会务材料保存/上传、发票 OCR 完成后同步触发, 不再等 FeeCalcScheduler 每分钟扫).
* <p>状态机: -1(计算中) → 算成功 1 / 材料发票仍未 OCR 完或异常 回滚 0 走 FeeCalcScheduler 兜底.
* 全程一个事务, 失败不落 -1 残留.
*/
void recomputeMeetingFee(Long meetingId);
/**
* 同步重算劳务费 (参会人增删改后立即调用).
* <p>labor_fee = 参会人应发金额之和, 纯 DB 求和不依赖 OCR, 可当场算;
* total_fee = labor_fee + 当前 meeting_fee. 不动 meeting_fee / fee_calc_status
* (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器).
*/
void recomputeLaborFee(Long meetingId);
}
@@ -15,6 +15,12 @@ public interface IBizMessageService
/** 收件人的最近消息 (含未读) */
List<BizMessage> selectMyRecent(Long receiverUserId, Integer limit);
/** 收件人的分页消息 (offset/limit, 未读在前) */
List<BizMessage> selectMyPage(Long receiverUserId, int offset, int pageSize);
/** 收件人的全部消息总数 (分页 total) */
int countMy(Long receiverUserId);
/** 收件人未读总数 (SSE 推送用) */
int countUnread(Long receiverUserId);
@@ -21,6 +21,8 @@ public interface IBizPersonService
List<BizPerson> selectExecutorList(BizPerson entity);
SysUser insert(BizPerson entity, Long mainUserId);
int updateByPrimaryKey(BizPerson entity);
/** 个人资料编辑: 按 user_id 反查 personId 后走 updateByPrimaryKey (同步 nick_name/phonenumber/email) */
int updateProfileByUserId(BizPerson entity);
int deleteByPrimaryKey(String personId);
int deleteByPrimaryKeys(String[] personId);
/**
@@ -1,5 +1,7 @@
package com.ruoyi.business.service;
import java.util.Date;
import org.springframework.stereotype.Component;
import com.ruoyi.business.domain.BizMeeting;
@@ -50,6 +52,8 @@ public class StageDeriver
* 10 值物理阶段 (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/
* SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN), 由事实推导.
* 两轨取最小进度 (流程最靠前的一轨决定会议物理态), 用于 current_stage 缓存 (列表筛选按物理态精确匹配).
* <p>注意: IN_PROGRESS(执行中) 是时间窗口态, 由 MeetingStageScheduler 在 start_time 到点直接写 current_stage,
* 不在此处由事实推导 (本函数只在已执行后的材料动作里被调, 那时早已越过该窗口).
*/
public String derivePhysicalStage(BizMeeting m)
{
@@ -85,7 +89,7 @@ public class StageDeriver
if (t(m.getIsSettled())) return "已结算";
int chosen = chooseState(role, laborState(m), serviceState(m));
return render(role, chosen, t(m.getIsExecuted()));
return render(role, chosen, executionPhase(m));
}
/** 按角色优先级选代表轨 (两轨中优先级更高的那轨). */
@@ -130,15 +134,16 @@ public class StageDeriver
}
/** 单轨措辞 (代表轨状态 + 角色 → 展示名). */
private static String render(String role, int s, boolean executed)
private static String render(String role, int s, int phase)
{
switch (s)
{
case 0: // R 退回
return "executor".equals(role) ? "已退回" : "待整改";
case 1: // N 未提交
if (!executed) return "未执行";
return "executor".equals(role) ? "执行中" : "执行未传材料";
case 1: // N 未提交 (时间驱动三态: 未执行 → 执行中 → 已执行, 所有角色统一)
if (phase == 0) return "未执行";
if (phase == 1) return "执行";
return "已执行";
case 2: // C0 合规审中
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读
return "待审核"; // executor / manager / admin
@@ -149,4 +154,19 @@ public class StageDeriver
return "待结算";
}
}
/**
* 执行进度三态: 0=未执行 (now &lt; start_time), 1=执行中 (start_time ≤ now &lt; end_time),
* 2=已执行 (now ≥ end_time 或 is_executed=1). 仅用于材料未提交 (N) 的展示措辞.
* <p>is_executed=1 是 scheduler 在 end_time 到点落库的「已执行」事实, 优先采信;
* 未落库前用 start_time/end_time 现场判断.
*/
private static int executionPhase(BizMeeting m)
{
if (t(m.getIsExecuted())) return 2;
Date now = new Date();
if (m.getEndTime() != null && !m.getEndTime().after(now)) return 2;
if (m.getStartTime() != null && !m.getStartTime().after(now)) return 1;
return 0;
}
}
@@ -108,10 +108,23 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
return u;
}
/** admin / manager: 纯 sys_user */
/** admin: 纯 sys_user; manager: sys_user + biz_person (unit_type='manager', 让 biz_project.create_user_name 反查得到姓名) */
private SysUser insertPlain(AdminUserCreateBody b, String role, String encPwd) {
SysUser u = baseUser(b, role, encPwd);
sysUserService.insertUser(u);
if ("manager".equals(role)) {
BizPerson p = new BizPerson();
SnowflakeId.injectIfEmpty(p, "personId");
p.setName(u.getNickName() == null || u.getNickName().isEmpty() ? "用户" : u.getNickName());
p.setPhone(b.getPhonenumber() == null ? "" : b.getPhonenumber());
p.setDepartment("合规部");
p.setPosition("合规员");
p.setUnitType("manager");
p.setUserId(u.getUserId());
p.setCreateBy(SecurityUtils.getUsername());
p.setUpdateBy(SecurityUtils.getUsername());
bizPersonMapper.insert(p);
}
return u;
}
@@ -170,7 +183,7 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
BizPerson self = new BizPerson();
SnowflakeId.injectIfEmpty(self, "personId");
self.setName(contactName);
self.setName(u.getNickName());
self.setPhone(contactPhone);
self.setOrgId(org.getOrgId());
self.setDepartment("管理部");
@@ -195,13 +208,18 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
if (!role.equals(org.getOrgType())) {
throw new ServiceException("所选单位类型与角色不匹配");
}
// 主账号 user_id: 按 biz_person.org_id + sys_user.account_type='MAIN' 反查, 不取 biz_org.user_id (可能脏/过期)
Long mainUserId = bizPersonMapper.selectMainUserIdByOrgId(b.getOrgId());
if (mainUserId == null) {
throw new ServiceException("该单位暂无主账号,请先创建主账号");
}
if (b.getNickName() == null || b.getNickName().isEmpty()) {
throw new ServiceException("姓名不能为空");
}
SysUser u = baseUser(b, role, encPwd);
u.setAccountType("SUB");
u.setParentUserId(org.getUserId()); // 主账号 user_id, 可为 null (单位暂无主账号时留空待分配)
u.setParentUserId(mainUserId); // 主账号 user_id (按 account_type='MAIN' 反查)
sysUserService.insertUser(u);
BizPerson p = new BizPerson();
@@ -121,14 +121,21 @@ public class BizExpertServiceImpl implements IBizExpertService
@Override
public int updateByPrimaryKey(BizExpert entity)
{
BizExpert existed = null;
String oldAuditStatus = null;
if (entity.getExpertId() != null) {
BizExpert existed = bizExpertMapper.selectByPrimaryKey(entity.getExpertId());
existed = bizExpertMapper.selectByPrimaryKey(entity.getExpertId());
if (existed != null) {
oldAuditStatus = existed.getAuditStatus();
}
}
int n = bizExpertMapper.updateByPrimaryKey(entity);
// 姓名变更 → 同步 sys_user.nick_name (单一可信源 nick_name, biz_expert.name 作镜像)
if (n > 0 && entity.getName() != null && !entity.getName().isEmpty()) {
Long uid = entity.getUserId() != null ? entity.getUserId()
: (existed != null ? existed.getUserId() : null);
syncNickName(uid, entity.getName());
}
if (n > 0 && entity.getAuditStatus() != null
&& !entity.getAuditStatus().equals(oldAuditStatus)) {
// 重新读一次拿 userId (entity 可能只传了 expertId+auditStatus)
@@ -174,12 +181,31 @@ public class BizExpertServiceImpl implements IBizExpertService
@Override
public int updateProfileByUserId(BizExpert entity)
{
int n;
BizExpert existed = bizExpertMapper.selectByUserId(entity.getUserId());
if (existed == null) {
return bizExpertMapper.insertWithUserId(entity);
n = bizExpertMapper.insertWithUserId(entity);
} else {
n = bizExpertMapper.updateByUserId(entity);
}
return bizExpertMapper.updateByUserId(entity);
// 姓名变更 → 同步 sys_user.nick_name
if (n > 0 && entity.getUserId() != null
&& entity.getName() != null && !entity.getName().isEmpty()) {
syncNickName(entity.getUserId(), entity.getName());
}
return n;
}
/** 同步 sys_user.nick_name (单一可信源), userId/name 为空时静默跳过 */
private void syncNickName(Long userId, String name) {
if (userId == null || name == null || name.isEmpty()) return;
SysUser u = new SysUser();
u.setUserId(userId);
u.setNickName(name);
u.setUpdateBy(SecurityUtils.getUsername());
sysUserService.updateUser(u);
}
@Override
public int deleteByPrimaryKey(Long expertId)
{ return bizExpertMapper.deleteByPrimaryKey(expertId); }
@@ -25,11 +25,13 @@ 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.BizExpert;
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.BizExpertMapper;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizProjectMapper;
@@ -61,6 +63,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
private SysUserMapper sysUserMapper;
@Autowired
private ISysUserService sysUserService;
@Autowired
private BizExpertMapper bizExpertMapper;
/** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -116,6 +120,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
}
phone = phone.trim();
// 校验实发金额不超所选角色劳务金额合计 (与手动新增弹窗 MeetingDetail.validateFee 一致;
// 无角色/无金额/项目无 role_labor 时跳过)
validateFeeAgainstRole(loadRoleLabor(body.getMeetingId()), body.getLaborForm(), body.getFee());
// 1. 按 phone 查 sys_user (单条 IN 查, selectByPhoneList 接受 List<String>)
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
Long userId;
@@ -152,6 +160,9 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
}
// 3.5 确保专家档案存在 (不存在则新建, 存在则补齐空字段), 让参会人在专家库可见
ensureExpertProfile(body, userId);
// 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增)
body.setUserId(userId);
body.setCreateBy(SecurityUtils.getUsername());
@@ -162,8 +173,71 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return newId;
}
/**
* 确保参会人对应的 biz_expert 记录存在且字段尽量完整:
* - 不存在 → 新建 (status='Y' 正常, auditStatus='2' 通过, 与 importExpert 约定一致)
* - 存在但部分字段为空 → 只用参会人档案里的非空值补齐空字段, 不覆盖已有值
*/
private void ensureExpertProfile(BizMeetingAttendee body, Long userId) {
if (userId == null) return;
BizExpert existed = bizExpertMapper.selectByUserId(userId);
if (existed == null) {
BizExpert e = new BizExpert();
e.setExpertId(IdGenerator.generateId());
e.setUserId(userId);
e.setName(isBlank(body.getName()) ? body.getPhone() : body.getName());
e.setPhone(body.getPhone());
e.setWorkUnit(body.getWorkUnit());
e.setDepartment(body.getDepartment());
e.setTitle(body.getTitle());
e.setIdCard(body.getIdCard());
e.setBankCard(body.getBankCard());
e.setBankName(body.getBankName());
e.setBankBranch(body.getBankBranch());
e.setBankRegion(body.getBankRegion());
e.setBankAddress(body.getBankAddress());
e.setIdCardAttachments(body.getIdCardAttachments());
e.setStatus("Y");
e.setAuditStatus("2");
e.setAuditBy(SecurityUtils.getUsername());
e.setAuditTime(new Date());
e.setCreateBy(SecurityUtils.getUsername());
e.setCreateTime(new Date());
bizExpertMapper.insert(e);
return;
}
// 补充: 只填 biz_expert 里为空的字段, 已有值不覆盖
BizExpert u = new BizExpert();
u.setUserId(userId);
boolean changed = false;
if (isBlank(existed.getName()) && !isBlank(body.getName())) { u.setName(body.getName()); changed = true; }
if (isBlank(existed.getPhone()) && !isBlank(body.getPhone())) { u.setPhone(body.getPhone()); changed = true; }
if (isBlank(existed.getWorkUnit()) && !isBlank(body.getWorkUnit())) { u.setWorkUnit(body.getWorkUnit()); changed = true; }
if (isBlank(existed.getDepartment()) && !isBlank(body.getDepartment())) { u.setDepartment(body.getDepartment()); changed = true; }
if (isBlank(existed.getTitle()) && !isBlank(body.getTitle())) { u.setTitle(body.getTitle()); changed = true; }
if (isBlank(existed.getIdCard()) && !isBlank(body.getIdCard())) { u.setIdCard(body.getIdCard()); changed = true; }
if (isBlank(existed.getBankCard()) && !isBlank(body.getBankCard())) { u.setBankCard(body.getBankCard()); changed = true; }
if (isBlank(existed.getBankName()) && !isBlank(body.getBankName())) { u.setBankName(body.getBankName()); changed = true; }
if (isBlank(existed.getBankBranch()) && !isBlank(body.getBankBranch())) { u.setBankBranch(body.getBankBranch()); changed = true; }
if (isBlank(existed.getBankRegion()) && !isBlank(body.getBankRegion())) { u.setBankRegion(body.getBankRegion()); changed = true; }
if (isBlank(existed.getBankAddress()) && !isBlank(body.getBankAddress())) { u.setBankAddress(body.getBankAddress()); changed = true; }
if (isBlank(existed.getIdCardAttachments()) && !isBlank(body.getIdCardAttachments())) { u.setIdCardAttachments(body.getIdCardAttachments()); changed = true; }
if (changed) {
bizExpertMapper.updateByUserId(u);
}
}
private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); }
@Override
public int updateProfile(BizMeetingAttendee entity) {
Long meetingId = entity.getMeetingId();
if (meetingId == null) {
BizMeetingAttendee old = mapper.selectById(entity.getId());
if (old != null) meetingId = old.getMeetingId();
}
// 校验实发金额不超所选角色劳务金额合计 (与手动新增弹窗 MeetingDetail.validateFee 一致)
validateFeeAgainstRole(loadRoleLabor(meetingId), entity.getLaborForm(), entity.getFee());
return mapper.updateProfile(entity);
}
@@ -304,18 +378,36 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
}
/**
* 在项目角色劳务 JSON 数组 [{role, customName, amount}] 里按角色名匹配劳务金额.
* 解析某会议的 project.role_labor JSON 数组, 失败/无数据返回 null.
* 供"实发金额 ≤ 角色设置金额"校验复用 (单条 add/edit 各自加载).
*/
private JsonNode loadRoleLabor(Long meetingId) {
if (meetingId == null) return null;
try {
BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null || meeting.getProjectId() == null) return null;
BizProject project = projectMapper.selectByPrimaryKey(meeting.getProjectId());
if (project == null || project.getRoleLabor() == null || project.getRoleLabor().trim().isEmpty()) return null;
return objectMapper.readTree(project.getRoleLabor());
} catch (Exception e) {
log.warn("[attendee] 解析项目角色劳务失败 meetingId={}", meetingId, e);
return null;
}
}
/**
* 在项目角色劳务 JSON 数组 [{role, customName, amount}] 里按单个角色名匹配劳务金额.
* 匹配规则与前端 ProjectRoleSelect 一致: role === '其他' 时用 customName 作 label, 否则用 role.
*
* @param nodes 已解析的 role_labor JSON (可为 null/非数组)
* @param laborForm 参会人填的角色名 (可为 null/空)
* @param roleLabel 单个角色名 (可为 null/空)
* @return 匹配到的 amount (BigDecimal); 没匹配到或 amount 非法 → null
*/
private BigDecimal findRoleAmount(JsonNode nodes, String laborForm) {
if (nodes == null || !nodes.isArray() || laborForm == null || laborForm.trim().isEmpty()) {
private BigDecimal matchRoleAmount(JsonNode nodes, String roleLabel) {
if (nodes == null || !nodes.isArray() || roleLabel == null || roleLabel.trim().isEmpty()) {
return null;
}
String target = laborForm.trim();
String target = roleLabel.trim();
for (JsonNode n : nodes) {
if (n == null || n.isNull()) continue;
String role = n.path("role").asText("");
@@ -334,6 +426,47 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return null;
}
/**
* 在项目角色劳务 JSON 里按角色名匹配劳务金额 (导入自动带出金额用, 单个角色).
* 保留原 findRoleAmount 语义: 单个 laborForm 精确匹配.
*/
private BigDecimal findRoleAmount(JsonNode nodes, String laborForm) {
return matchRoleAmount(nodes, laborForm);
}
/**
* 参会人角色 (laborForm, 可能逗号分隔多选) 对应的角色劳务金额合计.
* 与前端 MeetingDetail.roleAmountSum 一致: 按逗号拆分逐个匹配求和.
*
* @return 金额合计; nodes 为空/非数组 → null (无 role_labor 数据, 无法比较)
*/
private BigDecimal sumRoleAmount(JsonNode nodes, String laborForm) {
if (nodes == null || !nodes.isArray() || laborForm == null || laborForm.trim().isEmpty()) {
return null;
}
BigDecimal sum = BigDecimal.ZERO;
for (String item : laborForm.split(",")) {
BigDecimal a = matchRoleAmount(nodes, item.trim());
if (a != null) sum = sum.add(a);
}
return sum;
}
/**
* 校验实发金额(fee)不超所选角色劳务金额合计 (与前端 MeetingDetail.validateFee 一致).
* laborForm 为空 / fee 为空 / 项目无 role_labor 数据时跳过 (无"设置金额"可比较).
*/
private void validateFeeAgainstRole(JsonNode roleLaborNodes, String laborForm, BigDecimal fee) {
if (laborForm == null || laborForm.trim().isEmpty()) return;
if (fee == null) return;
BigDecimal sum = sumRoleAmount(roleLaborNodes, laborForm);
if (sum == null) return;
if (fee.compareTo(sum) > 0) {
throw new ServiceException("实发金额不能超过角色金额 "
+ sum.setScale(2, RoundingMode.HALF_UP).toPlainString() + "");
}
}
/**
* 批量导入参会人 (Excel → biz_meeting_attendee).
*
@@ -467,11 +600,12 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
}
String meetingName = m != null ? m.getMeetingName() : null;
Date startTime = m != null ? m.getStartTime() : null;
Date endTime = m != null ? m.getEndTime() : null;
// 1. 短信 (电子签模板: name=参会人姓名, date=会议日期, link=签署链接)
// 1. 短信 (邀请签署模板: name=参会人姓名, time=会议时间, attendeeId=参会人id)
String phone = a.getPhone();
if (phone != null && !phone.trim().isEmpty()) {
aliyunSmsSender.sendEsign(phone.trim(), a.getName(), startTime, aliyunSmsSender.esignLink(id));
aliyunSmsSender.sendEsign(phone.trim(), a.getName(), startTime, endTime, id);
}
// 2. 站内信 (劳务协议待签署 + 签署链接, 与短信同一链接)
bizNotifyService.esignPushed(a.getUserId(), id, mid, meetingName, aliyunSmsSender.esignLink(id));
@@ -5,12 +5,14 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.text.SimpleDateFormat;
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 java.net.URI;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -202,6 +204,45 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
return list;
}
@Override
public boolean isMaterialSetChanged(Long meetingId, List<BizMeetingMaterial> list) {
// 只关心"影响会务费"的服务类发票 (M_*): 劳务材料/凭证/现场照片等不影响会务费, 直接忽略.
// 会务费口径 (见 FeeCalcScheduler.computeMeetingFee): 有总发票 M_INVOICE 就用它, 否则各 M_* 子发票之和.
List<BizMeetingMaterial> oldList = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
Map<String, String> oldUrl = new HashMap<>();
if (oldList != null) {
for (BizMeetingMaterial o : oldList) {
if (isFeeRelevant(o.getSubType())) oldUrl.put(o.getSubType(), o.getOssUrl());
}
}
Map<String, String> newUrl = new HashMap<>();
if (list != null) {
for (BizMeetingMaterial m : list) {
if (isFeeRelevant(m.getSubType())) newUrl.put(m.getSubType(), m.getOssUrl());
}
}
boolean hasInvoice = oldUrl.containsKey("M_INVOICE") || newUrl.containsKey("M_INVOICE");
if (hasInvoice) {
// 有总发票: 会务费只看总发票, 其余子发票被覆盖 → 仅总发票变化才算变
return !Objects.equals(oldUrl.get("M_INVOICE"), newUrl.get("M_INVOICE"));
}
// 无总发票: 任一 M_* 子发票 (排除 M_INVOICE / M_SETTLEMENT 两个汇总单据) 增删或换 URL 都算变
Set<String> subs = new HashSet<>();
subs.addAll(oldUrl.keySet());
subs.addAll(newUrl.keySet());
subs.remove("M_INVOICE");
subs.remove("M_SETTLEMENT");
for (String sub : subs) {
if (!Objects.equals(oldUrl.get(sub), newUrl.get(sub))) return true;
}
return false;
}
/** 是否影响会务费: 仅服务类发票 M_* (劳务 L_*、凭证 LV/SV、结算单等都不算) */
private static boolean isFeeRelevant(String subType) {
return subType != null && subType.startsWith("M_");
}
@Override
public int updateAmount(Long materialId, BigDecimal amount) {
if (materialId == null || amount == null) return 0;
@@ -217,7 +258,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
/**
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行.
* 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底.
* 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不 markFeeCalcPending.
* 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不触发费用重算.
* extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空.
*/
@Override
@@ -309,9 +350,10 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
String prefix = "download/huiwu/" + meetingId + "/";
String name = singleZipName(meeting, "会务");
String prefix = "download/huiwu/" + name + "/";
ossZipService.clearPrefix(prefix);
int copied = stageServiceMaterials(meetingId, prefix, projectFolderName(meeting));
int copied = stageServiceMaterials(meetingId, prefix, name);
if (copied == 0) throw new ServiceException("该会议暂无可下载的会务材料");
return ossZipService.zipDownload(prefix);
}
@@ -327,9 +369,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
String prefix = "download/labor/" + meetingId + "/";
String name = singleZipName(meeting, "劳务");
String prefix = "download/labor/" + name + "/";
ossZipService.clearPrefix(prefix);
int copied = stageLaborMaterials(meetingId, prefix, projectFolderName(meeting));
int copied = stageLaborMaterials(meetingId, prefix, name);
copied += stageSchedulePoster(meeting, prefix, name);
if (copied == 0) throw new ServiceException("该会议暂无可下载的劳务材料");
return ossZipService.zipDownload(prefix);
}
@@ -343,7 +387,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
public String buildBatchServiceZipUrl(List<Long> meetingIds)
{
List<Long> ids = normalizeIds(meetingIds);
String prefix = "download/huiwu/batch/" + System.currentTimeMillis() + "/";
String prefix = "download/huiwu/batch/" + batchZipName("会务") + "/";
ossZipService.clearPrefix(prefix);
Set<String> usedFolders = new HashSet<>();
int copied = 0;
@@ -362,7 +406,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
public String buildBatchLaborZipUrl(List<Long> meetingIds)
{
List<Long> ids = normalizeIds(meetingIds);
String prefix = "download/labor/batch/" + System.currentTimeMillis() + "/";
String prefix = "download/labor/batch/" + batchZipName("劳务") + "/";
ossZipService.clearPrefix(prefix);
Set<String> usedFolders = new HashSet<>();
int copied = 0;
@@ -370,12 +414,40 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(id);
if (meeting == null) continue;
copied += stageLaborMaterials(id, prefix, uniqueFolder(usedFolders, batchMeetingFolderName(meeting)));
String folder = uniqueFolder(usedFolders, batchMeetingFolderName(meeting));
copied += stageLaborMaterials(id, prefix, folder);
copied += stageSchedulePoster(meeting, prefix, folder);
}
if (copied == 0) throw new ServiceException("所选会议暂无可下载的劳务材料");
return ossZipService.zipDownload(prefix);
}
// ===================================================================
// 下载文件名 (FC 固定命名 output_1-xxx.zip, 需后端代理改名下发)
// ===================================================================
@Override
public String serviceZipFilename(Long meetingId)
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
return singleZipName(meeting, "会务") + ".zip";
}
@Override
public String laborZipFilename(Long meetingId)
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
return singleZipName(meeting, "劳务") + ".zip";
}
@Override
public String batchServiceZipFilename() { return batchZipName("会务") + ".zip"; }
@Override
public String batchLaborZipFilename() { return batchZipName("劳务") + ".zip"; }
/** 收集该会议会务材料并 copy 到 staging 前缀 prefix + folderName 下, 返回 copy 的文件数 (0 = 无会务材料) */
private int stageServiceMaterials(Long meetingId, String prefix, String folderName)
{
@@ -432,16 +504,38 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
return copied;
}
/** 会议 zip 顶层目录名: 项目名 (为空回退项目编号, 再回退会议ID), 统一消毒 */
private static String projectFolderName(BizMeeting meeting)
/** 会议日程海报 (biz_meeting.schedule_url) staging 到 zip 的"日程海报"目录, 返回写入的文件数 (0 = 无日程海报) */
private int stageSchedulePoster(BizMeeting meeting, String prefix, String folderName)
{
String name = meeting.getProjectName();
if (name == null || name.trim().isEmpty())
if (meeting == null || meeting.getScheduleUrl() == null || meeting.getScheduleUrl().isEmpty()) return 0;
String srcKey = ossZipService.extractKey(meeting.getScheduleUrl());
if (srcKey == null || srcKey.isEmpty()) return 0;
String ext = extOf(srcKey);
String dstKey = prefix + folderName + "/日程海报/日程海报" + ext;
ossZipService.copyObject(srcKey, dstKey);
return 1;
}
/** 单会议下载命名 (zip 文件名 + 顶层目录): 项目编号_项目名称_第N期_{suffix} (缺项跳过, 全空回退会议ID), 统一消毒 */
private static String singleZipName(BizMeeting meeting, String suffix)
{
StringBuilder sb = new StringBuilder();
appendPart(sb, meeting.getProjectNo());
appendPart(sb, meeting.getProjectName());
if (meeting.getPeriodNo() != null)
{
name = (meeting.getProjectNo() != null && !meeting.getProjectNo().trim().isEmpty())
? meeting.getProjectNo() : "会议" + meeting.getMeetingId();
appendPart(sb, "" + meeting.getPeriodNo() + "");
}
return safeName(name);
String base = sb.toString();
if (base.isEmpty()) base = "会议" + meeting.getMeetingId();
return safeName(base) + "_" + suffix;
}
/** 批量下载命名 (zip 文件名): {suffix}_下载时间 (yyyyMMdd_HHmmss) */
private static String batchZipName(String suffix)
{
String time = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
return suffix + "_" + time;
}
/** 批量 zip 每个会议顶层目录名: 项目编号_会议名_第N期 (缺项跳过, 全空回退会议ID) */
@@ -586,24 +680,33 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
if (files == null || files.isEmpty()) continue;
List<String> names = folderNames.get(subType);
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
BizMeetingMaterial old = existingBySubType.get(subType);
String ossUrl;
// 先算好要上传的字节 + 文件名 (单文件原样 / 多文件先打 zip), 便于做"内容未变"判断
String fileName;
byte[] toUpload;
boolean isZip = files.size() > 1;
if (files.size() == 1) {
fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg");
ossUrl = ossUploader.upload(files.get(0), fileName, subDir);
toUpload = files.get(0);
} else {
// 目录内多文件 → 先打 zip 再上传 OSS
fileName = label + ".zip";
ossUrl = ossUploader.upload(zipFiles(files, names), fileName, subDir);
toUpload = zipFiles(files, names);
}
// 内容未变且已计算过 (fee_status=1) → 跳过重传重 OCR, 保留旧金额, 不触发会务费重算
if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1
&& contentEquals(old.getOssUrl(), toUpload)) {
log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType);
continue;
}
String ossUrl = ossUploader.upload(toUpload, fileName, subDir);
// 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 待 OCR(0), 其余已计算(1)
boolean isZip = files.size() > 1;
boolean ocrNeeded = isZip || (ossUrl != null && RECOGNIZABLE.matcher(ossUrl).find());
int feeStatus = ocrNeeded ? 0 : 1;
BizMeetingMaterial old = existingBySubType.get(subType);
Long materialId;
Long oldMaterialId;
if (old != null) {
@@ -696,6 +799,22 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
return out.toByteArray();
}
/** 下载 OSS 旧文件并与新文件字节比对是否一致. 下载失败按"不一致"处理 (走正常重传), 不阻塞打包上传. */
private boolean contentEquals(String ossUrl, byte[] newBytes) {
if (ossUrl == null || ossUrl.isEmpty() || newBytes == null) return false;
try {
java.net.URLConnection conn = URI.create(ossUrl).toURL().openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
try (InputStream in = conn.getInputStream()) {
return Arrays.equals(readAllBytes(in), newBytes);
}
} catch (Exception e) {
log.warn("[material] 下载旧材料对比失败, 按不一致处理 url={} err={}", ossUrl, e.getMessage());
return false;
}
}
/**
* 是否会被前端提交 OCR (与 MeetingDetail.saveMaterials 门控一致):
* 文件可识别 (jpg/jpeg/png/pdf) 且 不在非发票 subType 集合里.
@@ -1,14 +1,19 @@
package com.ruoyi.business.service.impl;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.mapper.BizMeetingSupervisorMapper;
@@ -23,6 +28,8 @@ import com.ruoyi.common.utils.id.IdGenerator;
@Service
public class BizMeetingServiceImpl implements IBizMeetingService
{
private static final Logger log = LoggerFactory.getLogger(BizMeetingServiceImpl.class);
@Autowired
private BizMeetingMapper bizMeetingMapper;
@Autowired
@@ -48,6 +55,9 @@ public class BizMeetingServiceImpl implements IBizMeetingService
public List<BizMeeting> selectList(BizMeeting entity)
{ return bizMeetingMapper.selectList(entity); }
@Override
public List<Map<String, Object>> selectStageStats(BizMeeting entity)
{ return bizMeetingMapper.selectStageStats(entity); }
@Override
public int insert(BizMeeting entity) {
// meetingId: 从 DB AUTO_INCREMENT 改为应用赋值 — 10 位数字会议ID = 开始日期(yyMMdd) + 4 位序列号(0001 起, 每开始日期 Redis 独立计数)
if (entity.getMeetingId() == null) {
@@ -145,9 +155,81 @@ public class BizMeetingServiceImpl implements IBizMeetingService
{ return bizMeetingMapper.countByProjectIdExecutionUnitPeriod(projectId, executionUnitId, periodNo); }
@Override
public void markFeeCalcPending(Long meetingId) {
if (meetingId != null) {
bizMeetingMapper.markFeeCalcPending(meetingId);
public int countByProjectIdExecutionUnitPeriodExclude(Long projectId, Long executionUnitId, Long periodNo, Long excludeMeetingId)
{ return bizMeetingMapper.countByProjectIdExecutionUnitPeriodExclude(projectId, executionUnitId, periodNo, excludeMeetingId); }
@Override
@Transactional(rollbackFor = Exception.class)
public void recomputeMeetingFee(Long meetingId) {
if (meetingId == null) return;
// ① 置计算中 -1, 让 FeeCalcScheduler (只扫 =0) 不并发重复算
bizMeetingMapper.updateFeeCalcStatus(meetingId, -1);
try {
List<BizMeetingMaterial> mats = materialMapper.selectByMeetingId(meetingId);
// ② 任一材料发票仍未 OCR 完 (fee_status=0) → 会务费金额未定, 回滚 0 走调度器兜底 (选项 A)
boolean ocrReady = true;
if (mats != null) {
for (BizMeetingMaterial m : mats) {
if (m.getFeeStatus() != null && m.getFeeStatus() == 0) {
ocrReady = false;
break;
}
}
}
if (!ocrReady) {
bizMeetingMapper.updateFeeCalcStatus(meetingId, 0);
return;
}
// ③ 汇总: labor_fee + meeting_fee → total_fee, 成功置 1 (updateFeeSummary 内含)
BigDecimal laborFee = attendeeMapper.sumFeePreTaxByMeetingId(meetingId);
if (laborFee == null) laborFee = BigDecimal.ZERO;
BigDecimal meetingFee = computeMeetingFee(mats);
BigDecimal totalFee = laborFee.add(meetingFee);
bizMeetingMapper.updateFeeSummary(meetingId, laborFee, meetingFee, totalFee);
log.info("[recomputeMeetingFee] 立即汇总完成 meetingId={} labor={} meeting={} total={}",
meetingId, laborFee, meetingFee, totalFee);
} catch (Exception e) {
// ④ 算错了 → 回滚 0, 交给 FeeCalcScheduler 下分钟兜底 (不让 -1 残留卡死)
log.warn("[recomputeMeetingFee] 立即汇总失败, 回滚 0 走调度器兜底 meetingId={} err={}", meetingId, e.getMessage(), e);
bizMeetingMapper.updateFeeCalcStatus(meetingId, 0);
}
}
/**
* 会务费口径: 有总发票 (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;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void recomputeLaborFee(Long meetingId) {
if (meetingId == null) return;
BigDecimal laborFee = attendeeMapper.sumFeePreTaxByMeetingId(meetingId);
if (laborFee == null) laborFee = BigDecimal.ZERO;
BizMeeting m = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (m == null) return;
BigDecimal meetingFee = m.getMeetingFee() != null ? m.getMeetingFee() : BigDecimal.ZERO;
BigDecimal totalFee = laborFee.add(meetingFee);
bizMeetingMapper.updateLaborFee(meetingId, laborFee, totalFee);
}
}
@@ -38,6 +38,22 @@ public class BizMessageServiceImpl implements IBizMessageService
return bizMessageMapper.selectMyRecent(q);
}
@Override
public List<BizMessage> selectMyPage(Long receiverUserId, int offset, int pageSize)
{
BizMessage q = new BizMessage();
q.setReceiverUserId(receiverUserId);
q.getParams().put("offset", offset);
q.getParams().put("limit", pageSize);
return bizMessageMapper.selectMyRecent(q);
}
@Override
public int countMy(Long receiverUserId)
{
return bizMessageMapper.countMy(receiverUserId);
}
@Override
public int countUnread(Long receiverUserId)
{
@@ -34,12 +34,35 @@ public class BizOrgServiceImpl implements IBizOrgService {
@Override
public int insert(BizOrg entity) {
// 单位名称查重: 同 orgType 下 org_name 不能重复 (trim 后精确匹配)
String orgName = entity.getOrgName() == null ? null : entity.getOrgName().trim();
if (orgName != null && !orgName.isEmpty()) {
BizOrg probe = new BizOrg();
probe.setOrgType(entity.getOrgType());
probe.setOrgName(orgName);
if (bizOrgMapper.countByOrgName(probe) > 0) {
throw new ServiceException("单位名称「" + orgName + "」已存在");
}
}
// 主键由 AUTO_INCREMENT 自增, 不需要 Snowflake
return bizOrgMapper.insert(entity);
}
@Override
public int updateByPrimaryKey(BizOrg entity) { return bizOrgMapper.updateByPrimaryKey(entity); }
public int updateByPrimaryKey(BizOrg entity) {
// 改名时同样查重: 同 orgType 下 org_name 不能重复 (trim 精确匹配), 排除自身
String orgName = entity.getOrgName() == null ? null : entity.getOrgName().trim();
if (orgName != null && !orgName.isEmpty()) {
BizOrg probe = new BizOrg();
probe.setOrgType(entity.getOrgType());
probe.setOrgName(orgName);
probe.setOrgId(entity.getOrgId());
if (bizOrgMapper.countByOrgName(probe) > 0) {
throw new ServiceException("单位名称「" + orgName + "」已存在");
}
}
return bizOrgMapper.updateByPrimaryKey(entity);
}
@Override
public int deleteByPrimaryKeys(Long[] orgIds) {
@@ -79,6 +79,15 @@ public class BizPersonServiceImpl implements IBizPersonService
}
// 1. 创建 sys_user 子账号
// 归属父账号: 优先取所属机构主账号 (biz_org.user_id), 而非 getUserId() (当前登录人).
// 否则 admin/manager 在 sponsor-people 建人时, 子账号会错挂到 admin 自己名下,
// 导致 sponsor 主账号在自己的 sponsor/people 页 (parent_user_id 过滤) 看不到这些人.
Long parentUid = mainUserId;
BizOrg ownerOrg = bizOrgMapper.selectByPrimaryKey(entity.getOrgId());
if (ownerOrg != null && ownerOrg.getUserId() != null) {
parentUid = ownerOrg.getUserId();
}
// role_type 取 person.unitType (sponsor/executor/doctor), 而非继承创建者角色:
// 否则 admin/manager 在 admin/sponsor-people 建人会把子账号错位成 admin/manager (后台管理员).
// 仅当 unitType 缺失时才回退到主账号 role_type 兜底.
@@ -94,7 +103,7 @@ public class BizPersonServiceImpl implements IBizPersonService
newUser.setEmail(entity.getEmail());
newUser.setPassword(SecurityUtils.encryptPassword(entity.getLoginPassword()));
newUser.setAccountType("SUB");
newUser.setParentUserId(mainUserId);
newUser.setParentUserId(parentUid);
newUser.setStatus("0");
newUser.setDelFlag("0");
if (roleType != null) {
@@ -139,6 +148,17 @@ public class BizPersonServiceImpl implements IBizPersonService
return n;
}
/** 个人资料编辑: 按 user_id 反查 personId, 再复用 updateByPrimaryKey (更新 person + 同步 nick_name/phonenumber/email) */
@Override
public int updateProfileByUserId(BizPerson entity) {
BizPerson existed = bizPersonMapper.selectByUserId(entity.getUserId());
if (existed == null) {
throw new ServiceException("未找到人员档案");
}
entity.setPersonId(existed.getPersonId());
return updateByPrimaryKey(entity);
}
@Override
public int deleteByPrimaryKey(String personId)
{
@@ -1,8 +1,10 @@
package com.ruoyi.business.service.impl;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizProjectPlan;
import com.ruoyi.business.mapper.BizProjectPlanMapper;
import com.ruoyi.business.notify.BizNotifyService;
@@ -23,7 +25,14 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
public List<BizProjectPlan> selectList(BizProjectPlan entity)
{ return bizProjectPlanMapper.selectList(entity); }
@Override
public int insert(BizProjectPlan entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "planId"); return bizProjectPlanMapper.insert(entity); }
public int insert(BizProjectPlan entity)
{
com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "planId");
// 补 create_by / create_time (历史一直没写, 导致"提交时间"为空; 列表按 create_time 倒序)
if (entity.getCreateBy() == null) entity.setCreateBy(SecurityUtils.getUsername());
if (entity.getCreateTime() == null) entity.setCreateTime(new Date());
return bizProjectPlanMapper.insert(entity);
}
/**
* 通用 update. #4 触发点 (方案审核结果通知) 在这里插桩:
@@ -42,6 +51,10 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
oldStatus = existed.getStatus();
}
}
// 提交动作 (状态 → '1' 待审核): 写入提交时间; 拒绝 '3' 重提也会刷新
if ("1".equals(entity.getStatus()) && !"1".equals(oldStatus)) {
entity.setSubmitTime(new Date());
}
int n = bizProjectPlanMapper.updateByPrimaryKey(entity);
if (n > 0 && entity.getStatus() != null && !entity.getStatus().equals(oldStatus)) {
// 重新读一次拿 submitterId/planName/auditOpinion (entity 可能只传了 planId+status+opinion)
@@ -62,6 +62,13 @@ public class BizProjectServiceImpl implements IBizProjectService
if (entity.getCreateUserId() == null) {
entity.setCreateUserId(SecurityUtils.getUserId());
}
// 补 create_by / create_time (历史一直没写, 导致项目列表"创建人/创建时间"两列为空)
if (entity.getCreateBy() == null) {
entity.setCreateBy(SecurityUtils.getUsername());
}
if (entity.getCreateTime() == null) {
entity.setCreateTime(new java.util.Date());
}
return bizProjectMapper.insert(entity);
}
@Override
@@ -16,6 +16,7 @@ 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.BizOrg;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper;
@@ -23,6 +24,7 @@ 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.IBizOrgService;
import com.ruoyi.business.service.PdfService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
@@ -42,6 +44,8 @@ public class BizSignServiceImpl implements BizSignService {
private PdfService pdfService;
@Autowired
private BizProjectMapper projectMapper;
@Autowired
private IBizOrgService bizOrgService;
@Override
public Map<String, Object> getSignInfo(Long attendeeId) {
@@ -134,7 +138,12 @@ public class BizSignServiceImpl implements BizSignService {
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);
result.put("totalPeriods", assignedTotalPeriods(meeting));
// 已签署状态: laborProtocol 非空即已签 (重复打开签署链接 → 前端直接展示 PDF)
boolean signed = attendee.getLaborProtocol() != null && !attendee.getLaborProtocol().trim().isEmpty();
result.put("signed", signed);
result.put("laborProtocol", attendee.getLaborProtocol());
return result;
}
@@ -159,11 +168,27 @@ public class BizSignServiceImpl implements BizSignService {
Map<String, Object> result = new HashMap<>();
result.put("meetingName", meeting.getMeetingName());
result.put("periodNo", meeting.getPeriodNo());
result.put("totalPeriods", meeting.getTotalPeriods());
result.put("totalPeriods", assignedTotalPeriods(meeting));
result.put("attendeeId", attendeeId);
return result;
}
/** 期数分母: 分配给执行方 (本公司) 的总场次 (biz_project_assign.sessions 之和, 按 execution_unit_id 隔离).
* 会议无 execution_unit_id / 反查不到执行方 MAIN / assigned 算出 0 时, 回退项目总期数, 避免显示 1/0. */
private Long assignedTotalPeriods(BizMeeting meeting) {
if (meeting == null) return null;
if (meeting.getExecutionUnitId() == null || meeting.getProjectId() == null) {
return meeting.getTotalPeriods();
}
BizOrg org = bizOrgService.getById(meeting.getExecutionUnitId());
Long executorUserId = org == null ? null : org.getUserId();
if (executorUserId == null) {
return meeting.getTotalPeriods();
}
int assigned = projectMapper.countAssignedSessions(meeting.getProjectId(), executorUserId);
return assigned > 0 ? (long) assigned : meeting.getTotalPeriods();
}
private Map<String, String> map(String value, String label) {
Map<String, String> m = new HashMap<>();
m.put("value", value);
@@ -7,6 +7,7 @@ import java.util.List;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -58,6 +59,9 @@ public class InvoiceOcrService
@Autowired
private IBizMeetingMaterialService materialService;
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired
@Qualifier("ocrExecutor")
private ExecutorService ocrExecutor;
@@ -138,6 +142,8 @@ public class InvoiceOcrService
{
// OCR 处理完毕 (成功/非发票/失败), 该材料金额最终确定 → fee_status=1
materialService.updateFeeStatus(materialId, 1);
// 立即尝试重算会务费 (若仍有其它发票待 OCR 会回滚 0 走兜底), 不再等 FeeCalcScheduler 每分钟扫
bizMeetingService.recomputeMeetingFee(meetingId);
}
});
@@ -302,6 +308,8 @@ public class InvoiceOcrService
{
// 兜底 OCR 处理完毕, 金额最终确定 → fee_status=1
materialService.updateFeeStatus(inv.getMaterialId(), 1);
// 立即尝试重算会务费 (若仍有其它发票待 OCR 会回滚 0 走兜底)
bizMeetingService.recomputeMeetingFee(inv.getMeetingId());
}
}
@@ -75,10 +75,11 @@ public class AliyunSmsSender {
}
/**
* 发送电子签短信 (模板占位符 name=姓名, date=日期 MM月dd日, link=签署链接).
* 发送邀请签署短信 (模板 SMS_512040098 占位符 name=姓名, time=会议时间, attendeeId=参会人id).
* 与验证码模板 (ruoyi.sms.template) 分离, 成功返回 true.
* time 由 startTime/endTime 统一格式化: 当天 "8月13日18:20 - 19:20", 跨天 "8月13日18:20 - 8月16日19:20".
*/
public boolean sendEsign(String phone, String name, Date date, String link) {
public boolean sendEsign(String phone, String name, Date startTime, Date endTime, Long attendeeId) {
try {
SendSmsRequest req = new SendSmsRequest();
req.setPhoneNumbers(phone);
@@ -86,28 +87,42 @@ public class AliyunSmsSender {
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 : "");
params.put("time", formatMeetingTime(startTime, endTime));
params.put("attendeeId", attendeeId != null ? String.valueOf(attendeeId) : "");
req.setTemplateParam(JSONUtil.toJsonStr(params));
SendSmsResponse resp = getClient().getAcsResponse(req);
if ("OK".equalsIgnoreCase(resp.getCode())) {
log.info("[SMS] 电子签短信发送成功 phone={}, bizId={}", phone, resp.getBizId());
log.info("[SMS] 邀请签署短信发送成功 phone={}, bizId={}", phone, resp.getBizId());
return true;
}
log.error("[SMS] 电子签短信发送失败 phone={}, code={}, msg={}, requestId={}",
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);
log.error("[SMS] 邀请签署短信异常 phone={}", phone, e);
return false;
}
}
/** 会议时间统一格式化: 当天 "M月d日HH:mm - HH:mm", 跨天 "M月d日HH:mm - M月d日HH:mm" */
private String formatMeetingTime(Date start, Date end) {
if (start == null && end == null) return "";
SimpleDateFormat full = new SimpleDateFormat("M月d日HH:mm");
SimpleDateFormat hm = new SimpleDateFormat("HH:mm");
if (start == null) return full.format(end);
if (end == null) return full.format(start);
SimpleDateFormat day = new SimpleDateFormat("yyyyMMdd");
if (day.format(start).equals(day.format(end))) {
return full.format(start) + " - " + hm.format(end);
}
return full.format(start) + " - " + full.format(end);
}
/**
* 拼电子签签署链接: {esignBaseUrl}/#/doctor/sign-fill?attendeeId={attendeeId}
* 例: https://risingdoctor.com/hg/#/doctor/sign-fill?attendeeId=123
* (nginx 子路径 /hg 已配在 ruoyi.sms.esignBaseUrl 里, 前端 Vue Router 是 hash 模式,
* 所以 Java 只拼 #/doctor/sign-fill 路由 + attendeeId)
* 例: https://hegui.bahim.org.cn/#/doctor/sign-fill?attendeeId=123
* (前端 Vue Router 是 hash 模式, Java 只拼 #/doctor/sign-fill 路由 + attendeeId,
* 域名/子路径由 ruoyi.sms.esignBaseUrl 提供)
*/
public String esignLink(Long attendeeId) {
return esignBaseUrl + "/#/doctor/sign-fill?attendeeId=" + attendeeId;
@@ -0,0 +1,63 @@
package com.ruoyi.business.supplier;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* 供应商账号接口解密后的单条账号 (对应 refer/bindingdemo 里 decrypted rows[] 的一行).
* <p>
* 登录名 = account/email (邮箱); password 为 BCrypt 哈希 (不可逆), 同步时写入 sys_user.password2.
* 字段名与接口返回 JSON 的 camelCase 一致, 直接由 Jackson 反序列化.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class SupplierAccount
{
/** 登录账号 (= 邮箱) */
private String account;
/** 邮箱 */
private String email;
/** 密码 (BCrypt 哈希, 不可逆) */
private String password;
/** 联系人 */
private String contactName;
/** 联系电话 */
private String contactPhone;
/** 企业名称 */
private String enterpriseName;
/** 企业类型 */
private String enterpriseType;
/** 公司地址 */
private String companyAddress;
/** 税号 */
private String taxNo;
/** 删除标识 (true=已删除) */
private Boolean deleted;
/** 禁用标识 (true=已禁用) */
private Boolean disabled;
/** 供应商编码 */
private String supplierCode;
public String getAccount() { return account; }
public void setAccount(String account) { this.account = account; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getContactName() { return contactName; }
public void setContactName(String contactName) { this.contactName = contactName; }
public String getContactPhone() { return contactPhone; }
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
public String getEnterpriseName() { return enterpriseName; }
public void setEnterpriseName(String enterpriseName) { this.enterpriseName = enterpriseName; }
public String getEnterpriseType() { return enterpriseType; }
public void setEnterpriseType(String enterpriseType) { this.enterpriseType = enterpriseType; }
public String getCompanyAddress() { return companyAddress; }
public void setCompanyAddress(String companyAddress) { this.companyAddress = companyAddress; }
public String getTaxNo() { return taxNo; }
public void setTaxNo(String taxNo) { this.taxNo = taxNo; }
public Boolean getDeleted() { return deleted; }
public void setDeleted(Boolean deleted) { this.deleted = deleted; }
public Boolean getDisabled() { return disabled; }
public void setDisabled(Boolean disabled) { this.disabled = disabled; }
public String getSupplierCode() { return supplierCode; }
public void setSupplierCode(String supplierCode) { this.supplierCode = supplierCode; }
}
@@ -0,0 +1,201 @@
package com.ruoyi.business.supplier;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.mapper.BizOrgMapper;
import com.ruoyi.business.mapper.BizPersonMapper;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.id.SnowflakeId;
import com.ruoyi.system.mapper.SysUserMapper;
import lombok.extern.slf4j.Slf4j;
/**
* 供应商账号同步: 把供应商接口拉取/解密的账号, upsert 到执行方侧三张表.
* <p>
* 去重键 = 邮箱 (account == email). 每个供应商账号对应:
* 1. sys_user — 登录账号 (user_name/email=邮箱, password2=供应商 BCrypt 哈希, role_type=executor)
* 2. biz_org — 企业档案 (org_type=executor)
* 3. biz_person — 联系人档案 (unit_type=executor, is_synced=1)
* <p>
* 状态同步: disabled → sys_user.status='1' + biz_org.status='1'; deleted → sys_user.del_flag='2'.
* 幂等: 每分钟重跑, 按邮箱 upsert, 单条失败只 log 不影响后续.
*/
@Slf4j
@Service
public class SupplierAccountSyncService
{
@Autowired
private SysUserMapper sysUserMapper;
@Autowired
private BizOrgMapper bizOrgMapper;
@Autowired
private BizPersonMapper bizPersonMapper;
public int sync(List<SupplierAccount> accounts)
{
if (accounts == null || accounts.isEmpty())
{
return 0;
}
int done = 0;
int failed = 0;
for (SupplierAccount a : accounts)
{
try
{
syncOne(a);
done++;
}
catch (Exception e)
{
failed++;
log.warn("[SupplierAccountSync] 同步失败 account={} err={}", a.getAccount(), e.getMessage());
}
}
log.info("[SupplierAccountSync] 本轮 {} 条, 成功 {} 失败 {}", accounts.size(), done, failed);
return done;
}
private void syncOne(SupplierAccount a)
{
String email = firstNonBlank(a.getEmail(), a.getAccount());
if (email == null)
{
log.warn("[SupplierAccountSync] 账号缺邮箱, 跳过 supplierCode={}", a.getSupplierCode());
return;
}
String status = Boolean.TRUE.equals(a.getDisabled()) ? "1" : "0";
String delFlag = Boolean.TRUE.equals(a.getDeleted()) ? "2" : "0";
// 供应商侧部分账号缺联系人/电话, 做非空回退 + 按列宽截断 (避免 NOT NULL/UNIQUE/超长报错)
String nickName = truncate(firstNonBlank(a.getContactName(), a.getEnterpriseName(), email), 30); // sys_user.nick_name
String personName = truncate(firstNonBlank(a.getContactName(), a.getEnterpriseName(), email), 50); // biz_person.name
String phone = truncate(firstNonBlank(a.getContactPhone(), a.getSupplierCode(), email), 20); // biz_person.phone 非空唯一
String phonenumber = truncate(a.getContactPhone(), 11); // sys_user.phonenumber 可空
String orgName = truncate(firstNonBlank(a.getEnterpriseName(), email), 200); // biz_org.org_name
String businessNature = truncate(a.getEnterpriseType(), 20);
String address = truncate(a.getCompanyAddress(), 500);
String taxNo = truncate(a.getTaxNo(), 50);
// 1. sys_user upsert (去重键=邮箱, 不过滤 del_flag, 以支持"删除→恢复")
SysUser user = sysUserMapper.selectUserByEmailIgnoreDel(email);
Long userId;
if (user == null)
{
SysUser nu = new SysUser();
nu.setUserName(email);
nu.setNickName(nickName);
nu.setEmail(email);
nu.setPhonenumber(phonenumber);
nu.setPassword2(a.getPassword());
nu.setStatus(status);
nu.setDelFlag(delFlag);
nu.setAccountType("MAIN");
nu.setRoleType("executor");
sysUserMapper.insertSyncedUser(nu);
userId = nu.getUserId();
}
else
{
userId = user.getUserId();
SysUser upd = new SysUser();
upd.setUserId(userId);
upd.setPassword2(a.getPassword());
upd.setNickName(nickName);
upd.setPhonenumber(phonenumber);
upd.setEmail(email);
upd.setStatus(status);
upd.setDelFlag(delFlag);
sysUserMapper.updateSyncedUser(upd);
}
// 2. biz_org upsert (按主账号 user_id)
BizOrg q = new BizOrg();
q.setUserId(userId);
q.setOrgType("executor");
List<BizOrg> orgs = bizOrgMapper.selectList(q);
BizOrg org = orgs.isEmpty() ? null : orgs.get(0);
if (org == null)
{
BizOrg no = new BizOrg();
no.setUserId(userId);
no.setOrgName(orgName);
no.setOrgType("executor");
no.setBusinessNature(businessNature);
no.setAddress(address);
no.setTaxNo(taxNo);
no.setContactName(a.getContactName());
no.setContactPhone(a.getContactPhone());
no.setStatus(status);
bizOrgMapper.insert(no);
org = no;
}
else
{
org.setOrgName(orgName);
org.setBusinessNature(businessNature);
org.setAddress(address);
org.setTaxNo(taxNo);
org.setContactName(a.getContactName());
org.setContactPhone(a.getContactPhone());
org.setStatus(status);
bizOrgMapper.updateByPrimaryKey(org);
}
Long orgId = org.getOrgId();
// 3. biz_person upsert (按 user_id), 打同步标记
BizPerson person = bizPersonMapper.selectByUserId(userId);
if (person == null)
{
BizPerson np = new BizPerson();
SnowflakeId.injectIfEmpty(np, "personId");
np.setName(personName);
np.setPhone(phone);
np.setOrgId(orgId);
np.setUnitType("executor");
np.setUserId(userId);
np.setIsSynced(1);
np.setCreateBy("supplier-sync");
np.setUpdateBy("supplier-sync");
bizPersonMapper.insert(np);
}
else
{
person.setName(personName);
person.setPhone(phone);
person.setOrgId(orgId);
person.setIsSynced(1);
person.setUpdateBy("supplier-sync");
bizPersonMapper.updateByPrimaryKey(person);
}
}
private static String firstNonBlank(String... vals)
{
for (String v : vals)
{
if (v != null && !v.isBlank())
{
return v;
}
}
return null;
}
private static String truncate(String s, int max)
{
if (s == null)
{
return null;
}
return s.length() > max ? s.substring(0, max) : s;
}
}
@@ -15,6 +15,7 @@
<result property="titleCertUrl" column="title_cert_url" />
<result property="bankCard" column="bank_card" />
<result property="bankName" column="bank_name" />
<result property="bankBranch" column="bank_branch" />
<result property="bankRegion" column="bank_region" />
<result property="bankAddress" column="bank_address" />
<result property="idCardAttachments" column="id_card_attachments" />
@@ -27,7 +28,7 @@
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectFields">
select expert_id, user_id, name, phone, region, id_card, work_unit, department, title, practice_cert_url, title_cert_url, bank_card, bank_name, bank_region, bank_address, id_card_attachments, audit_status, audit_by, audit_time, status, create_by, create_time, update_by, update_time
select expert_id, user_id, name, phone, region, id_card, work_unit, department, title, practice_cert_url, title_cert_url, bank_card, bank_name, bank_branch, bank_region, bank_address, id_card_attachments, audit_status, audit_by, audit_time, status, create_by, create_time, update_by, update_time
from biz_expert
</sql>
<select id="selectByUserId" resultMap="BizExpertResult" parameterType="Long">
@@ -68,6 +69,7 @@
<if test="titleCertUrl != null">title_cert_url,</if>
<if test="bankCard != null">bank_card,</if>
<if test="bankName != null">bank_name,</if>
<if test="bankBranch != null">bank_branch,</if>
<if test="bankRegion != null">bank_region,</if>
<if test="bankAddress != null">bank_address,</if>
<if test="auditStatus != null">audit_status,</if>
@@ -94,6 +96,7 @@
<if test="titleCertUrl != null">#{titleCertUrl},</if>
<if test="bankCard != null">#{bankCard},</if>
<if test="bankName != null">#{bankName},</if>
<if test="bankBranch != null">#{bankBranch},</if>
<if test="bankRegion != null">#{bankRegion},</if>
<if test="bankAddress != null">#{bankAddress},</if>
<if test="auditStatus != null">#{auditStatus},</if>
@@ -118,6 +121,7 @@
<if test="idCard != null">id_card = #{idCard},</if>
<if test="bankCard != null">bank_card = #{bankCard},</if>
<if test="bankName != null">bank_name = #{bankName},</if>
<if test="bankBranch != null">bank_branch = #{bankBranch},</if>
<if test="bankRegion != null">bank_region = #{bankRegion},</if>
<if test="bankAddress != null">bank_address = #{bankAddress},</if>
<if test="idCardAttachments != null">id_card_attachments = #{idCardAttachments},</if>
@@ -144,6 +148,7 @@
<if test="idCard != null">id_card,</if>
<if test="bankCard != null">bank_card,</if>
<if test="bankName != null">bank_name,</if>
<if test="bankBranch != null">bank_branch,</if>
<if test="bankRegion != null">bank_region,</if>
<if test="bankAddress != null">bank_address,</if>
<if test="idCardAttachments != null">id_card_attachments,</if>
@@ -162,6 +167,7 @@
<if test="idCard != null">#{idCard},</if>
<if test="bankCard != null">#{bankCard},</if>
<if test="bankName != null">#{bankName},</if>
<if test="bankBranch != null">#{bankBranch},</if>
<if test="bankRegion != null">#{bankRegion},</if>
<if test="bankAddress != null">#{bankAddress},</if>
<if test="idCardAttachments != null">#{idCardAttachments},</if>
@@ -178,6 +184,7 @@
<if test="idCard != null and idCard != ''">id_card = #{idCard},</if>
<if test="bankCard != null and bankCard != ''">bank_card = #{bankCard},</if>
<if test="bankName != null and bankName != ''">bank_name = #{bankName},</if>
<if test="bankBranch != null and bankBranch != ''">bank_branch = #{bankBranch},</if>
<if test="bankRegion != null and bankRegion != ''">bank_region = #{bankRegion},</if>
<if test="bankAddress != null and bankAddress != ''">bank_address = #{bankAddress},</if>
<if test="idCardAttachments != null and idCardAttachments != ''">id_card_attachments = #{idCardAttachments},</if>
@@ -201,7 +208,7 @@
(expert_id, user_id, name, phone, region, id_card,
work_unit, department, title,
practice_cert_url, title_cert_url,
bank_card, bank_name, bank_region, bank_address,
bank_card, bank_name, bank_branch, bank_region, bank_address,
id_card_attachments,
audit_status, audit_by, audit_time, status,
create_by, create_time)
@@ -210,7 +217,7 @@
(#{e.expertId}, #{e.userId}, #{e.name}, #{e.phone}, #{e.region}, #{e.idCard},
#{e.workUnit}, #{e.department}, #{e.title},
#{e.practiceCertUrl}, #{e.titleCertUrl},
#{e.bankCard}, #{e.bankName}, #{e.bankRegion}, #{e.bankAddress},
#{e.bankCard}, #{e.bankName}, #{e.bankBranch}, #{e.bankRegion}, #{e.bankAddress},
#{e.idCardAttachments},
#{e.auditStatus}, #{e.auditBy}, #{e.auditTime}, #{e.status},
#{e.createBy}, sysdate())
@@ -41,6 +41,7 @@
<result property="isDeleted" column="is_deleted" />
<result property="isEsigned" column="is_esigned" />
<result property="isInvited" column="is_invited" />
<result property="hasIntent" column="has_intent" />
</resultMap>
<insert id="insert" parameterType="BizMeetingAttendee">
insert into biz_meeting_attendee(id, meeting_id, user_id, create_by, create_time)
@@ -152,7 +153,12 @@
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, labor_protocol_masked, create_by, create_time, is_deleted, is_esigned, is_invited
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,
(select if(exists(
select 1 from biz_execution_intent e
where e.user_id = biz_meeting_attendee.user_id
and e.project_no collate utf8mb4_unicode_ci = (select p.project_no collate utf8mb4_unicode_ci from biz_meeting m join biz_project p on m.project_id = p.project_id where m.meeting_id = #{meetingId})
), 1, 0)) as has_intent
from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
order by id
</select>
@@ -11,6 +11,7 @@
<result property="meetingName" column="meeting_name" />
<result property="periodNo" column="period_no" />
<result property="totalPeriods" column="total_periods" />
<result property="assignedSessions" column="assigned_sessions" />
<result property="projectForm" column="project_form" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
@@ -62,12 +63,14 @@
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>
(select o.org_name from biz_project p join biz_org o on o.org_id = p.sponsor_org_id where p.project_id = biz_meeting.project_id limit 1) as org_name,
(select p.invitation_url from biz_project p where p.project_id = biz_meeting.project_id limit 1) as project_invitation_url,
<if test="params.assignedExecutorUserId != null">(select coalesce(sum(a.sessions), 0) from biz_project_assign a where a.project_id = biz_meeting.project_id and a.is_deleted = 0 and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.assignedExecutorUserId} and org_type = 'executor')) as assigned_sessions,</if>
<include refid="selectFields"/>
from biz_meeting
<where>
@@ -78,15 +81,15 @@
<if test="periodNo != null">and period_no = #{periodNo}</if>
<if test="projectForm != null and projectForm != ''">and project_form = #{projectForm}</if>
<if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if>
<if test="currentStageNotIn != null and currentStageNotIn != ''">
and current_stage not in
<foreach collection="currentStageNotIn.split(',')" item="s" open="(" separator="," close=")">#{s}</foreach>
</if>
<if test="startTime != null">and start_time &gt;= #{startTime}</if>
<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_org_id (经 user_id 反查 org_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_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') 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>
<!-- 结题且未开通的项目, sponsor 会议不可见 (与项目列表 selectSponsorList 同口径) -->
<if test="params.sponsorAdminUserId != null or params.monitorUserId != null">
and not exists (
select 1 from biz_project p2
@@ -95,20 +98,39 @@
and p2.is_finished = '1' and p2.open_status = 'N'
)
</if>
<!-- executor 数据权限: MAIN 看本执行单位项目下的会议 (biz_project_assign.execution_unit_id), SUB(执行人) 只看本人创建的会议 (create_by) -->
<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.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')
)</if>
<if test="params.executorCreatorUsername != null and params.executorCreatorUsername != ''">and create_by = #{params.executorCreatorUsername}</if>
<!-- 合规人员(manager) 数据权限: 只看本人创建项目的会议 -->
<if test="params.managerCreateUserId != null">and project_id in (
select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0
)</if>
</where>
order by meeting_id desc
</select>
<!-- 会议阶段统计 (仅 sponsor/Home KPI 用): 按 current_stage 分组计数, 只套 sponsor 数据权限 (其他角色单独修, 不在此复用多角色过滤) -->
<select id="selectStageStats" resultType="java.util.HashMap" parameterType="BizMeeting">
select current_stage as currentStage, count(*) as cnt
from biz_meeting
<where>
is_deleted = 0
<if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') 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>
<if test="params.sponsorAdminUserId != null or params.monitorUserId != null">
and not exists (
select 1 from biz_project p2
where p2.project_id = biz_meeting.project_id
and p2.is_deleted = 0
and p2.is_finished = '1' and p2.open_status = 'N'
)
</if>
</where>
group by current_stage
</select>
<insert id="insert" parameterType="BizMeeting">
insert into biz_meeting
<trim prefix="(" suffix=")" suffixOverrides=",">
@@ -250,7 +272,27 @@
and execution_unit_id = #{executionUnitId}
and period_no = #{periodNo}
</select>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): start_time 已过 且 material 未提交 且未执行的会议 → is_executed=1 + RUNNING. 已软删/已冻结/已提交材料的不动. -->
<!-- 修改校验用 (执行方隔离): 同上, 但排除指定 meetingId (修改自身会议不触发相同期数误报) -->
<select id="countByProjectIdExecutionUnitPeriodExclude" resultType="int">
select count(*) from biz_meeting where project_id = #{projectId} and is_deleted = 0
and execution_unit_id = #{executionUnitId}
and period_no = #{periodNo}
and meeting_id != #{excludeMeetingId}
</select>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): start_time 已过 且 end_time 未到 且 material 未提交 且未执行的会议 → current_stage = IN_PROGRESS (执行中). 已软删/已冻结/已执行的不动. -->
<update id="markInProgress">
update biz_meeting
set current_stage = 'IN_PROGRESS'
where is_deleted = 0
and is_executed = 0
and is_frozen = 0
and start_time is not null
and start_time &lt;= NOW()
and end_time &gt; NOW()
and labor_audit_stage = 'NOT_SUBMITTED'
and service_audit_stage = 'NOT_SUBMITTED'
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): end_time 已过 且 material 未提交 且未执行的会议 → is_executed=1 + RUNNING (已执行). 已软删/已冻结/已提交材料的不动. -->
<update id="markExecuted">
update biz_meeting
set is_executed = 1,
@@ -259,8 +301,8 @@
where is_deleted = 0
and is_executed = 0
and is_frozen = 0
and start_time is not null
and start_time &lt;= NOW()
and end_time is not null
and end_time &lt;= NOW()
and labor_audit_stage = 'NOT_SUBMITTED'
and service_audit_stage = 'NOT_SUBMITTED'
</update>
@@ -282,9 +324,9 @@
<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}
<!-- 费用重算状态机: 直接置 fee_calc_status (-1 计算中 / 0 待算兜底); 成功(1) 由 updateFeeSummary 一并写入 -->
<update id="updateFeeCalcStatus">
update biz_meeting set fee_calc_status = #{status} where meeting_id = #{meetingId}
</update>
<!-- 汇总回写 3 个费用字段 + 置已汇总 -->
<update id="updateFeeSummary">
@@ -295,4 +337,11 @@
fee_calc_status = 1
where meeting_id = #{meetingId}
</update>
<!-- 同步重算劳务费 (参会人增删改后立即调用): 只回写 labor_fee + total_fee, 不动 meeting_fee / fee_calc_status -->
<update id="updateLaborFee">
update biz_meeting
set labor_fee = #{laborFee},
total_fee = #{totalFee}
where meeting_id = #{meetingId}
</update>
</mapper>
@@ -21,9 +21,11 @@
<sql id="selectFields">
select m.msg_id, m.receiver_user_id, m.msg_type, m.title, m.content, m.biz_type, m.biz_id,
m.is_read, m.read_time, m.create_by, m.create_time, m.update_by, m.update_time,
u.user_name as receiver_name
COALESCE(NULLIF(bp.name, ''), NULLIF(be.name, ''), u.nick_name) as receiver_name
from biz_message m
left join sys_user u on u.user_id = m.receiver_user_id
left join biz_person bp on bp.user_id = m.receiver_user_id
left join biz_expert be on be.user_id = m.receiver_user_id
</sql>
<select id="selectByPrimaryKey" resultMap="BizMessageResult" parameterType="Long">
@@ -49,9 +51,23 @@
m.receiver_user_id = #{receiverUserId}
</where>
order by m.is_read asc, m.msg_id desc
<if test="params.limit != null">
limit #{params.limit}
</if>
<!-- 分页: params.offset 有值时用 offset,limit; 否则只用 limit (嵌入式列表) -->
<choose>
<when test="params.offset != null">
limit #{params.offset}, #{params.limit}
</when>
<otherwise>
<if test="params.limit != null">
limit #{params.limit}
</if>
</otherwise>
</choose>
</select>
<!-- 某用户的全部消息总数 (分页 total 用, 与 countUnread 解耦) -->
<select id="countMy" resultType="int" parameterType="Long">
select count(*) from biz_message
where receiver_user_id = #{receiverUserId}
</select>
<select id="countUnread" resultType="int" parameterType="Long">
@@ -120,7 +120,7 @@
<if test="orgId != null">and o.org_id = #{orgId}</if>
<if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if>
order by o.org_id desc
LIMIT 5
LIMIT 10
</select>
<!--
@@ -146,7 +146,7 @@
<if test="orgId != null">and o.org_id = #{orgId}</if>
<if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if>
order by o.org_id desc
LIMIT 5
LIMIT 10
</select>
<!--
@@ -198,4 +198,12 @@
(select org_id from biz_person where user_id = #{userId} limit 1)
)
</select>
<!-- 单位名称查重 (新增/改名前): 同 orgType 下 org_name 精确匹配 (trim 后) 的条数; orgId 传非空则排除自身 (改名用) -->
<select id="countByOrgName" parameterType="BizOrg" resultType="int">
select count(*) from biz_org
where org_type = #{orgType}
and trim(org_name) = #{orgName}
<if test="orgId != null"> and org_id != #{orgId}</if>
</select>
</mapper>
@@ -11,6 +11,7 @@
<result property="department" column="department" />
<result property="position" column="position" />
<result property="userId" column="user_id" />
<result property="isSynced" column="is_synced" />
<result property="unitType" column="unit_type" />
<result property="accountType" column="account_type" />
<result property="parentUserId" column="parent_user_id" />
@@ -23,14 +24,14 @@
</resultMap>
<sql id="selectFields">
select person_id, name, phone, org_id, department, position, unit_type, user_id, create_by, create_time, update_by, update_time
select person_id, name, phone, org_id, department, position, unit_type, is_synced, user_id, create_by, create_time, update_by, update_time
from biz_person
</sql>
<!-- 通用列表: LEFT JOIN biz_org 取公司名/类型, LEFT JOIN sys_user 取账号状态/类型 -->
<sql id="selectFieldsWithAccount">
select p.person_id, p.name, p.phone, p.org_id, o.org_name, o.org_type,
p.department, p.position, p.unit_type, p.user_id,
p.department, p.position, p.unit_type, p.is_synced, 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.user_name as user_name,
@@ -82,6 +83,7 @@
<if test="department != null">department,</if>
<if test="position != null">position,</if>
<if test="unitType != null">unit_type,</if>
<if test="isSynced != null">is_synced,</if>
<if test="userId != null">user_id,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
create_time,
@@ -96,6 +98,7 @@
<if test="department != null">#{department},</if>
<if test="position != null">#{position},</if>
<if test="unitType != null">#{unitType},</if>
<if test="isSynced != null">#{isSynced},</if>
<if test="userId != null">#{userId},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
sysdate(),
@@ -110,6 +113,7 @@
<if test="userId != null and userId != ''">user_id = #{userId},</if>
<if test="orgId != null">org_id = #{orgId},</if>
<if test="unitType != null and unitType != ''">unit_type = #{unitType},</if>
<if test="isSynced != null">is_synced = #{isSynced},</if>
<if test="name != null">name = #{name},</if>
<if test="phone != null">phone = #{phone},</if>
<if test="department != null">department = #{department},</if>
@@ -145,15 +149,13 @@
</foreach>
</update>
<!-- sponsor 专属: 通过 biz_person.user_id 关联 sys_user, 利用 sys_user.parent_user_id 过滤主账号归属 -->
<!-- 主账号自己 (parent_user_id=NULL) 也通过 OR u.user_id=#{sponsorOwnerUid} 一并带上 (否则主账号看不到自己) -->
<!-- sponsor 专属: SQL 硬编码 unit_type='sponsor' + 严格按 org_id 圈本机构所有用户 (含主账号自己) -->
<select id="selectSponsorList" resultMap="BizPersonResult" parameterType="BizPerson">
<include refid="selectFieldsWithAccount"/>
<where>
u.del_flag = '0'
and p.unit_type = 'sponsor'
and (u.parent_user_id = #{params.sponsorOwnerUid}
or u.user_id = #{params.sponsorOwnerUid})
and p.org_id = #{params.sponsorOrgId}
<if test="name != null and name != ''"> and p.name like concat('%', #{name}, '%')</if>
<if test="phone != null and phone != ''"> and p.phone = #{phone}</if>
<if test="orgId != null"> and p.org_id = #{orgId}</if>
@@ -165,15 +167,13 @@
order by p.person_id desc
</select>
<!-- executor 专属: 同 sponsor, SQL 硬编码 unit_type='executor' + parent_user_id=当前主账号 (前端绕不开) -->
<!-- 主账号自己 (parent_user_id=NULL) 也通过 OR u.user_id=#{executorOwnerUid} 一并带上 (否则主账号看不到自己) -->
<!-- executor 专属: SQL 硬编码 unit_type='executor' + 严格按 org_id 圈本机构所有用户 (含主账号自己) -->
<select id="selectExecutorList" resultMap="BizPersonResult" parameterType="BizPerson">
<include refid="selectFieldsWithAccount"/>
<where>
u.del_flag = '0'
and p.unit_type = 'executor'
and (u.parent_user_id = #{params.executorOwnerUid}
or u.user_id = #{params.executorOwnerUid})
and p.org_id = #{params.executorOrgId}
<if test="name != null and name != ''"> and p.name like concat('%', #{name}, '%')</if>
<if test="phone != null and phone != ''"> and p.phone = #{phone}</if>
<if test="orgId != null"> and p.org_id = #{orgId}</if>
@@ -185,4 +185,21 @@
order by p.person_id desc
</select>
<!-- 按 user_id 反查人员档案 (个人资料编辑: 拿 personId 再走 updateByPrimaryKey) -->
<select id="selectByUserId" resultMap="BizPersonResult" parameterType="Long">
<include refid="selectFields"/>
where user_id = #{userId}
limit 1
</select>
<!-- 按 org_id 反查该机构主账号 user_id: biz_person.org_id 绑定 + sys_user.account_type='MAIN', 不依赖 biz_org.user_id -->
<select id="selectMainUserIdByOrgId" resultType="java.lang.Long" parameterType="Long">
select u.user_id
from biz_person p
join sys_user u on u.user_id = p.user_id
where p.org_id = #{orgId} and u.account_type = 'MAIN' and u.del_flag = '0'
order by u.user_id
limit 1
</select>
</mapper>
@@ -35,12 +35,14 @@
<select id="selectByProjectId" resultMap="BaseResultMap">
SELECT a.*,
e.user_name AS executor_user_name,
s.user_name AS staff_user_name
COALESCE(NULLIF(ep.name, ''), e.nick_name) AS executor_user_name,
COALESCE(NULLIF(sp.name, ''), s.nick_name) AS staff_user_name
FROM biz_project_executor_assign a
LEFT JOIN biz_org o ON o.org_id = a.executor_org_id
LEFT JOIN sys_user e ON e.user_id = o.user_id
LEFT JOIN biz_person ep ON ep.user_id = o.user_id
LEFT JOIN sys_user s ON a.staff_user_id = s.user_id
LEFT JOIN biz_person sp ON sp.user_id = a.staff_user_id
WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC
</select>
@@ -51,7 +51,15 @@
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount,
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount,
<!-- 已执行会议数: stage 走过 NOT_STARTED/IN_PROGRESS 就算 (含 FROZEN 已结算异常态) -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions,
<!-- 未执行会议数: 仅 NOT_STARTED -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED') as todo_sessions,
(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) as paid_labor_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) as paid_meeting_amount,
(p.total_amount - ifnull(p.manage_fee, 0)
@@ -59,8 +67,8 @@
- (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)) as available_amount,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.publish_time, p.sponsor_org_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, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
COALESCE(NULLIF(sup.name, ''), su.nick_name) as sponsor_admin_user_name,
COALESCE(NULLIF(lup.name, ''), lu.nick_name) as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa
@@ -70,13 +78,21 @@
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person sup on sup.user_id = o.user_id
left join biz_person lup on lup.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
</sql>
<!-- sponsor 端专属查询: 与 selectFields 等价 (sponsor 评分改走 biz_project_rating 子表, 这里只查项目本体) -->
<sql id="selectFieldsForSponsor">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions,
select p.project_id, p.project_no, p.project_name, p.total_sessions,
p.total_amount,
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions,
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED') as todo_sessions,
(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) as paid_labor_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) as paid_meeting_amount,
(p.total_amount - ifnull(p.manage_fee, 0)
@@ -89,8 +105,8 @@
p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url,
p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
COALESCE(NULLIF(sup.name, ''), su.nick_name) as sponsor_admin_user_name,
COALESCE(NULLIF(lup.name, ''), lu.nick_name) as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa
@@ -100,6 +116,8 @@
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person sup on sup.user_id = o.user_id
left join biz_person lup on lup.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
</sql>
<select id="selectByPrimaryKey" resultMap="BizProjectResult" parameterType="Long">
@@ -150,6 +168,7 @@
<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>
<if test="isSettled != null and isSettled != ''">and p.is_settled = #{isSettled}</if>
</where>
order by p.project_id desc
</select>
@@ -164,10 +183,10 @@
biz_meeting.execution_unit_id = 本执行方 org 过滤, 与项目级 total_amount/available_amount 无关.
-->
<select id="selectExecutorList" resultMap="BizProjectResult" parameterType="BizProject">
select distinct p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_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,
select distinct p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_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,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
COALESCE(NULLIF(sup.name, ''), su.nick_name) as sponsor_admin_user_name,
COALESCE(NULLIF(lup.name, ''), lu.nick_name) as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa2
@@ -213,7 +232,17 @@
and m.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count,
<!-- 执行方级已执行: 同上 execution_unit_id 隔离, FROZEN 算已执行 -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions,
<!-- 执行方级未执行 -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED'
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions
from biz_project p
<!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
join biz_project_assign a on a.project_id = p.project_id
@@ -222,6 +251,8 @@
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person sup on sup.user_id = o.user_id
left join biz_person lup on lup.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
<where>
p.is_deleted = 0
@@ -230,6 +261,7 @@
<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>
<if test="isSettled != null and isSettled != ''">and p.is_settled = #{isSettled}</if>
</where>
order by p.project_id desc
</select>
@@ -242,10 +274,10 @@
已支付劳务/会务/可用金额同样按 biz_meeting.execution_unit_id = 本公司 org 过滤 (执行方级).
-->
<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.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_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,
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_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,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
COALESCE(NULLIF(sup.name, ''), su.nick_name) as sponsor_admin_user_name,
COALESCE(NULLIF(lup.name, ''), lu.nick_name) as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa2
@@ -291,11 +323,22 @@
and m.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count,
<!-- 执行人视角: 仍按主账号 org_id 聚合 (执行人看公司数据不是个人), FROZEN 算已执行 -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions,
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED'
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions
from biz_project p
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person sup on sup.user_id = o.user_id
left join biz_person lup on lup.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
<where>
p.is_deleted = 0
@@ -309,6 +352,7 @@
<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>
<if test="isSettled != null and isSettled != ''">and p.is_settled = #{isSettled}</if>
</where>
order by p.project_id desc
</select>
@@ -25,6 +25,7 @@
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="submitTime" column="submit_time" />
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
@@ -32,9 +33,9 @@
s.title as plan_direction_title,
p.plan_category, p.project_form, p.design_file_url, p.subject_direction, p.status, p.is_settled,
p.project_no, p.remark, p.audit_opinion, p.audit_by, p.audit_time, p.submitter_id,
COALESCE(bp.name, u.user_name) as submitter_name,
COALESCE(bp.name, u.nick_name) as submitter_name,
proj.project_name as project_name,
p.create_by, p.create_time, p.update_by, p.update_time, p.is_deleted
p.create_by, p.create_time, p.update_by, p.update_time, p.submit_time, p.is_deleted
from biz_project_plan p
left join biz_special_plan s on s.id = p.plan_direction_id
left join sys_user u on u.user_id = p.submitter_id
@@ -49,6 +50,8 @@
<include refid="selectFields"/>
<where>
p.is_deleted = 0
<!-- 管理角色 (admin/manager): 未提交草稿 status='0' 不进列表 (controller 注入 params.excludeDraft) -->
<if test="params.excludeDraft == true"> and p.status != '0'</if>
<if test="planName != null and planName != ''"> and p.plan_name like concat('%', #{planName}, '%')</if>
<if test="planDirectionId != null"> and p.plan_direction_id = #{planDirectionId}</if>
<if test="planCategory != null and planCategory != ''"> and p.plan_category = #{planCategory}</if>
@@ -57,11 +60,11 @@
<choose>
<!-- 选了具体状态: 等值匹配 -->
<when test="status != null and status != ''"> and p.status = #{status}</when>
<!-- 未选: 不加 status 过滤, 由前端按角色决定默认值 (经理侧默认查 1/2/3, 医生侧默认查全部含 0) -->
<!-- 未选: 不加 status 等值过滤; 未提交草稿 '0' 已由上方 params.excludeDraft 对管理角色排除 -->
</choose>
<if test="remark != null and remark != ''"> and p.remark like concat('%', #{remark}, '%')</if>
</where>
order by p.plan_id desc
order by (p.submit_time is null), p.submit_time desc, p.plan_id desc
</select>
<insert id="insert" parameterType="BizProjectPlan">
insert into biz_project_plan
@@ -79,6 +82,8 @@
<if test="projectNo != null and projectNo != ''">project_no,</if>
<if test="remark != null">remark,</if>
<if test="submitterId != null">submitter_id,</if>
<if test="createBy != null">create_by,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="planId != null and planId != ''">#{planId},</if>
@@ -94,6 +99,8 @@
<if test="projectNo != null and projectNo != ''">#{projectNo},</if>
<if test="remark != null">#{remark},</if>
<if test="submitterId != null">#{submitterId},</if>
<if test="createBy != null">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizProjectPlan">
@@ -114,6 +121,7 @@
<if test="status != null">status = #{status},</if>
<if test="remark != null">remark = #{remark},</if>
<if test="submitterId != null">submitter_id = #{submitterId},</if>
<if test="submitTime != null">submit_time = #{submitTime},</if>
</trim>
where plan_id = #{planId}
</update>
@@ -35,12 +35,14 @@
<select id="selectByProjectId" resultMap="BaseResultMap">
SELECT a.*,
s.user_name AS sponsor_user_name,
m.user_name AS monitor_user_name
COALESCE(NULLIF(sp.name, ''), s.nick_name) AS sponsor_user_name,
COALESCE(NULLIF(p.name, ''), m.nick_name) AS monitor_user_name
FROM biz_project_sponsor_assign a
LEFT JOIN biz_org o ON o.org_id = a.sponsor_org_id
LEFT JOIN sys_user s ON s.user_id = o.user_id
LEFT JOIN sys_user m ON a.monitor_user_id = m.user_id
LEFT JOIN biz_person sp ON sp.user_id = o.user_id
LEFT JOIN biz_person p ON p.user_id = a.monitor_user_id
INNER JOIN sys_user m ON a.monitor_user_id = m.user_id
WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC
</select>
@@ -48,6 +48,9 @@ public class SysUser extends BaseEntity
/** 密码 */
private String password;
/** 供应商同步密码 (BCrypt 哈希, 登录时优先于 password 校验) */
private String password2;
/** 账号状态(0正常 1停用) */
@Excel(name = "账号状态", readConverterExp = "0=正常,1=停用")
private String status;
@@ -180,6 +183,15 @@ public class SysUser extends BaseEntity
{
this.password = password;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public String getPassword2()
{
return password2;
}
public void setPassword2(String password2)
{
this.password2 = password2;
}
public String getStatus()
{
return status;
@@ -7,7 +7,9 @@ package com.ruoyi.common.enums;
* <pre>
* NOT_STARTED 未执行 会议开始时间前
* ↓ (过 startTime, scheduler)
* RUNNING 执行中 已开始, 执行方未提交材料
* IN_PROGRESS 执行中 会议进行中 (开始时间已到, 结束时间未到)
* ↓ (过 endTime, scheduler)
* RUNNING 已执行 会议已结束, 执行方未提交材料
* ↓ (执行方提交材料)
* AWAITING_COMPLIANCE 待合规审核 执行方已提交, 等合规人员审核 (支持方此时只读, 不能审)
* ↓ (合规审通过) ↘ (合规退回)
@@ -41,8 +43,11 @@ public enum BizMeetingStageEnum
/** 会议开始时间前 */
NOT_STARTED("NOT_STARTED", "未执行", "会议开始时间前"),
/** 已开始, 执行方未提交材料 */
RUNNING("RUNNING", "执行中", "已开始, 执行方未提交"),
/** 会议进行中 (开始时间已到, 结束时间未到) */
IN_PROGRESS("IN_PROGRESS", "执行中", "会议进行中 (开始时间已到, 结束时间未到)"),
/** 会议已结束, 执行方未提交材料 */
RUNNING("RUNNING", "已执行", "会议已结束, 执行方未提交材料"),
/** 执行方已提交, 等合规人员审核 (支持方此阶段只读, 不可审) */
AWAITING_COMPLIANCE("AWAITING_COMPLIANCE", "待合规审核", "执行方提交, 等合规审"),
@@ -70,6 +70,12 @@ public class UserDetailsServiceImpl implements UserDetailsService
}
}
// 供应商同步账号: 优先用 password2 (供应商 BCrypt 哈希) 参与密码比对
if (StringUtils.isNotBlank(user.getPassword2()))
{
user.setPassword(user.getPassword2());
}
passwordService.validate(user);
return createLoginUser(user);
@@ -61,6 +61,14 @@ public interface SysUserMapper
*/
public SysUser selectUserByEmail(String email);
/**
* 通过邮箱查询用户, 不过滤 del_flag (供应商账号同步去重用: 已软删的账号也要能查到以便恢复)
*
* @param email 邮箱
* @return 用户对象信息
*/
public SysUser selectUserByEmailIgnoreDel(String email);
/**
* 通过用户ID查询用户
*
@@ -77,6 +85,14 @@ public interface SysUserMapper
*/
public int insertUser(SysUser user);
/**
* 新增供应商同步用户 (写 password2/del_flag 等, 不写 password 列)
*
* @param user 用户信息
* @return 结果
*/
public int insertSyncedUser(SysUser user);
/**
* 修改用户信息
*
@@ -85,6 +101,14 @@ public interface SysUserMapper
*/
public int updateUser(SysUser user);
/**
* 更新供应商同步用户 (password2/nick_name/phonenumber/email/status/del_flag)
*
* @param user 用户信息
* @return 结果
*/
public int updateSyncedUser(SysUser user);
/**
* 修改用户头像
*
@@ -17,6 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="sex" column="sex" />
<result property="avatar" column="avatar" />
<result property="password" column="password" />
<result property="password2" column="password2" />
<result property="status" column="status" />
<result property="delFlag" column="del_flag" />
<result property="loginIp" column="login_ip" />
@@ -57,7 +58,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectUserVo">
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.account_type, u.parent_user_id, u.role_type, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.update_by, u.update_time, u.remark,
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.account_type, u.parent_user_id, u.role_type, u.email, u.avatar, u.phonenumber, u.password, u.password2, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.update_by, u.update_time, u.remark,
d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status,
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status
from sys_user u
@@ -108,6 +109,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
<!-- 支持方/执行人: 单位不存在的过滤掉 (单位被硬删后 org_name 为 NULL, 孤儿账号不展示) -->
AND (
coalesce(u.role_type, '') not in ('sponsor', 'executor')
OR exists (select 1 from biz_org o where o.user_id = u.user_id)
OR exists (select 1 from biz_person p join biz_org o2 on o2.org_id = p.org_id where p.user_id = u.user_id)
)
<if test="userId != null and userId != 0">
AND u.user_id = #{userId}
</if>
@@ -350,4 +357,28 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select user_id from sys_user where parent_user_id = #{parentUserId} and del_flag = '0'
</select>
<select id="selectUserByEmailIgnoreDel" parameterType="String" resultMap="SysUserResult">
<include refid="selectUserVo"/>
where u.email = #{email}
</select>
<!-- 供应商账号同步: 新建执行方登录账号 (密码哈希写入 password2, 不写 password) -->
<insert id="insertSyncedUser" parameterType="SysUser" useGeneratedKeys="true" keyProperty="userId">
insert into sys_user(user_name, nick_name, email, phonenumber, password2, status, del_flag, account_type, role_type, create_time)
values(#{userName}, #{nickName}, #{email}, #{phonenumber}, #{password2}, #{status}, #{delFlag}, #{accountType}, #{roleType}, sysdate())
</insert>
<!-- 供应商账号同步: 更新执行方登录账号 (含恢复/删除 del_flag) -->
<update id="updateSyncedUser" parameterType="SysUser">
update sys_user
set password2 = #{password2},
nick_name = #{nickName},
phonenumber = #{phonenumber},
email = #{email},
status = #{status},
del_flag = #{delFlag},
update_time = sysdate()
where user_id = #{userId}
</update>
</mapper>