刘光辉
3 小时以前 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
package jnpf.dmsService;
 
import jnpf.dmsEntity.center.DmsCenterVersionSnapshot;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jnpf.dmsEntity.*;
import jnpf.dmsEntity.storage.approval.*;
import jnpf.dmsMapper.*;
import jnpf.exception.DataException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
 
/** Reads the complete native source; never accepts a client-provided subset of records. */
@Component
@RequiredArgsConstructor
public class DmsApprovedAuditSource {
    // Persisted approval hashes are an internal contract, independent of HTTP JSON settings.
    // Retain the existing UTC ISO dates, camelCase fields, nulls and compact JSON bytes.
    private static final ObjectMapper SOURCE_JSON = new ObjectMapper()
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .setTimeZone(TimeZone.getTimeZone("UTC"));
    private final DmsWenjianXinzengShenheMapper newMapper;
    private final DmsWailaiWenjianXinzengShenheMapper externalMapper;
    private final DmsWenjianXiudingShenheMapper revisionMapper;
    private final DmsWenjianXinzengShenheJiluMingxiMapper newRecords;
    private final DmsWenjianXiudingShenheJiluMingxiMapper revisionRecords;
    private final DmsWenjianXiudingShenqingZixiangMapper revisionDetails;
    private final DmsWenjianXiudingShenqingMapper revisionRequests;
    private final DmsWenjianGuanxiMapper existingRelations;
    private final DmsWenjianBianhaoMapper numbers;
    private final DmsWenjianBanbenMapper businessVersions;
    private final jnpf.dmsService.center.DmsCenterQueryService centerVersions;
    private final DmsWenjianGuanlianMingxiMapper relations;
    private final ObjectMapper json;
    private final DmsDisableApplicationGuard disableApplications;
    private final DmsXiudingKanwuService revisionErrata;
 
    public static final class Source {
        private final DmsApprovedBatch batch;
        private final String preparedBy;
        private final String reviewerIds;
        private final Date preparedOn;
        private final boolean settled;
        private final List<jnpf.dmsEntity.kanwu.DmsXiudingKanwuRow> errata;
        public Source(DmsApprovedBatch batch, String preparedBy, Date preparedOn, boolean settled) {
            this(batch, preparedBy, preparedOn, settled, Collections.emptyList());
        }
        public Source(DmsApprovedBatch batch, String preparedBy, Date preparedOn, boolean settled,
                      List<jnpf.dmsEntity.kanwu.DmsXiudingKanwuRow> errata) {
            this(batch, preparedBy, preparedOn, settled, errata, null);
        }
        public Source(DmsApprovedBatch batch, String preparedBy, Date preparedOn, boolean settled,
                      List<jnpf.dmsEntity.kanwu.DmsXiudingKanwuRow> errata, String reviewerIds) {
            this.reviewerIds = reviewerIds;
            this.batch = batch; this.preparedBy = preparedBy; this.preparedOn = preparedOn; this.settled = settled;
            this.errata = errata;
        }
        public DmsApprovedBatch batch() { return batch; }
        public String reviewerIds() { return reviewerIds; }
        public String preparedBy() { return preparedBy; }
        public Date preparedOn() { return preparedOn; }
        public boolean settled() { return settled; }
        public List<jnpf.dmsEntity.kanwu.DmsXiudingKanwuRow> errata() { return errata; }
    }
 
