sinake
13 小时以前 e00825ef6dc894e11dffa7f8015fccc9f2f1071a
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
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
package com.ruoyi.project.service.impl;
 
import com.ruoyi.project.domain.BaseFinancesubject;
import com.ruoyi.project.domain.BaseItemexpense;
import com.ruoyi.project.domain.ServiceFund;
import com.ruoyi.project.domain.ServiceFunddetail;
import com.ruoyi.project.domain.ServiceFunddetailVO;
import com.ruoyi.project.domain.vo.ServiceFundVO;
import com.ruoyi.project.mapper.BaseFinancesubjectMapper;
import com.ruoyi.project.mapper.BaseItemexpenseMapper;
import com.ruoyi.project.mapper.ServiceFundMapper;
import com.ruoyi.project.mapper.ServiceFunddetailMapper;
import com.ruoyi.project.service.ExpertFeeCheckResult;
import com.ruoyi.project.service.ExpertFeeCheckResult.DetailRow;
import com.ruoyi.system.service.ISysConfigService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
 
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
 
/**
 * 专家劳务费校验服务(9 条规则)
 *
 * <p>业务背景:同一捐献案例(service_fund.infoid)可能有多条专家劳务费报销单
 * (service_fund.applytype = '1'),所有费用控制都是针对同一案例进行的累计控制。
 *
 * <p>校验时机(两处都调用):
 * <ul>
 *   <li>报销单填报保存前 —— ServiceFundServiceImpl#addOrUpdateNew</li>
 *   <li>报销单审核通过前 —— ServiceFundServiceImpl#checkFund</li>
 * </ul>
 *
 * <p>规则明细:
 * <ol>
 *   <li>捐献医院器官功能维护费(itemid=5) 合计 ≤ 60000(税前)</li>
 *   <li>上级捐献医院医疗团队器官功能维护费(itemid=7) 合计 ≤ 11000(税后)</li>
 *   <li>上级捐献医院护理团队器官功能维护费(itemid=8) 合计 ≤ 1000(税后)</li>
 *   <li>ECOM团队劳务费(itemid=2):院内 ≤ 5000 / 市内 ≤ 10000 / 市外 ≤ 12000(按费用说明分档)</li>
 *   <li>同一专家不得同时领取「捐献医院初步评估费用(1)」「捐献病情评估专家费(18)」
 *       「脑死亡临床判定专家费(19)」,只能就高取其一 —— 仅提示,不阻断</li>
 *   <li><b>基本规则(2026-10-08 新增)</b>,applytype=1 的<b>全部明细</b>:
 *       同一案例 × 同一专家 × 同一项目 × 同一费用说明的<b>合计</b>不得超过
 *       base_itemexpense(del_flag=0 且 UseState=1)中该项目的费用标准;
 *       金额为 0、费用说明为空、匹配不到标准,一律阻断;同组出现多条只提示不阻断</li>
 *   <li>器官获取加成(2026-10-08 重写,itemid 32~39):同一案例同一专家在该类项目上
 *       获取多个器官时,按金额降序保高,第二个及以后的每条金额上限 =
 *       费用说明含「双侧」取 1000、其他取 500(阻断,参数见 sys_config)</li>
 *   <li>助手费用:按助手类型(itemid 41、71、72、73、74)<b>分别</b>合计,市内 ≤ 1000 / 市外 ≤ 2000</li>
 *   <li>手术室人工费-巡回(itemid=43 且费用说明含“巡回”) ≤ 500</li>
 *   <li>手术室人工费-麻醉(itemid=43 且费用说明含“麻醉”) ≤ 500</li>
 * </ol>
 *
 * <p>金额一律使用 BigDecimal 累加,禁止 double 运算(本项目此前出现过金额浮点精度问题)。
 *
 * @author ruoyi
 */
@Slf4j
@Service
public class ExpertFeeValidateService {
 
    /** 专家劳务费的 applytype 取值 */
    private static final String APPLYTYPE_EXPERT = "1";
 
    /** 费用说明中的场景关键字(客户环境实际下拉文本为「院内院区 / 市内 / 市外」,同时兼容「本市 / 外市」旧写法) */
    private static final String[] SCOPE_IN_HOSPITAL = {"院内"};
    private static final String[] SCOPE_IN_CITY = {"市内", "本市"};
    private static final String[] SCOPE_OUT_CITY = {"市外", "外市"};
    private static final String SCOPE_TAXED = "税后";
    private static final String SCOPE_CIRCUIT = "巡回";
    private static final String SCOPE_ANESTHESIA = "麻醉";
    /** 费用说明中的「双侧」关键字(肾、肺的获取区分单侧/双侧) */
    private static final String SCOPE_DOUBLE_SIDE = "双侧";
 
    // ---- 费用项目:全部按项目 id 判定 ----
    // 规则不再依赖项目名称(名称每年带年度前缀、各院也可能不同),
    // 统一由 sys_config 的 sys.expertfee.itemid.* 配置项目 id(逗号分隔,可多个)。
 
    // ---- 兼容历史年度的项目 id(项目表改造前使用) ----
    // 与配置项求并集,保证历史单据仍能被识别;配置项是主要依据。
    private static final Set<Long> FALLBACK_ORGAN_MAINTAIN = setOf(5L);
    private static final Set<Long> FALLBACK_SUPERIOR_MEDICAL = setOf(7L);
    private static final Set<Long> FALLBACK_SUPERIOR_NURSING = setOf(8L);
    private static final Set<Long> FALLBACK_ECOM = setOf(2L);
    private static final Set<Long> FALLBACK_OPERATION_ROOM = setOf(43L);
    private static final Set<Long> FALLBACK_ASSESS = setOf(1L, 18L, 19L);
    private static final Set<Long> FALLBACK_DOCTOR_ORGAN = setOf(32L, 33L, 34L, 35L, 36L, 37L, 38L, 39L);
    private static final Set<Long> FALLBACK_ASSISTANT = setOf(41L, 71L, 72L, 73L, 74L);
 
