diff --git a/refer/BiddingSupplierAccountApiCodec.java b/refer/BiddingSupplierAccountApiCodec.java deleted file mode 100644 index 4423255..0000000 Binary files a/refer/BiddingSupplierAccountApiCodec.java and /dev/null differ diff --git a/refer/bindingdemo(1).txt b/refer/bindingdemo(1).txt new file mode 100644 index 0000000..7f4f3b1 --- /dev/null +++ b/refer/bindingdemo(1).txt @@ -0,0 +1,147 @@ +package com.bidding.supplier.security.openapi; + +import com.alibaba.fastjson2.JSON; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** + * Supplier account API codec delivered to the customer for payload decryption + * and BCrypt password verification. + */ +public class BiddingSupplierAccountApiCodec +{ + private static final String PROTOCOL = "BSA"; + private static final String VERSION = "v1"; + private static final int IV_LENGTH = 12; + private static final int GCM_TAG_BITS = 128; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + + private final String keyId; + private final String prefix; + private final byte[] aesKey; + + public BiddingSupplierAccountApiCodec(String keyId, String aesKeyBase64) + { + if (keyId == null || !keyId.matches("^[A-Za-z0-9_-]{1,32}$")) + { + throw new IllegalArgumentException("Invalid supplier account API key id"); + } + try + { + this.aesKey = Base64.getDecoder().decode(aesKeyBase64 == null ? "" : aesKeyBase64.trim()); + } + catch (IllegalArgumentException exception) + { + throw new IllegalArgumentException("Invalid supplier account API AES key", exception); + } + if (aesKey.length != 32) + { + throw new IllegalArgumentException("Supplier account API AES key must be 32 bytes"); + } + this.keyId = keyId; + this.prefix = PROTOCOL + "." + VERSION + "." + keyId + "."; + } + + public String decryptPayload(String payload) + { + try + { + String[] parts = parsePayload(payload); + byte[] iv = Base64.getUrlDecoder().decode(parts[3]); + byte[] ciphertextAndTag = Base64.getUrlDecoder().decode(parts[4]); + if (iv.length != IV_LENGTH || ciphertextAndTag.length <= GCM_TAG_BITS / 8) + { + throw new IllegalArgumentException("Invalid supplier account API payload"); + } + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"), + new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ciphertextAndTag), StandardCharsets.UTF_8); + } + catch (IllegalArgumentException exception) + { + throw exception; + } + catch (Exception exception) + { + throw new IllegalArgumentException("Unable to decrypt supplier account API payload", exception); + } + } + + public T decryptPayload(String payload, Class targetType) + { + if (targetType == null) + { + throw new IllegalArgumentException("Target type is required"); + } + return JSON.parseObject(decryptPayload(payload), targetType); + } + + /** + * Generate a BCrypt hash for a new or changed customer-platform password. + */ + public String encodePassword(String rawPassword) + { + if (rawPassword == null) + { + throw new IllegalArgumentException("Raw password is required"); + } + return PASSWORD_ENCODER.encode(rawPassword); + } + + public boolean matchesPassword(String rawPassword, String bcryptPasswordHash) + { + if (rawPassword == null || bcryptPasswordHash == null || bcryptPasswordHash.isBlank()) + { + return false; + } + try + { + return PASSWORD_ENCODER.matches(rawPassword, bcryptPasswordHash); + } + catch (IllegalArgumentException exception) + { + return false; + } + } + + String encryptPayload(Object payload) + { + try + { + byte[] iv = new byte[IV_LENGTH]; + SECURE_RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"), + new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ciphertextAndTag = cipher.doFinal(JSON.toJSONBytes(payload)); + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + return prefix + encoder.encodeToString(iv) + "." + encoder.encodeToString(ciphertextAndTag); + } + catch (Exception exception) + { + throw new IllegalStateException("Unable to encrypt supplier account API payload", exception); + } + } + + private String[] parsePayload(String payload) + { + if (payload == null || !payload.startsWith(prefix)) + { + throw new IllegalArgumentException("Invalid supplier account API payload"); + } + String[] parts = payload.split("\\.", 5); + if (parts.length != 5 || !PROTOCOL.equals(parts[0]) || !VERSION.equals(parts[1]) + || !keyId.equals(parts[2])) + { + throw new IllegalArgumentException("Invalid supplier account API payload"); + } + return parts; + } +} diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java index 7719c89..43bb07a 100644 --- a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java @@ -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()); + } + /** * 获取用户信息 * diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index cdd4b46..fd4f0a5 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -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 微服务) diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizAuthController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizAuthController.java index adf0e38..0d74d1c 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizAuthController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizAuthController.java @@ -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 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("待分配"); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExecutionIntentController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExecutionIntentController.java index 7d28fc3..7d58e73 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExecutionIntentController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizExecutionIntentController.java @@ -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()); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java index 6c56842..1f50321 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java @@ -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); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java index c63c100..9ad2156 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingController.java @@ -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); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java index c40dc6f..5ed7b55 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingMaterialController.java @@ -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 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 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 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); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingStageStatsController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingStageStatsController.java new file mode 100644 index 0000000..6c20d66 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingStageStatsController.java @@ -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 用). + * + *

独立成 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 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 r : bizMeetingService.selectStageStats(q)) { + // resultType=map 的 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0" + String stage = null; + long cnt = 0L; + for (Map.Entry 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); + } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMessageController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMessageController.java index 1e27b17..de3ba30 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMessageController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMessageController.java @@ -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 rows = bizMessageService.selectMyRecent(uid, limit); + List 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 data = new HashMap<>(); data.put("rows", rows); data.put("unread", unread); - data.put("total", rows.size()); + data.put("total", total); return success(data); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPersonController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPersonController.java index ee93c5d..c74b6ae 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPersonController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPersonController.java @@ -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 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 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 diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java index 91a8502..e05a9f7 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java @@ -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 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 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 `` 仅更新这一列 - } + // 写明细后重算聚合分 (聚合分只由后端从明细算, 前端不再手写单值) + 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 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 `` 仅更新对应列 + } + private static long safeLong(Long v) { return v == null ? 0 : v; } /** diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectPlanController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectPlanController.java index 6813cc8..d805d55 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectPlanController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectPlanController.java @@ -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 list = BizProjectPlanService.selectList(BizProjectPlan); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicityIntentController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicityIntentController.java index e40d5a6..61024fb 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicityIntentController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicityIntentController.java @@ -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); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizExpert.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizExpert.java index 4b284ce..eba568e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizExpert.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizExpert.java @@ -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; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java index 5c26b4f..90aaec7 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeeting.java @@ -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; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java index f843157..b426c0b 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizMeetingAttendee.java @@ -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; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPerson.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPerson.java index f5ef8d3..659bb37 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPerson.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPerson.java @@ -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; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectPlan.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectPlan.java index ecc40ba..1c39d6f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectPlan.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectPlan.java @@ -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; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizPublicityExecutionIntentExportVo.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizPublicityExecutionIntentExportVo.java index 3332056..889d915 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizPublicityExecutionIntentExportVo.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizPublicityExecutionIntentExportVo.java @@ -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; } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizPublicitySupportIntentExportVo.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizPublicitySupportIntentExportVo.java index 3fa160b..33bccb3 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizPublicitySupportIntentExportVo.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizPublicitySupportIntentExportVo.java @@ -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; } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java index 69c7be6..fc0048d 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMeetingMapper.java @@ -1,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 selectList(BizMeeting entity); + /** + * 会议阶段统计 (仅 sponsor/Home KPI 用): 按 current_stage 分组计数, 只套 sponsor 数据权限 (不参与分页). + * 返回 [{currentStage: 'RUNNING', cnt: 10}, ...]. 其他角色的统计另行实现, 不在此复用多角色过滤. + */ + List> 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 (执行中). + *