    public Source load(String tenant, String flow, String task, String type, String actor, Date approvedAt) {
        if ("wailai_wenjian_xinzeng_shenhe".equals(type))
            return loadExternal(tenant, flow, task, actor, approvedAt);
        Object audit;
        List<?> records;
        String baseId = null;
        String baseFileId = null;
        String documentNo = null;
        String approvalPageFileJson = null;
        String numberSnapshot = null;
        Date effective, expires, created;
        String creator;
        String reviewerIds;
        if ("xinzeng_shenhe".equals(type)) {
            DmsWenjianXinzengShenheEntity a = unique(newMapper.selectByFlowForUpdate(tenant, flow, task));
            audit = a;
            reviewerIds = a.getShenheRenId();
            records = newRecords.selectByAuditForUpdate(tenant, a.getId());
            DmsWenjianBianhaoEntity n = numbers.selectActiveByIdForUpdate(tenant, a.getWenjianBianhaoId());
            if (n == null || !"using".equals(n.getBizStatus()) || n.getDangqianWenjianMingcheng() == null || !Objects.equals(n.getWenjianBianhao(), a.getWenjianBianhao()))
                throw new DataException("审核文件编号与已发放编号不一致");
            documentNo = n.getWenjianBianhao();
            approvalPageFileJson = approvalPageFileJson(n);
            numberSnapshot = n.getGuizeKuaizhao();
            effective = a.getShengxiaoRiqi(); expires = a.getYouxiaoQiZhi();
            creator = a.getCreatorUserId(); created = a.getCreatorTime();
        } else if ("xiuding_shenhe".equals(type)) {
            DmsWenjianXiudingShenheEntity a = unique(revisionMapper.selectByFlowForUpdate(tenant, flow, task));
            audit = a;
            reviewerIds = a.getShenheRenId();
            records = revisionRecords.selectByAuditForUpdate(tenant, a.getId());
            DmsWenjianXiudingShenqingZixiangEntity detail = revisionDetails.selectByIdForUpdate(tenant, a.getXiudingShenqingZixiangId());
            if (detail == null) throw new DataException("修订申请子项不存在");
            DmsWenjianXiudingShenqingEntity request = revisionRequests.selectByIdForUpdate(tenant, detail.getForeignId());
            if (request == null || !Integer.valueOf(2).equals(request.getFlowState()))
                throw new DataException("修订申请尚未审批通过");
            if ("occupied".equals(detail.getBizStatus()) && !a.getId().equals(detail.getZhanYongDanjuId()))
                throw new DataException("修订申请子项已被其他审核单占用");
            baseId = detail.getYuanWenjianBanbenId();
            baseFileId = detail.getWenjianId();
            disableApplications.requireFileEditable(tenant, baseFileId);
            effective = a.getShengxiaoRiqi(); expires = a.getYouxiaoQiZhi();
            creator = a.getCreatorUserId() != null ? a.getCreatorUserId() : request.getCreatorUserId();
            created = a.getCreatorTime() != null ? a.getCreatorTime() : request.getCreatorTime();
        } else throw new DataException("审核类型无效");
        ObjectNode main = SOURCE_JSON.valueToTree(audit);
        if (main.path("flowState").asInt(-1) != 2) throw new DataException("原生审核单尚未保存批准状态");
        if ("yes".equals(main.path("shifouPeixun").asText()) && !main.hasNonNull("peixunWanchengRiqi"))
            throw new DataException("需要培训的文件尚未完成培训");
        if (creator == null || created == null) throw new DataException("原生审核单缺少起草人或起草时间");
        String auditId = text(main, "id");
        List<jnpf.dmsEntity.kanwu.DmsXiudingKanwuRow> errata = "xiuding_shenhe".equals(type)
                ? revisionErrata.loadAndValidate(tenant, auditId, baseFileId, baseId) : Collections.emptyList();
        DmsApprovedBatch batch = new DmsApprovedBatch();
        batch.setTenantId(tenant); batch.setFlowId(flow); batch.setTaskId(task); batch.setAuditId(auditId);
        batch.setAuditType(type); batch.setActorId(actor); batch.setApprovedAt(approvedAt);
        batch.setScheduledAt(DmsApprovedFileDates.scheduled(effective));
        batch.setEffectiveTo(DmsApprovedFileDates.expires(expires, batch.getScheduledAt()));
        List<DmsApprovedItem> items = new ArrayList<>();
        String prefix = type + ":" + auditId + ":";
        DmsApprovedItem mainItem = item(tenant, prefix + "zhuwenjian", null, main, true,
                "xinzeng_shenhe".equals(type) ? "xinjian" : text(main, "biangengFangshi"), baseId, baseFileId, documentNo);
        if ("REVISE".equals(mainItem.getChangeType())) {
            approvalPageFileJson = revisionApprovalPageFileJson(tenant, baseId, baseFileId);
        }
        mainItem.setApprovalPageFileJson(approvalPageFileJson);
        items.add(mainItem);
        copySignaturePage(numberSnapshot, mainItem);
        // Use the already validated source for both the legacy and the new physical-import paths.
        mainItem.setQianpiyeFiles(approvalPageFileJson);
        List<JsonNode> sourceRows = new ArrayList<>();
        if (records != null) for (Object row : records) sourceRows.add(SOURCE_JSON.valueToTree(row));
        sourceRows.sort(Comparator.comparing(row -> text(row, "id")));
        for (JsonNode row : sourceRows) {
            if (!Objects.equals(text(main, "wenjianMuluId"), text(row, "wenjianMuluId")))
                throw new DataException("记录文件目录必须与主文件一致");
            String change = "xinzeng_shenhe".equals(type) ? "xinjian" : text(row, "biangengFangshi");
            if ("xinzeng_shenhe".equals(type) && row.hasNonNull("biangengFangshi")
                    && !row.path("biangengFangshi").asText().isBlank() && !"xinjian".equals(row.path("biangengFangshi").asText()))
                throw new DataException("新增记录只能选择新建");
            items.add(item(tenant, prefix + "jilu:" + text(row, "id"), text(row, "id"), row, false,
                    change, row.path("yuanWenjianBanbenId").asText(null), row.path("jiluWenjianId").asText(null), row.path("fujianBianhao").asText(null)));
        }
        List<DmsWenjianGuanlianMingxiEntity> related = "xinzeng_shenhe".equals(type) ? relations.selectNewAuditRelations(tenant, auditId)
                : relations.selectRevisionAuditRelations(tenant, auditId);
        if (baseFileId != null) {
            Set<String> recordIds = new HashSet<>();
            sourceRows.forEach(row -> { if (row.hasNonNull("jiluWenjianId")) recordIds.add(row.path("jiluWenjianId").asText()); });
            appendRemovedRecords(tenant, baseFileId, recordIds, prefix, items);
            Set<String> relatedIds = new HashSet<>();
            if (related != null) for (DmsWenjianGuanlianMingxiEntity relation : related) {
                if (!relatedIds.add(relation.getGuanlianWenjianId())) throw new DataException("审核单包含重复关联文件");
            }
            requireRetained(tenant, baseFileId, "guanlian_wenjian", relatedIds);
        }
        if (items.stream().map(DmsApprovedItem::getSourceKey).distinct().count() != items.size())
            throw new DataException("审核来源包含重复记录");
        if (items.stream().filter(i -> i.getBaseFileVersionId() != null).map(DmsApprovedItem::getBaseFileVersionId).distinct().count()
                != items.stream().filter(i -> i.getBaseFileVersionId() != null).count())
            throw new DataException("审核单不能重复使用同一来源文件");
        // Exclude only result fields written by settlement. All exposed native inputs participate.
        boolean settled = main.hasNonNull("shengxiaoWenjianBanbenId");
        scrub(main);
        omitEmptyReadScopeFromDigest(main);
        main.remove(Arrays.asList("wenjianId", "yuanWenjianBanbenId"));
        sourceRows.forEach(row -> {
            scrub((ObjectNode) row);
            omitEmptyReadScopeFromDigest((ObjectNode) row);
            if ("xinzeng_shenhe".equals(type) || "xinjian".equals(row.path("biangengFangshi").asText()))
                ((ObjectNode) row).remove("jiluWenjianId");
        });
        List<Object> digestInputs = new ArrayList<>(Arrays.asList(main, sourceRows, related, baseId, baseFileId));
        List<Object> removals = new ArrayList<>();
        items.stream().filter(i -> "RETIRE".equals(i.getChangeType())).forEach(i ->
                removals.add(Arrays.asList(i.getFileId(), i.getBusinessVersionId(), i.getDocumentId(), i.getFileVersionId())));
        if (!removals.isEmpty()) digestInputs.add(removals);
        if (approvalPageFileJson != null) digestInputs.add(approvalPageFileJson);
        // Preserve historic hashes when no errata exist; new handling decisions are approval inputs.
        if (!errata.isEmpty()) digestInputs.add(errata);
        batch.setSourceDigest(digest(digestInputs));
        batch.setItems(items);
        return new Source(batch, creator, created, settled, errata, reviewerIds);
    }
 