    // ---- sys_config 配置键 ----
    private static final String CFG_ORGAN_MAINTAIN = "sys.expertfee.organMaintain.limit";
    private static final String CFG_SUPERIOR_MEDICAL = "sys.expertfee.superiorMedical.limit";
    private static final String CFG_SUPERIOR_NURSING = "sys.expertfee.superiorNursing.limit";
    private static final String CFG_ECOM_IN_HOSPITAL = "sys.expertfee.ecom.limit.inhospital";
    private static final String CFG_ECOM_IN_CITY = "sys.expertfee.ecom.limit.incity";
    private static final String CFG_ECOM_OUT_CITY = "sys.expertfee.ecom.limit.outcity";
    private static final String CFG_ASSISTANT_IN_CITY = "sys.expertfee.assistant.limit.incity";
    private static final String CFG_ASSISTANT_OUT_CITY = "sys.expertfee.assistant.limit.outcity";
    /** 手术室人工费-巡回:合计限额(itemid=43 且费用说明含「巡回」) */
    private static final String CFG_OPERATION_ROOM_CIRCUIT = "sys.expertfee.operationroom.circuit.limit";
    /** 手术室人工费-麻醉:合计限额(itemid=43 且费用说明含「麻醉」) */
    private static final String CFG_OPERATION_ROOM_ANESTHESIA = "sys.expertfee.operationroom.anesthesia.limit";
    /**
     * 第二个及以后的器官获取:双侧上限(itemid 32~39,费用说明含「双侧」)。
     * 该阈值无法用 base_itemexpense 表达(标准表里记的是首个器官的足额标准),所以放 sys_config。
     */
    private static final String CFG_ORGAN_SECOND_DOUBLE = "sys.expertfee.organ.second.double.limit";
    /** 第二个及以后的器官获取:其他(单侧、以及无单双侧概念的项目)上限 */
    private static final String CFG_ORGAN_SECOND_OTHER = "sys.expertfee.organ.second.other.limit";
 
    // ---- 费用项目 id 配置(逗号分隔,可多个)。全部规则统一按项目 id 判定;
    //      未配置时仅靠下方历史 id 兼容,对应规则会记 warn 日志(便于发现漏配) ----
    private static final String CFG_ITEM_ORGAN_MAINTAIN = "sys.expertfee.itemid.organMaintain";
    private static final String CFG_ITEM_SUPERIOR_MEDICAL = "sys.expertfee.itemid.superiorMedical";
    private static final String CFG_ITEM_SUPERIOR_NURSING = "sys.expertfee.itemid.superiorNursing";
    private static final String CFG_ITEM_ECOM = "sys.expertfee.itemid.ecom";
    private static final String CFG_ITEM_OPERATION_ROOM = "sys.expertfee.itemid.operationRoom";
    private static final String CFG_ITEM_ASSESS = "sys.expertfee.itemid.assess";
    private static final String CFG_ITEM_ASSISTANT = "sys.expertfee.itemid.assistant";
    private static final String CFG_ITEM_DOCTOR = "sys.expertfee.itemid.doctor";
 
    // ---- 9 条规则校验总开关 ----
    /**
     * 专家劳务费 9 条规则校验总开关,取值:1/true/y/on = 开启,0/false/n/off = 关闭。
     *
     * <p>规则仍在与业务方确认中,2026-09-28 起<b>暂时关闭</b>;
     * 规则确认后把该配置改为 true 即可重新开启(无需改代码、无需发版)。
     */
    private static final String CFG_VALIDATE_ENABLED = "sys.expertfee.validate.enabled";
    /**
     * 总开关缺省值:<b>关闭</b>。
     *
     * <p>缺省取关闭,可保证即使 sys_config 漏配也不会误拦业务;
     * 规则确认后需显式配置为 true 才开启校验。
     */
    private static final boolean DEFAULT_VALIDATE_ENABLED = false;
 
    // ---- 配置缺省值(sys_config 未配置时兜底,避免校验失效) ----
    private static final BigDecimal DEFAULT_ORGAN_MAINTAIN = new BigDecimal("60000");
    private static final BigDecimal DEFAULT_SUPERIOR_MEDICAL = new BigDecimal("11000");
    private static final BigDecimal DEFAULT_SUPERIOR_NURSING = new BigDecimal("1000");
    private static final BigDecimal DEFAULT_ECOM_IN_HOSPITAL = new BigDecimal("5000");
    private static final BigDecimal DEFAULT_ECOM_IN_CITY = new BigDecimal("10000");
    private static final BigDecimal DEFAULT_ECOM_OUT_CITY = new BigDecimal("12000");
    private static final BigDecimal DEFAULT_ASSISTANT_IN_CITY = new BigDecimal("1000");
    private static final BigDecimal DEFAULT_ASSISTANT_OUT_CITY = new BigDecimal("2000");
    /** 手术室人工费-巡回合计限额 */
    private static final BigDecimal DEFAULT_OPERATION_ROOM_CIRCUIT = new BigDecimal("500");
    /** 手术室人工费-麻醉合计限额 */
    private static final BigDecimal DEFAULT_OPERATION_ROOM_ANESTHESIA = new BigDecimal("500");
    /** 第二个及以后的器官获取:双侧上限缺省值 */
    private static final BigDecimal DEFAULT_ORGAN_SECOND_DOUBLE = new BigDecimal("1000");
    /** 第二个及以后的器官获取:其他上限缺省值 */
    private static final BigDecimal DEFAULT_ORGAN_SECOND_OTHER = new BigDecimal("500");
 
    @Autowired
    private ServiceFundMapper serviceFundMapper;
 
    @Autowired
    private ServiceFunddetailMapper serviceFunddetailMapper;
 
    /** 费用项目主表(base_financesubject),用于按项目名称动态解析项目 id */
    @Autowired
    private BaseFinancesubjectMapper baseFinancesubjectMapper;
 
    /** 费用标准表(base_itemexpense):基本规则按「项目 id + 费用说明」取标准金额 */
    @Autowired
    private BaseItemexpenseMapper baseItemexpenseMapper;
 
    @Autowired
    private ISysConfigService sysConfigService;
 
    /** 项目 id -> 项目名称(带缓存),仅用于拼接可读的错误提示文案 */
    private volatile Map<Long, String> expertFeeItemNameCache;
 
    /**
     * 费用标准缓存:itemid -> (费用说明 -> 标准金额)。
     *
     * <p>带 60 秒过期时间:标准表是可维护的基础数据,改了不必重启即可生效,
     * 同时避免一次校验里按项目反复查库。
     */
    private final Map<Long, ItemExpenseEntry> itemExpenseCache = new ConcurrentHashMap<>();
 
    /** 费用标准缓存有效期(毫秒) */
    private static final long ITEM_EXPENSE_CACHE_MILLIS = 60 * 1000L;
 
    /** 费用标准缓存项 */
    private static final class ItemExpenseEntry {
        private final Map<String, BigDecimal> standards;
        private final long expireAt;
 
        private ItemExpenseEntry(Map<String, BigDecimal> standards, long expireAt) {
            this.standards = standards;
            this.expireAt = expireAt;
        }
    }
 
    // ==================================================================
    // 对外入口
    // ==================================================================
 
