package jnpf.dmsService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import jnpf.base.ActionResult; import jnpf.base.entity.VisualdevEntity; import jnpf.dmsEntity.storage.approval.DmsApprovedBatch; import jnpf.dmsEntity.storage.approval.DmsApprovedItem; import jnpf.exception.DataException; import jnpf.flowable.WorkFlowApi; import jnpf.flowable.entity.RecordEntity; import jnpf.flowable.entity.TemplateJsonEntity; import jnpf.flowable.entity.TemplateNodeEntity; import jnpf.flowable.enums.NodeEnum; import jnpf.flowable.enums.RecordEnum; import jnpf.flowable.model.templatenode.nodejson.NodeModel; import jnpf.flowable.model.util.FlowNature; import jnpf.onlinedev.VisualdevOnlineApi; import jnpf.permission.OrganizeApi; import jnpf.permission.UserApi; import jnpf.permission.entity.OrganizeEntity; import jnpf.permission.entity.UserEntity; import jnpf.util.JsonUtil; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.text.SimpleDateFormat; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.Date; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TimeZone; import java.util.TreeMap; import java.util.stream.Collectors; /** Builds and restores the immutable value map sent to the DOCX approval-page writer. */ @Service @RequiredArgsConstructor public class DmsQianpiyeRenderSnapshotService { private static final int PROTOCOL_VERSION = 1; private static final TimeZone ZONE = TimeZone.getTimeZone("Asia/Shanghai"); private final WorkFlowApi workflow; private final VisualdevOnlineApi forms; private final UserApi users; private final OrganizeApi organizes; private final ObjectMapper json; public void attach(DmsApprovedBatch batch, String persistedSnapshot, String preparedBy, Date preparedOn) { if ("wailai_wenjian_xinzeng_shenhe".equals(batch.getAuditType())) { batch.setApprovalRenderSnapshot(null); return; } List renderItems = renderItems(batch); if (renderItems.isEmpty()) { batch.setApprovalRenderSnapshot(null); return; } RenderSnapshot snapshot = StringUtils.hasText(persistedSnapshot) ? read(persistedSnapshot) : create(batch, preparedBy, preparedOn); if (!Objects.equals(batch.getTaskId(), snapshot.getTaskId()) || !Objects.equals(batch.getFlowId(), snapshot.getFlowId())) { throw new DataException("签批页回填快照与当前流程任务不匹配"); } String canonical = write(snapshot); batch.setApprovalRenderSnapshot(canonical); batch.setSourceDigest(digest(batch.getSourceDigest() + "\n" + canonical)); for (DmsApprovedItem item : renderItems) { item.setApprovalValuesByTag(new LinkedHashMap<>(snapshot.getValuesByTag())); item.setApprovalFlowVersionId(snapshot.getFlowId()); } } private RenderSnapshot create(DmsApprovedBatch batch, String preparedBy, Date preparedOn) { String taskId = batch.getTaskId(); String flowId = batch.getFlowId(); List nodes = workflow.getNodesByFlowId(flowId); if (nodes == null || nodes.isEmpty()) throw new DataException("无法读取本次任务的签批流程节点"); String templateId = workflow.getTemplateByVersionId(flowId); List versions = StringUtils.hasText(templateId) ? workflow.getFlowIdsByTemplate(templateId) : null; TemplateJsonEntity version = versions == null ? null : versions.stream() .filter(item -> Objects.equals(flowId, item.getId())).findFirst().orElse(null); if (version == null) throw new DataException("无法读取本次任务的签批流程版本"); nodes = DmsQianpiyeNodeOrder.order(version.getFlowXml(), nodes); List records = workflow.getRecordList(taskId); if (records == null) throw new DataException("无法读取本次任务的审批记录"); RenderSnapshot result = new RenderSnapshot(); result.setProtocolVersion(PROTOCOL_VERSION); result.setTaskId(taskId); result.setFlowId(flowId); result.setGeneratedAt(Date.from(Instant.now())); result.getWarnings().add("流程记录未冻结人员和部门显示名,本快照使用首次归档时的目录信息解析"); Set userIds = new LinkedHashSet<>(); if (StringUtils.hasText(preparedBy)) userIds.add(preparedBy); records.stream().map(RecordEntity::getHandleId).filter(StringUtils::hasText).forEach(userIds::add); Map userMap = loadUsers(userIds); int start = 0; int approve = 0; for (TemplateNodeEntity node : nodes) { String role = role(node); if (role == null) continue; String stepKey; List actions; if ("START".equals(role)) { start++; stepKey = start == 1 ? "start" : "start-" + start; actions = new ArrayList<>(); actions.add(action(preparedBy, preparedOn, userMap)); } else { approve++; stepKey = "approve-" + approve; actions = approvalActions(node.getNodeCode(), records, userMap); } addFlowValues(result.getValuesByTag(), flowId, stepKey, role, node.getNodeCode(), nodeName(node), actions); } loadFormValues(nodes, batch, result.getValuesByTag()); return result; } private void addFlowValues(Map values, String flowId, String stepKey, String role, String nodeCode, String stepName, List actions) { if ("START".equals(role)) { putFlow(values, flowId, nodeCode, stepKey, "applicant", join(actions, "name")); putFlow(values, flowId, nodeCode, stepKey, "applicantDepartment", join(actions, "department")); putFlow(values, flowId, nodeCode, stepKey, "applicantTime", join(actions, "time")); } else { putFlow(values, flowId, nodeCode, stepKey, "approver", join(actions, "name")); putFlow(values, flowId, nodeCode, stepKey, "approverDepartment", join(actions, "department")); putFlow(values, flowId, nodeCode, stepKey, "approveTime", join(actions, "time")); } putFlow(values, flowId, nodeCode, stepKey, "stepName", empty(stepName)); } private void putFlow(Map values, String flowId, String nodeCode, String stepKey, String field, String value) { values.put("dms.qianpiye.flow." + stepKey + "." + field, value); if (StringUtils.hasText(nodeCode)) { values.put("dms.qianpiye." + flowId + "." + nodeCode + "." + field, value); } } private String join(List actions, String field) { List values = new ArrayList<>(); for (ApprovalAction action : actions) { if (action == null) continue; String value; switch (field) { case "name": value = action.getActorName(); break; case "department": value = action.getDepartmentName(); break; case "time": value = format(action.getActionAt()); break; default: value = ""; } values.add(empty(value)); } return String.join("\n", values); } private List approvalActions(String nodeCode, List records, Map userMap) { List valid = records.stream() .filter(record -> Objects.equals(nodeCode, record.getNodeCode())) .filter(record -> Objects.equals(RecordEnum.AUDIT.getCode(), record.getHandleType())) .filter(record -> !Objects.equals(FlowNature.INVALID, record.getStatus())) .filter(record -> StringUtils.hasText(record.getNodeId())) .collect(Collectors.toList()); if (valid.isEmpty()) return new ArrayList<>(); RecordEntity latest = valid.stream().max(Comparator .comparing(RecordEntity::getHandleTime, Comparator.nullsFirst(Comparator.naturalOrder())) .thenComparing(RecordEntity::getId, Comparator.nullsFirst(String::compareTo))).orElse(null); if (latest == null) return new ArrayList<>(); return valid.stream().filter(record -> Objects.equals(latest.getNodeId(), record.getNodeId())) .sorted(Comparator.comparing(RecordEntity::getHandleTime, Comparator.nullsLast(Comparator.naturalOrder())) .thenComparing(RecordEntity::getId, Comparator.nullsLast(String::compareTo))) .map(record -> action(record.getHandleId(), record.getHandleTime(), userMap)) .collect(Collectors.toList()); } private ApprovalAction action(String userId, Date time, Map userMap) { ApprovalAction result = new ApprovalAction(); result.setActionAt(time); UserEntity user = userMap.get(userId); result.setActorName(user == null ? "" : empty(user.getRealName())); result.setDepartmentName(departmentName(user == null ? null : firstId(user.getOrganizeId()))); return result; } private Map loadUsers(Set ids) { Map result = new LinkedHashMap<>(); if (ids.isEmpty()) return result; List loaded = users.getUserName(new ArrayList<>(ids)); if (loaded == null) throw new DataException("无法解析签批页审批人员"); for (UserEntity user : loaded) { if (user != null && StringUtils.hasText(user.getId())) result.put(user.getId(), user); } return result; } private String departmentName(String organizeId) { if (!StringUtils.hasText(organizeId)) return ""; OrganizeEntity organize = organizes.getInfoById(organizeId); if (organize == null) return ""; return StringUtils.hasText(organize.getOrgNameTree()) ? organize.getOrgNameTree() : empty(organize.getFullName()); } private String firstId(String ids) { if (!StringUtils.hasText(ids)) return null; String value = ids.split(",", -1)[0].trim(); return value.isEmpty() ? null : value; } private String role(TemplateNodeEntity node) { if (NodeEnum.START.getType().equals(node.getNodeType())) return "START"; if (NodeEnum.APPROVER.getType().equals(node.getNodeType())) return "APPROVE"; return null; } private String nodeName(TemplateNodeEntity node) { NodeModel model = JsonUtil.getJsonToBean(node.getNodeJson(), NodeModel.class); return model != null && StringUtils.hasText(model.getNodeName()) ? model.getNodeName() : empty(node.getNodeCode()); } private void loadFormValues(List nodes, DmsApprovedBatch batch, Map values) { Set formIds = new LinkedHashSet<>(); for (TemplateNodeEntity node : nodes) { if (node != null && StringUtils.hasText(node.getFormId())) formIds.add(node.getFormId()); } Map legacy = new TreeMap<>(); Map rowValues = new TreeMap<>(); Map signatures = new TreeMap<>(); Set ambiguousRows = new LinkedHashSet<>(); Set ambiguous = new LinkedHashSet<>(); for (String formId : formIds) { if (!formId.matches("[A-Za-z0-9_-]+")) throw new DataException("表单标识无效: " + formId); VisualdevEntity form = forms.getFormConfig(formId); if (form == null) throw new DataException("无法读取签批页流程表单配置: " + formId); List fields = jnpf.onlinedev.model.FormDisplaySchema.parse(form.getFormData()); ActionResult response = forms.displayInfo(formId, batch.getTaskId()); if (response == null || !Integer.valueOf(200).equals(response.getCode())) throw new DataException("无法读取签批页流程表单显示值: " + formId); JsonNode root = json.valueToTree(response.getData()); if (root != null && root.isTextual()) { try { root = json.readTree(root.asText()); } catch (Exception e) { throw new DataException("签批页流程表单数据格式不正确: " + formId); } } if (root == null || !root.isObject()) throw new DataException("签批页流程表单数据必须为对象: " + formId); Map>> tables = new LinkedHashMap<>(); for (jnpf.onlinedev.model.FormDisplaySchema.Field field : fields) { if (!field.isSupported()) continue; String value; if (field.getTable().isEmpty()) { value = "banbenhao".equals(field.getColumn()) ? mainVersionLabel(batch) : displayText(root.get(field.getColumn()), field.getPath()); } else { JsonNode rows = root.path(field.getTable()); if (!rows.isMissingNode() && !rows.isNull() && !rows.isArray()) throw new DataException("签批页子表数据必须为数组: " + field.getTable()); List> displayRows = tables.computeIfAbsent(field.getTable(), key -> { List> result = new ArrayList<>(); for (int i = 0; i < rows.size(); i++) result.add(new TreeMap<>()); return result; }); List column = new ArrayList<>(); for (int i = 0; i < rows.size(); i++) { if (!rows.get(i).isObject()) throw new DataException("签批页子表行必须为对象: " + field.getTable()); String text = "jilu_mingxi.banbenhao".equals(field.getPath()) ? recordVersionLabel(batch, rows.get(i)) : displayText(rows.get(i).get(field.getColumn()), field.getPath()); column.add(text); displayRows.get(i).put(field.getColumn(), text); } value = String.join("\n", column); } String oldSignature = signatures.putIfAbsent(field.getPath(), field.displaySignature()); if (oldSignature != null && !oldSignature.equals(field.displaySignature())) ambiguous.add(field.getPath()); String previous = legacy.putIfAbsent(field.getPath(), value); if (previous != null && !previous.equals(value)) ambiguous.add(field.getPath()); } for (Map.Entry>> table : tables.entrySet()) { try { String serialized = json.writeValueAsString(table.getValue()); String previous = rowValues.putIfAbsent(table.getKey(), serialized); if (previous != null && !previous.equals(serialized)) ambiguousRows.add(table.getKey()); } catch (Exception e) { throw new DataException("无法冻结子表显示值: " + table.getKey()); } } } for (Map.Entry row : rowValues.entrySet()) { if (!ambiguousRows.contains(row.getKey()) && ambiguous.stream().noneMatch(path -> path.startsWith(row.getKey() + "."))) values.put("dms.qianpiye.rows.common." + row.getKey(), row.getValue()); } for (Map.Entry entry : legacy.entrySet()) { // Ambiguous bindings stay absent so template controls render blank instead of guessing a source. if (!ambiguous.contains(entry.getKey())) values.put("dms.qianpiye.form." + entry.getKey(), entry.getValue()); } } // The form still shows the source version during revision approval. Use the same // CREATE/REVISE/REUSE target already resolved for center registration, before freezing // both column values and row patterns. Saved snapshots remain unchanged on retries. private String mainVersionLabel(DmsApprovedBatch batch) { List matches = batch.getItems().stream() .filter(item -> item.getSourceKey() != null && item.getSourceKey().endsWith(":zhuwenjian")) .toList(); return requireVersionLabel(matches, "主文件"); } private String recordVersionLabel(DmsApprovedBatch batch, JsonNode row) { String rowId = row.path("id").asText(""); if (!StringUtils.hasText(rowId)) rowId = row.path("f_id").asText(""); if (!StringUtils.hasText(rowId)) throw new DataException("签批页记录版本回填缺少子表行ID"); String sourceRowId = rowId; List matches = batch.getItems().stream() .filter(item -> Objects.equals(sourceRowId, item.getSourceRowId())) .toList(); return requireVersionLabel(matches, "记录行 " + rowId); } private String requireVersionLabel(List matches, String source) { if (matches.size() != 1 || !StringUtils.hasText(matches.get(0).getVersionLabel()) || !Set.of("CREATE", "REVISE", "REUSE").contains(matches.get(0).getChangeType())) throw new DataException("签批页无法确定本次归档的文件版本号: " + source); return matches.get(0).getVersionLabel(); } private String displayText(JsonNode value, String path) { if (value == null || value.isNull() || value.isMissingNode()) return ""; if (value.isValueNode()) return value.isBoolean() ? (value.asBoolean() ? "是" : "否") : value.asText(); if (value.isArray()) { List items = new ArrayList<>(); for (JsonNode item : value) items.add(displayText(item, path)); return String.join("、", items); } throw new DataException("字段尚未转换为显示文本: " + path); } private List renderItems(DmsApprovedBatch batch) { List result = new ArrayList<>(); if (batch == null || batch.getItems() == null) return result; for (DmsApprovedItem item : batch.getItems()) { if (item != null && item.requiresContentRegistration() && (StringUtils.hasText(item.getApprovalPageFileJson()) || StringUtils.hasText(item.getQianpiyeFiles()) || isDocx(item.getFileJson()))) { result.add(item); } } return result; } private boolean isDocx(String fileJson) { if (!StringUtils.hasText(fileJson)) return false; try { JsonNode file = json.readTree(fileJson); if (file != null && file.isArray() && file.size() == 1) file = file.get(0); if (file == null || !file.isObject()) return false; // Match the file reader's preferred original name before falling back to stored paths. for (String key : List.of("name", "fileName", "fileId", "url")) { String name = file.path(key).asText(""); if (!StringUtils.hasText(name)) continue; if ("url".equals(key)) name = name.split("[?#]", 2)[0]; if (name.trim().toLowerCase(Locale.ROOT).endsWith(".docx")) return true; if ("name".equals(key) || "fileName".equals(key)) return false; } return false; } catch (Exception e) { throw new DataException("签批页标注回填的文件信息格式不正确"); } } private RenderSnapshot read(String source) { try { RenderSnapshot result = json.readValue(source, RenderSnapshot.class); if (result == null || result.getProtocolVersion() != PROTOCOL_VERSION || result.getValuesByTag() == null) throw new DataException("签批页回填快照版本无效"); return result; } catch (DataException e) { throw e; } catch (Exception e) { throw new DataException("签批页回填快照格式不正确"); } } private String write(RenderSnapshot snapshot) { // jsonb does not preserve map order. Match the initial TreeMap representation // on retries too, so existing approval digests remain valid after a DB round trip. try { return json.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) .writeValueAsString(snapshot); } catch (Exception e) { throw new DataException("无法保存签批页回填快照"); } } private String digest(String source) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") .digest(source.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new DataException("无法计算签批页回填摘要"); } } private String format(Date value) { if (value == null) return ""; SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); format.setTimeZone(ZONE); return format.format(value); } private String empty(String value) { return value == null ? "" : value; } @Data public static class RenderSnapshot { private int protocolVersion; private String taskId; private String flowId; private Date generatedAt; private Map valuesByTag = new TreeMap<>(); private List warnings = new ArrayList<>(); } @Data private static class ApprovalAction { private String actorName; private String departmentName; private Date actionAt; } }