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:
+4
-2
@@ -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, "参会人");
|
||||
|
||||
+5
-1
@@ -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 = 自己)
|
||||
|
||||
+29
@@ -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>
|
||||
|
||||
+7
-3
@@ -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 过滤
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
+41
-35
@@ -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; }
|
||||
|
||||
+9
@@ -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) 档案, 返回不一致字段列表.
|
||||
*
|
||||
|
||||
+29
@@ -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>
|
||||
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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();
|
||||
|
||||
+25
-12
@@ -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++;
|
||||
|
||||
+650
-23
@@ -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);
|
||||
|
||||
|
||||
+103
-5
@@ -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 <= 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user