    /**
     * 报销单填报保存前的校验。
     *
     * <p>此时本次提交的明细尚未落库,需要把「本次提交的明细」与「案例下已落库的其他单据明细」
     * 合并后判定;本次提交的明细会按 id 覆盖库中同一条记录,避免重复计算。
     *
     * @param serviceFundVO 本次提交的表单
     * @return 校验结果(errors 阻断保存,warnings 仅提示)
     */
    public ExpertFeeCheckResult validateBeforeSave(ServiceFundVO serviceFundVO) {
        if (!isValidateEnabled()) {
            log.info("[专家费校验-保存] 9 条规则校验总开关已关闭({}),跳过校验",
                    CFG_VALIDATE_ENABLED);
            return new ExpertFeeCheckResult();
        }
        if (serviceFundVO == null) {
            log.info("[专家费校验-保存] 入参为空,跳过");
            return new ExpertFeeCheckResult();
        }
        log.info("[专家费校验-保存] 开始 applytype={} infoid={} fundId={} 本次明细数={}",
                serviceFundVO.getApplytype(), serviceFundVO.getInfoid(), serviceFundVO.getId(),
                CollectionUtils.isEmpty(serviceFundVO.getServiceFunddetails())
                        ? 0 : serviceFundVO.getServiceFunddetails().size());
        if (!isExpertFee(serviceFundVO.getApplytype())) {
            log.info("[专家费校验-保存] applytype={} 不是专家劳务费(1),跳过", serviceFundVO.getApplytype());
            return new ExpertFeeCheckResult();
        }
        Long infoid = serviceFundVO.getInfoid();
        if (infoid == null) {
            log.warn("[专家费校验-保存] infoid 为空,无法做案例内累计,跳过(fundId={})", serviceFundVO.getId());
            return new ExpertFeeCheckResult();
        }
 
        List<Long> fundIds = listExpertFundIds(infoid);
        List<DetailRow> storedRows = loadDetailRows(fundIds);
        log.info("[专家费校验-保存] infoid={} 命中专家费单据 {} 张,库中明细 {} 条",
                infoid, fundIds.size(), storedRows.size());
 
        // 用本次提交的明细覆盖库中同 id 的记录(新增的追加进来)
        Map<String, DetailRow> merged = new LinkedHashMap<>();
        for (DetailRow row : storedRows) {
            merged.put(rowKey(row), row);
        }
        List<ServiceFunddetailVO> submitDetails = serviceFundVO.getServiceFunddetails();
        if (!CollectionUtils.isEmpty(submitDetails)) {
            for (ServiceFunddetailVO vo : submitDetails) {
                if (vo == null) {
                    continue;
                }
                if (vo.getInfoid() == null) {
                    vo.setInfoid(infoid);
                }
                merged.put(rowKey(vo), DetailRow.of(vo));
            }
        }
 
        return doValidate(new ArrayList<>(merged.values()));
    }
 
    /**
     * 报销单审核通过前的校验。数据以数据库为准。
     *
     * @param fundid 待审核的报销单 id
     * @return 校验结果
     */
    public ExpertFeeCheckResult validateBeforeCheck(Long fundid) {
        if (!isValidateEnabled()) {
            log.info("[专家费校验-审核] 9 条规则校验总开关已关闭({}),跳过校验",
                    CFG_VALIDATE_ENABLED);
            return new ExpertFeeCheckResult();
        }
        ServiceFund fund = serviceFundMapper.selectById(fundid);
        if (fund == null || !isExpertFee(applytypeOf(fund.getApplytype()))) {
            return new ExpertFeeCheckResult();
        }
        if (fund.getInfoid() == null) {
            log.warn("专家劳务费审核校验跳过:infoid 为空,fundId={}", fundid);
            return new ExpertFeeCheckResult();
        }
        return doValidate(loadDetailRows(listExpertFundIds(fund.getInfoid())));
    }
 
    // ==================================================================
    // 校验主体
    // ==================================================================
 
    private ExpertFeeCheckResult doValidate(List<DetailRow> rows) {
        ExpertFeeCheckResult result = new ExpertFeeCheckResult();
        if (CollectionUtils.isEmpty(rows)) {
            log.info("[专家费校验] 参与校验的明细为空,直接通过");
            return result;
        }
        log.info("[专家费校验] 参与校验明细 {} 条:{}", rows.size(), describeRows(rows));
 
        // 规则 1~4、7~9:合计限额类(阻断),全部按项目 id 判定
        checkSingleItemLimit(rows, resolveItemIds(CFG_ITEM_ORGAN_MAINTAIN, "捐献医院器官功能维护费", FALLBACK_ORGAN_MAINTAIN),
                false,
                cfg(CFG_ORGAN_MAINTAIN, DEFAULT_ORGAN_MAINTAIN), result, "捐献医院器官功能维护费用");
        checkSingleItemLimit(rows, resolveItemIds(CFG_ITEM_SUPERIOR_MEDICAL, "上级医疗团队器官功能维护费", FALLBACK_SUPERIOR_MEDICAL),
                true,
                cfg(CFG_SUPERIOR_MEDICAL, DEFAULT_SUPERIOR_MEDICAL), result, "上级捐献医院医疗团队器官功能维护费用");
        checkSingleItemLimit(rows, resolveItemIds(CFG_ITEM_SUPERIOR_NURSING, "上级护理团队器官功能维护费", FALLBACK_SUPERIOR_NURSING),
                true,
                cfg(CFG_SUPERIOR_NURSING, DEFAULT_SUPERIOR_NURSING), result, "上级捐献医院护理团队器官功能维护费用");
        checkEcom(rows, result);
        checkAssistant(rows, result);
        checkOperationRoom(rows, result);
 
        // 规则 5:仅提示;基本规则与器官获取加成:硬拦截
        checkAssessConflict(rows, result);
        checkItemStandard(rows, result);
        checkDoctorOrganSecond(rows, result);
 
        log.info("[专家费校验] 校验完成 阻断={} 提示={} 阻断信息={} 提示信息={}",
                result.getErrors().size(), result.getWarnings().size(),
                result.getErrors(), result.getWarnings());
        return result;
    }
 
    /** 把参与校验的明细拼成一行,便于排查「为什么没拦住」(金额、项目、场景一目了然) */
    private String describeRows(List<DetailRow> rows) {
        StringBuilder sb = new StringBuilder();
        for (DetailRow row : rows) {
            if (sb.length() > 0) {
                sb.append(" | ");
            }
            sb.append("[fundid=").append(row.getFundid())
                    .append(" itemid=").append(row.getItemid())
                    .append(" 金额=").append(row.getAmount())
                    .append(" 税后=").append(row.getTaxedamount())
                    .append(" 场景=").append(row.getServicesscopename())
                    .append("]");
        }
        return sb.toString();
    }
 
