feat: 会议材料上传/发票OCR识别/用户管理增强 + 日志降噪 + 薪亿通接口规范

- 会议/劳务材料支持直接上传与提交审核 (BizMeetingMaterial 等)
- 发票 OCR 识别增强 (InvoiceOcrService)
- 现场照片/证书批量导入覆盖式回填 (ExcelUtil/AttendeeImportVo/CameraQrUpload)
- 用户管理增强、超级管理员校验与权限拒绝处理 (SuperManagerChecker/AccessDeniedHandlerImpl)
- 日志级别调整为 info/warn (生产降噪)
- 新增薪亿通·个人票税接口规范文档 (自然人开票服务分册 + 轻量化对接分册)
- .gitignore 排除 key.txt/*.rdb/*.key/tmp/opt 等敏感与本地文件

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-11-12 01:13:17 +00:00
co-authored by Claude Code
parent 44d4920777
commit 3f587fbd9f
39 changed files with 1544 additions and 192 deletions
@@ -284,8 +284,10 @@ public class BizMeetingAttendeeController extends BaseController {
public void export(HttpServletResponse response, @RequestParam("meetingId") Long meetingId) {
List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
List<BizMeetingAttendeeImportVo> 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<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
util.exportExcel(response, exportList, "参会人");
@@ -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 = 自己)
@@ -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<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
}
/**
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
* <p>
@@ -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 过滤
@@ -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;
@@ -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; }
@@ -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) 档案, 返回不一致字段列表.
*
@@ -128,6 +128,35 @@ public interface IBizMeetingMaterialService {
*/
String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception;
/**
* 生成"劳务材料"空目录模板 zip (劳务材料 tab "打包上传" 的"下载目录"按钮).
* <p>
* 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} 拿进度与结果.
* <p>
* 签到表/前全景/后全景 仅 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 上传 (同步, 追加式保存).
* <p>
@@ -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} 标记「全局管理员」.
*
* <p>role_type=manager 且 account_type=MAIN → 全局管理员 (等同 admin 可见全量, 不受「只看本人创建的项目/会议」数据范围限制);
* account_type=SUB 或 null → 普通合规员 (只看本人创建的). 其它角色不受影响.</p>
*
* <p>不再依赖配置白名单, 改由 admin/用户管理 里切换 manager 的 account_type (MAIN/SUB) 控制, 免改代码.</p>
*/
@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;
}
}
}
@@ -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();
@@ -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<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
List<BizMeetingAttendeeImportVo> rows = util.importExcel(file.getInputStream());
List<BizMeetingAttendeeImportVo> 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<BizMeetingAttendee> list) {
List<BizMeetingAttendeeImportVo> 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++;
@@ -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<String, String> 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<String, String> 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<String> LABOR_TEMPLATE_CATEGORIES = Arrays.asList(
"劳务明细表", "劳务协议", "企业权益", "电子签到表",
"签到表", "前全景", "后全景", "专家照片", "日程海报", "邀请函");
/** 拍照签到目录 (仅 admin/manager 可下载模板 + 回填) */
private static final Set<String> 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<String> 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<BizMeetingAttendee> 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<String, Object> 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<String, Object> 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<String, List<LaborZipItem>> 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<String, Object> agreementResult = new HashMap<>();
Map<String, Object> expertResult = new HashMap<>();
List<String> generalErrors = new ArrayList<>();
// 2) 劳务明细表 → 单遍批量导入 (成功/失败行合并到 detailResult)
List<LaborZipItem> 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<BizMeetingAttendee> attendees = attendeeService.selectByMeetingId(meetingId);
// 3) 劳务协议 → 双规则匹配回填 (序号_姓名/ 子目录 + 平铺 姓名_手机号.pdf)
List<LaborZipItem> 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<LaborZipItem> esignItems = byCategory.get("ESIGN_IN");
if (esignItems != null) {
processed++;
try {
List<FileBytes> excels = collectEsignExcels(esignItems);
if (excels.isEmpty()) {
generalErrors.add("电子签到表: 目录内未找到 Excel 文件");
} else {
Map<String, Object> 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<LaborZipItem> 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<String, Object> 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<String, List<LaborZipItem>> 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<String, Object> backfillAgreements(List<LaborZipItem> items, List<BizMeetingAttendee> attendees,
Long meetingId, String username) throws Exception {
LinkedHashMap<String, List<LaborZipItem>> dirGroups = new LinkedHashMap<>();
List<LaborZipItem> 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<Map<String, Object>> failures = new ArrayList<>();
String subDir = "ry8080/meeting/" + meetingId + "/labor-protocol";
// 子目录模式: 序号 + 姓名 严格匹配 (名单变动/重名错位 → 跳过并给出原因)
for (Map.Entry<String, List<LaborZipItem>> 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<String, Object> result = new HashMap<>();
result.put("updated", updated);
result.put("total", attendees.size());
result.put("failures", failures);
return result;
}
/** 专家照片回填: 两种放法都兼容 (序号_姓名/ 子目录 + 平铺 姓名_手机号[_后缀]), 全部照片逗号拼接覆盖式回填 on_site_photos */
private Map<String, Object> backfillExpertPhotos(List<LaborZipItem> items, List<BizMeetingAttendee> attendees,
Long meetingId, String username) throws Exception {
LinkedHashMap<Long, List<LaborZipItem>> groups = new LinkedHashMap<>();
List<Map<String, Object>> 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<Long, List<LaborZipItem>> 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<String, Object> 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<LaborZipItem> items, Long meetingId, String subType, Long userId, List<String> errors) {
if (items == null || items.isEmpty()) return 0;
List<byte[]> files = new ArrayList<>();
List<String> 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<LaborZipItem> items, Long meetingId, String username,
boolean schedule, String label, List<String> errors) {
if (items == null || items.isEmpty()) return 0;
List<LaborZipItem> 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<FileBytes> collectEsignExcels(List<LaborZipItem> items) throws Exception {
List<FileBytes> 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<BizMeetingAttendee> 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<BizMeetingAttendee> 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<String, Object> failEntry(String name, String reason) {
Map<String, Object> 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<String, Object> appendEsignInFiles(List<FileBytes> 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);
@@ -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<String> 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<String> 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<String> parseUrlList(String jsonUrls)
{
List<String> 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), 重新识别
* <p>
@@ -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 &lt;= NOW()
and end_time &gt; 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 &lt;= 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
@@ -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,
<!-- 未执行会议数: 仅 NOT_STARTED -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED') as todo_sessions,
<!-- 未执行 = 总数 - 已执行 (含未建会议/执行中的场次) -->
(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: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
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