    private Source loadExternal(String tenant, String flow, String task, String actor, Date approvedAt) {
        DmsWailaiWenjianXinzengShenheEntity audit = unique(externalMapper.selectByFlowForUpdate(tenant, flow, task));
        if (!Integer.valueOf(2).equals(audit.getFlowState())) throw new DataException("外来文件审核单尚未批准");
        if (audit.getShenqingRen() == null || audit.getShenqingRen().isBlank()
                || audit.getShenqingRiqi() == null) throw new DataException("外来文件审核单缺少申请人或申请日期");
        String version = requireExternal(audit.getBanbenhao(), "版本号");
        String fileJson = requireExternal(audit.getBizFiles(), "附件");
        try {
            JsonNode files = json.readTree(fileJson);
            if (files == null || !files.isArray() || files.size() != 1 || !files.get(0).isObject())
                throw new DataException("外来文件审核单必须包含一份附件");
        } catch (java.io.IOException error) { throw new DataException("外来文件附件格式无效"); }
        String folder = requireExternal(audit.getWenjianMuluId(), "文件目录");
        try { UUID.fromString(folder); } catch (IllegalArgumentException error) {
            throw new DataException("外来文件目录不是有效的中心目录ID");
        }
        DmsApprovedItem item = new DmsApprovedItem();
        item.setTenantId(tenant);
        item.setSourceKey("wailai_wenjian_xinzeng_shenhe:" + audit.getId() + ":wailaiWenjian");
        item.setChangeType("CREATE");
        item.setDocumentNo(requireExternal(audit.getWailaiWenjianBianhao(), "文件编号"));
        item.setDocumentName(requireExternal(audit.getWenjianMingcheng(), "文件名称"));
        item.setDepartmentId(requireExternal(audit.getBumenId(), "归属部门"));
        item.setFolderId(folder);
        item.setVersionLabel(version);
        item.setFileJson(fileJson);
        item.setShifouShoukong("yes");
 
        DmsApprovedBatch batch = new DmsApprovedBatch();
        batch.setTenantId(tenant);
        batch.setFlowId(flow);
        batch.setTaskId(task);
        batch.setAuditId(audit.getId());
        batch.setAuditType("wailai_wenjian_xinzeng_shenhe");
        batch.setActorId(actor);
        batch.setApprovedAt(approvedAt);
        batch.setScheduledAt(DmsApprovedFileDates.scheduled(approvedAt));
        ObjectNode source = SOURCE_JSON.valueToTree(audit);
        source.remove(Arrays.asList("wenjianId", "shengxiaoWenjianBanbenId", "banbenShengxiaoShijian"));
        batch.setSourceDigest(digest(source));
        batch.setItems(List.of(item));
        return new Source(batch, audit.getShenqingRen(), audit.getShenqingRiqi(),
                audit.getShengxiaoWenjianBanbenId() != null);
    }
 