    /**
     * 规则 1、2、3:单一项目合计限额。
     *
     * @param taxed 金额是否取税后(规则 2、3 取税后,其余取税前)
     */
    private void checkSingleItemLimit(List<DetailRow> rows, Set<Long> itemids, boolean taxed,
                                      BigDecimal limit, ExpertFeeCheckResult result, String itemLabel) {
        List<DetailRow> matched = new ArrayList<>();
        BigDecimal total = BigDecimal.ZERO;
        for (DetailRow row : rows) {
            if (isTargetItem(row, itemids)) {
                matched.add(row);
                total = total.add(taxed ? row.getTaxedamount() : row.getAmount());
            }
        }
        if (matched.isEmpty()) {
            return;
        }
        log.info("[专家费校验] {} 命中{}条 合计={} 限额={} 是否超限={}",
                itemLabel, matched.size(), stripZero(total), stripZero(limit), total.compareTo(limit) > 0);
        if (total.compareTo(limit) > 0) {
            result.addError(String.format(
                    "%s合计%s元,超过限额%s元(案例内累计,共%d条明细)",
                    itemLabel, stripZero(total), stripZero(limit), matched.size()));
        }
    }
 
    /** 规则 4:ECOM团队劳务费,按费用说明分院内/市内/市外三档 */
    private void checkEcom(List<DetailRow> rows, ExpertFeeCheckResult result) {
        BigDecimal inHospital = BigDecimal.ZERO;
        BigDecimal inCity = BigDecimal.ZERO;
        BigDecimal outCity = BigDecimal.ZERO;
        int count = 0;
        int unknownScopeRows = 0;
        Set<Long> ecomItemIds = resolveItemIds(CFG_ITEM_ECOM, "ECOM团队劳务费", FALLBACK_ECOM);
        for (DetailRow row : rows) {
            if (!isTargetItem(row, ecomItemIds)) {
                continue;
            }
            count++;
            if (row.scopeContainsAny(SCOPE_IN_HOSPITAL)) {
                inHospital = inHospital.add(row.getAmount());
            } else if (row.scopeContainsAny(SCOPE_OUT_CITY)) {
                outCity = outCity.add(row.getAmount());
            } else if (row.scopeContainsAny(SCOPE_IN_CITY)) {
                inCity = inCity.add(row.getAmount());
            } else if (row.getAmount().compareTo(BigDecimal.ZERO) > 0) {
                // 场景无法识别时不归入任何档(避免误判),但不能静默放过,提示经办人核实
                unknownScopeRows++;
                log.warn("[专家费校验] ECOM团队劳务费存在无法识别场景的明细:fundid={} itemid={} 费用说明={} 金额={}",
                        row.getFundid(), row.getItemid(), row.getServicesscopename(), row.getAmount());
            }
        }
        if (count == 0) {
            return;
        }
        if (unknownScopeRows > 0) {
            result.addWarning(String.format(
                    "ECOM团队劳务费有%d条明细的费用说明不在「院内/市内/市外」范围内,未纳入限额校验,请核实",
                    unknownScopeRows));
        }
        checkLimit(result, "ECOM团队劳务费(院内)", inHospital, cfg(CFG_ECOM_IN_HOSPITAL, DEFAULT_ECOM_IN_HOSPITAL));
        checkLimit(result, "ECOM团队劳务费(市内)", inCity, cfg(CFG_ECOM_IN_CITY, DEFAULT_ECOM_IN_CITY));
        checkLimit(result, "ECOM团队劳务费(市外)", outCity, cfg(CFG_ECOM_OUT_CITY, DEFAULT_ECOM_OUT_CITY));
    }
 
    /**
     * 规则 7:助手费用——按【助手类型(项目 id)】分别合计,
     * 每个类型各自:市内 ≤ 1000、外市 ≤ 2000(itemid 41/71/72/73/74)。
     *
     * <p>2026-09-30 需求变更:原实现"不区分具体是哪个器官/哪个助手项目,所有助手明细合并累计"
     * (一个市内合计 + 一个外市合计),与原始需求「同个器官多个助手合计限额」不符,会大面积误拦
     * (本地库 1004 个含助手明细的案例中,旧口径拦 647 个,按类型分开后只拦 75 个)。
     * 现改为:同一案例、同一助手类型内累计,不同类型之间不互相占用额度;
     * 市内外仍由 servicesscopename(费用说明)判定。
     */
    private void checkAssistant(List<DetailRow> rows, ExpertFeeCheckResult result) {
        BigDecimal inCityLimit = cfg(CFG_ASSISTANT_IN_CITY, DEFAULT_ASSISTANT_IN_CITY);
        BigDecimal outCityLimit = cfg(CFG_ASSISTANT_OUT_CITY, DEFAULT_ASSISTANT_OUT_CITY);
        Set<Long> assistantItemIds = resolveItemIds(CFG_ITEM_ASSISTANT, "助手费用", FALLBACK_ASSISTANT);
 
        // key = itemid(助手类型),LinkedHashMap + 末尾按 itemid 排序,保证提示顺序稳定
        Map<Long, AssistantTotal> groups = new LinkedHashMap<>();
        int unknownScopeCount = 0;
        for (DetailRow row : rows) {
            if (!isAssistantItem(row, assistantItemIds)) {
                continue;
            }
            boolean outCity = row.scopeContainsAny(SCOPE_OUT_CITY);
            boolean inCity = !outCity && row.scopeContainsAny(SCOPE_IN_CITY);
            if (!outCity && !inCity) {
                // 未标注市内外的不归入任何档,避免误判(仅记日志,不打扰业务)
                unknownScopeCount++;
                continue;
            }
            AssistantTotal group = groups.computeIfAbsent(row.getItemid(),
                    key -> new AssistantTotal(key, itemNameOf(row)));
            if (outCity) {
                group.outCity = group.outCity.add(row.getAmount());
                group.outCityCount++;
            } else {
                group.inCity = group.inCity.add(row.getAmount());
                group.inCityCount++;
            }
        }
 
        List<AssistantTotal> ordered = new ArrayList<>(groups.values());
        ordered.sort((a, b) -> {
            if (a.itemid == null) {
                return b.itemid == null ? 0 : 1;
            }
            return b.itemid == null ? -1 : a.itemid.compareTo(b.itemid);
        });
 
        for (AssistantTotal group : ordered) {
            log.info("[专家费校验] 助手费用[{}] 市内{}条合计={} 限额={};外市{}条合计={} 限额={}",
                    group.itemName, group.inCityCount, stripZero(group.inCity), stripZero(inCityLimit),
                    group.outCityCount, stripZero(group.outCity), stripZero(outCityLimit));
            if (group.inCityCount > 0 && group.inCity.compareTo(inCityLimit) > 0) {
                result.addError(String.format(
                        "助手费用(%s,市内)合计%s元,超过限额%s元(同一案例下该类型助手合计,共%d条)",
                        group.itemName, stripZero(group.inCity), stripZero(inCityLimit), group.inCityCount));
            }
            if (group.outCityCount > 0 && group.outCity.compareTo(outCityLimit) > 0) {
                result.addError(String.format(
                        "助手费用(%s,外市)合计%s元,超过限额%s元(同一案例下该类型助手合计,共%d条)",
                        group.itemName, stripZero(group.outCity), stripZero(outCityLimit), group.outCityCount));
            }
        }
 
        if (unknownScopeCount > 0) {
            log.warn("[专家费校验] 助手费用有{}条明细的费用说明既不含市内/本市也不含市外/外市,未纳入限额校验",
                    unknownScopeCount);
        }
    }
 
