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<DmsApprovedItem> 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<TemplateNodeEntity> nodes = workflow.getNodesByFlowId(flowId);
|
if (nodes == null || nodes.isEmpty()) throw new DataException("无法读取本次任务的签批流程节点");
|
String templateId = workflow.getTemplateByVersionId(flowId);
|
List<TemplateJsonEntity> 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<RecordEntity> 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<String> userIds = new LinkedHashSet<>();
|
if (StringUtils.hasText(preparedBy)) userIds.add(preparedBy);
|
records.stream().map(RecordEntity::getHandleId).filter(StringUtils::hasText).forEach(userIds::add);
|
Map<String, UserEntity> userMap = loadUsers(userIds);
|
|
int start = 0;
|
int approve = 0;
|
for (TemplateNodeEntity node : nodes) {
|
String role = role(node);
|
if (role == null) continue;
|
String stepKey;
|
List<ApprovalAction> 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<String, String> values, String flowId, String stepKey,
|
String role, String nodeCode, String stepName,
|
List<ApprovalAction> 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<String, String> 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<ApprovalAction> actions, String field) {
|
List<String> 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<ApprovalAction> approvalActions(String nodeCode, List<RecordEntity> records,
|
Map<String, UserEntity> userMap) {
|
List<RecordEntity> 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<String, UserEntity> 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<String, UserEntity> loadUsers(Set<String> ids) {
|
Map<String, UserEntity> result = new LinkedHashMap<>();
|
if (ids.isEmpty()) return result;
|
List<UserEntity> 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<TemplateNodeEntity> nodes, DmsApprovedBatch batch, Map<String, String> values) {
|
Set<String> formIds = new LinkedHashSet<>();
|
for (TemplateNodeEntity node : nodes) {
|
if (node != null && StringUtils.hasText(node.getFormId())) formIds.add(node.getFormId());
|
}
|
Map<String, String> legacy = new TreeMap<>();
|
Map<String, String> rowValues = new TreeMap<>();
|
Map<String, String> signatures = new TreeMap<>();
|
Set<String> ambiguousRows = new LinkedHashSet<>();
|
Set<String> 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<jnpf.onlinedev.model.FormDisplaySchema.Field> fields =
|
jnpf.onlinedev.model.FormDisplaySchema.parse(form.getFormData());
|
ActionResult<Object> 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<String, List<Map<String, String>>> 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<Map<String, String>> displayRows = tables.computeIfAbsent(field.getTable(), key -> {
|
List<Map<String, String>> result = new ArrayList<>();
|
for (int i = 0; i < rows.size(); i++) result.add(new TreeMap<>());
|
return result;
|
});
|
List<String> 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<String, List<Map<String, String>>> 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<String, String> 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<String, String> 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<DmsApprovedItem> 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<DmsApprovedItem> matches = batch.getItems().stream()
|
.filter(item -> Objects.equals(sourceRowId, item.getSourceRowId()))
|
.toList();
|
return requireVersionLabel(matches, "记录行 " + rowId);
|
}
|
|
private String requireVersionLabel(List<DmsApprovedItem> 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<String> items = new ArrayList<>();
|
for (JsonNode item : value) items.add(displayText(item, path));
|
return String.join("、", items);
|
}
|
throw new DataException("字段尚未转换为显示文本: " + path);
|
}
|
|
private List<DmsApprovedItem> renderItems(DmsApprovedBatch batch) {
|
List<DmsApprovedItem> 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<String, String> valuesByTag = new TreeMap<>();
|
private List<String> warnings = new ArrayList<>();
|
}
|
|
@Data
|
private static class ApprovalAction {
|
private String actorName;
|
private String departmentName;
|
private Date actionAt;
|
}
|
}
|