    private static String requireExternal(String value, String name) {
        if (value == null || value.isBlank()) throw new DataException("外来文件审核单缺少" + name);
        return value.trim();
    }
 
    private void copySignaturePage(String snapshot, DmsApprovedItem main) {
        if (snapshot == null || snapshot.isBlank()) return;
        try {
            JsonNode rule = json.readTree(snapshot);
            if (!rule.isObject()) throw new DataException("编号规则快照格式不正确");
            main.setQianpiyeId(optionalSnapshotText(rule, "qianpiye_id"));
            main.setQianpiyeFiles(optionalSnapshotText(rule, "qianpiye_files"));
        } catch (DataException e) { throw e; }
        catch (Exception e) { throw new DataException("无法读取编号规则签批页快照"); }
    }
 
    private String optionalSnapshotText(JsonNode rule, String field) {
        JsonNode value = rule.get(field);
        if (value == null || value.isNull()) return null;
        if (!value.isTextual()) throw new DataException("编号规则快照字段必须为文本:" + field);
        return value.textValue();
    }
 
    private DmsApprovedItem item(String tenant, String key, String rowId, JsonNode row, boolean main,
                                  String change, String baseId, String baseFileId, String no) {
        DmsApprovedItem item = new DmsApprovedItem();
        item.setTenantId(tenant); item.setSourceKey(key); item.setSourceRowId(rowId);
        item.setReadAccessScope(DmsApprovedDepartmentScope.normalize(row.path("chayueShouquanFanweiId").asText(null)));
        item.setDocumentName(text(row, main ? "wenjianMingcheng" : "fujianMingcheng"));
        String fileJson = row.path(main ? "wenjian" : "jiluWenjian").asText(null);
        if ((fileJson == null || fileJson.isBlank()) && !"wubianhua".equals(change)) throw new DataException("审核来源缺少文件");
        item.setFileJson(fileJson);
        item.setDepartmentId(text(row, "guishuBumenId")); item.setFolderId(text(row, "wenjianMuluId"));
        try { UUID.fromString(item.getFolderId()); } catch (IllegalArgumentException e) { throw new DataException("文件目录不是有效的中心目录ID"); }
        item.setDocumentNo(no);
        if (main) item.setShifouShoukong("yes");
        if (!main) {
            // Audit records choose their control flag directly; legacy grade metadata is optional.
            String controlled = text(row, "shifouShoukong");
            if (!"yes".equals(controlled) && !"no".equals(controlled)) throw new DataException("记录受控属性无效");
            item.setShifouShoukong(controlled);
            if ("xinjian".equals(change) && row.hasNonNull("banbenhao") && !row.path("banbenhao").asText().isBlank()
                    && !"D(00)".equals(row.path("banbenhao").asText())) throw new DataException("新增记录版本必须为 D(00)");
        }
        switch (change) {
            case "xinjian": item.setChangeType("CREATE"); item.setVersionLabel("D(00)"); break;
            case "shengji":
            case "wubianhua": {
                disableApplications.requireFileEditable(tenant, baseFileId);
                DmsWenjianBanbenEntity base = businessVersions.selectById(tenant, baseId);
                if (base == null || !Objects.equals(base.getWenjianId(), baseFileId) || base.getWenjianZhongxinBanbenId() == null)
                    throw new DataException("来源业务版本尚未关联文件中心,不能自动升级或沿用");
                DmsCenterVersionSnapshot center = centerVersions.version(tenant, UUID.fromString(base.getWenjianZhongxinBanbenId()));
                if (center == null) throw new DataException("来源中心版本不存在");
                item.setDocumentId(center.documentId()); item.setBaseFileVersionId(center.fileVersionId());
                item.setChangeType("wubianhua".equals(change) ? "REUSE" : "REVISE");
                item.setVersionLabel("REUSE".equals(item.getChangeType()) ? center.versionLabel()
                        : String.format(java.util.Locale.ROOT, "D(%02d)", Math.addExact(base.getBanbenXuhao(), 1)));
                if ("REUSE".equals(item.getChangeType())) {
                    if (item.getFileJson() != null && !item.getFileJson().isBlank()
                            && !sameContent(base.getWenjianNeirong(), item.getFileJson()))
                        throw new DataException("沿用版本不能修改文件内容");
                    item.setFileJson(base.getWenjianNeirong());
                    item.setFileVersionId(center.fileVersionId()); item.setChecksumSha256(center.checksumSha256());
                }
            }
                break;
            default: throw new DataException("文件变更方式无效");
        }
        if ("CREATE".equals(item.getChangeType()) && (no == null || no.isBlank())) throw new DataException("文件编号缺失");
        return item;
    }
 