    /** 规则 7 的累计单元:同一助手类型(项目 id)在同一案例下的市内/外市合计 */
    private static final class AssistantTotal {
        private final Long itemid;
        private final String itemName;
        private BigDecimal inCity = BigDecimal.ZERO;
        private int inCityCount;
        private BigDecimal outCity = BigDecimal.ZERO;
        private int outCityCount;
 
        private AssistantTotal(Long itemid, String itemName) {
            this.itemid = itemid;
            this.itemName = itemName;
        }
    }
 
    /**
     * 规则 8、9:手术室人工费按费用说明分档,各自独立限额 ——
     * 「巡回」合计 ≤ 500、「麻醉」合计 ≤ 500(itemid=43)。
     *
     * <p>2026-09-30 需求变更:原实现把巡回与麻醉合并成一个 500 的总限额,
     * 现按业务要求拆成两档各自校验(如巡回 300 + 麻醉 300 不再判超限)。
     *
     * <p>费用说明既不含「巡回」也不含「麻醉」的明细不计入任何档,仅给提示;
     * 同时含两个词的明细分别计入两档(并记 warn 日志)。
     */
    private void checkOperationRoom(List<DetailRow> rows, ExpertFeeCheckResult result) {
        Set<Long> roomItemIds = resolveItemIds(CFG_ITEM_OPERATION_ROOM, "手术室人工费", FALLBACK_OPERATION_ROOM);
 
        BigDecimal circuitTotal = BigDecimal.ZERO;
        int circuitCount = 0;
        BigDecimal anesthesiaTotal = BigDecimal.ZERO;
        int anesthesiaCount = 0;
        int unknownScopeCount = 0;
        int bothScopeCount = 0;
 
        for (DetailRow row : rows) {
            if (!isTargetItem(row, roomItemIds)) {
                continue;
            }
            boolean circuit = row.scopeContains(SCOPE_CIRCUIT);
            boolean anesthesia = row.scopeContains(SCOPE_ANESTHESIA);
            if (circuit && anesthesia) {
                bothScopeCount++;
            }
            if (circuit) {
                circuitTotal = circuitTotal.add(row.getAmount());
                circuitCount++;
            }
            if (anesthesia) {
                anesthesiaTotal = anesthesiaTotal.add(row.getAmount());
                anesthesiaCount++;
            }
            if (!circuit && !anesthesia) {
                unknownScopeCount++;
            }
        }
 
        if (bothScopeCount > 0) {
            log.warn("[专家费校验] 手术室人工费有{}条明细的费用说明同时含「巡回」与「麻醉」,已分别计入两档",
                    bothScopeCount);
        }
 
        BigDecimal circuitLimit = cfg(CFG_OPERATION_ROOM_CIRCUIT, DEFAULT_OPERATION_ROOM_CIRCUIT);
        if (circuitCount > 0) {
            log.info("[专家费校验] 手术室人工费(巡回) 命中{}条 合计={} 限额={} 是否超限={}",
                    circuitCount, stripZero(circuitTotal), stripZero(circuitLimit),
                    circuitTotal.compareTo(circuitLimit) > 0);
            if (circuitTotal.compareTo(circuitLimit) > 0) {
                result.addError(String.format(
                        "手术室人工费(巡回)合计%s元,超过限额%s元(同一案例下合计,共%d条)",
                        stripZero(circuitTotal), stripZero(circuitLimit), circuitCount));
            }
        }
 
        BigDecimal anesthesiaLimit = cfg(CFG_OPERATION_ROOM_ANESTHESIA, DEFAULT_OPERATION_ROOM_ANESTHESIA);
        if (anesthesiaCount > 0) {
            log.info("[专家费校验] 手术室人工费(麻醉) 命中{}条 合计={} 限额={} 是否超限={}",
                    anesthesiaCount, stripZero(anesthesiaTotal), stripZero(anesthesiaLimit),
                    anesthesiaTotal.compareTo(anesthesiaLimit) > 0);
            if (anesthesiaTotal.compareTo(anesthesiaLimit) > 0) {
                result.addError(String.format(
                        "手术室人工费(麻醉)合计%s元,超过限额%s元(同一案例下合计,共%d条)",
                        stripZero(anesthesiaTotal), stripZero(anesthesiaLimit), anesthesiaCount));
            }
        }
 
        if (unknownScopeCount > 0) {
            result.addWarning(String.format(
                    "手术室人工费有%d条明细的费用说明既不含「巡回」也不含「麻醉」,未纳入限额校验,请核实",
                    unknownScopeCount));
        }
    }
 
    /**
     * 规则 5:同一专家不得同时在「捐献医院初步评估费用」「捐献病情评估专家费」
     * 「脑死亡临床判定专家费」中出现,只能就高取其一。
     *
     * <p>硬拦截:违反时不允许保存/审核通过,不自动删除也不自动改数。
     */
    private void checkAssessConflict(List<DetailRow> rows, ExpertFeeCheckResult result) {
        Set<Long> assessItemIds = resolveItemIds(CFG_ITEM_ASSESS, "评估类费用", FALLBACK_ASSESS);
        Map<String, Map<Long, String>> expertItems = new LinkedHashMap<>();
        for (DetailRow row : rows) {
            if (!isTargetItem(row, assessItemIds)) {
                continue;
            }
            String expertKey = expertKey(row);
            if (expertKey == null) {
                continue;
            }
            // 记录命中的项目及名称,便于提示时告知具体是哪几个项目
            expertItems.computeIfAbsent(expertKey, k -> new LinkedHashMap<>())
                    .put(row.getItemid(), itemNameOf(row));
        }
        for (Map.Entry<String, Map<Long, String>> entry : expertItems.entrySet()) {
            if (entry.getValue().size() > 1) {
                result.addError(String.format(
                        "专家[%s]同时在 %s 等多个评估类项目中领取费用,同一专家只能就高取其一,请删除其余条明细",
                        entry.getKey(), entry.getValue().values()));
            }
        }
    }
 
    // ==================================================================
    // 基本规则(2026-10-08 新增):单项金额不得超过 base_itemexpense 的费用标准
    // ==================================================================
 