由 MeetingStageScheduler 每分钟触发. 只写 current_stage 缓存, 不动 is_executed 事实. + */ + int markInProgress(); + /** + * 自动流转: end_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING (已执行). *

由 MeetingStageScheduler 每分钟触发. 事实 + current_stage 缓存一起写. */ int markExecuted(); @@ -40,9 +53,10 @@ public interface BizMeetingMapper */ List selectPendingFeeCalcIds(); /** - * 置未汇总 (人员/材料变化触发, 幂等). + * 费用重算状态机用: 直接置 fee_calc_status = #{status} (-1 计算中 / 0 待算兜底). + *

成功(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); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMessageMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMessageMapper.java index 4c03901..7fc67cc 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMessageMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizMessageMapper.java @@ -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); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java index b587725..ff13a0d 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java @@ -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); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizPersonMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizPersonMapper.java index 61ad0fb..d5712a9 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizPersonMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizPersonMapper.java @@ -13,6 +13,10 @@ public interface BizPersonMapper List selectSponsorList(BizPerson entity); /** executor 专属: 同 sponsor, SQL 硬编码 unit_type='executor' (前端绕不开) */ List 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); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssZipService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssZipService.java index dfb7f21..e9e3fad 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssZipService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/oss/OssZipService.java @@ -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(); + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/FeeCalcScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/FeeCalcScheduler.java index 6476ab8..db9dcdc 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/FeeCalcScheduler.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/FeeCalcScheduler.java @@ -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; /** - * 会议费用汇总调度器 (每分钟一次, 多线程无锁). + * 会议费用汇总调度器 (每分钟一次, 多线程无锁) — 兜底通道. *

- * 两态设计 (不用抢占锁): 会议 fee_calc_status (0未汇总/1已汇总) + 材料 fee_status (0未计算/1已计算). + * 主通道: 材料保存/上传、发票 OCR 完成后由 {@link IBizMeetingService#recomputeMeetingFee} 立即同步重算, + * 状态机 -1(计算中) → 1(成功) / 0(失败回滚). 本调度器只兜底扫描 fee_calc_status=0 的会议重试, + * 覆盖"立即算时发票还没 OCR 完 / 算错"留下的 0 态. *

- *   每分钟: 查 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 重算
  * 
- * 为什么不需要锁: 汇总前已检查"材料全算完", 天然防半成品; 汇总纯 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 mats = materialMapper.selectByMeetingId(meetingId); - // 任一材料 fee_status=0 (发票待 OCR) → 跳过 - if (mats != null) - { - for (BizMeetingMaterial m : mats) - { - if (m.getFeeStatus() != null && m.getFeeStatus() == 0) - { - return; - } - } - } - - BigDecimal laborFee = attendeeMapper.sumFeePreTaxByMeetingId(meetingId); - if (laborFee == null) laborFee = BigDecimal.ZERO; - BigDecimal meetingFee = computeMeetingFee(mats); - BigDecimal totalFee = laborFee.add(meetingFee); - - meetingMapper.updateFeeSummary(meetingId, laborFee, meetingFee, totalFee); - log.info("[FeeCalcScheduler] 汇总完成 meetingId={} labor={} meeting={} total={}", - meetingId, laborFee, meetingFee, totalFee); - } - catch (Exception e) - { - log.warn("[FeeCalcScheduler] 汇总失败 meetingId={} err={}", meetingId, e.getMessage(), e); - } - } - - /** - * 会务费口径: 有总发票 (M_INVOICE 金额>0) 则只用总发票; 否则各 SUB 子类 (M_ 开头) 金额之和, - * 排除 M_INVOICE / M_SETTLEMENT (两者是汇总单据, 非子类发票). - */ - private BigDecimal computeMeetingFee(List mats) - { - if (mats == null || mats.isEmpty()) return BigDecimal.ZERO; - BigDecimal main = null; - for (BizMeetingMaterial m : mats) - { - if ("M_INVOICE".equals(m.getSubType())) - { - main = m.getAmount(); - break; - } - } - if (main != null && main.compareTo(BigDecimal.ZERO) > 0) - { - return main; - } - BigDecimal sum = BigDecimal.ZERO; - for (BizMeetingMaterial m : mats) - { - if (m.getSubType() == null || !m.getSubType().startsWith("M_")) continue; - if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType())) continue; - if (m.getAmount() != null) sum = sum.add(m.getAmount()); - } - return sum; - } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java index c88ebf5..61e667e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/MeetingStageScheduler.java @@ -11,10 +11,11 @@ import lombok.extern.slf4j.Slf4j; *

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

- *   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 (冻结)
  * 
* 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围. *

@@ -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); } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java index c1ce976..4af1100 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/scheduler/SupplierAccountPullScheduler.java @@ -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 分钟更新的账号, 解密后同步到执行方侧. *

* 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts} * 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回. * 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密. *

- * 说明: 只打印不落库 (后续需要持久化时再扩展). + * 解密后按邮箱 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 accounts = + objectMapper.convertValue(rows, new TypeReference>() {}); + supplierAccountSyncService.sync(accounts); + } + log.info("[SupplierAccountPull] page={} total={} 本页={} 同步完成", pageNum, total, size); if (size == 0 || fetched >= total) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java index 14fb1f0..66b364a 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingMaterialService.java @@ -28,6 +28,15 @@ public interface IBizMeetingMaterialService { */ List replaceByMeetingId(Long meetingId, List list); + /** + * 判断保存的材料集合相对库里是否有实际变化 (增/删 subType, 或同 subType 的 ossUrl 变化). + *

+ * 用于"保存"按钮: 会务材料没变时不应把会议费用置"统计中" (fee_calc_status=0), + * 避免无谓的汇总重算与前端"统计中"闪烁. + * 必须在 {@link #replaceByMeetingId} 之前调用 (后者会先删旧记录). + */ + boolean isMaterialSetChanged(Long meetingId, List list); + /** * 单条更新 amount (OCR 识别为发票后回写). * 不动其他字段, 不抛异常 (失败仅 log). @@ -81,6 +90,18 @@ public interface IBizMeetingMaterialService { */ String buildBatchLaborZipUrl(List meetingIds); + /** 单个会务下载文件名 (项目编号_项目名称_第N期_会务.zip) */ + String serviceZipFilename(Long meetingId); + + /** 单个劳务下载文件名 (项目编号_项目名称_第N期_劳务.zip) */ + String laborZipFilename(Long meetingId); + + /** 批量会务下载文件名 (会务_下载时间.zip) */ + String batchServiceZipFilename(); + + /** 批量劳务下载文件名 (劳务_下载时间.zip) */ + String batchLaborZipFilename(); + /** * 生成"会务材料"空目录模板 zip (会务材料 tab "打包上传" 的"下载目录"按钮). *

diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java index be29a18..3dc09fc 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingService.java @@ -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 selectList(BizMeeting entity); + /** + * 会议阶段统计 (仅 sponsor/Home KPI 用): 按 current_stage 分组计数, 只套 sponsor 数据权限. + * 返回 [{currentStage: 'RUNNING', cnt: 10}, ...]. 其他角色的统计另行实现. + */ + List> 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 每分钟扫). + *

