刘光辉
4 小时以前 06f314545ac3514d7ce28939920866ae8188d31c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
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;
    }
}