    /**
     * 基本规则:applytype=1(专家劳务费)的<b>全部明细</b>逐条与费用标准比对。
     *
     * <p>判据(2026-10-08 业务方确认):
     * <ol>
     *   <li>同一案例 × 同一专家 × 同一项目 × 同一费用说明的<b>合计</b> ≤ 该项目费用标准;</li>
     *   <li>金额为 0 的明细一律阻断(明细金额不能为 0);</li>
     *   <li>费用说明为空、或匹配不到标准(含 UseState≠1、已删除),一律阻断;</li>
     *   <li>同一组出现多条时<b>只提示不阻断</b>(是否超限由上面的合计判定)。</li>
     * </ol>
     *
     * <p>金额口径:费用说明含「税后」的取<b>税后金额</b>(taxedamount),其余取税前金额(amount)。
     * 标准表里「税后」类标准的 Expense 本身就是税后口径,
     * 若拿税前金额(含税加价后的值)去比,会把大量正常单据误判为超标准(实测 3168 组 vs 162 组)。
     */
    private void checkItemStandard(List<DetailRow> rows, ExpertFeeCheckResult result) {
        Map<String, List<DetailRow>> groups = new LinkedHashMap<>();
        Map<String, BigDecimal> groupStandards = new LinkedHashMap<>();
        Map<String, String> groupLabels = new LinkedHashMap<>();
 
        for (DetailRow row : rows) {
            BigDecimal amount = effectiveAmount(row);
            if (amount.compareTo(BigDecimal.ZERO) <= 0) {
                result.addError(String.format("%s金额为%s元,明细金额不能为0,请填写金额或删除该条明细",
                        itemLabel(row), stripZero(amount)));
                continue;
            }
            String scope = scopeOf(row);
            if (scope.isEmpty()) {
                result.addError(String.format("%s未填写费用说明,无法校验费用标准,请补充费用说明", itemLabel(row)));
                continue;
            }
            BigDecimal standard = loadItemStandard(row.getItemid(), scope);
            if (standard == null) {
                result.addError(String.format(
                        "%s未找到对应的费用标准(项目id=%s,费用说明=%s),不允许报销,请联系管理员维护费用标准",
                        itemLabel(row), row.getItemid(), scope));
                continue;
            }
            String key = groupKey(row, scope);
            groups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
            groupStandards.putIfAbsent(key, standard);
            groupLabels.putIfAbsent(key, groupLabel(row));
        }
 
        for (Map.Entry<String, List<DetailRow>> entry : groups.entrySet()) {
            String key = entry.getKey();
            List<DetailRow> list = entry.getValue();
            BigDecimal total = BigDecimal.ZERO;
            for (DetailRow row : list) {
                total = total.add(effectiveAmount(row));
            }
            BigDecimal standard = groupStandards.get(key);
            String label = groupLabels.get(key);
            log.info("[专家费校验] 基本规则 {} 命中{}条 合计={} 标准={} 是否超标={}",
                    label, list.size(), stripZero(total), stripZero(standard),
                    total.compareTo(standard) > 0);
            if (total.compareTo(standard) > 0) {
                result.addError(String.format(
                        "%s合计%s元,超过费用标准%s元(同一案例同一专家同一项目的累计,共%d条明细)",
                        label, stripZero(total), stripZero(standard), list.size()));
            }
            if (list.size() > 1) {
                result.addWarning(String.format("%s在同一案例下有%d条明细(合计%s元),请确认是否重复录入",
                        label, list.size(), stripZero(total)));
            }
        }
    }
 
    /**
     * 器官获取加成(2026-10-08 重写,取代原「同一专家只允许一条超过 500」)。
     *
     * <p>同一案例内,同一专家在医生获取/修整类项目(默认 itemid 32~39)上获取多个器官时:
     * 按金额降序保高,<b>第二个及以后的每条</b>金额上限 = 费用说明含「双侧」取 1000、其他取 500。
     * 第一个(金额最高的)那条按费用标准校验,由上面的「基本规则」负责。
     *
     * <p>该阈值无法用 base_itemexpense 表达(标准表里记的是首个器官的足额标准),因此放 sys_config。
     */
    private void checkDoctorOrganSecond(List<DetailRow> rows, ExpertFeeCheckResult result) {
        BigDecimal doubleSideLimit = cfg(CFG_ORGAN_SECOND_DOUBLE, DEFAULT_ORGAN_SECOND_DOUBLE);
        BigDecimal otherLimit = cfg(CFG_ORGAN_SECOND_OTHER, DEFAULT_ORGAN_SECOND_OTHER);
        Set<Long> doctorItemIds = resolveItemIds(CFG_ITEM_DOCTOR, "医生获取/修整类费用", FALLBACK_DOCTOR_ORGAN);
 
        Map<String, List<DetailRow>> byExpert = new LinkedHashMap<>();
        for (DetailRow row : rows) {
            if (!isDoctorOrganItem(row, doctorItemIds)) {
                continue;
            }
            String expertKey = expertKey(row);
            if (expertKey == null) {
                continue;
            }
            byExpert.computeIfAbsent(expertKey, k -> new ArrayList<>()).add(row);
        }
 
        for (Map.Entry<String, List<DetailRow>> entry : byExpert.entrySet()) {
            List<DetailRow> list = new ArrayList<>(entry.getValue());
            if (list.size() < 2) {
                continue;
            }
            // 金额降序保高:最高的那条按费用标准(基本规则已校验),其余按 500/1000
            list.sort((a, b) -> effectiveAmount(b).compareTo(effectiveAmount(a)));
            log.info("[专家费校验] 器官获取加成 专家={} 器官获取明细{}条 上限(双侧/其他)={}/{}",
                    entry.getKey(), list.size(), stripZero(doubleSideLimit), stripZero(otherLimit));
            for (int i = 1; i < list.size(); i++) {
                DetailRow row = list.get(i);
                BigDecimal amount = effectiveAmount(row);
                BigDecimal limit = row.scopeContains(SCOPE_DOUBLE_SIDE) ? doubleSideLimit : otherLimit;
                if (amount.compareTo(limit) > 0) {
                    result.addError(String.format(
                            "专家[%s]的第%d个器官获取%s金额%s元,超过上限%s元"
                                    + "(同一案例中第二个及以后的器官获取:双侧上限%s元、其他上限%s元),请调整金额",
                            entry.getKey(), i + 1, itemLabel(row), stripZero(amount), stripZero(limit),
                            stripZero(doubleSideLimit), stripZero(otherLimit)));
                }
            }
        }
    }
 
    /** 明细的「有效金额」:费用说明含「税后」取税后金额,其余取税前金额 */
    private BigDecimal effectiveAmount(DetailRow row) {
        return row.scopeContains(SCOPE_TAXED) ? row.getTaxedamount() : row.getAmount();
    }
 
    /** 明细的费用说明(去空格) */
    private String scopeOf(DetailRow row) {
        return row.getServicesscopename() == null ? "" : row.getServicesscopename().trim();
    }
 