    private void appendRemovedRecords(String tenant, String mainFileId, Set<String> retained,
                                      String prefix, List<DmsApprovedItem> items) {
        List<DmsWenjianGuanxiEntity> existing = existingRelations.selectEffectiveByMainAndType(
                tenant, mainFileId, DmsWenjianYewuBanbenService.RELATION_RECORD);
        if (existing == null) return;
        existing.stream().filter(r -> !retained.contains(r.getGuanlianWenjianId()))
                .sorted(Comparator.comparing(DmsWenjianGuanxiEntity::getGuanlianWenjianId)).forEach(relation -> {
            String fileId = relation.getGuanlianWenjianId();
            disableApplications.requireFileEditable(tenant, fileId);
            DmsWenjianBanbenEntity base = businessVersions.selectCurrentByFileId(tenant, fileId);
            if (base == null || !DmsWenjianYewuBanbenService.RECORD_FILE.equals(base.getWenjianLeixing())
                    || !"effective".equals(base.getBizStatus()) || base.getWenjianZhongxinBanbenId() == null)
                throw new DataException("移除记录缺少有效业务版本或中心关联");
            DmsCenterVersionSnapshot center = centerVersions.version(tenant, UUID.fromString(base.getWenjianZhongxinBanbenId()));
            if (center == null) throw new DataException("移除记录的中心版本不存在");
            DmsApprovedItem item = new DmsApprovedItem();
            item.setTenantId(tenant); item.setSourceKey(prefix + "removed:" + fileId);
            item.setChangeType("RETIRE"); item.setFileId(fileId); item.setBusinessVersionId(base.getId());
            item.setDocumentId(center.documentId()); item.setFileVersionId(center.fileVersionId());
            item.setBaseFileVersionId(center.fileVersionId()); item.setChecksumSha256(center.checksumSha256());
            items.add(item);
        });
    }
 
