package jnpf.dmsService; import jnpf.dmsIntegration.audit.DmsBusinessChangeAudit; import static jnpf.dmsIntegration.audit.DmsBusinessChangeAudit.values; import jnpf.base.UserInfo; import jnpf.dmsEntity.fenfa.*; import jnpf.dmsEntity.fenfa.DmsWenjianDayinFasongEntity.Batch; import jnpf.dmsEntity.fenfa.form.DmsWenjianChongdaForm; import jnpf.dmsMapper.*; import jnpf.dmsPermission.DmsCurrentIdentity; import jnpf.exception.DataException; import jnpf.util.JsonUtil; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; @Service @RequiredArgsConstructor public class DmsWenjianChongdaService { private final DmsBusinessChangeAudit businessAudit; private final DmsCurrentIdentity identity; private final DmsWenjianFenfaMapper taskMapper; private final DmsWenjianDayinQuerenMapper versions; private final DmsWenjianChongdaMapper mapper; private final DmsWenjianBupinghengMapper documents; private final DmsFileVersionMapper fileVersions; private final DmsPermissionDecisionService permissions; /** 可展示给同租户用户的占用摘要,不返回可补回执的令牌和原签名。 */ @Transactional(rollbackFor = Exception.class) public jnpf.dmsEntity.fenfa.vo.DmsWenjianChongdaLockVO lockStatus(String recordId) throws DataException { UserInfo actor = identity.requireUser(); String tenant = identity.tenantId(actor); if (taskMapper.selectPrintForUpdate(tenant, recordId, null) == null) throw new DataException("打印记录不存在或已删除"); var summary = new jnpf.dmsEntity.fenfa.vo.DmsWenjianChongdaLockVO(); summary.setPrintRecordId(recordId); var attempt = mapper.selectActive(tenant, recordId); if (attempt == null) return summary; summary.setLocked(true); summary.setRequestId(attempt.getId()); summary.setUserId(attempt.getUserId()); summary.setCreatedAt(attempt.getCreatedAt()); summary.setReason(attempt.getReason()); var user = mapper.selectActor(tenant, attempt.getUserId()); summary.setUserName(user == null || blank(user.getUserName()) ? attempt.getUserId() : user.getUserName()); boolean owner = Objects.equals(actor.getUserId(), attempt.getUserId()); summary.setOwner(owner); List list = batches(attempt); boolean unknown = list.stream().anyMatch(b -> "SENDING".equals(b.getState())); int submitted = (int) list.stream().filter(b -> "SUBMITTED".equals(b.getState())).count(); summary.setSubmittedBatches(submitted); summary.setTotalBatches(list.size()); summary.setPhase("CONFIRMED".equals(attempt.getState()) ? "REGISTRATION_PENDING" : unknown ? "UNKNOWN" : submitted > 0 ? "PARTIAL" : "READY"); summary.setCanCancel(owner && "OPEN".equals(attempt.getState()) && neverDispatched(list)); summary.setCanResume(owner && !unknown); return summary; } /** 取消与begin共用副本行锁;只允许尚未取得任何派发许可的任务。 */ @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong cancel(DmsWenjianChongdaForm form) throws DataException { var attempt = lockAttempt(form); Map before = controlSnapshot(attempt); if ("CANCELLED".equals(attempt.getState())) return attempt; if (!"OPEN".equals(attempt.getState()) || !neverDispatched(batches(attempt))) throw new DataException("任务已派发或结果未知,不能取消解锁;发送成功后请恢复登记"); attempt.setState("CANCELLED"); attempt.setCancelledAt(new Date()); saveAttempt(attempt); recordControl("DMS_REPRINT_CANCEL", "重新打印取消", attempt, before); return attempt; } private boolean neverDispatched(List list) { return list.stream().allMatch(b -> "READY".equals(b.getState()) && blank(b.getToken())); } @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong active(String recordId) throws DataException { UserInfo actor = identity.requireUser(); String tenant = identity.tenantId(actor); if (taskMapper.selectPrintForUpdate(tenant, recordId, null) == null) throw new DataException("打印记录不存在或已删除"); DmsWenjianChongdaFasong attempt = mapper.selectActive(tenant, recordId); if (attempt != null) requireOwner(attempt, actor); return attempt; } @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong prepare(DmsWenjianChongdaForm form) throws DataException { if (form == null || blank(form.getPrintRecordId())) throw new DataException("原打印记录不能为空"); requireUuid(form.getRequestId()); UserInfo actor = identity.requireUser(); String tenant = identity.tenantId(actor); DmsWenjianDayinJiluEntity copy = taskMapper.selectPrintForUpdate(tenant, form.getPrintRecordId(), null); if (copy == null) throw new DataException("原打印记录不存在或已删除"); DmsWenjianChongdaFasong prior = mapper.selectAttempt(tenant, form.getRequestId()); if (prior != null) { requireOwner(prior, actor); if (!Objects.equals(prior.getPrintRecordId(), copy.getId())) throw new DataException("重打关联不能变更"); if ("CANCELLED".equals(prior.getState())) throw new DataException("本次重打已取消,请关闭弹窗重新发起"); return prior; } DmsWenjianChongdaFasong active = mapper.selectActive(tenant, copy.getId()); if (active != null) { requireOwner(active, actor); throw new DataException("该副本存在未完成重打,请关闭弹窗并从原记录恢复"); } eligible(copy); if (blank(form.getReason()) || form.getReason().trim().length() > 255 || blank(form.getBizSign())) throw new DataException("请填写255字以内重打原因并完成电子签名"); DmsWenjianDayinQuerenEntity version = versions.selectTaskVersion(tenant, copy.getDayinRenwuId()); if (version == null || blank(version.getFileVersionId()) || blank(copy.getDayinLiushuihao())) throw new DataException("原文件版本或流水缺失,不能重打"); requirePrintPermission(tenant, version.getFileVersionId()); if (mapper.validSignature(tenant, actor.getUserId(), form.getBizSign(), copy.getId(), form.getRequestId(), form.getReason().trim()) == null) throw new DataException("重打签名无效或不属于当前操作人"); DmsWenjianChongdaFasong attempt = new DmsWenjianChongdaFasong(); attempt.setId(form.getRequestId()); attempt.setTenantId(tenant); attempt.setPrintRecordId(copy.getId()); attempt.setTaskId(copy.getDayinRenwuId()); attempt.setFileVersionId(version.getFileVersionId()); attempt.setUserId(actor.getUserId()); attempt.setCopies(1); attempt.setState("OPEN"); attempt.setBatchesJson("[]"); attempt.setReason(form.getReason().trim()); attempt.setBizSign(form.getBizSign()); attempt.setCreatedAt(new Date()); DmsWenjianDayinHaoma number = new DmsWenjianDayinHaoma(); number.setSerialNumber(copy.getDayinLiushuihao()); number.setCountSerialNumber(copy.getDayinJishuLiushuihao()); attempt.setNumbersJson(JsonUtil.getObjectToString(Collections.singletonList(number))); if (mapper.insertAttempt(attempt) != 1) throw new DataException("建立重打发送任务失败"); return attempt; } /** 查询原生保存结果也用于响应丢失恢复,不创建或修改业务单据。 */ @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong status(DmsWenjianChongdaForm form) throws DataException { DmsWenjianChongdaFasong attempt = lockAttempt(form); DmsWenjianChongdaFasong saved = mapper.findDocument(attempt.getTenantId(), attempt.getId()); if (saved != null) attempt.setDocumentId(saved.getDocumentId()); return attempt; } /** 原生主子表保存完成后只确认执行副作用;不重新打印、取号或生成发放。 */ @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong finish(DmsWenjianChongdaForm form) throws DataException { DmsWenjianChongdaFasong attempt = lockAttempt(form); Map before = controlSnapshot(attempt); if ("CLOSED".equals(attempt.getState())) return attempt; if (!"CONFIRMED".equals(attempt.getState()) || attempt.getSubmittedAt() == null) throw new DataException("尚未收到全部重打成功回传,不能登记执行结果"); DmsWenjianBupinghengExecution doc = requireNativeDocument(attempt); if (doc.getZhixingShijian() != null && !doc.getZhixingShijian().equals(attempt.getSubmittedAt())) throw new DataException("登记执行时间与成功回传不一致,请核对原记录"); if (doc.getZhixingShijian() == null) { if (mapper.completeDocument(attempt) != 1) throw new DataException("重打已发送,登记确认失败,请只恢复登记"); businessAudit.record(attempt.getTenantId(), "DMS_REPRINT_EXECUTION_CONFIRMED", "回写重打执行结果", "dms_wenjian_bupingheng_dengji", attempt.getDocumentId(), values("zhixing_shijian", doc.getZhixingShijian()), values("zhixing_shijian", attempt.getSubmittedAt()), attempt.getReason(), controlContext(attempt)); } attempt.setState("CLOSED"); saveAttempt(attempt); recordControl("DMS_REPRINT_COMPLETE", "重新打印完成", attempt, before); return attempt; } /** 发送成功后校验原生单据并绑定,不接管主子表CRUD。 */ private DmsWenjianBupinghengExecution requireNativeDocument(DmsWenjianChongdaFasong attempt) throws DataException { DmsWenjianChongdaFasong saved = mapper.findDocument(attempt.getTenantId(), attempt.getId()); if (saved == null) throw new DataException("请先通过原生表单保存本次重打登记"); attempt.setDocumentId(saved.getDocumentId()); DmsWenjianBupinghengExecution doc = documents.lockDocument(attempt.getTenantId(), attempt.getDocumentId()); List items = documents.lockItems(attempt.getTenantId(), attempt.getDocumentId()); if (doc == null || !Objects.equals(doc.getBizSign(), attempt.getBizSign()) || !Objects.equals(doc.getDengjiRenId(), attempt.getUserId()) || items == null || items.size() != 1) throw new DataException("原生登记与本次重打的签名、执行人或明细不匹配"); DmsWenjianBupinghengExecution.Item item = items.get(0); if (!"cuoshi3".equals(item.getCuoshi()) || !Objects.equals(item.getDayinJiluId(), attempt.getPrintRecordId()) || !Objects.equals(item.getYuanyin(), attempt.getReason()) || !blank(item.getXinDayinRenwuId())) throw new DataException("重打登记不得改变原记录、措施或原因"); return doc; } private DmsWenjianChongdaFasong lockAttempt(DmsWenjianChongdaForm form) throws DataException { if (form == null || blank(form.getPrintRecordId())) throw new DataException("原打印记录不能为空"); requireUuid(form.getRequestId()); UserInfo actor = identity.requireUser(); String tenant = identity.tenantId(actor); // 与prepare一致先锁副本;不因发送后的状态变化丢弃真实成功回执。 taskMapper.selectPrintForUpdate(tenant, form.getPrintRecordId(), null); DmsWenjianChongdaFasong attempt = mapper.selectAttempt(tenant, form.getRequestId()); if (attempt == null || !Objects.equals(attempt.getPrintRecordId(), form.getPrintRecordId())) throw new DataException("重打发送任务不存在或关联不匹配"); requireOwner(attempt, actor); return attempt; } private void requireOwner(DmsWenjianChongdaFasong attempt, UserInfo actor) throws DataException { if (!Objects.equals(attempt.getUserId(), actor.getUserId())) throw new DataException("该副本存在其他用户的未完成重打,请由原发起人恢复"); } private void eligible(DmsWenjianDayinJiluEntity copy) throws DataException { if (copy == null || !("daifafang".equals(copy.getBizStatus()) || "daiqianshou".equals(copy.getBizStatus()))) throw new DataException("仅待发放、待签收的有效打印记录允许重打"); } private void requirePrintPermission(String tenant, String versionId) throws DataException { UUID id; try { id = UUID.fromString(versionId); } catch (RuntimeException e) { throw new DataException("文件版本ID无效"); } var version = fileVersions.selectReadableByTenantAndId(tenant, id); if (version == null) throw new DataException("原文件版本不存在或已删除"); if (jnpf.dmsPermission.DmsFileControlPolicy.requiresPermissionCheck(version.getShifouShoukong())) permissions.requireAllowed(jnpf.dmsEntity.permission.enums.DmsResourceType.FILE_VERSION, id, jnpf.dmsEntity.permission.enums.DmsPermissionAction.PRINT); } private void requireUuid(String value) throws DataException { try { if (!UUID.fromString(value).toString().equals(value)) throw new IllegalArgumentException(); } catch (RuntimeException ex) { throw new DataException("重打发送关联ID无效"); } } private boolean blank(String value) { return value == null || value.trim().isEmpty(); } /** 计划持久化后才能发;已有计划只能恢复,不能被另一窗口替换。 */ @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong plan(DmsWenjianChongdaForm form, String fileVersionId, int pageCount) throws DataException { DmsWenjianChongdaFasong attempt = lockAttempt(form); Map before = controlSnapshot(attempt); if (!batches(attempt).isEmpty() || !"OPEN".equals(attempt.getState())) return attempt; if (!Objects.equals(attempt.getFileVersionId(), fileVersionId) || pageCount < 1 || pageCount > 10000) throw new DataException("原版本或完整页数校验失败"); validatePlan(form.getBatches(), attempt, pageCount); attempt.setPageCount(pageCount); attempt.setBatchesJson(JsonUtil.getObjectToString(form.getBatches())); saveAttempt(attempt); recordControl("DMS_REPRINT_PLAN", "冻结重打发送计划", attempt, before); return attempt; } @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong split(DmsWenjianChongdaForm form) throws DataException { DmsWenjianChongdaFasong attempt = lockAttempt(form); Map before = controlSnapshot(attempt); List list = batches(attempt); Batch batch = findBatch(list, form.getBatchId()); if (!"OPEN".equals(attempt.getState()) || !"READY".equals(batch.getState())) throw new DataException("已发送的批次不能拆分"); List parts = form.getBatches(); if (parts == null || parts.size() != 2) throw new DataException("拆分计划无效"); validateBatch(parts.get(0)); validateBatch(parts.get(1)); List pages = new ArrayList<>(parts.get(0).getPages()); pages.addAll(parts.get(1).getPages()); if (!parts.get(0).getItems().equals(batch.getItems()) || !parts.get(1).getItems().equals(batch.getItems()) || !pages.equals(batch.getPages())) throw new DataException("拆分页段与原批次不一致"); int index = list.indexOf(batch); list.remove(index); list.addAll(index, parts); if (list.stream().map(Batch::getId).distinct().count() != list.size()) throw new DataException("批次ID重复"); attempt.setBatchesJson(JsonUtil.getObjectToString(list)); saveAttempt(attempt); recordControl("DMS_REPRINT_BATCH_SPLIT", "调整未派发重打页段", attempt, before); return attempt; } /** 非幂等派发许可:许可响应丢失也不能再次取得许可,必须保留结果未知。 */ @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong begin(DmsWenjianChongdaForm form) throws DataException { DmsWenjianChongdaFasong attempt = lockAttempt(form); Map before = controlSnapshot(attempt); List list = batches(attempt); Batch batch = findBatch(list, form.getBatchId()); if (!"OPEN".equals(attempt.getState()) || !"READY".equals(batch.getState())) { throw new DataException("该批次已发送或发送结果未知,请恢复原任务,禁止重复发送"); } for (Batch previous : list) { if (previous == batch) break; if (!"SUBMITTED".equals(previous.getState())) throw new DataException("前一批次尚未确认,不能发送后续批次"); } eligible(taskMapper.selectPrintForUpdate(attempt.getTenantId(), attempt.getPrintRecordId(), null)); // 新流程先打印后登记;兼容旧流程已保存的单据,仍核对一致性。 if (mapper.findDocument(attempt.getTenantId(), attempt.getId()) != null) { DmsWenjianBupinghengExecution doc = requireNativeDocument(attempt); if (doc.getZhixingShijian() != null) throw new DataException("本次登记已执行,不能再次发送重打"); } requireUuid(form.getToken()); batch.setToken(form.getToken()); batch.setState("SENDING"); attempt.setBatchesJson(JsonUtil.getObjectToString(list)); saveAttempt(attempt); recordControl("DMS_REPRINT_BATCH_BEGIN", "取得重打派发许可", attempt, before); return attempt; } /** 包括迟到回传;保存失败可用同一令牌重试,绝不重新派发。 */ @Transactional(rollbackFor = Exception.class) public DmsWenjianChongdaFasong submitted(DmsWenjianChongdaForm form) throws DataException { DmsWenjianChongdaFasong attempt = lockAttempt(form); Map before = controlSnapshot(attempt); List list = batches(attempt); Batch batch = findBatch(list, form.getBatchId()); requireUuid(form.getToken()); if (!Objects.equals(form.getToken(), batch.getToken()) || !("SENDING".equals(batch.getState()) || "SUBMITTED".equals(batch.getState()))) { throw new DataException("打印批次成功回传与发送任务不匹配"); } if ("SUBMITTED".equals(batch.getState())) return attempt; batch.setState("SUBMITTED"); attempt.setBatchesJson(JsonUtil.getObjectToString(list)); if (list.stream().allMatch(item -> "SUBMITTED".equals(item.getState()))) { attempt.setState("CONFIRMED"); attempt.setSubmittedAt(new Date()); } saveAttempt(attempt); recordControl("DMS_REPRINT_BATCH_RECEIPT", "确认重打提交回传", attempt, before); return attempt; } private List batches(DmsWenjianChongdaFasong attempt) { return JsonUtil.getJsonToList(attempt.getBatchesJson(), Batch.class); } private Batch findBatch(List list, String id) throws DataException { for (Batch batch : list) if (Objects.equals(batch.getId(), id)) return batch; throw new DataException("打印批次不存在,请恢复原任务"); } private void validateBatch(Batch batch) throws DataException { if (batch == null) throw new DataException("打印批次无效"); requireUuid(batch.getId()); if (batch.getPages() == null || batch.getPages().isEmpty() || batch.getPages().size() > 100 || batch.getItems() == null || batch.getItems().size() != 1 || batch.getItems().get(0) == null || batch.getItems().get(0) < 0) throw new DataException("打印页段无效"); batch.setState("READY"); batch.setToken(null); } private void validatePlan(List list, DmsWenjianChongdaFasong attempt, int pageCount) throws DataException { if (list == null || list.isEmpty() || list.size() > 100000) throw new DataException("打印计划无效"); Set ids = new HashSet<>(); int cursor = 0, expectedPages = 0; for (int copy = 0; copy < attempt.getCopies(); copy++) { int page = 1; while (cursor < list.size() && Collections.singletonList(copy).equals(list.get(cursor).getItems())) { Batch batch = list.get(cursor++); validateBatch(batch); if (!ids.add(batch.getId())) throw new DataException("批次ID重复"); for (Integer number : batch.getPages()) { if (!Objects.equals(number, page++) || page > 10001) throw new DataException("打印计划必须连续覆盖完整文档,不能重复或跳页"); } } if (page == 1 || (copy > 0 && page - 1 != expectedPages)) throw new DataException("每份必须包含相同完整页段"); expectedPages = page - 1; } if (cursor != list.size() || expectedPages != pageCount) throw new DataException("打印计划必须覆盖原版本全部页面"); } private Map controlSnapshot(DmsWenjianChongdaFasong attempt) { List> batchStates = batches(attempt).stream().map(batch -> values("batch_id", batch.getId(), "pages", batch.getPages(), "items", batch.getItems(), "state", batch.getState())).toList(); return values("fasong_zhuangtai", attempt.getState(), "pici_neirong", batchStates, "zong_yeshu", attempt.getPageCount(), "fasong_chenggong_shijian", attempt.getSubmittedAt(), "dengji_id", attempt.getDocumentId(), "quxiao_shijian", attempt.getCancelledAt()); } private Map controlContext(DmsWenjianChongdaFasong attempt) { return values("request_id", attempt.getId(), "print_record_id", attempt.getPrintRecordId(), "task_id", attempt.getTaskId(), "file_version_id", attempt.getFileVersionId(), "copies", attempt.getCopies(), "numbers", JsonUtil.getJsonToList(attempt.getNumbersJson(), DmsWenjianDayinHaoma.class), "document_id", attempt.getDocumentId(), "execution_user_id", attempt.getUserId(), "biz_sign", attempt.getBizSign()); } private void recordControl(String action, String label, DmsWenjianChongdaFasong attempt, Map before) { businessAudit.record(attempt.getTenantId(), action, label, "dms_wenjian_chongda_fasong", attempt.getId(), before, controlSnapshot(attempt), attempt.getReason(), controlContext(attempt)); } private void saveAttempt(DmsWenjianChongdaFasong attempt) throws DataException { if (mapper.updateAttempt(attempt) != 1) throw new DataException("保存打印发送进度失败"); } }