    /** 分组键:专家 + 项目 + 费用说明(案例范围已由 rows 限定) */
    private String groupKey(DetailRow row, String scope) {
        String expert = row.getIdcardno() == null ? "" : row.getIdcardno().trim();
        return expert + "|" + row.getItemid() + "|" + scope;
    }
 
    /** 提示文案:「项目·费用说明」 */
    private String itemLabel(DetailRow row) {
        String scope = scopeOf(row);
        return "「" + itemNameOf(row) + (scope.isEmpty() ? "" : "·" + scope) + "」";
    }
 
    /** 提示文案:专家[身份证]「项目·费用说明」 */
    private String groupLabel(DetailRow row) {
        String expert = row.getIdcardno() == null ? "" : row.getIdcardno().trim();
        return (expert.isEmpty() ? "" : "专家[" + expert + "]") + itemLabel(row);
    }
 
    /**
     * 取「项目 id + 费用说明」的费用标准。
     *
     * <p>标准表是可维护的基础数据:只认 {@code del_flag = 0 且 UseState = 1} 的记录
     * (实体没有 delFlag 字段,因此 del_flag 用 SQL 片段限定)。
     *
     * @return 找不到时返回 {@code null},调用方按「匹配不到标准」阻断
     */
    private BigDecimal loadItemStandard(Long itemid, String scope) {
        return loadItemStandards(itemid).get(scope);
    }
 
    /** 按项目 id 加载费用标准(结果缓存 60 秒),key = 费用说明 */
    private Map<String, BigDecimal> loadItemStandards(Long itemid) {
        if (itemid == null) {
            return Collections.emptyMap();
        }
        long now = System.currentTimeMillis();
        ItemExpenseEntry cached = itemExpenseCache.get(itemid);
        if (cached != null && cached.expireAt > now) {
            return cached.standards;
        }
        Map<String, BigDecimal> standards = new LinkedHashMap<>();
        try {
            // 用字符串列名包装器:BaseItemexpense 实体没有 delFlag 字段(无法用 lambda 引用),
            // 且可避免依赖 MyBatis-Plus 的 lambda 缓存
            QueryWrapper<BaseItemexpense> wrapper = new QueryWrapper<>();
            wrapper.eq("ItemId", itemid);
            wrapper.eq("UseState", "1");
            wrapper.apply("del_flag = 0");
            wrapper.orderByAsc("Id");
            List<BaseItemexpense> list = baseItemexpenseMapper.selectList(wrapper);
            if (list != null) {
                for (BaseItemexpense item : list) {
                    if (item == null || item.getExpensedescribe() == null || item.getExpense() == null) {
                        continue;
                    }
                    standards.putIfAbsent(item.getExpensedescribe().trim(), item.getExpense());
                }
            }
        } catch (Exception e) {
            log.error("[专家费校验] 读取费用标准失败,itemid={}", itemid, e);
        }
        itemExpenseCache.put(itemid, new ItemExpenseEntry(standards, now + ITEM_EXPENSE_CACHE_MILLIS));
        return standards;
    }
 
    // ==================================================================
    // 费用项目动态解析
    //
    // 背景:费用项目表 base_financesubject 的项目按年度重建(例:“(2026)捐献医院器官功能维护费用”
    // 是 id=75,而早期年度为 5),因此不能把 itemid 写死在代码里。
    // 做法:按项目名称关键词到基础表解析当前有效的项目 id,再与“早期年度 id”求并集。
    // ==================================================================
 
    /**
     * 解析规则对应的费用项目 id 集合。
     *
     * <p>优先级:
     * <ol>
     *   <li>sys_config 中显式配置的项目 id(逗号分隔)——项目名称不规范时用这个兜底</li>
     *   <li>按项目名称关键词到基础表解析(自动适应项目年度重建)</li>
     *   <li>早期年度的历史项目 id</li>
     * </ol>
     * 仅按项目 id 判定:取 sys_config 中配置的项目 id 列表,与历史年度 id 求并集。
     * 配置为空且无历史 id 时返回空集,此时该规则不生效并会记 warn 日志。
     *
     * @param itemIdConfigKey sys_config 中项目 id 列表的配置键
     * @param ruleLabel       规则名称,仅用于日志可读性
     * @param fallback        历史年度(项目表改造前)使用的项目 id
     */
    private Set<Long> resolveItemIds(String itemIdConfigKey, String ruleLabel, Set<Long> fallback) {
        Set<Long> ids = new LinkedHashSet<>();
        ids.addAll(parseItemIdConfig(itemIdConfigKey));
        if (fallback != null) {
            ids.addAll(fallback);
        }
        if (ids.isEmpty()) {
            log.warn("[专家费校验] 规则[{}]未配置项目id(sys_config.{}),该规则本次不生效",
                    ruleLabel, itemIdConfigKey);
        }
        return ids;
    }
 
    /** 读取 sys_config 中逗号分隔的项目 id 列表(未配置或格式错误时返回空集) */
    private Set<Long> parseItemIdConfig(String configKey) {
        Set<Long> ids = new LinkedHashSet<>();
        if (configKey == null) {
            return ids;
        }
        try {
            String value = sysConfigService.selectConfigByKey(configKey);
            if (StringUtils.isEmpty(value)) {
                return ids;
            }
            for (String part : value.split(",")) {
                String trimmed = part.trim();
                if (StringUtils.isEmpty(trimmed)) {
                    continue;
                }
                try {
                    ids.add(Long.valueOf(trimmed));
                } catch (NumberFormatException e) {
                    log.warn("[专家费校验] 项目id配置[{}]中存在非法值“{}”,已忽略", configKey, trimmed);
                }
            }
        } catch (Exception e) {
            log.warn("[专家费校验] 读取项目id配置失败,key={}", configKey, e);
        }
        return ids;
    }
 
    /** 明细行是否属于给定项目 id 集合(全部规则统一按项目 id 判定) */
    private boolean isTargetItem(DetailRow row, Set<Long> itemids) {
        return !CollectionUtils.isEmpty(itemids)
                && row.getItemid() != null
                && itemids.contains(row.getItemid());
    }
 
    /** 规则 6:医生“XX获取/修整”类项目(仅按项目 id) */
    private boolean isDoctorOrganItem(DetailRow row, Set<Long> itemids) {
        return isTargetItem(row, itemids);
    }
 
    /** 规则 7:助手类项目(仅按项目 id) */
    private boolean isAssistantItem(DetailRow row, Set<Long> itemids) {
        return isTargetItem(row, itemids);
    }
 
    /** 取明细的项目名称(明细未携带时回查基础表),仅用于提示文案 */
    private String itemNameOf(DetailRow row) {
        String name = row.getItemname();
        if (StringUtils.hasText(name)) {
            return name;
        }
        if (row.getItemid() != null) {
            String cached = loadExpertFeeItemNames().get(row.getItemid());
            if (StringUtils.hasText(cached)) {
                return cached;
            }
        }
        return "itemid=" + row.getItemid();
    }
 