    private void requireRetained(String tenant, String fileId, String type, Set<String> retained) {
        List<DmsWenjianGuanxiEntity> existing = existingRelations.selectEffectiveByMainAndType(tenant, fileId, type);
        if (existing != null) for (DmsWenjianGuanxiEntity row : existing) {
            if (!retained.contains(row.getGuanlianWenjianId())) throw new DataException("现行记录或关联文件不能从修订审核中删除");
        }
    }
 
    private void scrub(ObjectNode value) {
        value.remove(Arrays.asList("shengxiaoWenjianBanbenId", "banbenShengxiaoShijian", "banbenhao"));
    }
 
    private String approvalPageFileJson(DmsWenjianBianhaoEntity number) {
        String snapshot = number.getGuizeKuaizhao();
        if (snapshot == null || snapshot.isBlank()) return null;
        try {
            JsonNode value = json.readTree(snapshot).get("qianpiye_files");
            if (value == null || value.isNull()) return null;
            String fileJson = value.isTextual() ? value.asText().trim() : value.toString();
            if (fileJson.isEmpty()) return null;
            JsonNode files = json.readTree(fileJson);
            if (files == null || files.isNull() || (files.isArray() && files.isEmpty())) return null;
            return fileJson;
        } catch (Exception exception) {
            throw new DataException("文件编号规则签批页快照格式不正确");
        }
    }
 
    private String revisionApprovalPageFileJson(String tenant, String baseId, String baseFileId) {
        DmsWenjianBanbenEntity base = businessVersions.selectById(tenant, baseId);
        if (base == null || !Objects.equals(base.getWenjianId(), baseFileId)) {
            throw new DataException("来源业务版本不存在,无法读取签批页规则");
        }
        String numberId = base.getWenjianBianhaoId();
        if (numberId == null || numberId.isBlank()) return null;
        DmsWenjianBianhaoEntity number = numbers.selectActiveByIdForUpdate(tenant, numberId);
        if (number == null || !"using".equals(number.getBizStatus())
                || !Objects.equals(number.getWenjianBianhao(), base.getWenjianBianhao())) {
            return null;
        }
        return approvalPageFileJson(number);
    }
 
    // Empty scope has one business meaning. Omitting it only from the digest preserves pre-field approvals
    // after migration fills their native fields with []; non-empty scopes remain protected inputs.
    private void omitEmptyReadScopeFromDigest(ObjectNode row) {
        if (DmsApprovedDepartmentScope.parse(row.path("chayueShouquanFanweiId").asText(null)).isEmpty())
            row.remove("chayueShouquanFanweiId");
    }
 
    private String digest(Object value) {
        try {
            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
                    .digest(SOURCE_JSON.writeValueAsString(value).getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) { throw new DataException("无法计算批准来源摘要"); }
    }
 
    @org.springframework.beans.factory.annotation.Autowired
    private jnpf.file.FileContentApi fileContentApi;
 
    private boolean sameContent(String left, String right) {
        return jnpf.file.FileContentEquality.same(left, right, fileContentApi);
    }
    private static String text(JsonNode value, String field) {
        String result = value.path(field).asText(null);
        if (result == null || result.isBlank()) throw new DataException("审核来源缺少字段:" + field);
        return result.trim();
    }
    private static <T> T unique(List<T> rows) {
        if (rows == null || rows.size() != 1) throw new DataException("必须匹配唯一的原生审核单");
        return rows.get(0);
    }
}