简介:这份面向医保信息化实施人员与系统维护者的资料包,围绕昌吉州医保结算系统的落地实施展开,覆盖参保人员信息管理、医疗服务项目编码、费用审核与报销、智能审核规则、与医疗机构HIS接口、跨区域结算及数据安全等核心业务环节,可作为了解地方医保政策与结算流程的参考。压缩包共3个文件,以1个htm说明页、1个txt下载说明和1个pbl程序库文件为主:说明页用于呈现系统功能与操作指引,txt交代文件构成与安装要点,pbl作为PowerBuilder程序库承载系统主体程序,整体约929KB,体积轻便、便于随取随查。目前已有1835人学习下载。读者可从中获得昌吉州医保结算实施版的说明文档与程序文件,借助说明页快速理解报销标准、审核规则与业务流程,并为二次开发、接口对接与日常运维提供参照,适合需要掌握医保结算系统结构与实施细节的从业人员查漏补缺。
1. 把医保结算系统跑起来只要十分钟,把金额改对要一晚上
一份医保结算系统的源码拉下来跑通,登录、建档、录费用、点结算,一张结算单带着统筹支付和个人自付两行数字弹出来,这大概是最顺利的十分钟。真正让人熬夜的是接下来:把报销比例从 85% 改成 75%,金额纹丝不动;把一条乙类药品的等级调一下,总额反而比调之前更高;退了一笔费,汇总表里那笔钱还在。医保结算系统这类资源的价值,从来不是"能不能启动",而是它有没有把"政策参数 → 三大目录匹配 → 分段计算 → 结算单落库"这条链路拆成可改、可查、可回归的几层。
这套资源适合三类人:要交课程设计或毕设、需要一套结构完整能讲清楚的业务系统骨架的;自有业务系统里要补一块费用结算能力、不想从零设计的;以及接手了别人留下的结算模块、面对一张金额对不上的单据不知道从哪下手的。往下看,先讲结算链路怎么拆,再讲目录怎么匹,最后讲金额对不上时先查哪里。
2. 结算链路拆解:一张结算单算准的四层结构
2.1 公式为什么不能写死在 Service 里
见过太多把计算逻辑堆进一个settle()方法的写法:起付线判断、目录等级判断、比例相乘、封顶截断,五百多行,中间夹着七八个 if。这种代码在参数稳定的时候没问题,一旦险种、人员类别、机构等级三个维度组合起来,分支就是乘法级增长,改一个比例要通读全文件。血泪经验是,结算模块的复杂度不在算术,在组合。
合理的选型是"参数表 + 计算节点链"。政策参数(起付线、报销比例、封顶线、乙类先自付比例)全部落表,按险种 + 人员类别 + 机构等级 + 生效日期区间匹配;计算过程拆成若干个职责单一的节点,串成一条固定顺序的链,每个节点只干一件事。这样做的好处很直接:调参数不用改代码,改顺序只动一行配置,出问题能按节点回溯。
另一个必须提前想清楚的点是快照。政策参数是会变的,而历史结算单必须能按"当时的政策"重算和解释。所以结算单落库时要把本次用到的参数值、目录版本、每个节点的输入输出一起存下来。这份快照就是排查时唯一的后悔药,没有它,三个月后你面对一张差额 0.01 的单子只能靠猜。
2.2 上下文对象与节点接口
链条要跑起来,先得有一个贯穿全程的上下文,节点之间只通过它传值,不允许互相调用。
/** 结算上下文:一次结算的所有中间结果都挂这里,节点只读改写它 */ public class SettlementContext { private String insType; // 险种:EMP 职工 / RES 居民 private String personLevel; // 人员类别:ACTIVE 在职 / RETIRE 退休 / CHILD 学生儿童 private String orgLevel; // 机构等级:L1 / L2 / L3 private LocalDate settleDate; // 结算日期,决定取哪一版参数和目录 private BigDecimal totalFee = BigDecimal.ZERO; // 费用总额 private BigDecimal compliant = BigDecimal.ZERO; // 合规费用(进目录且非全自费) private BigDecimal preSelfPay = BigDecimal.ZERO; // 乙类先自付 private BigDecimal selfPay = BigDecimal.ZERO; // 目录外/丙类全自费 private BigDecimal deductible = BigDecimal.ZERO; // 本次起付线 private BigDecimal pooledPay = BigDecimal.ZERO; // 统筹支付 private BigDecimal personalPay= BigDecimal.ZERO; // 个人负担合计 private BigDecimal capRemain = BigDecimal.ZERO; // 封顶剩余额度 private List<FeeDetail> details; // 费用明细 // getter/setter 省略 } public interface CalcNode { /** 节点名会写进结算单快照,出问题一眼看出是哪一步 */ String name(); void apply(SettlementContext ctx); }SettlementContext里所有金额一律BigDecimal,这是硬约束,不是风格偏好。settleDate单独留一个字段,是因为参数匹配和目录匹配都要按日期取版本,如果直接用LocalDate.now(),补录历史单据时必然算错。
引擎本身很薄,只负责按顺序跑节点并记录轨迹:
@Component public class SettlementEngine { // 顺序即业务语义:目录先匹,起付线才能从合规费用里扣 private final List<CalcNode> nodes = List.of( new CatalogMatchNode(), // 1 目录匹配 + 甲乙丙分类 + 乙类先自付 new DeductibleNode(), // 2 扣起付线 new RatioNode(), // 3 按比例算统筹支付 new CapNode(), // 4 封顶截断 new SupplementNode() // 5 补充/大病,无政策时为空实现 ); public SettlementResult settle(SettlementContext ctx) { List<NodeSnapshot> trace = new ArrayList<>(); for (CalcNode node : nodes) { BigDecimal pooledBefore = ctx.getPooledPay(); BigDecimal personalBefore = ctx.getPersonalPay(); node.apply(ctx); trace.add(new NodeSnapshot(node.name(), pooledBefore, ctx.getPooledPay(), personalBefore, ctx.getPersonalPay())); } return SettlementResult.of(ctx, trace); // trace 与主单同事务落库 } }NodeSnapshot记录的是每个节点执行前后的统筹支付和个人负担,五个节点就是五行,一次结算多写五行数据,代价很小。等到线上出现"这个人为什么只报了这么点",把 trace 拉出来从第一行看到最后一行,基本三分钟定位。这五行是我认为整套设计里性价比最高的部分。
2.3 政策参数表怎么落
参数表的设计目标只有一个:任意一组(险种、人员类别、机构等级、日期)都能唯一命中一行,且能解释为什么命中这一行。
CREATE TABLE policy_param ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ins_type VARCHAR(16) NOT NULL COMMENT '险种编码', person_level VARCHAR(16) NOT NULL COMMENT '人员类别', org_level VARCHAR(16) NOT NULL COMMENT '机构等级', eff_date DATE NOT NULL COMMENT '生效日期', exp_date DATE NOT NULL COMMENT '失效日期', deductible DECIMAL(12,2) NOT NULL COMMENT '起付线', ratio DECIMAL(6,4) NOT NULL COMMENT '报销比例,存 0.8500 不存 85', cap_amount DECIMAL(12,2) NOT NULL COMMENT '封顶线', pre_self_ratio DECIMAL(6,4) NOT NULL DEFAULT 0 COMMENT '乙类先自付比例', priority INT NOT NULL DEFAULT 0 COMMENT '同条件多条时取大,用于过渡期', UNIQUE KEY uk_param (ins_type, person_level, org_level, eff_date) );几个参数值得单独说。ratio存0.8500而不是85,是为了避免下游再除一次 100——每多一次除法就多一次舍入,多一次舍入就多一个对不上的可能。eff_date和exp_date用闭区间,补录三个月前的单据时按settleDate查就行,不用改代码。priority是为过渡期准备的:同一组条件如果同时存在新旧两套参数,取 priority 大的那条,而不是靠插入顺序碰运气。
查询语句按业务语义写清楚,不要图省事用ORDER BY id DESC LIMIT 1:
SELECT * FROM policy_param WHERE ins_type = #{insType} AND person_level = #{personLevel} AND org_level = #{orgLevel} AND eff_date <= #{settleDate} AND exp_date >= #{settleDate} ORDER BY priority DESC, eff_date DESC LIMIT 1;如果这条 SQL 查不到数据,正确做法是抛出明确的业务异常并提示"缺少某险种某人员类别某等级的结算参数",而不是返回一个默认 0.5 的比例让流程继续走下去。默认值兜底是结算系统里最危险的写法,它会把配置错误变成金额错误,而且不报错。
2.4 五步顺序为什么一步都不能换
节点顺序不是随手排的,每一步都依赖前一步的产物。目录匹配必须排第一,因为起付线扣的是合规费用,而合规费用得先减掉目录外的全自费和乙类的先自付部分。这个顺序我在一个模拟项目里搞反过一次,把起付线节点放到了目录匹配前面,结果起付线按费用总额扣,导致所有含乙类药品的单子统筹支付都偏高,测试环境跑了三天才发现。
比例节点必须在起付线之后,因为比例乘的是"合规费用减起付线"的余额。封顶节点必须在比例之后,因为封顶截断的是算出来的统筹支付额,截断后再算比例没有意义。补充节点放最后,它处理的是统筹支付完之后个人负担部分还能不能再报一层,输入是前四步的结果。
顺序之外还有个容易忽略的细节:个人负担合计不应该由某个节点单独算,而应该在链路结束后用总额 - 统筹支付反推,同时和各个节点累加出来的自付分项做一次对平校验。两个口径对不上,说明中间某一步出现了资金凭空产生或消失,这时候应该直接抛异常回滚,而不是把差额悄悄塞进某个字段。
2.5 跑通一次带断言的结算
启动前先把表结构和参数灌进去,参数种子数据里放一组三级机构退休职工的数据:起付线 300,比例 0.85,乙类先自付 0.10。
# 建表 + 灌参数、目录种子数据 mysql -uroot -p settlement < sql/schema.sql mysql -uroot -p settlement < sql/policy_seed.sql mysql -uroot -p settlement < sql/catalog_seed.sql # 起服务,dev profile 会打开 SQL 日志方便对账 mvn -q spring-boot:run -Dspring-boot.run.profiles=dev请求体只传原始费用,金额全部由引擎算,前端不允许传任何金额字段——这一条能挡掉后面一半的脏数据问题。
curl -s -X POST http://localhost:8080/api/settlement/calc \ -H 'Content-Type: application/json' \ -d '{ "visitId": "V2024001", "insType": "EMP", "personLevel": "RETIRE", "orgLevel": "L3", "settleDate": "2024-06-01", "details": [ {"hospItemCode": "DRUG001", "chargeType": "A", "qty": 2, "unitPrice": 35.50}, {"hospItemCode": "EXAM010", "chargeType": "B", "qty": 1, "unitPrice": 260.00} ] }'手算一遍对清楚每一分钱:甲类药品 2 × 35.50 = 71.00 全额进合规;乙类检查 260.00 先自付 10% 得 26.00,剩 234.00 进合规。合规费用 71.00 + 234.00 = 305.00。扣起付线 300.00 后余额 5.00,统筹支付 5.00 × 0.85 = 4.25,个人按比例负担 0.75。个人负担合计 = 起付线 300.00 + 先自付 26.00 + 比例自付 0.75 = 326.75,加上统筹 4.25 正好 331.00。
接口返回的pooledPay必须是 4.25,personalPay必须是 326.75。这两个数字记下来,后面改任何参数、做任何重构,先跑这一条,对得上再往下走。
3. 三大目录匹配:编码、甲乙丙与先自付的落地细节
3.1 匹配失败的代价是一条药变成全自费
目录匹配是结算链路上第一个真正接触脏数据的地方。上游传过来的费用明细里,项目编码可能是院内码,可能是目录标准码,也可能两个都带但其中一个已经停用。匹配不上,这条明细就只能按目录外处理,全额计入自费。
对患者来说,这意味着一笔本该报销的钱变成了自己掏;对系统来说,这是一个不会报错的静默失败,日志里干干净净,只有金额悄悄变了。这类问题在测试环境几乎发现不了,因为测试数据都是手工录的,编码干干净净;一上线接到真实数据,前导零、尾部空格、全角字符全来了。
所以目录匹配模块必须做两件事:一是多级回退匹配,能救回来的尽量救;二是把匹配不上的明细单独落一条待确认记录,让业务能看到并人工处理,而不是默默按自费结掉。
3.2 三类目录的表结构差异
药品、诊疗项目、耗材的匹配键不一样,硬塞进一张表会很难受。常见做法是三张目录表,字段各自贴合业务。
| 目录类型 | 表名 | 关键匹配字段 | 特有字段 | 注意事项 |
|---|---|---|---|---|
| 药品 | drug_catalog | 院内码、标准码、通用名 | 剂型、规格、转换比、甲乙丙 | 转换比影响计价单位,别漏 |
| 诊疗 | treat_catalog | 院内码、标准码、项目名称 | 计价单位、是否可另收费 | 同名称不同计价单位很常见 |
| 耗材 | material_catalog | 院内码、耗材编码、规格型号 | 是否可单独收费 | 规格型号参与匹配,不能只匹编码 |
三张表都要带eff_date/exp_date两个字段。目录是有版本的,结算日期决定用哪一版,这条规则和参数表一致。很多项目图省事只保留最新版目录,结果补录历史单据时按新目录算,金额和当时差出一大截。
甲乙丙分类的做法是在目录表里存一个charge_type字段,A 甲类、B 乙类、C 丙类。乙类还要存一个pre_self_ratio,因为不同乙类项目的先自付比例可能不同,全局统一一个 10% 是简化处理,真实场景要按目录走。
3.3 三级回退的匹配实现
匹配优先级从快到慢:院内码直连、标准码、名称规格模糊匹配。前两级自动入账,第三级只做人工确认。
public MatchResult match(FeeDetail d, LocalDate settleDate) { // 1 院内码直连:命中率最高,走唯一索引 MatchResult r = byHospCode(trim(d.getHospItemCode()), d.getOrgId(), settleDate); if (r != null) return r; // 2 标准码:上游有时只传标准码,这一列别在入库时丢掉 r = byStdCode(trim(d.getStdCode()), settleDate); if (r != null) return r; // 3 名称 + 规格:只落待确认,不自动入账,避免张冠李戴 return MatchResult.manual(d, "NO_CATALOG"); }trim看起来不值一提,实际能救掉相当一部分匹配失败。更稳妥的做法是在明细入库时就把原始值存到raw_code列,同时存一份清洗后的norm_code,清洗规则包括去首尾空格、全角转半角、去掉不可见字符。清洗是有损的,所以原始值必须留,否则出了问题连原始数据都回不去。
第三级模糊匹配我一般只用来生成待确认清单,不让它直接参与结算。药品名称相似度高的太多了,两个字的差别可能就是两种完全不同的药,靠算法猜是给自己埋雷。结算时按全自费预结,业务确认后走退费重结,虽然多一步操作,但账是清楚的。
3.4 先自付与自费的位次
这一段的顺序错一次,金额就错一片。正确的处理是逐条明细判断分类,在进入起付线和比例计算之前就把钱分成三堆:合规费用、乙类先自付、全自费。
for (FeeDetail d : ctx.getDetails()) { // 行金额按行取整,HALF_UP,全链路统一这一种舍入方式 BigDecimal lineAmt = d.getQty().multiply(d.getUnitPrice()) .setScale(2, RoundingMode.HALF_UP); switch (d.getChargeType()) { case "A": // 甲类:全额进合规 ctx.addCompliant(lineAmt); break; case "B": // 乙类:先切一刀先自付,余额进合规 BigDecimal pre = lineAmt.multiply(d.getPreSelfRatio()) .setScale(2, RoundingMode.HALF_UP); ctx.addPreSelfPay(pre); ctx.addCompliant(lineAmt.subtract(pre)); break; default: // 丙类 / 目录外 ctx.addSelfPay(lineAmt); } ctx.addTotal(lineAmt); }这里有个争议点值得说清楚:先自付是"按行取整后累加"还是"累加后统一取整"。两种做法在大额单据上会差几分钱。我统一选按行取整,理由是每一行都能独立复核,业务拿着明细逐条算,算出来的和系统一致;如果累加后再取整,单行数据就对不上,解释成本高得多。
RoundingMode一旦选定就不能局部改。全链路统一 HALF_UP,包括行金额、先自付、比例支付、封顶截断,全用同一个模式。混用 HALF_UP 和 DOWN 是差额的经典来源,而且极难查,因为每一处单独看都"合理"。
3.5 用 SQL 抽查匹配率
匹配率是要天天看的指标,一条 SQL 就能拉出来:
SELECT COUNT(*) AS total_lines, SUM(CASE WHEN c.id IS NULL THEN 1 ELSE 0 END) AS unmatched, ROUND(SUM(CASE WHEN c.id IS NULL THEN 1 ELSE 0 END) / COUNT(*), 4) AS unmatched_rate FROM fee_detail f LEFT JOIN drug_catalog c ON c.hosp_code = TRIM(f.hosp_item_code) AND c.org_id = f.org_id AND f.settle_date BETWEEN c.eff_date AND c.exp_date WHERE f.settle_date = CURDATE();unmatched_rate超过千分之五就该看具体是哪些编码了。常见原因是新项目上线后目录没同步,或者上游换了一套院内码但映射表没更新。把unmatched明细按hosp_item_code分组排序,一般前五个编码就占了八九成,补完就下来了。
4. 数据模型与状态机:结算、退费、冲正怎么不打架
4.1 三张表:主单、明细、资金流水
结算的数据模型最容易出问题的地方,是把"业务单据"和"资金变动"混成一张表。单据是可以改状态的,资金是不能改的,只能追加。
| 表 | 作用 | 关键字段 | 写入方式 |
|---|---|---|---|
settle_order | 结算主单,一次结算一行 | 结算单号、就诊号、状态、金额汇总、active_visit_id | 只插不改金额,只改状态 |
settle_detail | 结算明细,一条费用一行 | 结算单号、项目编码、分类、行金额、先自付 | 与主单同事务插入 |
fund_flow | 资金流水,只增不改 | 结算单号、方向(1 收 / -1 退)、资金类型、金额 | 只 insert,永不 update |
主单上有一个字段值得单独设计:active_visit_id。结算有效时它等于visit_id,退费或冲正后置为 NULL。配合唯一索引,就得到了"同一就诊只能有一条有效结算单"的数据库级约束。
-- MySQL 唯一索引允许多个 NULL,所以退费后能重新结算,且有效单只能有一张 active_visit_id VARCHAR(32) DEFAULT NULL, UNIQUE KEY uk_active_visit (active_visit_id)这个约束的价值在并发场景下才体现出来。两个请求同时点结算,应用层的判重可能都通过了,但第二个请求插库时会被唯一索引挡住,直接抛异常回滚,不会产生两张有效单据。把并发正确性交给数据库,比在应用层写一堆分布式锁靠谱得多。
4.2 状态机:四条合法跃迁,其余一律拒绝
状态字段用整数存是可以的,但状态跃迁必须显式约束,否则迟早出现"已退费又变成已结算"这种脏数据。
public enum SettleStatus { DRAFT(0), SETTLED(1), REFUNDED(2), REVERSED(3); private static final Map<SettleStatus, Set<SettleStatus>> ALLOW = Map.of( DRAFT, EnumSet.of(SETTLED), SETTLED, EnumSet.of(REFUNDED, REVERSED), REFUNDED, EnumSet.noneOf(SettleStatus.class), REVERSED, EnumSet.noneOf(SettleStatus.class) ); public void check(SettleStatus to) { if (!ALLOW.get(this).contains(to)) { throw new BizException("非法状态跃迁: " + this + " -> " + to); } } }退费和冲正是两个不同的概念,不要合并。退费是患者不做了,钱原路退回,业务上是一次正常的反向操作;冲正是单据本身有错,需要作废重开。两者都可以用"新增一张负数结算单"来实现,但状态和备注要分开,否则后面统计退费率的时候会算错。
跃迁校验里我坚持抛异常而不是返回布尔值。返回布尔值的结果往往是调用方忘了判断,流程继续走下去,最后落一张状态诡异的单子。异常至少能让事务回滚。
4.3 幂等:请求号 + 唯一索引双保险
应用层的幂等键用"就诊号 + 结算类型 + 请求号",请求号由前端生成,同一个请求重试时复用。键值放缓存,过期时间五分钟。
public SettlementResult settle(SettleRequest req) { String idemKey = "settle:" + req.getVisitId() + ":" + req.getRequestNo(); // setIfAbsent 成功才放行,失败说明是重复提交 Boolean first = redis.opsForValue().setIfAbsent(idemKey, "1", Duration.ofMinutes(5)); if (Boolean.FALSE.equals(first)) { throw new BizException("请勿重复提交"); } try { return doSettle(req); // 内部依赖 uk_active_visit 兜底 } catch (Exception e) { redis.delete(idemKey); // 失败要放开,允许用户重试 throw e; } }缓存只是第一道关,真正防住的是数据库唯一索引。缓存会过期、会丢、会在主从切换时不一致,只有索引是硬的。另外注意catch里要把键删掉,否则一次失败的重试会被挡住五分钟,用户体验极差——这个坑我踩过,用户连点三次全部失败,最后发现是第一次失败留下的键还没过期。
4.4 退费与冲正:反向记账,不改原单
退费的正确做法是新增一张金额为负的结算单和对应的负数流水,原单只改状态,金额一分不动。
-- 1 原单置为已退费,同时释放唯一索引 UPDATE settle_order SET status = 2, active_visit_id = NULL, version = version + 1 WHERE settle_no = #{settleNo} AND status = 1; -- 2 新增负数结算单(金额字段全部取负),settle_no 用新的 INSERT INTO settle_order (settle_no, visit_id, status, total_fee, pooled_pay, personal_pay) SELECT CONCAT('R', settle_no), visit_id, 1, -total_fee, -pooled_pay, -personal_pay FROM settle_order WHERE settle_no = #{settleNo}; -- 3 负数资金流水,方向 -1 INSERT INTO fund_flow (settle_no, direction, fund_type, amount) SELECT settle_no, -1, fund_type, -amount FROM fund_flow WHERE settle_no = #{settleNo};WHERE status = 1这个条件不能省,它是乐观锁,防止并发重复退费。version字段同样可以加,但对退费这种一次性操作,状态条件已经够了。
对账口径要统一成"流水合计",而不是"有效单据金额合计"。流水只增不改,任何时点的余额都能重算出来;单据金额会因为状态变化而消失,用它做对账基准,退费一多就永远对不平。
5. 避坑与排查:金额差一分、单据对不上时先看哪里
5.1 差额恰好 0.01:舍入模式和精度
现象:后台结算单的统筹支付是 4.25,前端展示或个人端查询是 4.24;或者同一笔业务重算两次结果不一样。
原因:链路上混进了double,或者某处BigDecimal.divide()没有指定 scale 和RoundingMode,触发ArithmeticException后被catch吃掉,值变成了 0;也可能是不同节点用了不同的舍入模式,比如行金额 HALF_UP、汇总 DOWN。
解决:全链路BigDecimal,实体、DTO、VO 一个double都不留,包括对接上游的报文解析。所有divide强制写setScale(2, RoundingMode.HALF_UP)。可以在编译期加一条 ArchUnit 规则,扫描到double/float金额字段直接构建失败,比人肉 review 靠谱。已经在跑的存量数据,靠结算单里的 trace 快照逐节点对比,找出是从哪一步开始偏的。
5.2 结算成功但明细表是空的
现象:主单金额正确,settle_detail里一条记录都没有;或者明细只写进去一半。
原因:明细写入被包在try-catch里,异常被吞了;或者用了@Async、CompletableFuture在事务提交后异步写明细,子线程拿不到事务上下文,主单提交了明细没提交;也有的是在循环里逐条插入,某一条失败后continue了。
解决:明细和主单必须同事务、同线程写入。事务方法里不要自己起线程,确实需要异步的放到事务提交之后的回调里,并且接受失败可重试。catch块里记录完必须重新抛出。加一条兜底校验:插入完成后比对"主单总额"和"明细行金额合计",不平直接抛异常回滚,这条校验能拦住绝大部分半截写入。
5.3 同一就诊出现两张有效结算单
现象:对账时发现某个就诊号对应两条status = 1的记录,金额还一模一样。
原因:前端按钮没有防抖,后端也没有幂等;或者幂等键用的是结算单号而不是就诊维度,两个不同的结算单号自然都能通过;还有一种情况是唯一索引建在了settle_no上,等于没建。
解决:数据库层加uk_active_visit唯一索引兜底,应用层加请求幂等键,两层都要有。已经产生的重复单据不能直接删,走冲正流程,保留痕迹。真要排查历史重复,按visit_id分组HAVING COUNT(*) > 1一把捞出来,比翻日志快得多。
5.4 某条药一直按全自费结算
现象:一类项目在所有单据里都被计为自费,业务反馈"这个明明能报"。
原因:上游传的院内码带前导零(000123与123)、尾部有空格、或者中间混了全角字符;也可能是目录版本没更新,结算日期落在exp_date之后,按日期查不到。
解决:入库时同时存raw_code和清洗后的norm_code,清洗规则包含 trim、全角转半角、去不可见字符。匹配时用norm_code,排查时看raw_code。目录查询统一带日期条件,禁止SELECT * FROM drug_catalog WHERE hosp_code = ?这种不带日期版本的写法。每天跑一次匹配率 SQL,超阈值告警。
5.5 退费之后汇总金额没变
现象:明细退了,患者账户也退钱了,但结算汇总报表里那笔统筹支付还在。
原因:退费只update了原单状态,没有生成负数流水;或者对账报表取的是"所有 status 不为 2 的单据",而退费单的状态没有被正确更新;还有一种是退了明细但没退主单,两张表状态不一致。
解决:退费必须三步原子完成——原单改状态并释放active_visit_id、生成负数主单、生成负数流水,三者在同一个事务里。对账报表统一取fund_flow的合计,不看单据状态。加一条日终校验:fund_flow当日合计 =settle_order当日有效单据金额合计,不平就报警,这条能在当天发现问题,不至于拖到月底。
6. 把结算引擎钉在黄金用例上:一套能天天跑的回归口径
改参数这件事,在结算系统里是最容易出事的操作。一个比例从 0.85 改成 0.80,看起来只是一行数据,但受影响的是所有在职退休人员的所有单据。靠人工点几下验证是远远不够的,你永远不知道自己漏了哪个组合。
我的习惯是准备一份黄金用例表,把"给定输入 → 期望统筹 → 期望个人负担"固定成 CSV,用参数化测试跑真实节点链,不 mock 任何计算节点。这样每一次改参数、每一次重构、每一次升级依赖,跑一遍就知道有没有把金额算歪。
@ParameterizedTest(name = "golden-{0}") @CsvFileSource(resources = "/golden/settle_cases.csv", numLinesToSkip = 1) void goldenCase(String caseNo, String insType, String personLevel, String orgLevel, String totalFee, String preSelfPay, String expectPooled, String expectPersonal) { SettlementContext ctx = fixture.of(insType, personLevel, orgLevel, new BigDecimal(totalFee)); ctx.setPreSelfPay(new BigDecimal(preSelfPay)); new SettlementEngine().settle(ctx); // 走真实节点链 assertThat(ctx.getPooledPay()).isEqualByComparingTo(expectPooled); assertThat(ctx.getPersonalPay()).isEqualByComparingTo(expectPersonal); }配套的 CSV 里至少覆盖四类边界:刚好等于起付线、起付线以下一分钱、刚好触发封顶、封顶之后还有费用。每条用例的两个期望值都必须手算过一遍并且能写进备注,不能拿系统跑出来的结果反填——那是自己给自己出题,永远全绿。
caseNo,insType,personLevel,orgLevel,totalFee,preSelfPay,expectPooled,expectPersonal G001,EMP,RETIRE,L3,331.00,26.00,4.25,326.75 G002,EMP,RETIRE,L3,1000.00,0.00,595.00,405.00 G003,EMP,RETIRE,L3,300.00,0.00,0.00,300.00除了单元级的黄金用例,还有三条日终校验值得固定成定时任务:结算单总额与明细合计对平、流水合计与有效单据金额对平、目录匹配率不低于阈值。这三条跑绿了,一天的结算数据基本可以放心。
从那以后我每次动结算参数,哪怕是改一个不起眼的先自付比例,都强制先跑一遍黄金用例再发版,跑不过就不发,没有例外。这套东西搭起来花不了半天,省下来的是无数个对着差额几十块钱的报表熬到半夜的晚上。这份资源的结算链路、目录匹配和状态机三块我都对着代码走过一遍,拿到之后建议先别急着改业务,把黄金用例补上跑一遍,再动手。
希望帮到你。
本文还有配套的精品资源,点击获取