    /** 项目 id -> 项目名称(带缓存),仅用于明细未携带名称时回查、拼接提示文案 */
    private Map<Long, String> loadExpertFeeItemNames() {
        Map<Long, String> cache = this.expertFeeItemNameCache;
        if (cache != null) {
            return cache;
        }
        synchronized (this) {
            if (this.expertFeeItemNameCache == null) {
                Map<Long, String> names = new LinkedHashMap<>();
                for (BaseFinancesubject subject : loadExpertFeeItems()) {
                    if (subject.getId() != null && subject.getItemname() != null) {
                        names.put(subject.getId(), subject.getItemname());
                    }
                }
                this.expertFeeItemNameCache = names;
                cache = names;
            } else {
                cache = this.expertFeeItemNameCache;
            }
        }
        return cache;
    }
 
    /** 读取专家劳务费(费用大类=1)的全部在用项目 */
    private List<BaseFinancesubject> loadExpertFeeItems() {
        List<BaseFinancesubject> items = baseFinancesubjectMapper.getAllInfoByExpenseType(APPLYTYPE_EXPERT);
        return CollectionUtils.isEmpty(items) ? new ArrayList<>() : items;
    }
 
    /** 字面量集合构造(供 static final 常量使用) */
    private static Set<Long> setOf(Long... values) {
        Set<Long> set = new LinkedHashSet<>();
        if (values != null) {
            for (Long value : values) {
                set.add(value);
            }
        }
        return set;
    }
 
    // ==================================================================
    // 工具方法
    // ==================================================================
 
    private void checkLimit(ExpertFeeCheckResult result, String label, BigDecimal total, BigDecimal limit) {
        if (total.compareTo(BigDecimal.ZERO) > 0 && total.compareTo(limit) > 0) {
            result.addError(String.format("%s合计%s元,超过限额%s元(案例内累计)",
                    label, stripZero(total), stripZero(limit)));
        }
    }
 
    /** 取该案例下全部专家劳务费报销单的 id */
    private List<Long> listExpertFundIds(Long infoid) {
        List<ServiceFund> funds = serviceFundMapper.getInfoByInfoId(infoid);
        if (CollectionUtils.isEmpty(funds)) {
            return Collections.emptyList();
        }
        return funds.stream()
                .filter(f -> f != null && isExpertFee(applytypeOf(f.getApplytype())))
                .map(ServiceFund::getId)
                .filter(java.util.Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());
    }
 
    /** 按报销单 id 加载明细(XML 中已带 del_flag = '0') */
    private List<DetailRow> loadDetailRows(List<Long> fundIds) {
        if (CollectionUtils.isEmpty(fundIds)) {
            return Collections.emptyList();
        }
        List<DetailRow> rows = new ArrayList<>();
        for (Long fundId : fundIds) {
            List<ServiceFunddetail> details = serviceFunddetailMapper.selectFundDetailListById(fundId);
            if (CollectionUtils.isEmpty(details)) {
                continue;
            }
            for (ServiceFunddetail detail : details) {
                DetailRow row = new DetailRow();
                row.setId(detail.getId());
                row.setFundid(detail.getFundid());
                row.setInfoid(detail.getInfoid());
                row.setItemid(detail.getItemid());
                row.setItemname(detail.getItemname());
                row.setIdcardno(detail.getIdcardno());
                row.setServicesscopename(detail.getServicesscopename());
                row.setAmount(toDecimal(detail.getAmount()));
                row.setTaxedamount(toDecimal(detail.getTaxedamount()));
                rows.add(row);
            }
        }
        return rows;
    }
 
    /**
     * 明细行的唯一键。
     *
     * <p>已落库的明细用主键 id;新增(尚无 id)的用
     * 「报销单 + 项目 + 身份证 + 费用说明 + 金额」组合,
     * 确保同一项目下的多行明细不会被合并(否则会漏算或重复计算)。
     */
    private String rowKey(DetailRow row) {
        if (row.getId() != null) {
            return "id:" + row.getId();
        }
        return "new:" + row.getFundid() + "|" + row.getItemid() + "|" + row.getIdcardno()
                + "|" + row.getServicesscopename() + "|" + row.getAmount();
    }
 
    private String rowKey(ServiceFunddetailVO vo) {
        if (vo.getId() != null) {
            return "id:" + vo.getId();
        }
        // 新增明细尚无 id:用「报销单 + 项目 + 身份证 + 费用说明 + 金额」近似定位,保证同一项目多行不会被合并
        return "new:" + vo.getFundid() + "|" + vo.getItemid() + "|" + vo.getIdcardno()
                + "|" + vo.getServicesscopename() + "|" + vo.getAmount();
    }
 
    /** 专家的识别键:优先身份证号,其次受益人编号,都没有时跳过该行 */
    private String expertKey(DetailRow row) {
        if (StringUtils.hasText(row.getIdcardno())) {
            return row.getIdcardno().trim();
        }
        return null;
    }
 
    private boolean isExpertFee(String applytype) {
        return APPLYTYPE_EXPERT.equals(applytype);
    }
 
    private String applytypeOf(String applytype) {
        return applytype;
    }
 
    /**
     * 9 条规则校验是否开启。
     *
     * <p>关闭时所有入口直接返回"通过"(既不做库查询,也不产生 errors/warnings),
     * 前端预校验、保存、审核三条链路同时放行。
     */
    private boolean isValidateEnabled() {
        try {
            String value = sysConfigService.selectConfigByKey(CFG_VALIDATE_ENABLED);
            if (StringUtils.hasText(value)) {
                String v = value.trim();
                return "1".equals(v) || "true".equalsIgnoreCase(v)
                        || "y".equalsIgnoreCase(v) || "on".equalsIgnoreCase(v);
            }
        } catch (Exception e) {
            log.warn("读取专家劳务费校验总开关失败,key={},按默认值({})处理",
                    CFG_VALIDATE_ENABLED, DEFAULT_VALIDATE_ENABLED, e);
        }
        return DEFAULT_VALIDATE_ENABLED;
    }
 
    private BigDecimal cfg(String key, BigDecimal defaultValue) {
        try {
            String value = sysConfigService.selectConfigByKey(key);
            if (StringUtils.hasText(value)) {
                return new BigDecimal(value.trim());
            }
        } catch (Exception e) {
            log.warn("读取专家劳务费限额配置失败,key={},使用默认值{}", key, defaultValue, e);
        }
        return defaultValue;
    }
 
    private static BigDecimal toDecimal(Double value) {
        return value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value);
    }
 
    /** 去掉金额末尾多余的 0,便于提示文案阅读(60000.00 -> 60000) */
    private static String stripZero(BigDecimal value) {
        return value.stripTrailingZeros().toPlainString();
    }
}