状态机: -1(计算中) → 算成功 1 / 材料发票仍未 OCR 完或异常 回滚 0 走 FeeCalcScheduler 兜底. + * 全程一个事务, 失败不落 -1 残留. + */ + void recomputeMeetingFee(Long meetingId); + /** + * 同步重算劳务费 (参会人增删改后立即调用). + *

labor_fee = 参会人应发金额之和, 纯 DB 求和不依赖 OCR, 可当场算; + * total_fee = labor_fee + 当前 meeting_fee. 不动 meeting_fee / fee_calc_status + * (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器). + */ + void recomputeLaborFee(Long meetingId); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMessageService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMessageService.java index 1bbcaa6..26b9c26 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMessageService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMessageService.java @@ -15,6 +15,12 @@ public interface IBizMessageService /** 收件人的最近消息 (含未读) */ List selectMyRecent(Long receiverUserId, Integer limit); + /** 收件人的分页消息 (offset/limit, 未读在前) */ + List selectMyPage(Long receiverUserId, int offset, int pageSize); + + /** 收件人的全部消息总数 (分页 total) */ + int countMy(Long receiverUserId); + /** 收件人未读总数 (SSE 推送用) */ int countUnread(Long receiverUserId); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizPersonService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizPersonService.java index 59c00e4..a6a2c87 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizPersonService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizPersonService.java @@ -21,6 +21,8 @@ public interface IBizPersonService List 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); /** diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java index ff10d7a..1d6eb95 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/StageDeriver.java @@ -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 缓存 (列表筛选按物理态精确匹配). + *

注意: 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 < start_time), 1=执行中 (start_time ≤ now < end_time), + * 2=已执行 (now ≥ end_time 或 is_executed=1). 仅用于材料未提交 (N) 的展示措辞. + *

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; + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizAdminUserServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizAdminUserServiceImpl.java index f69cc68..f320d28 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizAdminUserServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizAdminUserServiceImpl.java @@ -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(); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExpertServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExpertServiceImpl.java index 3893685..a6b93da 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExpertServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizExpertServiceImpl.java @@ -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); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java index b88b810..1960988 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java @@ -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) List 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)); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java index 95debd3..b1a0ed9 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java @@ -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 list) { + // 只关心"影响会务费"的服务类发票 (M_*): 劳务材料/凭证/现场照片等不影响会务费, 直接忽略. + // 会务费口径 (见 FeeCalcScheduler.computeMeetingFee): 有总发票 M_INVOICE 就用它, 否则各 M_* 子发票之和. + List oldList = bizMeetingMaterialMapper.selectByMeetingId(meetingId); + Map oldUrl = new HashMap<>(); + if (oldList != null) { + for (BizMeetingMaterial o : oldList) { + if (isFeeRelevant(o.getSubType())) oldUrl.put(o.getSubType(), o.getOssUrl()); + } + } + Map 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 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 meetingIds) { List ids = normalizeIds(meetingIds); - String prefix = "download/huiwu/batch/" + System.currentTimeMillis() + "/"; + String prefix = "download/huiwu/batch/" + batchZipName("会务") + "/"; ossZipService.clearPrefix(prefix); Set usedFolders = new HashSet<>(); int copied = 0; @@ -362,7 +406,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService public String buildBatchLaborZipUrl(List meetingIds) { List ids = normalizeIds(meetingIds); - String prefix = "download/labor/batch/" + System.currentTimeMillis() + "/"; + String prefix = "download/labor/batch/" + batchZipName("劳务") + "/"; ossZipService.clearPrefix(prefix); Set 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 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 集合里. diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java index aa9b06a..348f214 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingServiceImpl.java @@ -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 selectList(BizMeeting entity) { return bizMeetingMapper.selectList(entity); } @Override + public List> 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 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 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); + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java index fb560f4..f3dd6ce 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java @@ -38,6 +38,22 @@ public class BizMessageServiceImpl implements IBizMessageService return bizMessageMapper.selectMyRecent(q); } + @Override + public List 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) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizOrgServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizOrgServiceImpl.java index c09db9d..b93668c 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizOrgServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizOrgServiceImpl.java @@ -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) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java index bf5aff8..a166ba0 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizPersonServiceImpl.java @@ -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) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectPlanServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectPlanServiceImpl.java index 88f2d44..803ebe8 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectPlanServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectPlanServiceImpl.java @@ -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 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) diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java index 4cd35e9..b22412f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java @@ -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 diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java index 168d290..62fc987 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizSignServiceImpl.java @@ -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 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 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 map(String value, String label) { Map m = new HashMap<>(); m.put("value", value); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java index 0f881d4..7931abc 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java @@ -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()); } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java index 4da25d6..8ff50ea 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sms/AliyunSmsSender.java @@ -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 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; diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccount.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccount.java new file mode 100644 index 0000000..84f4239 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccount.java @@ -0,0 +1,63 @@ +package com.ruoyi.business.supplier; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * 供应商账号接口解密后的单条账号 (对应 refer/bindingdemo 里 decrypted rows[] 的一行). + *

+ * 登录名 = 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; } +} diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountSyncService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountSyncService.java new file mode 100644 index 0000000..a276fcc --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/supplier/SupplierAccountSyncService.java @@ -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 到执行方侧三张表. + *

+ * 去重键 = 邮箱 (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) + *

+ * 状态同步: 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 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 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; + } +} diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizExpertMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizExpertMapper.xml index 42c6deb..d08f247 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizExpertMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizExpertMapper.xml @@ -15,6 +15,7 @@ + @@ -27,7 +28,7 @@ - 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 - 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 diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml index 6475c6a..a4c7640 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMeetingMapper.xml @@ -11,6 +11,7 @@ + @@ -62,12 +63,14 @@ from biz_meeting where meeting_id = #{meetingId} and is_deleted = 0 + + + + + insert into biz_meeting @@ -250,7 +272,27 @@ and execution_unit_id = #{executionUnitId} and period_no = #{periodNo} - + + + + + 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 <= NOW() + and end_time > NOW() + and labor_audit_stage = 'NOT_SUBMITTED' + and service_audit_stage = 'NOT_SUBMITTED' + + 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 <= NOW() + and end_time is not null + and end_time <= NOW() and labor_audit_stage = 'NOT_SUBMITTED' and service_audit_stage = 'NOT_SUBMITTED' @@ -282,9 +324,9 @@ - - - update biz_meeting set fee_calc_status = 0 where meeting_id = #{meetingId} + + + update biz_meeting set fee_calc_status = #{status} where meeting_id = #{meetingId} @@ -295,4 +337,11 @@ fee_calc_status = 1 where meeting_id = #{meetingId} + + + update biz_meeting + set labor_fee = #{laborFee}, + total_fee = #{totalFee} + where meeting_id = #{meetingId} + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMessageMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMessageMapper.xml index 80018db..f12f0a2 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMessageMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizMessageMapper.xml @@ -21,9 +21,11 @@ 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 + + + + diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizPersonMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizPersonMapper.xml index 2a980ea..9cc04fd 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizPersonMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizPersonMapper.xml @@ -11,6 +11,7 @@ + @@ -23,14 +24,14 @@ - 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 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 @@ department, position, unit_type, + is_synced, user_id, create_by, create_time, @@ -96,6 +98,7 @@ #{department}, #{position}, #{unitType}, + #{isSynced}, #{userId}, #{createBy}, sysdate(), @@ -110,6 +113,7 @@ user_id = #{userId}, org_id = #{orgId}, unit_type = #{unitType}, + is_synced = #{isSynced}, name = #{name}, phone = #{phone}, department = #{department}, @@ -145,15 +149,13 @@ - - + - - + + + + + + + diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml index 7c4e159..9d0a4c9 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectExecutorAssignMapper.xml @@ -35,12 +35,14 @@ diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml index 7f4db12..3df9052 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml @@ -51,7 +51,15 @@ - 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, + + (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) @@ -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 - 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 @@ -164,10 +183,10 @@ biz_meeting.execution_unit_id = 本执行方 org 过滤, 与项目级 total_amount/available_amount 无关. --> @@ -242,10 +274,10 @@ 已支付劳务/会务/可用金额同样按 biz_meeting.execution_unit_id = 本公司 org 过滤 (执行方级). --> diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml index 5e8ab7a..64de2a6 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml @@ -25,6 +25,7 @@ + @@ -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 @@ p.is_deleted = 0 + + and p.status != '0' and p.plan_name like concat('%', #{planName}, '%') and p.plan_direction_id = #{planDirectionId} and p.plan_category = #{planCategory} @@ -57,11 +60,11 @@ and p.status = #{status} - + and p.remark like concat('%', #{remark}, '%') - order by p.plan_id desc + order by (p.submit_time is null), p.submit_time desc, p.plan_id desc insert into biz_project_plan @@ -79,6 +82,8 @@ project_no, remark, submitter_id, + create_by, + create_time, #{planId}, @@ -94,6 +99,8 @@ #{projectNo}, #{remark}, #{submitterId}, + #{createBy}, + #{createTime}, @@ -114,6 +121,7 @@ status = #{status}, remark = #{remark}, submitter_id = #{submitterId}, + submit_time = #{submitTime}, where plan_id = #{planId} diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectSponsorAssignMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectSponsorAssignMapper.xml index 48acfe5..22cf3e6 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectSponsorAssignMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectSponsorAssignMapper.xml @@ -35,12 +35,14 @@ diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java index 1c0947c..0dbd7d4 100644 --- a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java @@ -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; diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/BizMeetingStageEnum.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/BizMeetingStageEnum.java index 49ba38a..61c9331 100644 --- a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/BizMeetingStageEnum.java +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/BizMeetingStageEnum.java @@ -7,7 +7,9 @@ package com.ruoyi.common.enums; *

  *   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", "待合规审核", "执行方提交, 等合规审"),
diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/UserDetailsServiceImpl.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/UserDetailsServiceImpl.java
index 682226f..a04c1bf 100644
--- a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/UserDetailsServiceImpl.java
+++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/UserDetailsServiceImpl.java
@@ -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);
diff --git a/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java b/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java
index 830d566..6047093 100644
--- a/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java
+++ b/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java
@@ -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);
+
     /**
      * 修改用户头像
      * 
diff --git a/ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml
index 1da3337..ae10285 100644
--- a/ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml
+++ b/ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml
@@ -17,6 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         
         
         
+        
         
         
         
@@ -57,7 +58,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     
 	
 	
-        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'
+		
+		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)
+		)
 		
 			AND u.user_id = #{userId}
 		
@@ -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'
 	
 
+	
+
+	
+	
+		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())
+	
+
+	
+	
+		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}
+	
+
  
\ No newline at end of file
diff --git a/ry-vue3/src/assets/banner.jpg b/ry-vue3/src/assets/banner.jpg
new file mode 100644
index 0000000..6396f1d
Binary files /dev/null and b/ry-vue3/src/assets/banner.jpg differ
diff --git a/ry-vue3/src/assets/login.jpg b/ry-vue3/src/assets/login.jpg
new file mode 100644
index 0000000..c322520
Binary files /dev/null and b/ry-vue3/src/assets/login.jpg differ
diff --git a/ry-vue3/src/assets/login.jpg1 b/ry-vue3/src/assets/login.jpg1
new file mode 100644
index 0000000..0798b77
Binary files /dev/null and b/ry-vue3/src/assets/login.jpg1 differ
diff --git a/ry-vue3/src/assets/theme.scss b/ry-vue3/src/assets/theme.scss
index 6b4f8c6..3d448a7 100644
--- a/ry-vue3/src/assets/theme.scss
+++ b/ry-vue3/src/assets/theme.scss
@@ -1,7 +1,7 @@
 /* =============================================================
    品牌主题色(Brand Theme)— 低调绿色主调
    ------------------------------------------------------------
-   主色:#15803D 低饱和深绿 (政务/卫健委稳重型, 不刺眼)
+   主色:#42a288 低饱和绿 (政务/卫健委稳重型, 不刺眼)
    次色:#0E7490 青蓝 (保留原品牌青蓝, 用作次要强调/链接)
    配色逻辑:
      - 主色绿 = 品牌色 (按钮/标题/侧栏高亮/KPI)
@@ -11,7 +11,7 @@
 
 :root {
   /* —— 主色:低调深绿 (政务/卫健委风格) —— */
