yxh
2 天以前 6356db280804feed69ac8c31cce50ec4dfc5fd9c
修改了报销校验
已添加3个文件
1379 ■■■■■ 文件已修改
ruoyi-project/src/main/java/com/ruoyi/project/common/FundNotifyMessageBuilder.java 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-project/src/main/java/com/ruoyi/project/service/ExpertFeeCheckResult.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-project/src/main/java/com/ruoyi/project/service/impl/ExpertFeeValidateService.java 1078 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-project/src/main/java/com/ruoyi/project/common/FundNotifyMessageBuilder.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
package com.ruoyi.project.common;
import com.ruoyi.common.enums.ApplyTypeEnum;
import com.ruoyi.common.utils.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
 * æŠ¥é”€å®¡æ‰¹é’‰é’‰æ¶ˆæ¯å†…容组装。
 *
 * <p>统一「标题 + å•据信息 + å®¡æ‰¹æ„è§æ–‡æ¡ˆã€ï¼Œä¾›ä»¥ä¸‹å‘送点复用:
 * <ul>
 *   <li>致远 OA å›žä¼ å®¡æ‰¹æ„è§ â€”— {@code ServiceFundServiceImpl#processlog} â†’ {@code sendMeg}</li>
 *   <li>系统内审核驳回(费用报销) â€”— {@code ServiceFundServiceImpl#checkFund}</li>
 *   <li>系统内审核驳回(差旅费) â€”— {@code ServiceReimbursementServiceImpl#checkFund}</li>
 *   <li>差旅费上报提交 â€”— {@code ServiceReimbursementController#travelexpensereport}</li>
 * </ul>
 *
 * <p><b>为什么要一行一个 map:</b>{@code DingTalkReqVo.contents} çš„元素是
 * {@link ConcurrentHashMap},其遍历顺序不保证;一条 map é‡Œå¡žå¤šè¡Œä¼šå¯¼è‡´é’‰é’‰è¡¨å•行序错乱。
 * è¿™é‡Œæ¯æ¡ä¿¡æ¯å•独一个「单键 map」,用 List çš„顺序保证显示顺序。
 *
 * @author ruoyi
 */
public final class FundNotifyMessageBuilder {
    /** é’‰é’‰æ¶ˆæ¯ç»Ÿä¸€æ ‡é¢˜ï¼ˆ2026-10-08 ä¸šåŠ¡æ–¹ç¡®è®¤ç»Ÿä¸€ä¸ºã€ŒOPO报销审批」) */
    public static final String TITLE = "OPO报销审批";
    /** å®¡æ‰¹ç»“论:通过 */
    public static final String CONCLUSION_PASS = "1";
    /** å®¡æ‰¹ç»“论:驳回 */
    public static final String CONCLUSION_REJECT = "2";
    /** å®¡æ‰¹ç»“论:完结 */
    public static final String CONCLUSION_FINISH = "3";
    private static final String KEY_TYPE = "报销单类型:";
    private static final String KEY_BENEFICIARY = "报销人:";
    private static final String KEY_AMOUNT = "金额:";
    private static final String KEY_DONOR = "捐献案例:";
    private static final String KEY_BILL_NO = "单据编号:";
    private static final String KEY_TRAVELERS = "出差人:";
    private static final String KEY_REASON = "出差事由:";
    private static final String KEY_CHECKER = "审批人:";
    private static final String KEY_CHECK_TIME = "审批时间:";
    private static final String KEY_OPINION = "意见:";
    private static final String KEY_SUBMITTER = "提交人:";
    private static final String KEY_SUBMIT_TIME = "提交时间:";
    private static final String KEY_CONTENT = "内容:";
    private FundNotifyMessageBuilder() {
    }
    /** æ–°å»ºå†…容行容器 */
    public static List<ConcurrentHashMap<String, Object>> newRows() {
        return new ArrayList<ConcurrentHashMap<String, Object>>();
    }
    /**
     * è¿½åŠ ä¸€è¡Œã€‚å€¼ä¸ºç©ºï¼ˆnull / ç©ºä¸² / å…¨ç©ºç™½ï¼‰æ—¶**不追加**,避免钉钉里出现空行。
     */
    public static void addRow(List<ConcurrentHashMap<String, Object>> rows, String key, Object value) {
        if (rows == null || key == null || value == null) {
            return;
        }
        if (value instanceof String && StringUtils.isEmpty(((String) value).trim())) {
            return;
        }
        ConcurrentHashMap<String, Object> row = new ConcurrentHashMap<String, Object>(1);
        row.put(key, value);
        rows.add(row);
    }
    /** æŠ¥é”€å•类型:applytype â†’ ä¸­æ–‡åï¼›æœªåŒ¹é…è¿”回 null(该行不显示) */
    public static String typeName(String applytype) {
        return StringUtils.isEmpty(applytype) ? null : ApplyTypeEnum.getDescByCode(applytype);
    }
    /** å•据编号:优先取业务编号 bh,缺失时退回主键 id */
    public static String billNo(String bh, Long id) {
        if (StringUtils.isNotEmpty(bh)) {
            return bh;
        }
        return id == null ? null : String.valueOf(id);
    }
    /**
     * è¿½åŠ æŠ¥é”€å•åŸºç¡€ä¿¡æ¯ï¼šæŠ¥é”€å•ç±»åž‹ / æŠ¥é”€äºº / é‡‘额 / æçŒ®æ¡ˆä¾‹ / å•据编号。
     *
     * <p>金额口径:费用类取 {@code service_fund.pretaxcost}(审核页「申请金额」列),
     * å·®æ—…费取 {@code service_reimbursement.amountrequested}(审核页「报销金额」列)。
     */
    public static void addBillInfo(List<ConcurrentHashMap<String, Object>> rows,
                                   String applytype, String beneficiary, Double amount,
                                   String donorname, String billNo) {
        addRow(rows, KEY_TYPE, typeName(applytype));
        addRow(rows, KEY_BENEFICIARY, beneficiary);
        addRow(rows, KEY_AMOUNT, money(amount));
        addRow(rows, KEY_DONOR, donorname);
        addRow(rows, KEY_BILL_NO, billNo);
    }
    /** è¿½åŠ å·®æ—…è´¹ä¸“æœ‰ä¿¡æ¯ï¼šå‡ºå·®äºº / å‡ºå·®äº‹ç”± */
    public static void addTravelInfo(List<ConcurrentHashMap<String, Object>> rows,
                                     String travelers, String reason) {
        addRow(rows, KEY_TRAVELERS, travelers);
        addRow(rows, KEY_REASON, reason);
    }
    /**
     * è¿½åŠ å®¡æ‰¹ç»“æžœï¼šå®¡æ‰¹äºº / å®¡æ‰¹æ—¶é—´ / æ„è§ã€‚
     *
     * <p>审批时间传 {@link Date}:键名含「审批时间」,由 {@code DingTalkServiceImpl} ç»Ÿä¸€æ ¼å¼åŒ–。
     */
    public static void addAuditResult(List<ConcurrentHashMap<String, Object>> rows,
                                     String checker, Date checkTime, String opinion) {
        addRow(rows, KEY_CHECKER, checker);
        addRow(rows, KEY_CHECK_TIME, checkTime);
        addRow(rows, KEY_OPINION, opinion);
    }
    /** è¿½åŠ æäº¤é€šçŸ¥ä¿¡æ¯ï¼šæäº¤äºº / æäº¤æ—¶é—´ / å†…容(时间在此预格式化,避免 Date.toString()) */
    public static void addSubmitInfo(List<ConcurrentHashMap<String, Object>> rows,
                                    String submitter, Date submitTime, String content) {
        addRow(rows, KEY_SUBMITTER, submitter);
        addRow(rows, KEY_SUBMIT_TIME, time(submitTime));
        addRow(rows, KEY_CONTENT, content);
    }
    /**
     * å®¡æ‰¹æ„è§æ–‡æ¡ˆï¼ˆ2026-10-08 ä¸šåŠ¡æ–¹ç¡®è®¤ï¼‰ã€‚
     *
     * <pre>
     *   é€šè¿‡(1)  â†’ "审批通过。"   + æ„è§å¤‡æ³¨ï¼ˆè‹¥æœ‰ï¼‰
     *   é©³å›ž(2)  â†’ "审批未通过。" + æ„è§å¤‡æ³¨ï¼ˆè‹¥æœ‰ï¼‰
     *   å®Œç»“(3)  â†’ "流程已完结。"
     *   å…¶ä»–/空  â†’ åŽŸæ ·è¿”å›žå¤‡æ³¨ï¼›æ— å¤‡æ³¨è¿”å›žç©ºä¸²
     * </pre>
     *
     * <p>实测依据(本地库 {@code service_fundflow.flowlevel=100} å³è‡´è¿œå›žä¼ è®°å½•):
     * é€šè¿‡ 11618 æ¡ä¸­ 9606 æ¡æ„è§ä¸ºç©ºã€å®Œç»“ 1457 æ¡å…¨éƒ¨ä¸ºç©ºã€é©³å›ž 743 æ¡ä¸­ 161 æ¡ä¸ºç©ºã€‚
     * è‹¥ä¸ç»™ç»“论兜底文案,钉钉消息的「意见」一栏就是空白(这正是业务反馈的问题)。
     */
    public static String opinion(String conclusion, String content) {
        String remark = content == null ? "" : content.trim();
        String base;
        if (CONCLUSION_PASS.equals(conclusion)) {
            base = "审批通过。";
        } else if (CONCLUSION_REJECT.equals(conclusion)) {
            base = "审批未通过。";
        } else if (CONCLUSION_FINISH.equals(conclusion)) {
            base = "流程已完结。";
        } else {
            // ç»“论缺失:只显示备注,不臆测结论
            return remark;
        }
        return remark.isEmpty() ? base : base + remark;
    }
    /** é‡‘额显示:两位小数、无千分位,如 {@code 1200.00 å…ƒ} */
    public static String money(Double amount) {
        if (amount == null) {
            return null;
        }
        return new BigDecimal(String.valueOf(amount))
                .setScale(2, RoundingMode.HALF_UP)
                .toPlainString() + " å…ƒ";
    }
    /** æ—¶é—´æ˜¾ç¤ºï¼š{@code yyyy-MM-dd HH:mm:ss}(24 å°æ—¶åˆ¶ï¼‰ */
    public static String time(Date date) {
        return date == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
    }
}
ruoyi-project/src/main/java/com/ruoyi/project/service/ExpertFeeCheckResult.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,121 @@
package com.ruoyi.project.service;
import com.ruoyi.project.domain.ServiceFunddetailVO;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
 * ä¸“家劳务费校验结果
 *
 * åŒºåˆ†ä¸¤ç±»ç»“果:
 * - errors   é˜»æ–­æ€§é—®é¢˜ï¼šç»åŠžäººä¸èƒ½ä¿å­˜ã€å®¡æ‰¹äººä¸èƒ½å®¡æ‰¹é€šè¿‡
 * - warnings æç¤ºæ€§é—®é¢˜ï¼šä¸é˜»æ–­æµç¨‹ï¼Œä»…提示经办人自行处理(规则 5、6)
 *
 * @author ruoyi
 */
@Data
public class ExpertFeeCheckResult {
    /** é˜»æ–­æ€§é”™è¯¯ï¼ˆè¶…限额等) */
    private List<String> errors = new ArrayList<>();
    /** æç¤ºä¿¡æ¯ï¼ˆåŒä¸€ä¸“家跨项目取高、第二器官超 500 ç­‰ï¼‰ */
    private List<String> warnings = new ArrayList<>();
    public void addError(String message) {
        this.errors.add(message);
    }
    public void addWarning(String message) {
        this.warnings.add(message);
    }
    /** æ˜¯å¦å­˜åœ¨é˜»æ–­æ€§é—®é¢˜ */
    public boolean hasError() {
        return !this.errors.isEmpty();
    }
    public boolean hasWarning() {
        return !this.warnings.isEmpty();
    }
    /** é”™è¯¯ä¿¡æ¯æ‹¼ä¸ºä¸€è¡Œï¼Œä¾¿äºŽæŽ¥å£ç›´æŽ¥è¿”回 */
    public String errorMessage() {
        return String.join(";", this.errors);
    }
    /** æç¤ºä¿¡æ¯æ‹¼ä¸ºä¸€è¡Œ */
    public String warningMessage() {
        return String.join(";", this.warnings);
    }
    /** é”™è¯¯ + æç¤ºåˆå¹¶ï¼ˆç”¨äºŽå‰ç«¯ä¸€æ¬¡æ€§å±•示) */
    public String allMessage() {
        List<String> all = new ArrayList<>();
        all.addAll(this.errors);
        all.addAll(this.warnings);
        return String.join(";", all);
    }
    /**
     * æ ¡éªŒç”¨çš„æ˜Žç»†è¡Œï¼ˆæŠŠé‡‘额统一成 BigDecimal,避免后续 double è¿ç®—)
     */
    @Data
    public static class DetailRow {
        /** æ˜Žç»†ä¸»é”®ï¼ˆè½åº“后才有值) */
        private Long id;
        private Long fundid;
        private Long infoid;
        private Long itemid;
        private String itemname;
        private String idcardno;
        private String servicesscopename;
        /** ç¨Žå‰é‡‘额 */
        private java.math.BigDecimal amount;
        /** ç¨ŽåŽé‡‘额 */
        private java.math.BigDecimal taxedamount;
        public static DetailRow of(ServiceFunddetailVO vo) {
            DetailRow row = new DetailRow();
            row.setId(vo.getId());
            row.setFundid(vo.getFundid());
            row.setInfoid(vo.getInfoid());
            row.setItemid(vo.getItemid());
            row.setItemname(vo.getItemname());
            row.setIdcardno(vo.getIdcardno());
            row.setServicesscopename(vo.getServicesscopename());
            row.setAmount(toDecimal(vo.getAmount()));
            row.setTaxedamount(toDecimal(vo.getTaxedamount()));
            return row;
        }
        private static java.math.BigDecimal toDecimal(Double value) {
            return value == null ? java.math.BigDecimal.ZERO : java.math.BigDecimal.valueOf(value);
        }
        /** è´¹ç”¨è¯´æ˜Žæ˜¯å¦åŒ…含关键字(费用说明可能为空) */
        public boolean scopeContains(String keyword) {
            return this.servicesscopename != null && this.servicesscopename.contains(keyword);
        }
        /**
         * è´¹ç”¨è¯´æ˜Žæ˜¯å¦åŒ…含任一关键字(费用说明可能为空)。
         *
         * <p>同一场景在不同环境/年度的下拉文本不一致(如「市内」与「本市」),
         * ç”¨å…³é”®å­—集合兼容,避免因文案差异导致限额校验静默失效。
         */
        public boolean scopeContainsAny(String... keywords) {
            if (this.servicesscopename == null || keywords == null) {
                return false;
            }
            for (String keyword : keywords) {
                if (keyword != null && this.servicesscopename.contains(keyword)) {
                    return true;
                }
            }
            return false;
        }
    }
}
ruoyi-project/src/main/java/com/ruoyi/project/service/impl/ExpertFeeValidateService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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();
    }
}