diff --git a/.gitignore b/.gitignore index b08b603..b50491c 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,15 @@ oalogin/ 11.pdf 11_extracted.txt proto/合规系统0904/ + +# ===== 密钥 / 敏感数据 (严禁入库) ===== +key.txt +*.rdb +*.key + +# ===== 本地临时 / 会话脚本 ===== +tmp/ +claude.sh + +# ===== 本地工具链 (自包含 git 等) ===== +opt/ diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java index 14013c9..125ce93 100644 --- a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java @@ -216,6 +216,19 @@ public class SysUserController extends BaseController return toAjax(userService.updateUserStatus(user)); } + /** + * 切换合规人员(manager)的「全局管理员」标记 (复用 account_type: MAIN=全局管理员 / SUB=普通合规员). + * 仅对 role_type=manager 生效, 其它角色在 service 层拒绝. + */ + @PreAuthorize("@ss.hasPermi('system:user:edit')") + @Log(title = "用户管理", businessType = BusinessType.UPDATE) + @PutMapping("/changeManagerAdmin") + public AjaxResult changeManagerAdmin(@RequestBody SysUser user) + { + userService.checkUserAllowed(user); + return toAjax(userService.updateManagerAdmin(user.getUserId(), user.getAccountType())); + } + /** * 根据用户编号获取授权角色 */ diff --git a/ry-api/ruoyi-admin/src/main/resources/application-prod.yml b/ry-api/ruoyi-admin/src/main/resources/application-prod.yml index 3ee24ad..5610de3 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application-prod.yml @@ -43,10 +43,3 @@ bidding-api: detail-url: https://zbpublic.guojustar.com/#/detail/ # notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list # detail-url: https://zbpublictest.guojustar.com/#/detail/ - -# 生产环境日志级别: 覆盖 application.yml 的 debug 默认值, 生产只打印 info 及以上, 减少日志噪音 -logging: - level: - com.ruoyi: info - com.ruoyi.business: info - org.springframework: warn diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index 451afb3..aeec7bf 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -215,6 +215,6 @@ server: # 日志配置 logging: level: - com.ruoyi: debug - org.springframework: debug - com.ruoyi.business: debug + com.ruoyi: info + org.springframework: warn + com.ruoyi.business: info 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 3b8369f..2600d3e 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 @@ -284,8 +284,10 @@ public class BizMeetingAttendeeController extends BaseController { public void export(HttpServletResponse response, @RequestParam("meetingId") Long meetingId) { List list = attendeeService.selectByMeetingId(meetingId); List exportList = new ArrayList<>(list.size()); - for (BizMeetingAttendee a : list) { - exportList.add(BizMeetingAttendeeImportVo.from(a)); + for (int i = 0; i < list.size(); i++) { + BizMeetingAttendeeImportVo vo = BizMeetingAttendeeImportVo.from(list.get(i)); + vo.setSeq(String.valueOf(i + 1)); + exportList.add(vo); } ExcelUtil util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class); util.exportExcel(response, exportList, "参会人"); 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 cf58014..3a7e684 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 @@ -36,6 +36,7 @@ import com.ruoyi.business.service.IBizProjectSponsorAssignService; import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.StageDeriver; import com.ruoyi.business.service.PosterService; +import com.ruoyi.business.service.SuperManagerChecker; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.system.mapper.SysUserMapper; @@ -72,6 +73,8 @@ public class BizMeetingController extends BaseController { private StageDeriver stageDeriver; @Autowired private PosterService posterService; + @Autowired + private SuperManagerChecker superManagerChecker; @GetMapping("/list") public TableDataInfo list(BizMeeting bizMeeting, @@ -115,7 +118,8 @@ public class BizMeetingController extends BaseController { bizMeeting.getParams().put("assignedExecutorUserId", aggUid); } // 合规人员(manager) 数据权限: 只看"本人创建的项目"下的会议 (project_id ∈ create_user_id = 自己的项目) - else if ("manager".equals(roleType)) { + // manager01 等白名单超级合规员不受此限制 (等同 admin 可见全部会议) + else if ("manager".equals(roleType) && !superManagerChecker.isUnrestrictedManager()) { bizMeeting.getParams().put("managerCreateUserId", uid); } // 项目负责人(leader) 数据权限: 只看"本人负责的项目"下的会议 (project_id ∈ lead_user_id = 自己) 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 8ec6cd2..0ca80b6 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 @@ -205,6 +205,35 @@ public class BizMeetingMaterialController extends BaseController { return success(result); } + /** + * 劳务材料"打包上传" — 下载空目录模板 zip (劳务明细表/劳务协议/企业权益/电子签到表/签到表/前全景/后全景/专家照片/日程海报/邀请函 全空目录; + * 签到表/前全景/后全景 仅 admin/manager 下载的模板包含). + */ + @GetMapping("/laborTemplate/{meetingId}") + public void laborTemplate(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception { + String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); + byte[] data = bizMeetingMaterialService.buildLaborTemplateZip(meetingId, roleType); + response.setContentType("application/zip"); + response.setHeader("Content-Disposition", "attachment;filename=labor-material-template.zip"); + response.getOutputStream().write(data); + response.getOutputStream().flush(); + } + + /** + * 劳务材料"打包上传" — 上传 zip, 解压后按目录分类回填 (劳务明细表批量导入 / 劳务协议·专家照片按参会人回填 / + * 企业权益·电子签到表·签到表·前后全景材料回填 / 日程海报·邀请函单文件回填会议字段). + */ + @Log(title = "劳务材料回填", businessType = BusinessType.UPDATE) + @PostMapping("/uploadLaborMaterials") + public AjaxResult uploadLaborMaterials(@RequestParam("file") MultipartFile file, + @RequestParam("meetingId") Long meetingId) throws Exception { + String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); + String jobId = bizMeetingMaterialService.submitLaborMaterialsUpload(file, meetingId, roleType); + Map result = new HashMap<>(); + result.put("jobId", jobId); + return success(result); + } + /** * 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存). *

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 a92cfaf..4c4ebf2 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 @@ -39,6 +39,7 @@ import com.ruoyi.business.service.IBizProjectAssignService; import com.ruoyi.business.service.IBizProjectRatingService; import com.ruoyi.business.service.IBizProjectSponsorAssignService; import com.ruoyi.business.service.IBizProjectExecutorAssignService; +import com.ruoyi.business.service.SuperManagerChecker; import com.ruoyi.system.domain.vo.SysUserExtendVo; import com.ruoyi.business.mapper.BizSysUserQueryMapper; import com.ruoyi.common.core.domain.entity.SysUser; @@ -78,6 +79,8 @@ public class BizProjectController extends BaseController private IBizExpertService bizExpertService; @Autowired private IBizMeetingService bizMeetingService; + @Autowired + private SuperManagerChecker superManagerChecker; /** * 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目) @@ -110,8 +113,9 @@ public class BizProjectController extends BaseController public TableDataInfo list(BizProject bizProject) { // 数据权限: manager 只看本人创建(create_user_id), leader 只看本人负责(lead_user_id), admin 不限制 + // manager01 等白名单超级合规员不受此限制 (等同 admin 可见全部项目) String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); - if ("manager".equals(roleType)) { + if ("manager".equals(roleType) && !superManagerChecker.isUnrestrictedManager()) { bizProject.getParams().put("createUserId", SecurityUtils.getUserId()); } else if ("leader".equals(roleType)) { bizProject.getParams().put("leaderUserId", SecurityUtils.getUserId()); @@ -817,11 +821,11 @@ public class BizProjectController extends BaseController util.exportExcel(response, exportList, "报名专家"); } - /** 与 list() 一致的导出数据范围: manager 只看本人创建的项目; 勾选时按 projectIds 过滤 */ + /** 与 list() 一致的导出数据范围: manager 只看本人创建的项目 (白名单超级合规员不受限); 勾选时按 projectIds 过滤 */ private void applyManagerScope(BizProject bizProject) { String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); - if ("manager".equals(roleType)) { + if ("manager".equals(roleType) && !superManagerChecker.isUnrestrictedManager()) { bizProject.getParams().put("createUserId", SecurityUtils.getUserId()); } // 勾选的项目: 前端勾选时传 projectIds, 转 params.projectIds 做 IN 过滤 diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/dto/AdminUserCreateBody.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/dto/AdminUserCreateBody.java index a7f8015..194eb1c 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/dto/AdminUserCreateBody.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/dto/AdminUserCreateBody.java @@ -34,8 +34,9 @@ public class AdminUserCreateBody { private String practiceCertUrl; private String titleCertUrl; - // ===== executor / sponsor 专用 ===== - /** MAIN=新建单位(主账号) / SUB=选已有单位(子账号) */ + // ===== executor / sponsor / manager 专用 ===== + /** executor/sponsor: MAIN=新建单位(主账号) / SUB=选已有单位(子账号); + * manager: MAIN=全局管理员(可见全量) / SUB=普通合规员(只看本人创建), 默认 SUB */ private String accountType; /** SUB: 所属单位 biz_org.org_id */ private Long orgId; diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java index ee13ec8..c36ebd7 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java @@ -21,29 +21,29 @@ import com.ruoyi.common.annotation.Excel; */ public class BizMeetingAttendeeImportVo { + /** 序号 (导入模板展示行号 / 导出自动填行号; 导入时读取但忽略, 不落库) */ + @Excel(name = "序号", sort = 1) + private String seq; + /** 医生 (原"姓名") */ - @Excel(name = "医生##ysxm", sort = 1) + @Excel(name = "医生##ysxm", sort = 2) private String name; - /** 联系电话 (原"手机号", 必填, 按此查/建 sys_user) */ - @Excel(name = "联系电话##lxdh", sort = 2) - private String phone; - - /** 医院 (原"工作单位") */ - @Excel(name = "医院##yy", sort = 3) - private String workUnit; + /** 职称 */ + @Excel(name = "职称##zc", sort = 3) + private String title; /** 科室 */ @Excel(name = "科室##ks", sort = 4) private String department; - /** 职称 */ - @Excel(name = "职称##zc", sort = 5) - private String title; + /** 医院 (原"工作单位") */ + @Excel(name = "医院##yy", sort = 5) + private String workUnit; - /** 身份证号 */ - @Excel(name = "身份证号##sfzh", sort = 6) - private String idCard; + /** 联系电话 (原"手机号", 必填, 按此查/建 sys_user) */ + @Excel(name = "联系电话##lxdh", sort = 6) + private String phone; /** 银行 */ @Excel(name = "银行##yh", sort = 7) @@ -57,46 +57,52 @@ public class BizMeetingAttendeeImportVo { @Excel(name = "开户行##khh", sort = 9) private String bankBranch; - /** 角色 (原"劳务形式": 授课/主持/评审...) */ - @Excel(name = "角色", sort = 10) - private String laborForm; - - /** 应发金额 (decimal, 元) */ - @Excel(name = "应发金额##yfje", sort = 11) - private BigDecimal feePreTax; - - /** 个税税金 (decimal, 元) */ - @Excel(name = "个税税金##sj", sort = 12) - private BigDecimal tax; - - /** 增值税及附加成本 (decimal, 元) */ - @Excel(name = "增值税及附加成本##zzsjfjcb", sort = 13) - private BigDecimal vatAndSurcharge; + /** 身份证号 */ + @Excel(name = "身份证号##sfzh", sort = 10) + private String idCard; /** 实发金额 (decimal, 元) */ - @Excel(name = "实发金额##sfje", sort = 14) + @Excel(name = "实发金额##sfje", sort = 11) private BigDecimal fee; /** 摘要 */ - @Excel(name = "摘要##zy", sort = 15) + @Excel(name = "摘要##zy", sort = 12) private String summary; + /** 角色 (原"劳务形式": 授课/主持/评审...) */ + @Excel(name = "角色", sort = 13) + private String laborForm; + + /** 应发金额 (decimal, 元) */ + @Excel(name = "应发金额##yfje", sort = 14) + private BigDecimal feePreTax; + + /** 个税税金 (decimal, 元) */ + @Excel(name = "个税税金##sj", sort = 15) + private BigDecimal tax; + + /** 增值税及附加成本 (decimal, 元) */ + @Excel(name = "增值税及附加成本##zzsjfjcb", sort = 16) + private BigDecimal vatAndSurcharge; + /** 账户名称(持卡人姓名) */ - @Excel(name = "账户名称(持卡人姓名)", sort = 16) + @Excel(name = "账户名称(持卡人姓名)", sort = 17) private String accountName; /** 开户银行地址(省/市) */ - @Excel(name = "开户银行地址", sort = 17) + @Excel(name = "开户银行地址", sort = 18) private String bankRegion; /** 银行详细地址 */ - @Excel(name = "银行详细地址", sort = 18) + @Excel(name = "银行详细地址", sort = 19) private String bankAddress; /** 身份证附件(正反面 URL CSV)— 仅导出,不进入导入模板(附件文件无法通过 Excel 导入) */ - @Excel(name = "身份证附件", sort = 19, type = Excel.Type.EXPORT) + @Excel(name = "身份证附件", sort = 20, type = Excel.Type.EXPORT) private String idCardAttachments; + public String getSeq() { return seq; } + public void setSeq(String seq) { this.seq = seq; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public String getName() { return name; } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java index 5b4fc7c..7808c52 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java @@ -74,6 +74,15 @@ public interface IBizMeetingAttendeeService { */ ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception; + /** + * 从字节流批量导入参会人 (劳务材料"打包上传"内嵌的 劳务明细表 回填用). + * 逻辑与 {@link #importFromExcel} 完全一致, 只是入参为已解压的 Excel 字节, 免于构造 MultipartFile. + * + * @param excelBytes Excel 文件字节 (xls/xlsx) + * @return ImportResult { okNum, ngNum, ngList: [{rowNum, message}] } + */ + ImportResult importFromExcelBytes(byte[] excelBytes, Long meetingId, String operName) throws Exception; + /** * 新增/编辑参会人前, 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表. * 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 0609654..f6317b5 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 @@ -128,6 +128,35 @@ public interface IBizMeetingMaterialService { */ String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception; + /** + * 生成"劳务材料"空目录模板 zip (劳务材料 tab "打包上传" 的"下载目录"按钮). + *

+ * zip 内为 10 个空目录: 劳务明细表/劳务协议/企业权益/电子签到表/签到表/前全景/后全景/专家照片/日程海报/邀请函. + * 签到表/前全景/后全景 仅 admin/manager 可见 (其他角色下载的模板不含这三个目录), + * 劳务协议/专家照片 额外生成 {序号}_{姓名}/ 子目录 (每个参会人一个), 劳务协议目录内附 说明.txt. + * + * @param meetingId 会议 id + * @param roleType 当前用户角色类型 (admin/manager 可下载含拍照签到目录的模板) + * @return zip 字节 (UTF-8 目录名) + */ + byte[] buildLaborTemplateZip(Long meetingId, String roleType); + + /** + * 提交"劳务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId, + * 后台线程解压按目录名分类回填 (劳务明细表批量导入 / 劳务协议·专家照片按参会人回填 / + * 企业权益·电子签到表·签到表·前后全景材料回填 / 日程海报·邀请函单文件回填到会议字段), + * 前端轮询 /business/upload/progress/{jobId} 拿进度与结果. + *

+ * 签到表/前全景/后全景 仅 admin/manager 可回填, 其他角色的 zip 内这三个目录文件会被忽略. + * + * @param file 用户重新压缩的 zip + * @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表) + * @param roleType 当前用户角色类型 (用于拍照签到目录的权限过滤) + * @return jobId (进度/结果轮询键) + * @throws Exception 文件为空 / 读字节失败 + */ + String submitLaborMaterialsUpload(MultipartFile file, Long meetingId, String roleType) throws Exception; + /** * 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存). *

diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/SuperManagerChecker.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/SuperManagerChecker.java new file mode 100644 index 0000000..e3becfa --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/SuperManagerChecker.java @@ -0,0 +1,28 @@ +package com.ruoyi.business.service; + +import org.springframework.stereotype.Component; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.utils.SecurityUtils; + +/** + * 超级合规员(manager)判定: 复用 {@code sys_user.account_type} 标记「全局管理员」. + * + *

role_type=manager 且 account_type=MAIN → 全局管理员 (等同 admin 可见全量, 不受「只看本人创建的项目/会议」数据范围限制); + * account_type=SUB 或 null → 普通合规员 (只看本人创建的). 其它角色不受影响.

+ * + *

不再依赖配置白名单, 改由 admin/用户管理 里切换 manager 的 account_type (MAIN/SUB) 控制, 免改代码.

+ */ +@Component +public class SuperManagerChecker +{ + /** 当前登录用户是否为「不受限」的 manager (role_type=manager 且 account_type=MAIN) */ + public boolean isUnrestrictedManager() + { + try { + SysUser user = SecurityUtils.getLoginUser().getUser(); + return "manager".equals(user.getRoleType()) && "MAIN".equals(user.getAccountType()); + } catch (Exception e) { + return false; + } + } +} 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 87897d6..d8b1d0d 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 @@ -112,6 +112,10 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService { /** 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); + if ("manager".equals(role)) { + // 合规员默认 SUB (普通合规员, 只看本人创建的数据); 勾选「全局管理员」时前端传 MAIN + u.setAccountType(b.getAccountType() == null || b.getAccountType().isEmpty() ? "SUB" : b.getAccountType()); + } sysUserService.insertUser(u); if ("manager".equals(role)) { BizPerson p = new BizPerson(); 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 e580e9e..93a057f 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 @@ -476,9 +476,19 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService @Override public ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception { if (meetingId == null) throw new ServiceException("meetingId 不能为空"); + return doImport(file.getInputStream(), meetingId, operName); + } + @Override + public ImportResult importFromExcelBytes(byte[] excelBytes, Long meetingId, String operName) throws Exception { + if (meetingId == null) throw new ServiceException("meetingId 不能为空"); + if (excelBytes == null || excelBytes.length == 0) throw new ServiceException("导入数据不能为空"); + return doImport(new ByteArrayInputStream(excelBytes), meetingId, operName); + } + + private ImportResult doImport(InputStream in, Long meetingId, String operName) throws Exception { ExcelUtil util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class); - List rows = util.importExcel(file.getInputStream()); + List rows = util.importExcel(in); if (rows == null || rows.isEmpty()) { throw new ServiceException("导入数据不能为空"); } @@ -512,12 +522,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService result.fail(rowNo, "手机号格式不正确: " + phone); continue; } - // 身份证号位数校验 (18位, 末位可为 X/x); 空值跳过 (导入不强制必填, 与前端 dialog 的必填规则分开) + // 身份证号不校验格式 (可能为护照号等非18位证件号); 仅 trim 后入库 String idCard = vo.getIdCard() == null ? null : vo.getIdCard().trim(); - if (idCard != null && !idCard.isEmpty() && !idCard.matches("^\\d{17}[\\dXx]$")) { - result.fail(rowNo, "身份证号应为18位(末位可为X): " + idCard); - continue; - } // 角色(laborForm) 必填校验 (与前端 dialog 必填规则保持一致: 角色单选 radio 不能为空; // 2026-09-08 executor 在 2609010002 会议导入时漏校验, 单条无角色行被静默入库) String laborForm = vo.getLaborForm() == null ? null : vo.getLaborForm().trim(); @@ -789,8 +795,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService public byte[] buildAttendeeInfoExcel(List list) { List voList = new ArrayList<>(); if (list != null) { - for (BizMeetingAttendee a : list) { - voList.add(BizMeetingAttendeeImportVo.from(a)); + for (int i = 0; i < list.size(); i++) { + BizMeetingAttendeeImportVo vo = BizMeetingAttendeeImportVo.from(list.get(i)); + vo.setSeq(String.valueOf(i + 1)); + voList.add(vo); } } SXSSFWorkbook wb = new SXSSFWorkbook(500); @@ -899,13 +907,18 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService uploadProgressRegistry.update(jobId, processed); continue; } - // labor_protocol 是单 URL, 每目录只取第一个文件作为协议 - String fileName = (!f.fileNames.isEmpty()) ? f.fileNames.get(0) : "labor-protocol.pdf"; - String url = ossUploader.upload(f.files.get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol"); + // 劳务协议可多文件: 目录内所有文件都上传 OSS (不打包 zip), 逗号拼接覆盖式回填 labor_protocol, 前端按逗号拆分逐个预览 + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < f.files.size(); i++) { + String fileName = (i < f.fileNames.size()) ? f.fileNames.get(i) : ("labor-protocol-" + (i + 1) + ".pdf"); + String url = ossUploader.uploadWithOriginalName(f.files.get(i), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol"); + if (sb.length() > 0) sb.append(","); + sb.append(url); + } BizMeetingAttendee entity = new BizMeetingAttendee(); entity.setId(m.attendee.getId()); - entity.setLaborProtocol(url); + entity.setLaborProtocol(sb.toString()); entity.setUpdateBy(username); updateLaborProtocol(entity); updated++; 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 690d7c2..4e54828 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,6 +5,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; +import java.util.LinkedHashMap; import java.text.SimpleDateFormat; import java.util.HashSet; import java.util.List; @@ -12,6 +13,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.regex.Pattern; +import java.util.regex.Matcher; import java.net.URI; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -51,6 +53,9 @@ import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeetingAttendee; import com.ruoyi.business.domain.BizMeetingMaterial; +import com.ruoyi.business.domain.dto.ImportResult; +import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo; +import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingInvoiceMapper; import com.ruoyi.business.mapper.BizMeetingMaterialMapper; @@ -99,6 +104,9 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService "M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL", "M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_SETTLEMENT_STAMP", "M_INVOICE"); + /** 会务材料单类最多文件数 (多文件 JSON 数组写 oss_url, 防超列宽; 与电子签到表 ESIGN_MAX_FILES 对齐) */ + private static final int SERVICE_MAX_FILES = 50; + /** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */ private static final Map SERVICE_LABEL_TO_SUBTYPE = new HashMap<>(); static { @@ -127,6 +135,29 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService LABOR_SUBTYPE_LABEL.put("LV_PAYMENT", "劳务付款凭证"); } + /** 劳务"打包上传" zip 内目录名 → 处理类别 (DETAIL=劳务明细表/AGREEMENT=劳务协议/…/SCHEDULE=日程海报/INVITATION=邀请函) */ + private static final Map LABOR_CATEGORY_BY_LABEL = new HashMap<>(); + static { + LABOR_CATEGORY_BY_LABEL.put("劳务明细表", "DETAIL"); + LABOR_CATEGORY_BY_LABEL.put("劳务协议", "AGREEMENT"); + LABOR_CATEGORY_BY_LABEL.put("企业权益", "ENTERPRISE_BENEFIT"); + LABOR_CATEGORY_BY_LABEL.put("电子签到表", "ESIGN_IN"); + LABOR_CATEGORY_BY_LABEL.put("签到表", "SIGN_IN"); + LABOR_CATEGORY_BY_LABEL.put("前全景", "PANORAMA_FRONT"); + LABOR_CATEGORY_BY_LABEL.put("后全景", "PANORAMA_BACK"); + LABOR_CATEGORY_BY_LABEL.put("专家照片", "EXPERT_PHOTO"); + LABOR_CATEGORY_BY_LABEL.put("日程海报", "SCHEDULE"); + LABOR_CATEGORY_BY_LABEL.put("邀请函", "INVITATION"); + } + + /** 劳务"打包上传"模板目录顺序 (与前端 劳务材料 tab 展示顺序一致) */ + private static final List LABOR_TEMPLATE_CATEGORIES = Arrays.asList( + "劳务明细表", "劳务协议", "企业权益", "电子签到表", + "签到表", "前全景", "后全景", "专家照片", "日程海报", "邀请函"); + + /** 拍照签到目录 (仅 admin/manager 可下载模板 + 回填) */ + private static final Set LABOR_CAMERA_LABELS = new HashSet<>(Arrays.asList("签到表", "前全景", "后全景")); + private static final String XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; @Autowired @@ -217,6 +248,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService for (BizMeetingMaterial m : list) { m.setId(null); m.setMeetingId(meetingId); + checkServiceFileCount(m); BizMeetingMaterial old = oldBySubType.get(m.getSubType()); boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl()); if (unchanged) { @@ -876,30 +908,37 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType); BizMeetingMaterial old = existingBySubType.get(subType); - // 先算好要上传的字节 + 文件名 (单文件原样 / 多文件先打 zip), 便于做"内容未变"判断 - String fileName; - byte[] toUpload; - boolean isZip = files.size() > 1; + // 多文件数量上限 (JSON 数组写 oss_url, 防超列宽): 超限直接失败, 前端轮询展示错误 + if (files.size() > SERVICE_MAX_FILES) { + throw new ServiceException(label + ": 单个材料分类最多上传 " + SERVICE_MAX_FILES + " 个文件, 当前 " + files.size() + " 个"); + } + + // 单文件: 原样直传; 多文件: 逐个直传 OSS 后 oss_url 存 JSON 数组 (不再打 zip) + String fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg"); + boolean ocrNeeded = false; + String ossUrl; if (files.size() == 1) { - fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg"); - toUpload = files.get(0); + byte[] toUpload = files.get(0); + // 内容未变且已计算过 (fee_status=1) → 跳过重传重 OCR, 保留旧金额, 不触发会务费重算 + if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1 + && contentEquals(old.getOssUrl(), toUpload)) { + log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType); + uploadProgressRegistry.update(jobId, processed); + continue; + } + ocrNeeded = RECOGNIZABLE.matcher(fileName).find(); + ossUrl = ossUploader.upload(toUpload, fileName, subDir); } else { - fileName = label + ".zip"; - toUpload = zipFiles(files, names); + List urls = new ArrayList<>(); + for (int i = 0; i < files.size(); i++) { + String n = (names != null && i < names.size()) ? names.get(i) : (label + "-" + (i + 1)); + if (!ocrNeeded && RECOGNIZABLE.matcher(n).find()) ocrNeeded = true; + urls.add(ossUploader.upload(files.get(i), n, subDir)); + } + ossUrl = JSONUtil.toJsonStr(urls); } - // 内容未变且已计算过 (fee_status=1) → 跳过重传重 OCR, 保留旧金额, 不触发会务费重算 - if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1 - && contentEquals(old.getOssUrl(), toUpload)) { - log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType); - uploadProgressRegistry.update(jobId, processed); - continue; - } - - String ossUrl = ossUploader.upload(toUpload, fileName, subDir); - - // 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 待 OCR(0), 其余已计算(1) - boolean ocrNeeded = isZip || (ossUrl != null && RECOGNIZABLE.matcher(ossUrl).find()); + // 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单/多文件里任一可识别 (jpg/png/pdf/zip) → 待 OCR(0), 其余已计算(1) int feeStatus = ocrNeeded ? 0 : 1; Long materialId; @@ -1021,7 +1060,590 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService private boolean needsOcr(BizMeetingMaterial m) { if (m == null || m.getOssUrl() == null || m.getOssUrl().isEmpty()) return false; if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false; - return RECOGNIZABLE.matcher(m.getOssUrl()).find(); + String oss = m.getOssUrl().trim(); + // 多文件 (JSON 数组字符串, 会务材料多文件直传): 任一文件可识别即需 OCR + if (oss.startsWith("[")) { + try { + JSONArray arr = JSONUtil.parseArray(oss); + for (int i = 0; i < arr.size(); i++) { + String u = arr.getStr(i); + if (u != null && RECOGNIZABLE.matcher(u).find()) return true; + } + return false; + } catch (Exception e) { + // 解析失败按单 URL 兜底 + return RECOGNIZABLE.matcher(oss).find(); + } + } + return RECOGNIZABLE.matcher(oss).find(); + } + + /** + * 会务材料多文件数量上限校验 (PUT 全量保存路径): oss_url 存 JSON 数组, 超限在写库前拦截. + * 仅对 SERVICE 材料生效; 劳务/凭证/签到表等各自有独立限制或不受此约束. + */ + private void checkServiceFileCount(BizMeetingMaterial m) { + if (m == null || m.getOssUrl() == null || !"SERVICE".equals(m.getMaterialType())) return; + String oss = m.getOssUrl().trim(); + if (!oss.startsWith("[")) return; + try { + JSONArray arr = JSONUtil.parseArray(oss); + if (arr.size() > SERVICE_MAX_FILES) { + throw new ServiceException("会务材料单个分类最多上传 " + SERVICE_MAX_FILES + " 个文件, 当前 " + arr.size() + " 个"); + } + } catch (ServiceException se) { + throw se; + } catch (Exception ignored) { + // 非 JSON 数组, 忽略 + } + } + + // =================================================================== + // 劳务材料"打包上传" (下载空目录模板 + 上传按目录分类回填) + // =================================================================== + + @Override + public byte[] buildLaborTemplateZip(Long meetingId, String roleType) { + boolean cameraAllowed = "admin".equals(roleType) || "manager".equals(roleType); + List attendees = attendeeService.selectByMeetingId(meetingId); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) { + // 顶层说明, 保证 zip 内至少有一个真实文件 (纯空目录的 zip 部分工具无法解压) + zos.putNextEntry(new ZipEntry("README.txt")); + zos.write("请把材料文件放入对应目录后,压缩成zip包并上传".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + + for (String label : LABOR_TEMPLATE_CATEGORIES) { + if (LABOR_CAMERA_LABELS.contains(label) && !cameraAllowed) continue; // 非 admin/manager 不含拍照签到目录 + zos.putNextEntry(new ZipEntry(label + "/")); + zos.closeEntry(); + if ("劳务明细表".equals(label)) { + // 明细表目录内置一张带表头的空白导入模板, 用户按表头填数据后放回即可批量导入 + byte[] excelBytes = new ExcelUtil<>(BizMeetingAttendeeImportVo.class) + .importTemplateExcelBytes("参会人导入", null); + zos.putNextEntry(new ZipEntry(label + "/参会人导入模板.xlsx")); + zos.write(excelBytes); + zos.closeEntry(); + } else if ("劳务协议".equals(label)) { + // 两种放法都兼容: 平铺 姓名_手机号.pdf 或 序号_姓名/ 子目录 + zos.putNextEntry(new ZipEntry(label + "/说明.txt")); + zos.write("协议命名为「姓名_手机号.pdf」直接放在本目录下(如 张三_13800138000.pdf)".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + for (int i = 0; i < attendees.size(); i++) { + String dir = label + "/" + (i + 1) + "_" + laborAttendeeName(attendees.get(i).getName()) + "/"; + zos.putNextEntry(new ZipEntry(dir)); + zos.closeEntry(); + } + } else if ("专家照片".equals(label)) { + zos.putNextEntry(new ZipEntry(label + "/说明.txt")); + zos.write("专家照片请铺放在本目录下,文件名用「姓名_手机号」命名。".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + } catch (IOException e) { + throw new ServiceException("生成劳务材料目录模板失败: " + e.getMessage()); + } + return baos.toByteArray(); + } + + @Override + public String submitLaborMaterialsUpload(MultipartFile file, Long meetingId, String roleType) throws Exception { + if (meetingId == null) throw new ServiceException("meetingId 不能为空"); + if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件"); + + String jobId = UUID.randomUUID().toString().replace("-", ""); + byte[] zipBytes = file.getBytes(); + // 后台线程无 SecurityContext, 在请求线程提前取好操作人 + Long userId = SecurityUtils.getUserId(); + String username = SecurityUtils.getUsername(); + uploadProgressRegistry.start(jobId); + zipUploadExecutor.submit(() -> { + try { + Map result = doUploadLaborMaterials(zipBytes, meetingId, roleType, jobId, userId, username); + uploadProgressRegistry.finish(jobId, result); + } + catch (Exception e) { + log.warn("[material] 劳务材料后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e); + uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage()); + } + }); + return jobId; + } + + private Map doUploadLaborMaterials(byte[] zipBytes, Long meetingId, String roleType, + String jobId, Long userId, String username) throws Exception { + boolean cameraAllowed = "admin".equals(roleType) || "manager".equals(roleType); + + // 1) 解压 + 按目录名分类 + Map> byCategory = new HashMap<>(); + try { + readLaborZip(zipBytes, StandardCharsets.UTF_8, byCategory); + } catch (IllegalArgumentException | MalformedInputException e) { + // 中文 Windows 打包器用 GBK 编码条目名且不置 UTF-8 标志, 回退 GBK 重解析 + log.warn("[material] 劳务材料 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage()); + byCategory.clear(); + readLaborZip(zipBytes, Charset.forName("GBK"), byCategory); + } + if (byCategory.isEmpty()) throw new ServiceException("未在 zip 中找到劳务材料目录, 请按下载模板的目录结构放置文件"); + + uploadProgressRegistry.setTotal(jobId, byCategory.size()); + + int processed = 0; + int updated = 0; + ImportResult detailResult = new ImportResult(); + Map agreementResult = new HashMap<>(); + Map expertResult = new HashMap<>(); + List generalErrors = new ArrayList<>(); + + // 2) 劳务明细表 → 单遍批量导入 (成功/失败行合并到 detailResult) + List detailItems = byCategory.get("DETAIL"); + if (detailItems != null) { + processed++; + boolean foundExcel = false; + for (LaborZipItem it : detailItems) { + if (!isExcelName(it.name)) continue; // 忽略说明.txt 等 + foundExcel = true; + try { + ImportResult r = attendeeService.importFromExcelBytes(it.bytes, meetingId, username); + detailResult.setOkNum(detailResult.getOkNum() + r.getOkNum()); + detailResult.setNgNum(detailResult.getNgNum() + r.getNgNum()); + detailResult.getNgList().addAll(r.getNgList()); + updated += r.getOkNum(); + } catch (Exception e) { + detailResult.setNgNum(detailResult.getNgNum() + 1); + detailResult.getNgList().add(new ImportResult.NgRow(0, "文件「" + it.name + "」导入失败: " + + (e.getMessage() == null ? "未知错误" : e.getMessage()))); + } + } + if (!foundExcel) generalErrors.add("劳务明细表: 目录内未找到 Excel 文件"); + uploadProgressRegistry.update(jobId, processed); + } + + // 明细表导入会新增参会人, 这里再取最新名单供协议/照片匹配 + List attendees = attendeeService.selectByMeetingId(meetingId); + + // 3) 劳务协议 → 双规则匹配回填 (序号_姓名/ 子目录 + 平铺 姓名_手机号.pdf) + List agreementItems = byCategory.get("AGREEMENT"); + if (agreementItems != null) { + processed++; + agreementResult = backfillAgreements(agreementItems, attendees, meetingId, username); + updated += ((Number) agreementResult.getOrDefault("updated", 0)).intValue(); + uploadProgressRegistry.update(jobId, processed); + } + + // 4) 企业权益 → 材料回填 + if (byCategory.get("ENTERPRISE_BENEFIT") != null) { + processed++; + updated += backfillLaborMaterial(byCategory.get("ENTERPRISE_BENEFIT"), meetingId, "L_ENTERPRISE_BENEFIT", userId, generalErrors); + uploadProgressRegistry.update(jobId, processed); + } + + // 5) 电子签到表 → 复用 esign 追加逻辑 + List esignItems = byCategory.get("ESIGN_IN"); + if (esignItems != null) { + processed++; + try { + List excels = collectEsignExcels(esignItems); + if (excels.isEmpty()) { + generalErrors.add("电子签到表: 目录内未找到 Excel 文件"); + } else { + Map r = appendEsignInFiles(excels, meetingId, userId); + updated += ((Number) r.getOrDefault("count", 0)).intValue(); + } + } catch (Exception e) { + generalErrors.add("电子签到表: " + (e.getMessage() == null ? "处理失败" : e.getMessage())); + } + uploadProgressRegistry.update(jobId, processed); + } + + // 6) 签到表/前全景/后全景 → 仅 admin/manager 回填 (其他角色 zip 内这三个目录直接忽略) + if (cameraAllowed) { + updated += backfillLaborMaterial(byCategory.get("SIGN_IN"), meetingId, "L_SIGN_IN", userId, generalErrors); + updated += backfillLaborMaterial(byCategory.get("PANORAMA_FRONT"), meetingId, "L_PANORAMA_FRONT", userId, generalErrors); + updated += backfillLaborMaterial(byCategory.get("PANORAMA_BACK"), meetingId, "L_PANORAMA_BACK", userId, generalErrors); + } + for (String c : new String[]{"SIGN_IN", "PANORAMA_FRONT", "PANORAMA_BACK"}) { + if (byCategory.get(c) != null) { + processed++; + uploadProgressRegistry.update(jobId, processed); + } + } + + // 7) 专家照片 → 目录匹配回填 on_site_photos + List expertItems = byCategory.get("EXPERT_PHOTO"); + if (expertItems != null) { + processed++; + expertResult = backfillExpertPhotos(expertItems, attendees, meetingId, username); + updated += ((Number) expertResult.getOrDefault("updated", 0)).intValue(); + uploadProgressRegistry.update(jobId, processed); + } + + // 8) 日程海报 / 邀请函 → 单文件回填到会议字段 + if (byCategory.get("SCHEDULE") != null) { + processed++; + updated += backfillSingleMeetingFile(byCategory.get("SCHEDULE"), meetingId, username, true, "日程海报", generalErrors); + uploadProgressRegistry.update(jobId, processed); + } + if (byCategory.get("INVITATION") != null) { + processed++; + updated += backfillSingleMeetingFile(byCategory.get("INVITATION"), meetingId, username, false, "邀请函", generalErrors); + uploadProgressRegistry.update(jobId, processed); + } + + // 9) 汇总结果 (前端按 detail/agreement/expertPhoto 三个 tab 展示失败, errors 为其他目录错误) + Map result = new HashMap<>(); + result.put("updated", updated); + result.put("detail", detailResult); + result.put("agreement", agreementResult); + result.put("expertPhoto", expertResult); + result.put("errors", generalErrors); + log.info("[material] 劳务材料回填完成 meetingId={} updated={} detailNg={} agreementFail={} expertFail={}", + meetingId, updated, detailResult.getNgNum(), + ((List) agreementResult.getOrDefault("failures", Collections.emptyList())).size(), + ((List) expertResult.getOrDefault("failures", Collections.emptyList())).size()); + return result; + } + + /** 解压劳务 zip, 按路径段命中 10 个目录名之一归类文件 (保留完整路径供 序号_姓名 目录匹配) */ + private void readLaborZip(byte[] zipBytes, Charset charset, Map> byCategory) throws IOException { + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String path = entry.getName(); + if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue; + String category = matchLaborCategory(path); + if (category == null) continue; // 不在劳务目录下, 忽略 + byte[] data = readAllBytes(zis); + if (data.length == 0) continue; + byCategory.computeIfAbsent(category, k -> new ArrayList<>()).add(new LaborZipItem(path, lastSegment(path), data)); + } + } + } + + /** 从 zip 条目路径里匹配劳务类别 (扫描路径段命中目录名); 不匹配返回 null */ + private static String matchLaborCategory(String path) { + if (path == null || path.isEmpty()) return null; + for (String seg : path.split("/")) { + if (seg.isEmpty()) continue; + String c = LABOR_CATEGORY_BY_LABEL.get(seg); + if (c != null) return c; + } + return null; + } + + /** 劳务协议回填: 先按 序号_姓名/ 子目录匹配 (沿用协议批量逻辑), 再按平铺 姓名_手机号.pdf 匹配; 两者可混用 */ + private Map backfillAgreements(List items, List attendees, + Long meetingId, String username) throws Exception { + LinkedHashMap> dirGroups = new LinkedHashMap<>(); + List flatItems = new ArrayList<>(); + for (LaborZipItem it : items) { + if (isIgnorableName(it.name)) continue; + String[] token = parseAgreementDirSegment(it.path); + if (token != null) { + dirGroups.computeIfAbsent(token[0] + "_" + token[1], k -> new ArrayList<>()).add(it); + } else { + flatItems.add(it); + } + } + + int updated = 0; + List> failures = new ArrayList<>(); + String subDir = "ry8080/meeting/" + meetingId + "/labor-protocol"; + + // 子目录模式: 序号 + 姓名 严格匹配 (名单变动/重名错位 → 跳过并给出原因) + for (Map.Entry> e : dirGroups.entrySet()) { + String dir = e.getKey(); + int seq = Integer.parseInt(dir.substring(0, dir.indexOf('_'))); + String name = dir.substring(dir.indexOf('_') + 1); + BizMeetingAttendee a = matchAttendeeBySeqName(attendees, seq, name); + if (a == null) { + failures.add(failEntry(name, "目录[" + dir + "] 无法定位参会人")); + continue; + } + // 劳务协议可多文件: 目录内所有文件都上传 OSS (不打包 zip), 逗号拼接覆盖式回填 labor_protocol, 前端按逗号拆分逐个预览 + StringBuilder sb = new StringBuilder(); + for (LaborZipItem it : e.getValue()) { + String url = ossUploader.uploadWithOriginalName(it.bytes, it.name, subDir); + if (sb.length() > 0) sb.append(","); + sb.append(url); + } + BizMeetingAttendee entity = new BizMeetingAttendee(); + entity.setId(a.getId()); + entity.setLaborProtocol(sb.toString()); + entity.setUpdateBy(username); + attendeeService.updateLaborProtocol(entity); + updated++; + } + + // 平铺模式: 姓名_手机号.pdf + for (LaborZipItem it : flatItems) { + String[] np = parseFlatNamePhone(it.name); + if (np == null) { + failures.add(failEntry(it.name, "文件名应为「姓名_手机号.pdf」")); + continue; + } + BizMeetingAttendee a = matchAttendeeByNamePhone(attendees, np[0], np[1]); + if (a == null) { + failures.add(failEntry(np[0], "无法匹配参会人: 姓名=" + np[0] + " 手机号=" + np[1])); + continue; + } + String url = ossUploader.upload(it.bytes, it.name, subDir); + BizMeetingAttendee entity = new BizMeetingAttendee(); + entity.setId(a.getId()); + entity.setLaborProtocol(url); + entity.setUpdateBy(username); + attendeeService.updateLaborProtocol(entity); + updated++; + } + + Map result = new HashMap<>(); + result.put("updated", updated); + result.put("total", attendees.size()); + result.put("failures", failures); + return result; + } + + /** 专家照片回填: 两种放法都兼容 (序号_姓名/ 子目录 + 平铺 姓名_手机号[_后缀]), 全部照片逗号拼接覆盖式回填 on_site_photos */ + private Map backfillExpertPhotos(List items, List attendees, + Long meetingId, String username) throws Exception { + LinkedHashMap> groups = new LinkedHashMap<>(); + List> failures = new ArrayList<>(); + for (LaborZipItem it : items) { + if (isIgnorableName(it.name)) continue; + BizMeetingAttendee a = null; + // 子目录模式: 序号 + 姓名 严格匹配 (沿用原有放法) + String[] token = parseAgreementDirSegment(it.path); + if (token != null) { + a = matchAttendeeBySeqName(attendees, Integer.parseInt(token[0]), token[1]); + if (a == null) failures.add(failEntry(token[1], "目录[" + token[0] + "_" + token[1] + "] 无法定位参会人")); + } else { + // 平铺模式: 姓名_手机号[.ext] / 姓名_手机号_后缀[.ext] + String[] np = parseFlatNamePhonePrefix(it.name); + if (np == null) { + failures.add(failEntry(it.name, "文件名应为「姓名_手机号」, 如 张三_13800138000.jpg")); + } else { + a = matchAttendeeByNamePhone(attendees, np[0], np[1]); + if (a == null) failures.add(failEntry(np[0], "无法匹配参会人: 姓名=" + np[0] + " 手机号=" + np[1])); + } + } + if (a != null) groups.computeIfAbsent(a.getId(), k -> new ArrayList<>()).add(it); + } + + int updated = 0; + String subDir = "ry8080/meeting/" + meetingId + "/expert-photo"; + + for (Map.Entry> e : groups.entrySet()) { + StringBuilder sb = new StringBuilder(); + for (LaborZipItem it : e.getValue()) { + String url = ossUploader.upload(it.bytes, it.name, subDir); + if (sb.length() > 0) sb.append(","); + sb.append(url); + } + BizMeetingAttendee entity = new BizMeetingAttendee(); + entity.setId(e.getKey()); + entity.setOnSitePhotos(sb.toString()); + entity.setUpdateBy(username); + attendeeService.updateProfile(entity); + updated++; + } + + Map result = new HashMap<>(); + result.put("updated", updated); + result.put("total", attendees.size()); + result.put("failures", failures); + return result; + } + + /** 非发票劳务材料回填 (企业权益/签到表/前全景/后全景): 单文件直传 / 多文件打 zip, fee_status=1 不参与费用汇总 */ + private int backfillLaborMaterial(List items, Long meetingId, String subType, Long userId, List errors) { + if (items == null || items.isEmpty()) return 0; + List files = new ArrayList<>(); + List names = new ArrayList<>(); + for (LaborZipItem it : items) { + if (isIgnorableName(it.name)) continue; + files.add(it.bytes); + names.add(it.name); + } + if (files.isEmpty()) return 0; + + String label = LABOR_SUBTYPE_LABEL.getOrDefault(subType, subType); + String fileName; + byte[] toUpload; + if (files.size() == 1) { + fileName = names.get(0); + toUpload = files.get(0); + } else { + fileName = label + ".zip"; + try { + toUpload = zipFiles(files, names); + } catch (IOException e) { + errors.add(label + ": 多文件打 zip 失败 " + e.getMessage()); + return 0; + } + } + try { + String ossUrl = ossUploader.upload(toUpload, fileName, "ry8080/meeting/" + meetingId + "/labor"); + BizMeetingMaterial old = bizMeetingMaterialMapper.selectByMeetingAndSubType(meetingId, subType); + if (old != null) { + bizMeetingMaterialMapper.updateFile(old.getId(), ossUrl, fileName, 1); + } else { + BizMeetingMaterial ins = new BizMeetingMaterial(); + ins.setMeetingId(meetingId); + ins.setMaterialType("LABOR"); + ins.setSubType(subType); + ins.setOssUrl(ossUrl); + ins.setFileName(fileName); + ins.setAmount(null); + ins.setFeeStatus(1); + ins.setCreatorId(userId); + ins.setCreateTime(new Date()); + bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins)); + } + return 1; + } catch (Exception e) { + errors.add(label + ": " + (e.getMessage() == null ? "上传失败" : e.getMessage())); + return 0; + } + } + + /** 日程海报/邀请函 单文件回填到 biz_meeting 字段; 多文件 → 报错跳过 */ + private int backfillSingleMeetingFile(List items, Long meetingId, String username, + boolean schedule, String label, List errors) { + if (items == null || items.isEmpty()) return 0; + List real = new ArrayList<>(); + for (LaborZipItem it : items) { + if (isIgnorableName(it.name)) continue; + real.add(it); + } + if (real.isEmpty()) return 0; + if (real.size() > 1) { + errors.add(label + ": 该目录只能放一个文件, 当前有 " + real.size() + " 个, 已跳过"); + return 0; + } + LaborZipItem it = real.get(0); + try { + String ossUrl = ossUploader.upload(it.bytes, it.name, + "ry8080/meeting/" + meetingId + "/" + (schedule ? "schedule" : "invitation")); + BizMeeting upd = new BizMeeting(); + upd.setMeetingId(meetingId); + if (schedule) upd.setScheduleUrl(ossUrl); else upd.setInvitationUrl(ossUrl); + upd.setUpdateBy(username); + upd.setUpdateTime(new Date()); + bizMeetingMapper.updateByPrimaryKey(upd); + return 1; + } catch (Exception e) { + errors.add(label + ": " + (e.getMessage() == null ? "上传失败" : e.getMessage())); + return 0; + } + } + + /** 电子签到表目录 → 收集所有 Excel (含嵌套 zip 内 Excel), 其他文件忽略 */ + private List collectEsignExcels(List items) throws Exception { + List excels = new ArrayList<>(); + for (LaborZipItem it : items) { + if (isIgnorableName(it.name)) continue; + if (isZipName(it.name)) { + excels.addAll(unzipExcelFiles(it.bytes)); + } else if (isExcelName(it.name)) { + excels.add(new FileBytes(it.name, it.bytes)); + } + } + return excels; + } + + /** 忽略说明/元数据类文件名 (说明.txt/README/.DS_Store 等) */ + private static boolean isIgnorableName(String name) { + if (name == null) return true; + String n = name.trim(); + return n.isEmpty() || n.startsWith(".") || n.startsWith("__") + || "说明.txt".equals(n) || "readme.txt".equalsIgnoreCase(n) || "thumbs.db".equalsIgnoreCase(n); + } + + /** 在 zip 路径的目录段 (不含末段文件名) 里找 {序号}_姓名 → [序号, 姓名]; 找不到返回 null */ + private static String[] parseAgreementDirSegment(String path) { + if (path == null || path.isEmpty()) return null; + String[] segs = path.split("/"); + if (segs.length < 2) return null; + for (int i = segs.length - 2; i >= 0; i--) { + String[] t = attendeeTokenOf(segs[i]); + if (t != null) return t; + } + return null; + } + + /** 从单个路径段解析 {序号}_姓名 → [序号数字, 姓名段]; 不匹配返回 null */ + private static String[] attendeeTokenOf(String s) { + if (s == null || s.isEmpty()) return null; + int underscore = s.indexOf('_'); + if (underscore <= 0) return null; + String numPart = s.substring(0, underscore); + if (!numPart.matches("\\d+")) return null; + return new String[] { numPart, s.substring(underscore + 1) }; + } + + /** 解析平铺文件名 姓名_手机号[.ext] → [姓名, 手机号]; 不匹配返回 null */ + private static String[] parseFlatNamePhone(String fileName) { + if (fileName == null || fileName.isEmpty()) return null; + String stem = fileName; + int dot = stem.lastIndexOf('.'); + if (dot > 0) stem = stem.substring(0, dot); + Matcher m = Pattern.compile("^(.*)_(1\\d{10})$").matcher(stem); + if (!m.find()) return null; + return new String[] { m.group(1), m.group(2) }; + } + + /** 解析平铺文件名 姓名_手机号[_后缀][.ext] → [姓名, 手机号] (照片同人多张可加后缀); 找不到 1 开头的 11 位手机号返回 null */ + private static String[] parseFlatNamePhonePrefix(String fileName) { + if (fileName == null || fileName.isEmpty()) return null; + String stem = fileName; + int dot = stem.lastIndexOf('.'); + if (dot > 0) stem = stem.substring(0, dot); + Matcher m = Pattern.compile("1\\d{10}").matcher(stem); + if (!m.find()) return null; + String name = stem.substring(0, m.start()).replaceAll("[_\\-\\s]+$", ""); + if (name.isEmpty()) return null; + return new String[] { name, m.group() }; + } + + /** 序号 (order by id 的 1-based 下标) + 姓名 严格匹配; 不符返回 null */ + private static BizMeetingAttendee matchAttendeeBySeqName(List list, int seq, String name) { + int idx = seq - 1; + if (idx < 0 || idx >= list.size()) return null; + BizMeetingAttendee a = list.get(idx); + if (!laborAttendeeName(name).equals(laborAttendeeName(a.getName()))) return null; + return a; + } + + /** 姓名 + 手机号 精确匹配 (平铺协议文件名解析) */ + private static BizMeetingAttendee matchAttendeeByNamePhone(List list, String name, String phone) { + String want = laborAttendeeName(name); + for (BizMeetingAttendee a : list) { + if (phone.equals(a.getPhone()) && want.equals(laborAttendeeName(a.getName()))) return a; + } + return null; + } + + /** 目录名里的姓名段剔除非法字符, 避免 Windows 解压异常 (空值 → 未知) */ + private static String laborAttendeeName(String name) { + if (name == null || name.trim().isEmpty()) return "未知"; + return safeName(name); + } + + /** 失败明细条目 (name + reason) */ + private static Map failEntry(String name, String reason) { + Map m = new HashMap<>(); + m.put("name", name); + m.put("reason", reason); + return m; + } + + /** 劳务"打包上传" zip 解出的一个文件条目 (保留完整路径用于 序号_姓名 目录匹配) */ + private static final class LaborZipItem { + final String path; + final String name; + final byte[] bytes; + LaborZipItem(String path, String name, byte[] bytes) { this.path = path; this.name = name; this.bytes = bytes; } } // =================================================================== @@ -1230,6 +1852,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService if (!isExcelName(originalName)) throw new ServiceException("仅支持 xls / xlsx / csv 或 zip 文件"); files.add(new FileBytes(originalName, bytes)); } + return appendEsignInFiles(files, meetingId, SecurityUtils.getUserId()); + } + + /** 电子签到表追加保存核心 (parse all → upload all → merge → delete+insert), 供单文件上传与劳务"打包上传"复用. */ + private Map appendEsignInFiles(List files, Long meetingId, Long userId) throws Exception { if (files.size() > ESIGN_MAX_FILES) throw new ServiceException("一次最多上传 " + ESIGN_MAX_FILES + " 个签到表文件"); // 逐个解析 (整体失败: 任一解析失败/超限抛异常, 此时尚未上传任何文件) @@ -1286,11 +1913,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService ins.setMaterialType("LABOR"); ins.setSubType("L_ESIGN_IN"); ins.setOssUrl(mergedOssUrl); - ins.setFileName(originalName); + ins.setFileName(files.get(0).name); ins.setMaskedJson(mergedMaskedJson); ins.setAmount(null); ins.setFeeStatus(1); // 非发票, 不参与费用汇总 - ins.setCreatorId(SecurityUtils.getUserId()); + ins.setCreatorId(userId); ins.setCreateTime(new Date()); bizMeetingMaterialMapper.insert(ins); 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 873851d..97c8d37 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 @@ -2,9 +2,13 @@ package com.ruoyi.business.service.impl; import java.io.File; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Date; import java.util.List; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONUtil; + import com.ruoyi.business.service.IBizMeetingInvoiceService; import com.ruoyi.business.service.IBizMeetingMaterialService; import com.ruoyi.business.service.IBizMeetingService; @@ -91,6 +95,9 @@ public class InvoiceOcrService } // 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参 boolean isZip = ossUrl.toLowerCase().endsWith(".zip"); + // 是否多文件 (JSON 数组字符串, 会务材料多文件直传): 以 [ 开头 + boolean isMulti = !isZip && ossUrl.trim().startsWith("["); + final List multiUrls = isMulti ? parseUrlList(ossUrl) : null; // 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0) if (oldMaterialId != null) @@ -100,8 +107,8 @@ public class InvoiceOcrService materialService.updateAmount(oldMaterialId, BigDecimal.ZERO); } - // 2. 单文件: 插 UNRECOGNIZED 占位行 (zip 路径跳过 — 解压才知道有几张) - if (!isZip) + // 2. 单文件: 插 UNRECOGNIZED 占位行 (zip / 多文件 路径跳过 — 解压/遍历才知道有几张) + if (!isZip && !isMulti) { BizMeetingInvoice placeholder = new BizMeetingInvoice(); placeholder.setMeetingId(meetingId); @@ -115,7 +122,7 @@ public class InvoiceOcrService invoiceMapper.insert(placeholder); } - // 3. 后台执行 OCR (单文件或 zip) + // 3. 后台执行 OCR (单文件 / zip / 多文件) final String url = ossUrl; ocrExecutor.submit(() -> { @@ -125,6 +132,10 @@ public class InvoiceOcrService { recognizeZip(materialId, meetingId, url, creatorId); } + else if (isMulti) + { + recognizeMultiple(materialId, meetingId, multiUrls, creatorId); + } else { recognizeSingle(materialId, url); @@ -132,8 +143,8 @@ public class InvoiceOcrService } catch (Exception e) { - log.warn("OCR 后台任务失败 materialId={} isZip={} err={}", materialId, isZip, e.getMessage(), e); - if (!isZip) + log.warn("OCR 后台任务失败 materialId={} isZip={} isMulti={} err={}", materialId, isZip, isMulti, e.getMessage(), e); + if (!isZip && !isMulti) { // 单文件: 占位行标 FAILED invoiceMapper.updateStatusByMaterial(materialId, "FAILED", @@ -276,6 +287,93 @@ public class InvoiceOcrService } } + /** + * 多文件 OCR (会务材料 oss_url 存 JSON 数组): 逐个 URL 识别, 是发票 → 写 invoice 行 (直接复用原 URL, 无需重传), + * 累加 amount → material.amount = 总和. 不是发票 → 跳过 (不入 invoice 表). + */ + void recognizeMultiple(Long materialId, Long meetingId, List urls, Long creatorId) + { + if (urls == null || urls.isEmpty()) return; + BigDecimal total = BigDecimal.ZERO; + int invoiceCount = 0; + int skipCount = 0; + int failCount = 0; + for (String u : urls) + { + try + { + InvoiceResult ir = recognizer.recognizeByUrl(u); + if (!Boolean.TRUE.equals(ir.getSuccess()) || ir.getFields() == null + || !isRecognizedAsInvoice(ir.getFields())) + { + // 不是发票: 跳过, 不入库 + skipCount++; + continue; + } + BigDecimal amount = ir.getFields().getAmount() != null + ? BigDecimal.valueOf(ir.getFields().getAmount()) + : BigDecimal.ZERO; + BizMeetingInvoice inv = new BizMeetingInvoice(); + inv.setMeetingId(meetingId); + inv.setMaterialId(materialId); + inv.setOssUrl(u); + inv.setInvoiceType("SUB"); + inv.setAmount(amount); + inv.setInvoiceNo(ir.getFields().getInvoiceNo()); + inv.setInvoiceDate(ir.getFields().getInvoiceDate()); + inv.setRecognizeStatus("RECOGNIZED"); + inv.setSourceFilename(lastUrlSegment(u)); + inv.setCreatorId(creatorId); + inv.setCreateTime(new Date()); + inv.setUpdateTime(new Date()); + invoiceMapper.insert(inv); + total = total.add(amount); + invoiceCount++; + } + catch (Exception e) + { + log.warn("多文件子文件识别失败 url={} err={}", u, e.getMessage()); + failCount++; + } + } + // material.amount 只在"识别出至少 1 张发票"时回写求和; 0 张时不动 material + if (invoiceCount > 0) + { + materialService.updateAmount(materialId, total); + } + log.info("多文件 OCR 完成 materialId={} invoice={} skip={} fail={} total={}", + materialId, invoiceCount, skipCount, failCount, total); + } + + /** JSON 数组字符串 (会务材料多文件) → OSS URL 列表; 解析失败返回空列表 */ + private List parseUrlList(String jsonUrls) + { + List urls = new ArrayList<>(); + try + { + JSONArray arr = JSONUtil.parseArray(jsonUrls); + for (int i = 0; i < arr.size(); i++) + { + String u = arr.getStr(i); + if (u != null && !u.isEmpty()) urls.add(u); + } + } + catch (Exception e) + { + log.warn("解析多文件 oss_url 失败: {}", jsonUrls); + } + return urls; + } + + /** 取 URL 末段文件名 (去掉 query) */ + private static String lastUrlSegment(String url) + { + if (url == null) return ""; + String u = url.split("\\?")[0]; + int i = u.lastIndexOf('/'); + return i >= 0 ? u.substring(i + 1) : u; + } + /** * 兜底 OCR (InvoiceOcrScheduler 调用): 已知 invoice 行 (UNRECOGNIZED), 重新识别 *

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 1395600..0664e1e 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 @@ -308,6 +308,7 @@ where is_deleted = 0 and is_executed = 0 and is_frozen = 0 + and is_finished = 0 and start_time is not null and start_time <= NOW() and end_time > NOW() @@ -323,6 +324,7 @@ where is_deleted = 0 and is_executed = 0 and is_frozen = 0 + and is_finished = 0 and end_time is not null and end_time <= NOW() and labor_audit_stage = 'NOT_SUBMITTED' @@ -337,6 +339,7 @@ current_stage = 'FROZEN' where is_deleted = 0 and is_frozen = 0 + and is_finished = 0 and (labor_audit_stage in ('NOT_SUBMITTED', 'REJECTED') or service_audit_stage in ('NOT_SUBMITTED', 'REJECTED')) and submit_deadline is not null 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 a782897..1d353dc 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 @@ -56,10 +56,11 @@ (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, + + (greatest(0, p.total_sessions + - (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 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) @@ -90,9 +91,10 @@ (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, + (greatest(0, p.total_sessions + - (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 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) @@ -238,11 +240,15 @@ where m.project_id = p.project_id and m.is_deleted = 0 and m.current_stage not in ('NOT_STARTED','IN_PROGRESS') and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions, - - (select count(*) from biz_meeting m - where m.project_id = p.project_id and m.is_deleted = 0 - and m.current_stage = 'NOT_STARTED' - and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions + + (greatest(0, (select coalesce(sum(bpa.sessions), 0) + from biz_project_assign bpa + where bpa.project_id = p.project_id and bpa.is_deleted = 0 + and bpa.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) + - (select count(*) from biz_meeting m + where m.project_id = p.project_id and m.is_deleted = 0 + and m.current_stage not in ('NOT_STARTED','IN_PROGRESS') + and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')))) as todo_sessions from biz_project p join biz_project_assign a on a.project_id = p.project_id @@ -329,10 +335,14 @@ where m.project_id = p.project_id and m.is_deleted = 0 and m.current_stage not in ('NOT_STARTED','IN_PROGRESS') and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions, - (select count(*) from biz_meeting m - where m.project_id = p.project_id and m.is_deleted = 0 - and m.current_stage = 'NOT_STARTED' - and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions + (greatest(0, (select coalesce(sum(bpa.sessions), 0) + from biz_project_assign bpa + where bpa.project_id = p.project_id and bpa.is_deleted = 0 + and bpa.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) + - (select count(*) from biz_meeting m + where m.project_id = p.project_id and m.is_deleted = 0 + and m.current_stage not in ('NOT_STARTED','IN_PROGRESS') + and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')))) as todo_sessions from biz_project p left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor' left join sys_user su on su.user_id = o.user_id 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 0dbd7d4..56d7e30 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 @@ -58,7 +58,9 @@ public class SysUser extends BaseEntity /** 删除标志(0代表存在 2代表删除) */ private String delFlag; - /** 账号类型 (MAIN=主账号 / SUB=子账号) */ + /** 账号类型 (MAIN=主账号 / SUB=子账号, null 视为 SUB). 按角色语义复用: + * executor/sponsor: MAIN=机构主账号 / SUB=机构子账号(挂 parent_user_id); + * manager: MAIN=全局管理员(可见全量) / SUB 或 null=普通合规员(只看本人创建, parent_user_id 恒 NULL) */ @Excel(name = "账号类型") private String accountType; diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java index c6c6771..9461812 100644 --- a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java @@ -70,7 +70,7 @@ public class HttpUtils try { String urlNameString = StringUtils.isNotBlank(param) ? url + "?" + param : url; - log.info("sendGet - {}", urlNameString); + log.debug("sendGet - {}", urlNameString); URL realUrl = new URL(urlNameString); URLConnection connection = realUrl.openConnection(); // 连接/读超时: 防止外部接口不可达时无限挂起 (后台子任务会占死单线程调度器) @@ -86,7 +86,7 @@ public class HttpUtils { result.append(line); } - log.info("recv - {}", result); + log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200))); } catch (ConnectException e) { @@ -148,7 +148,7 @@ public class HttpUtils StringBuilder result = new StringBuilder(); try { - log.info("sendPost - {}", url); + log.debug("sendPost - {}", url); URL realUrl = new URL(url); URLConnection conn = realUrl.openConnection(); conn.setRequestProperty("accept", "*/*"); @@ -167,7 +167,7 @@ public class HttpUtils { result.append(line); } - log.info("recv - {}", result); + log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200))); } catch (ConnectException e) { @@ -217,7 +217,7 @@ public class HttpUtils String urlNameString = url + "?" + param; try { - log.info("sendSSLPost - {}", urlNameString); + log.debug("sendSSLPost - {}", urlNameString); SSLContext sc = SSLContext.getInstance("SSL"); sc.init(null, new TrustManager[] { new TrustAnyTrustManager() }, new java.security.SecureRandom()); URL console = new URL(urlNameString); @@ -243,7 +243,7 @@ public class HttpUtils result.append(new String(ret.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8)); } } - log.info("recv - {}", result); + log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200))); conn.disconnect(); br.close(); } diff --git a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/poi/ExcelUtil.java b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/poi/ExcelUtil.java index aecc0d8..59c70cb 100644 --- a/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/poi/ExcelUtil.java +++ b/ry-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/poi/ExcelUtil.java @@ -1,5 +1,6 @@ package com.ruoyi.common.utils.poi; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -746,6 +747,36 @@ public class ExcelUtil exportExcel(response); } + /** + * 生成导入模板 Excel 的字节 (仅表头, 无数据行), 用于打进 zip 模板. + * + * @param sheetName 工作表的名称 + * @param title 标题 + * @return Excel 字节 + */ + public byte[] importTemplateExcelBytes(String sheetName, String title) + { + ByteArrayOutputStream out = null; + try + { + this.init(null, sheetName, title, Type.IMPORT); + writeSheet(); + out = new ByteArrayOutputStream(); + wb.write(out); + return out.toByteArray(); + } + catch (Exception e) + { + log.error("导出Excel异常{}", e.getMessage()); + throw new UtilException("导出Excel失败,请联系网站管理员!"); + } + finally + { + IOUtils.closeQuietly(wb); + IOUtils.closeQuietly(out); + } + } + /** * 对list数据源将其里面的数据导入到excel表单 * diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index 1ede3d4..134b9c6 100644 --- a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -17,6 +17,7 @@ import org.springframework.web.filter.CorsFilter; import com.ruoyi.framework.config.properties.PermitAllUrlProperties; import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter; import com.ruoyi.framework.security.filter.LeaderReadOnlyFilter; +import com.ruoyi.framework.security.handle.AccessDeniedHandlerImpl; import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl; import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl; @@ -32,6 +33,8 @@ public class SecurityConfig @Autowired private AuthenticationEntryPointImpl unauthorizedHandler; @Autowired + private AccessDeniedHandlerImpl accessDeniedHandler; + @Autowired private LogoutSuccessHandlerImpl logoutSuccessHandler; @Autowired private JwtAuthenticationTokenFilter authenticationTokenFilter; @@ -54,7 +57,7 @@ public class SecurityConfig .headers((headersCustomizer) -> { headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin()); }) - .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) + .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler).accessDeniedHandler(accessDeniedHandler)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests((requests) -> { permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll()); diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/security/handle/AccessDeniedHandlerImpl.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/security/handle/AccessDeniedHandlerImpl.java new file mode 100644 index 0000000..4ffffed --- /dev/null +++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/security/handle/AccessDeniedHandlerImpl.java @@ -0,0 +1,33 @@ +package com.ruoyi.framework.security.handle; + +import java.io.IOException; +import java.io.Serializable; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import com.alibaba.fastjson2.JSON; +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.ServletUtils; + +/** + * 授权失败处理类 返回 403 + * + * @author ruoyi + */ +@Component +public class AccessDeniedHandlerImpl implements AccessDeniedHandler, Serializable +{ + private static final long serialVersionUID = -8970718410437077606L; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) + throws IOException + { + int code = HttpStatus.FORBIDDEN; + String msg = "没有权限,请联系管理员授权"; + ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg))); + } +} diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java index e13fc23..8dc445d 100644 --- a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java +++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java @@ -37,7 +37,7 @@ public class GlobalExceptionHandler public AjaxResult handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request) { String requestURI = request.getRequestURI(); - log.error("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage()); + log.warn("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage()); return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权"); } diff --git a/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java b/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java index d0474dc..568c153 100644 --- a/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java +++ b/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java @@ -239,6 +239,16 @@ public interface ISysUserService */ public int updateRoleType(Long userId, String roleType); + /** + * 切换合规人员(manager)的「全局管理员」标记 (复用 account_type: MAIN=全局管理员可见全量 / SUB=普通合规员只看本人创建). + * 仅对 role_type=manager 生效, 其它角色抛异常 (executor/sponsor 的 account_type 走「更换管理员」流程). + * + * @param userId 用户ID + * @param accountType 'MAIN' / 'SUB' + * @return 影响行数 + */ + public int updateManagerAdmin(Long userId, String accountType); + /** * 导入用户数据 * diff --git a/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java b/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java index 95b8b8d..79ef3bd 100644 --- a/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java +++ b/ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java @@ -231,6 +231,28 @@ public class SysUserServiceImpl implements ISysUserService return userMapper.updateRoleType(userId, roleType); } + /** + * 切换合规人员(manager)的「全局管理员」标记 (复用 account_type). + * 仅 manager 可切; parent_user_id 保持 NULL (manager 的 SUB 只是「受限」标记, 非真子账号). + * null 相当于 SUB (普通合规员), 兼容历史 null 数据. + */ + @Override + public int updateManagerAdmin(Long userId, String accountType) + { + SysUser target = userMapper.selectUserById(userId); + if (target == null) + { + throw new ServiceException("用户不存在"); + } + if (!"manager".equals(target.getRoleType())) + { + throw new ServiceException("仅合规人员可切换全局管理员"); + } + // 仅 MAIN 视为全局管理员; 其余 (null / SUB / '') 一律归一为 SUB + String effective = "MAIN".equals(accountType) ? "MAIN" : "SUB"; + return userMapper.updateAccountType(userId, effective, null); + } + /** * 校验手机号是否已注册 */ diff --git a/ry-api/sql/ry_20260417.sql b/ry-api/sql/ry_20260417.sql index 91d618c..cf088c8 100644 --- a/ry-api/sql/ry_20260417.sql +++ b/ry-api/sql/ry_20260417.sql @@ -45,7 +45,7 @@ create table sys_user ( user_name varchar(30) not null comment '用户账号', nick_name varchar(30) not null comment '用户昵称', user_type varchar(2) default '00' comment '用户类型(00系统用户)', - account_type varchar(8) default 'MAIN' comment '账号类型 (MAIN=主账号 / SUB=子账号)', + account_type varchar(8) default 'MAIN' comment '账号类型 (MAIN=主账号/全局管理员 / SUB=子账号/受限, null 视为 SUB; 按角色复用: executor/sponsor=机构主/子账号, manager=全局管理员/普通合规员)', parent_user_id bigint(20) default null comment '主账号ID (子账号指向其主账号的 user_id)', email varchar(50) default '' comment '用户邮箱', phonenumber varchar(11) default '' comment '手机号码', diff --git a/ry-vue3/src/components/CameraQrUpload.vue b/ry-vue3/src/components/CameraQrUpload.vue index 671fa2a..2b5285d 100644 --- a/ry-vue3/src/components/CameraQrUpload.vue +++ b/ry-vue3/src/components/CameraQrUpload.vue @@ -23,21 +23,24 @@ {{ photos.length }} 张 未上传 + 预览 等待手机回传… - -

- -
+ + +
+ +
+
+ +
+ 共回填 {{ result.updated }} 项 + ,失败 {{ failTotal }} 项 +
+ +
{{ e }}
+
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/ry-vue3/src/components/OssFileUploader.vue b/ry-vue3/src/components/OssFileUploader.vue index 1b805cd..7f589a9 100644 --- a/ry-vue3/src/components/OssFileUploader.vue +++ b/ry-vue3/src/components/OssFileUploader.vue @@ -35,7 +35,7 @@
移除 - 下载 + 下载
@@ -66,7 +66,7 @@
移除 - 下载 + 下载
@@ -105,6 +105,8 @@ const props = defineProps({ maxSize: { type: Number, default: 200 }, // MB block: { type: Boolean, default: false }, readonly: { type: Boolean, default: false }, + downloadable: { type: Boolean, default: true }, // 是否显示"下载"按钮 (如 sponsor 只读查看时不显示) + maxCount: { type: Number, default: 0 }, // 多文件模式最多文件数 (0 = 不限制) showPreview: { type: Boolean, default: true }, // 右侧预览框 (图片/PDF/其他) multiple: { type: Boolean, default: false } // 多文件模式: 一次选多个文件, modelValue 存 JSON 数组字符串 }) @@ -232,6 +234,10 @@ async function onFileChange(e) { const list = [...modelList.value] let added = 0 for (const file of files) { + if (props.maxCount && list.length >= props.maxCount) { + ElMessage.warning(`最多上传 ${props.maxCount} 个文件`) + break + } if (!validateFile(file)) continue uploading.value = true progress.value = 0 diff --git a/ry-vue3/src/views/admin/Users.vue b/ry-vue3/src/views/admin/Users.vue index 524a18a..6c53b0d 100644 --- a/ry-vue3/src/views/admin/Users.vue +++ b/ry-vue3/src/views/admin/Users.vue @@ -43,8 +43,8 @@ @@ -100,6 +100,12 @@ + + + 否 (普通合规员) + 是 (可见全量) + +