-  --brand-primary: #15803D;
+  --brand-primary: #42a288;
   --brand-primary-deep: #0F5F2E;     /* hover / active */
   --brand-primary-darker: #073D1D;   /* 深底 (登录页 banner / 关键装饰) */
   --brand-primary-text: #16A34A;     /* 链接/文字绿 (中等可读) */
diff --git a/ry-vue3/src/components/NoticeList.vue b/ry-vue3/src/components/NoticeList.vue
index 2811ee3..b00c0ec 100644
--- a/ry-vue3/src/components/NoticeList.vue
+++ b/ry-vue3/src/components/NoticeList.vue
@@ -41,6 +41,19 @@
       
  • {{ emptyText }}
  • + +
    + +
    + \ No newline at end of file diff --git a/ry-vue3/src/router/index.js b/ry-vue3/src/router/index.js index 03f6bb5..42d5f44 100644 --- a/ry-vue3/src/router/index.js +++ b/ry-vue3/src/router/index.js @@ -26,6 +26,7 @@ const routes = [ { path: 'users', name: 'admin-users', component: () => import('@/views/admin/Users.vue'), meta: { title: '用户管理' } }, { path: 'roles', name: 'admin-roles', component: () => import('@/views/admin/Roles.vue'), meta: { title: '角色管理' } }, { path: 'projects', name: 'admin-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } }, + { path: 'projects/detail/:projectId', name: 'admin-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } }, { path: 'meetings', name: 'admin-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议管理' } }, { path: 'meetings/detail/:meetingId', name: 'admin-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } }, { path: 'meetings/view/:meetingId', name: 'admin-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } }, @@ -49,6 +50,7 @@ const routes = [ { path: 'article', name: 'admin-article', component: () => import('@/views/admin/BizArticleAdmin.vue'), meta: { title: '协议管理' } }, { path: 'article/edit/:id', name: 'admin-article-edit', component: () => import('@/views/admin/BizArticleEdit.vue'), meta: { title: '编辑文章' } }, { path: 'special-plan', name: 'admin-special-plan', component: () => import('@/views/admin/BizSpecialPlanAdmin.vue'), meta: { title: '专项计划管理' } }, + { path: 'special-plan/new', name: 'admin-special-plan-new', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '新建专项计划' } }, { path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } }, { path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } }, { path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } }, @@ -109,7 +111,7 @@ const routes = [ ] }, { path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [ - { path: '', redirect: { name: 'executor-submissions' } }, + { path: '', redirect: { name: 'executor-overview' } }, { path: 'overview', name: 'executor-overview', component: () => import('@/views/executor/Overview.vue'), meta: { title: '首页' } }, { path: 'submissions', name: 'executor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } }, { path: 'submission/new', name: 'executor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } }, @@ -159,6 +161,15 @@ const router = createRouter({ routes }) +// 各角色首页映射 (role_type → 首页, 与 Login.vue roleHome 保持一致) +const ROLE_HOME = { + admin: '/admin/workbench', + manager: '/manager/workbench', + doctor: '/doctor/home', + executor: '/executor/overview', + sponsor: '/sponsor/home' +} + router.beforeEach((to, from, next) => { document.title = (to.meta?.title || 'BAHIM') + ' - 合规系统' // 公开路由 (无 role meta) 不拦截 @@ -168,7 +179,11 @@ router.beforeEach((to, from, next) => { // 扫码带 token 直登 (签劳务): 放行, 由页面 onMounted 用 token 完成登录 if (!user && to.query?.token) return next() if (!user) return next({ name: 'login', query: { redirect: to.fullPath } }) - // 已登录: role 不匹配由后端 401 拦截, 不在前端强跳 (避免误判让用户卡死) + // 已登录: 前端校验角色匹配 (role_type 单一可信源), 不匹配跳回自己角色首页 + const userRole = user.role + if (userRole && to.meta.role !== userRole) { + return next(ROLE_HOME[userRole] || '/') + } next() }) diff --git a/ry-vue3/src/utils/meetingStage.js b/ry-vue3/src/utils/meetingStage.js index 17b89f6..edf88bc 100644 --- a/ry-vue3/src/utils/meetingStage.js +++ b/ry-vue3/src/utils/meetingStage.js @@ -76,13 +76,14 @@ function chooseState(role, labor, service) { } /** 单轨措辞 (代表轨状态 + 角色 → 展示名). */ -function render(role, s, executed) { +function render(role, s, phase) { switch (s) { case 0: // R 退回 return role === 'executor' ? '已退回' : '待整改' - case 1: // N 未提交 - if (!executed) return '未执行' - return role === 'executor' ? '执行中' : '已执行未传材料' + case 1: // N 未提交 (时间驱动三态: 未执行 → 执行中 → 已执行, 所有角色统一) + if (phase === 0) return '未执行' + if (phase === 1) return '执行中' + return '已执行' case 2: // C0 合规审中 if (role === 'sponsor') return '已执行未传材料' // 只读 return '待审核' @@ -94,6 +95,21 @@ function render(role, s, executed) { } } +/** + * 执行进度三态: 0=未执行 (now < startTime), 1=执行中 (startTime ≤ now < endTime), + * 2=已执行 (now ≥ endTime 或 isExecuted=1). 仅用于材料未提交 (N) 的展示措辞. + * isExecuted=1 是 scheduler 在 end_time 到点落库的「已执行」事实, 优先采信. + */ +function executionPhase(row) { + if (isTrue(row.isExecuted)) return 2 + const now = Date.now() + const end = row.endTime ? new Date(row.endTime).getTime() : NaN + const start = row.startTime ? new Date(row.startTime).getTime() : NaN + if (!Number.isNaN(end) && now >= end) return 2 + if (!Number.isNaN(start) && now >= start) return 1 + return 0 +} + /** * 各角色展示阶段名 (镜像后端 StageDeriver.deriveDisplay). * role ∈ {executor, sponsor, manager, admin, doctor, expert}; 非流程角色回退 admin 中性. @@ -104,7 +120,7 @@ export function deriveStage(role, row) { if (isTrue(row.isFinished)) return '已完结' if (isTrue(row.isSettled)) return '已结算' const chosen = chooseState(role, laborState(row), serviceState(row)) - return render(role, chosen, isTrue(row.isExecuted)) + return render(role, chosen, executionPhase(row)) } /** @@ -118,7 +134,7 @@ export function stageLabel(role, row) { * 展示阶段名 → 颜色映射 (class + el-tag type), 与 render() 措辞一一对应. * 颜色跟随「各角色看到的展示阶段」而非物理阶段, 避免文案与颜色错位 * (如 sponsor 看「待审核」却因物理阶段 RECTIFYING 显示红色). - * 规则: 待整改/已退回=红, 未执行=灰, 执行中/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿. + * 规则: 待整改/已退回=红, 未执行=灰, 执行中/已执行/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿. */ const STAGE_STYLE = { '冻结中': { cls: 'frozen', tag: 'info' }, @@ -128,6 +144,7 @@ const STAGE_STYLE = { '已退回': { cls: 'waiting', tag: 'danger' }, '未执行': { cls: 'pending', tag: 'info' }, '执行中': { cls: 'running', tag: 'primary' }, + '已执行': { cls: 'running', tag: 'primary' }, '已执行未传材料': { cls: 'running', tag: 'primary' }, '待审核': { cls: 'reviewing', tag: 'warning' }, '审核通过': { cls: 'done', tag: 'success' }, @@ -154,12 +171,12 @@ export function stageTag(role, row) { */ export const STAGE_OPTIONS = [ { label: '未执行', value: 'NOT_STARTED' }, - { label: '执行中', value: 'RUNNING' }, + { label: '执行中', value: 'IN_PROGRESS' }, + { label: '已执行', value: 'RUNNING' }, { label: '待合规审核', value: 'AWAITING_COMPLIANCE' }, { label: '待支持方审核', value: 'AWAITING_SUPERVISION' }, { label: '待整改', value: 'RECTIFYING' }, { label: '待结算', value: 'AWAITING_SETTLEMENT' }, - { label: '已结算', value: 'SETTLED' }, { label: '已完结', value: 'FINISHED' }, { label: '冻结中', value: 'FROZEN' }, ] diff --git a/ry-vue3/src/views/admin/BizSpecialPlanAdmin.vue b/ry-vue3/src/views/admin/BizSpecialPlanAdmin.vue index 09ad678..e989d6f 100644 --- a/ry-vue3/src/views/admin/BizSpecialPlanAdmin.vue +++ b/ry-vue3/src/views/admin/BizSpecialPlanAdmin.vue @@ -25,6 +25,11 @@ + +
    + 新增 +
    + @@ -39,10 +44,11 @@ - + @@ -64,6 +70,7 @@ diff --git a/ry-vue3/src/views/auth/RegisterSponsor.vue b/ry-vue3/src/views/auth/RegisterSponsor.vue index 2cea36c..8e15ae3 100644 --- a/ry-vue3/src/views/auth/RegisterSponsor.vue +++ b/ry-vue3/src/views/auth/RegisterSponsor.vue @@ -37,6 +37,10 @@ + + + + @@ -110,6 +114,7 @@ const agreed = ref(false) const form = reactive({ username: '', orgId: null, + realName: '', phone: '', smsCode: '', password: '', @@ -123,6 +128,7 @@ const rules = { { pattern: /^[A-Za-z0-9_]+$/, message: '只能包含字母/数字/下划线', trigger: 'blur' } ], orgId: [{ required: true, message: '请选择企业', trigger: 'change' }], + realName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }], phone: [ { required: true, message: '请输入手机号码', trigger: 'blur' }, { pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' } diff --git a/ry-vue3/src/views/doctor/Account.vue b/ry-vue3/src/views/doctor/Account.vue index 8907cd3..75ad51e 100644 --- a/ry-vue3/src/views/doctor/Account.vue +++ b/ry-vue3/src/views/doctor/Account.vue @@ -244,7 +244,7 @@ async function loadUserProfile() { } onMounted(async () => { - form.name = store.user?.userName || '' + form.name = store.user?.nickName || '' form.phone = store.user?.phonenumber || '' await Promise.all([loadUserProfile(), loadExpertProfile()]) snapshot = ref(JSON.parse(JSON.stringify(form))) diff --git a/ry-vue3/src/views/doctor/Home.vue b/ry-vue3/src/views/doctor/Home.vue index 6c905b7..1dce1cf 100644 --- a/ry-vue3/src/views/doctor/Home.vue +++ b/ry-vue3/src/views/doctor/Home.vue @@ -14,44 +14,27 @@ - -
    -
    -

    - 待参加的会议 - 更多 → -

    -
      -
    • -
      - {{ m.meetingName || m.title }} -
      - {{ formatTime(m.startTime) }} -
    • -
    • 暂无待参加会议
    • -
    -
    -
    -

    - 待签署的协议 - 更多 → -

    -
      -
    • -
      - {{ s.meetingName || ('会议 #' + s.meetingId) }} -
      - {{ s.isEsigned === 1 ? '待签署' : '未推送' }} -
    • -
    • 暂无待签协议
    • -
    -
    + +
    +

    + 待签署的协议 + 更多 → +

    +
      +
    • +
      + {{ s.meetingName || ('会议 #' + s.meetingId) }} +
      + {{ s.isEsigned === 1 ? '待签署' : '未推送' }} +
    • +
    • 暂无待签协议
    • +
    @@ -60,7 +43,7 @@ 通知消息 更多 → - + @@ -87,18 +70,17 @@ import { useUserStore } from '@/store/user' import { ElMessage } from 'element-plus' import QRCode from 'qrcode' import { getMyExpertProfile } from '@/api/business/expert' -import { listUnsignedMeetingProtocols, listInvitedMeetings } from '@/api/business/meetingAttendee' +import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee' import NoticeList from '@/components/NoticeList.vue' const store = useUserStore() -const upcomingMeetings = ref([]) const pendingAgreements = ref([]) // 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号)) const expertName = ref('') // 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家' const displayName = computed(() => - expertName.value || store.user?.nickName || store.user?.userName || '专家' + expertName.value || store.user?.nickName || '专家' ) const nowTime = ref('') @@ -154,16 +136,6 @@ async function copyQrcodeUrl() { } } -function formatTime(t) { - if (!t) return '' - const d = new Date(t) - const today = new Date() - const diff = Math.floor((d - today) / 86400000) - if (diff === 0) return d.toTimeString().slice(0, 5) - if (diff === 1) return '明天' - return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` -} - function updateClock() { const now = new Date() nowTime.value = now.toTimeString().slice(0, 5) @@ -177,13 +149,8 @@ async function load() { expertName.value = data?.name || '' } catch (e) { expertName.value = '' } - // 待参加会议 + 待签署协议: 仅审核通过的医生才拉 (未通过时 2 个 pannel 隐藏) + // 待签署协议: 仅审核通过的医生才拉 (未通过时 panel 隐藏) if (store.expertAuditApproved) { - try { - const { data } = await listInvitedMeetings() - upcomingMeetings.value = (Array.isArray(data) ? data : []).slice(0, 5) - } catch (e) { upcomingMeetings.value = [] } - try { const { data } = await listUnsignedMeetingProtocols() pendingAgreements.value = (data || []).slice(0, 5).map(s => ({ @@ -195,7 +162,6 @@ async function load() { })) } catch (e) { pendingAgreements.value = [] } } else { - upcomingMeetings.value = [] pendingAgreements.value = [] } // 通知列表已抽到 组件, 本页不再处理 @@ -215,7 +181,7 @@ onBeforeUnmount(() => { .doctor-home { padding: 16px 20px; } .breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; } .welcome-bar { background: var(--brand-primary); border-radius: 4px; padding: 20px 24px; color: #fff; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } -.welcome-text h2 { font-size: 20px; font-weight: 600; margin-bottom: 6px; } +.welcome-text h2 { font-size: 20px; font-weight: 600; margin-bottom: 6px; color: #fff; } .welcome-text p { font-size: 13px; opacity: 0.85; } .welcome-time .now { font-size: 14px; font-weight: 500; } .welcome-time .date { font-size: 12px; opacity: 0.75; margin-top: 4px; } diff --git a/ry-vue3/src/views/doctor/Meetings.vue b/ry-vue3/src/views/doctor/Meetings.vue index 77790ff..a8d0acc 100644 --- a/ry-vue3/src/views/doctor/Meetings.vue +++ b/ry-vue3/src/views/doctor/Meetings.vue @@ -10,7 +10,7 @@ - 查找重置 + 查询重置 @@ -23,12 +23,20 @@ +
    + 查看 + 下载 +
    + + +
    + 查看 + 下载 +
    + + + + + + + + + + + + diff --git a/ry-vue3/src/views/sponsor-people/SponsorPersonDetail.vue b/ry-vue3/src/views/sponsor-people/SponsorPersonDetail.vue index e75c970..6e08c70 100644 --- a/ry-vue3/src/views/sponsor-people/SponsorPersonDetail.vue +++ b/ry-vue3/src/views/sponsor-people/SponsorPersonDetail.vue @@ -152,83 +152,33 @@ onMounted(loadDetail) /* ======================================== 移动端适配 (≤768px) - - 卡片 padding 收窄 - - 工具栏横向滚动 - - filter-form: label 与输入框横向 - - 表格字号收紧 ======================================== */ @media (max-width: 768px) { /* 卡片 padding */ .page-card { padding: 12px !important; border-radius: 4px !important; } .breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; } - /* 工具栏: 横向滚动 */ - .toolbar { - flex-wrap: nowrap !important; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - padding-bottom: 6px; - margin-bottom: 8px !important; - scrollbar-width: thin; - } - .toolbar::-webkit-scrollbar { height: 4px; } - .toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; } - .toolbar :deep(.action-btn), - .toolbar :deep(.el-button) { - flex-shrink: 0; - font-size: 12px !important; - padding: 0 10px !important; - height: 30px !important; - } + /* form-card 内部 padding 收窄 */ + :deep(.form-card .el-card__body) { padding: 12px !important; } - /* filter-form: 横向 (label 左 + input 右) */ - .filter-form { + /* form-item: 横向 label + content, flex-start 让 error message 占独立行 + 不动 __label — Element Plus 自带 label-width=100px 自然对齐 */ + :deep(.el-form-item) { display: flex !important; - flex-direction: column !important; - align-items: stretch !important; - gap: 10px !important; - } - .filter-form :deep(.el-form-item) { - display: flex !important; - align-items: center !important; + align-items: flex-start !important; margin-right: 0 !important; - margin-bottom: 0 !important; } - .filter-form :deep(.el-form-item__label) { - float: none !important; - width: auto !important; - min-width: 80px !important; - text-align: right !important; - padding: 0 8px 0 0 !important; - font-size: 13px !important; - color: var(--el-text-color-regular) !important; - line-height: 32px !important; - height: 32px !important; - } - .filter-form :deep(.el-form-item__content) { - margin-left: 0 !important; - line-height: 32px !important; + :deep(.el-form-item__content) { flex: 1 !important; min-width: 0 !important; } - /* 强制所有控件全宽 */ - .filter-form :deep(.el-select), - .filter-form :deep(.el-input), - .filter-form :deep(.el-date-editor), - .filter-form :deep(.el-button), - .filter-form :deep(.el-cascader) { + /* 底部按钮: 占满整行 (返回按钮单独占一行) */ + .form-actions { + margin-top: 12px !important; + } + .form-actions :deep(.el-button) { width: 100% !important; - min-width: 0 !important; - margin-left: 0 !important; - margin-right: 0 !important; - display: block !important; } - .filter-form :deep(.el-button + .el-button) { - margin-top: 8px !important; - } - - /* 表格字号收紧 */ - :deep(.el-table) { font-size: 12px !important; } } diff --git a/ry-vue3/src/views/sponsor-people/SponsorPersonNew.vue b/ry-vue3/src/views/sponsor-people/SponsorPersonNew.vue index c0c5e24..d133986 100644 --- a/ry-vue3/src/views/sponsor-people/SponsorPersonNew.vue +++ b/ry-vue3/src/views/sponsor-people/SponsorPersonNew.vue @@ -182,83 +182,36 @@ onMounted(() => { /* ======================================== 移动端适配 (≤768px) - - 卡片 padding 收窄 - - 工具栏横向滚动 - - filter-form: label 与输入框横向 - - 表格字号收紧 ======================================== */ @media (max-width: 768px) { /* 卡片 padding */ .page-card { padding: 12px !important; border-radius: 4px !important; } .breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; } - /* 工具栏: 横向滚动 */ - .toolbar { - flex-wrap: nowrap !important; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - padding-bottom: 6px; - margin-bottom: 8px !important; - scrollbar-width: thin; - } - .toolbar::-webkit-scrollbar { height: 4px; } - .toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; } - .toolbar :deep(.action-btn), - .toolbar :deep(.el-button) { - flex-shrink: 0; - font-size: 12px !important; - padding: 0 10px !important; - height: 30px !important; - } + /* form-card 内部 padding 收窄 */ + :deep(.form-card .el-card__body) { padding: 12px !important; } - /* filter-form: 横向 (label 左 + input 右) */ - .filter-form { + /* form-item: 横向 label + content, flex-start 让 error message 占独立行 + 不动 __label — Element Plus 自带 label-width=100px 自然对齐 */ + :deep(.el-form-item) { display: flex !important; - flex-direction: column !important; - align-items: stretch !important; - gap: 10px !important; - } - .filter-form :deep(.el-form-item) { - display: flex !important; - align-items: center !important; + align-items: flex-start !important; margin-right: 0 !important; - margin-bottom: 0 !important; } - .filter-form :deep(.el-form-item__label) { - float: none !important; - width: auto !important; - min-width: 80px !important; - text-align: right !important; - padding: 0 8px 0 0 !important; - font-size: 13px !important; - color: var(--el-text-color-regular) !important; - line-height: 32px !important; - height: 32px !important; - } - .filter-form :deep(.el-form-item__content) { - margin-left: 0 !important; - line-height: 32px !important; + :deep(.el-form-item__content) { flex: 1 !important; min-width: 0 !important; } - /* 强制所有控件全宽 */ - .filter-form :deep(.el-select), - .filter-form :deep(.el-input), - .filter-form :deep(.el-date-editor), - .filter-form :deep(.el-button), - .filter-form :deep(.el-cascader) { - width: 100% !important; - min-width: 0 !important; - margin-left: 0 !important; - margin-right: 0 !important; - display: block !important; + /* 底部按钮: 等宽并排 */ + .form-actions { + flex-wrap: wrap !important; + gap: 8px !important; + margin-top: 12px !important; } - .filter-form :deep(.el-button + .el-button) { - margin-top: 8px !important; + .form-actions :deep(.el-button) { + flex: 1 1 0 !important; + width: 0 !important; } - - /* 表格字号收紧 */ - :deep(.el-table) { font-size: 12px !important; } } diff --git a/ry-vue3/src/views/sponsor/Account.vue b/ry-vue3/src/views/sponsor/Account.vue index 236329f..517bf89 100644 --- a/ry-vue3/src/views/sponsor/Account.vue +++ b/ry-vue3/src/views/sponsor/Account.vue @@ -53,7 +53,7 @@ async function onSave() { await formRef.value.validate() saving.value = true try { - await request({ url: '/system/user/profile', method: 'put', data: { nickName: form.nickName, phonenumber: form.phonenumber, sex: profile.value.sex } }) + await request({ url: '/business/person/profile', method: 'put', data: { name: form.nickName, phone: form.phonenumber } }) if (form.newPassword) { await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: form.oldPassword, newPassword: form.newPassword } }) } diff --git a/ry-vue3/src/views/sponsor/Home.vue b/ry-vue3/src/views/sponsor/Home.vue index 9732343..422a392 100644 --- a/ry-vue3/src/views/sponsor/Home.vue +++ b/ry-vue3/src/views/sponsor/Home.vue @@ -25,7 +25,7 @@
    {{ stats.completedProjects }}
    查看已结题项目
    - +
    已执行会议
    {{ stats.executedMeetings }}
    查看已执行会议
    @@ -40,14 +40,14 @@

    消息通知更多 →

    - +
    - - \ No newline at end of file diff --git a/ry-vue3/src/views/sponsor/Projects.vue b/ry-vue3/src/views/sponsor/Projects.vue index 767aaae..452d34f 100644 --- a/ry-vue3/src/views/sponsor/Projects.vue +++ b/ry-vue3/src/views/sponsor/Projects.vue @@ -41,7 +41,7 @@ - 查找 + 查询 重置 @@ -62,7 +62,11 @@ - + + + @@ -72,13 +76,13 @@ @@ -196,7 +200,6 @@ ¥{{ formatMoney(detail.availableAmount) }} ¥{{ formatMoney(detail.paidLaborAmount) }} ¥{{ formatMoney(detail.paidMeetingAmount) }} - {{ detail.managerScore || '-' }} {{ detail.isFinished === '1' ? '已结题' : '未结题' }} @@ -207,6 +210,29 @@
    + + + + + + + + + + + + + + + +
    评价维度评价内容(简洁版)平均得分
    履约质量服务/活动效果达标度{{ scoreDetail.qualityScore ?? '-' }}
    时效响应执行 & 售后响应速度{{ scoreDetail.responseScore ?? '-' }}
    配合度沟通配合 & 问题处理{{ scoreDetail.cooperationScore ?? '-' }}
    合规安全流程合规 & 无安全事故{{ scoreDetail.complianceScore ?? '-' }}
    +
    + 平均分: {{ scoreDetailTotal }} +
    + +
    @@ -234,6 +260,12 @@ const form = reactive({ const rateOpen = ref(false) const rateForm = reactive({ projectId: null, projectNo: '', projectName: '', q1: 0, q2: 0, q3: 0, q4: 0, remark: '' }) +// ========== 评分详情 (只读 4 维度明细, 点列表 score 列查看) ========== +const scoreDetailOpen = ref(false) +const scoreDetailTitle = ref('评分详情') +const scoreDetail = reactive({ qualityScore: null, responseScore: null, cooperationScore: null, complianceScore: null }) +const scoreDetailTotal = ref('—') + const assignOpen = ref(false) const assignForm = reactive({ projectId: '', projectNo: '', projectName: '', supervisor: '', supervisionPoint: '' }) const personList = ref([]) @@ -335,6 +367,26 @@ function openRate(row) { Object.assign(rateForm, { projectId: row.projectId, projectNo: row.projectNo, projectName: row.projectName, q1: 0, q2: 0, q3: 0, q4: 0, remark: '' }) rateOpen.value = true } +// 点击列表「执行单位得分(合规)/评价(支持方)」列 → 只读显示该角色 4 维度评分明细 +async function openScoreDetail(row, role) { + scoreDetailTitle.value = role === 'sponsor' ? '执行单位评分详情(支持方)' : '执行单位评分详情(合规)' + Object.assign(scoreDetail, { qualityScore: null, responseScore: null, cooperationScore: null, complianceScore: null }) + scoreDetailTotal.value = role === 'sponsor' ? (row.sponsorScore ?? '—') : (row.managerScore ?? '—') + scoreDetailOpen.value = true + try { + const r = await request({ url: '/business/project/ratings', method: 'get', params: { projectId: row.projectId } }) + const list = (r.data && (Array.isArray(r.data) ? r.data : r.data.rows)) || r.rows || [] + const items = list.filter(x => String(x.projectId) === String(row.projectId) && x.raterRole === role) + if (items.length) { + const n = items.length + const dimAvg = (key) => (items.reduce((s, x) => s + (Number(x[key]) || 0), 0) / n).toFixed(1) + scoreDetail.qualityScore = dimAvg('qualityScore') + scoreDetail.responseScore = dimAvg('responseScore') + scoreDetail.cooperationScore = dimAvg('cooperationScore') + scoreDetail.complianceScore = dimAvg('complianceScore') + } + } catch (e) { /* 拉取失败保持空, 弹窗显示 - */ } +} async function submitRate() { if (!rateForm.q1 && !rateForm.q2 && !rateForm.q3 && !rateForm.q4) { ElMessage.warning('请至少完成一项评分'); return @@ -356,10 +408,7 @@ async function submitRate() { complianceScore: rateForm.q4, remark: rateForm.remark }) - // 2. 写聚合分到 biz_project.sponsor_score - if (avg != null) { - await bizUpdate('project', { projectId: rateForm.projectId, sponsorScore: Number(avg) }) - } + // 聚合分由后端 /rate 重算, 前端不再单独写 sponsor_score ElMessage.success(`评分已提交, 平均分:${avg ?? '-'}`) rateOpen.value = false load() diff --git a/ry-vue3/src/views/sponsor/SponsorProjects.vue b/ry-vue3/src/views/sponsor/SponsorProjects.vue index 82a7d2e..bad5d58 100644 --- a/ry-vue3/src/views/sponsor/SponsorProjects.vue +++ b/ry-vue3/src/views/sponsor/SponsorProjects.vue @@ -58,7 +58,7 @@ @@ -70,12 +70,12 @@ @@ -83,13 +83,13 @@ {{ row.isFinished === '1' ? '已结题' : '未结题' }} - + - + + + + + + + + + + + + + + + + +
    评价维度评价内容(简洁版)平均得分
    履约质量服务/活动效果达标度{{ scoreDetail.qualityScore ?? '-' }}
    时效响应执行 & 售后响应速度{{ scoreDetail.responseScore ?? '-' }}
    配合度沟通配合 & 问题处理{{ scoreDetail.cooperationScore ?? '-' }}
    合规安全流程合规 & 无安全事故{{ scoreDetail.complianceScore ?? '-' }}
    +
    + 平均分: {{ scoreDetailTotal }} +
    + +
    @@ -169,7 +192,6 @@ import { ElMessage, ElMessageBox } from 'element-plus' import GrTable from '@/components/GrTable.vue' import request from '@/utils/request' import { listSponsorProjects, rateProject, sponsorAssignProject, sponsorAssignBatch, getSponsorAssigns } from '@/api/business/project' -import { bizUpdate } from '@/api/public' import { listSponsorPerson } from '@/api/business/person' import { useUserStore } from '@/store/user' @@ -196,9 +218,9 @@ function fmtMoney(_row, _col, val) { if (val === null || val === undefined || val === '') return '—' return Number(val).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } -function fmtDate(val) { +function fmtDateTime(val) { if (!val) return '—' - return String(val).substring(0, 10) + return String(val) } async function load() { @@ -242,6 +264,12 @@ const singleScoreModalOpen = ref(false) const singleScoreTargetRow = ref(null) const singleScoreForm = reactive({ qualityScore: 0, responseScore: 0, cooperationScore: 0, complianceScore: 0 }) +// ========== 评分详情 (只读 4 维度明细, 点列表 score 列查看) ========== +const scoreDetailOpen = ref(false) +const scoreDetailTitle = ref('评分详情') +const scoreDetail = reactive({ qualityScore: null, responseScore: null, cooperationScore: null, complianceScore: null }) +const scoreDetailTotal = ref('—') + async function openSingleScore(row) { singleScoreTargetRow.value = row singleScoreForm.qualityScore = 0 @@ -279,9 +307,7 @@ async function confirmSingleScore() { const avg = ((f.qualityScore + f.responseScore + f.cooperationScore + f.complianceScore) / 4).toFixed(1) const projectId = singleScoreTargetRow.value.projectId try { - // 1. 写聚合分到 biz_project.sponsor_score - await bizUpdate('project', { projectId, sponsorScore: Number(avg) }) - // 2. 写 4 维度明细到 biz_project_rating (rater_role='sponsor', 后端自动填 raterId) + // 写 4 维度明细到 biz_project_rating (rater_role='sponsor', 后端自动填 raterId + 重算聚合分) await rateProject({ projectId, raterRole: 'sponsor', @@ -298,6 +324,27 @@ async function confirmSingleScore() { } } +// 点击列表「执行单位评价(支持方)」列 → 只读显示 sponsor 4 维度评分明细 +async function openScoreDetail(row, role) { + scoreDetailTitle.value = role === 'sponsor' ? '执行单位评分详情(支持方)' : '执行单位评分详情(合规)' + Object.assign(scoreDetail, { qualityScore: null, responseScore: null, cooperationScore: null, complianceScore: null }) + scoreDetailTotal.value = role === 'sponsor' ? (row.sponsorScore ?? '—') : (row.managerScore ?? '—') + scoreDetailOpen.value = true + try { + const r = await request({ url: '/business/project/ratings', method: 'get', params: { projectId: row.projectId } }) + const list = (r.data && (Array.isArray(r.data) ? r.data : r.data.rows)) || r.rows || [] + const items = list.filter(x => String(x.projectId) === String(row.projectId) && x.raterRole === role) + if (items.length) { + const n = items.length + const dimAvg = (key) => (items.reduce((s, x) => s + (Number(x[key]) || 0), 0) / n).toFixed(1) + scoreDetail.qualityScore = dimAvg('qualityScore') + scoreDetail.responseScore = dimAvg('responseScore') + scoreDetail.cooperationScore = dimAvg('cooperationScore') + scoreDetail.complianceScore = dimAvg('complianceScore') + } + } catch (e) { /* 拉取失败保持空, 弹窗显示 - */ } +} + // ========== Modal 2: 项目分配 (单选 / 批量) ========== const assignVisible = ref(false) const assignBatchMode = ref(false) // 批量模式开关 @@ -312,7 +359,8 @@ async function loadMonitors() { // 跟 sponsor/people (人员管理) 完全一致: 走 sponsorList, SQL 硬编码 unit_type='sponsor' // + (u.parent_user_id = mainUid OR u.user_id = mainUid), 自动取当前主账号 // 仅取 SUB 子账号 (监察员候选人), 排除主账号 - const res = await listSponsorPerson({ pageNum: 1, pageSize: 100 }) + // status='0': 只拉启用账号, 已禁用的监察员不进候选列表 (SQL 层过滤, 不影响人员管理列表) + const res = await listSponsorPerson({ pageNum: 1, pageSize: 100, status: '0' }) allMonitors.value = (res.rows || []).filter(p => p.accountType === 'SUB') } /** 拉项目当前已分配监察员 → 回显 monitorUserIds (单选模式) */ @@ -393,7 +441,6 @@ onMounted(load) .toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } .link { color: var(--brand-primary); } .link-bold { color: #303133; font-weight: 600; } -.mono { font-family: monospace; color: #606266; font-size: 12px; } .pagination { margin-top: 12px; justify-content: flex-end; display: flex; } .modal-project-info { background: #f5f7fa; padding: 12px 16px; border-radius: 4px; margin-bottom: 12px; }