挂账的粒度必须等于幂等的粒度¶
夜审那篇讲过语义锚点与幂等落账的基本盘,本文不重讲。本文讲夜审之后的账务窗口——挂账——踩到的坑:挂账的粒度如果和防重锚点的粒度不一致,幂等会以最难受的方式失效。
现象¶
渠道单按预离做整段预挂时,最直觉的落法是"一行总价"。后果有两个:夜审按晚防重,看到逐晚都没入账,照常逐晚落账——同一住宿双记;客人提前离店时,预挂进去的未住晚还挂在账上,副账金额与实际晚数对不上。
完整链路的行为面由一组计费回归测试钉住(本仓库 go test ./internal/mod/billing/ 实测通过),四步各有可重复执行的断言,而不是一张只能看不能验的截图:
- 整段预挂(
TestPrepayStayToDue):未入账的业务夜逐晚落行,各归各的营业日; - 夜审(
TestNightAuditBatch、TestNightAuditDryRunMatchesPost):预挂之后夜审按"账单 × 营业日已入"静默跳过已入晚,且试算与过账同价; - 提前离店(
TestTrueUpDuePrepaid):预离后才"该发生"的房费行按营业日筛出冲回,副账收敛到实际晚数; - 撤销退房(
TestRevertDueTransfer、TestUncheckoutRevertsDue):挂账转移按原因匹配镜像行精确冲回,账面还原到退房前。
我一开始的错误判断¶
"一次挂齐"的直觉非常强:渠道已经全额收过钱了,账上补一行应收就完事。
被打回后才看清楚——教训就标注在预挂函数头部的决策注释里("2026-10-26/27 渠道单整段补齐的教训"):夜审的防重锚点是"账单 × 营业日",一行总价和这个锚点不同粒度——防重逻辑根本认不出这行就是那些晚,于是各干各的,两边代码单看都对,合起来双记。定下逐晚模型之后,整段预挂才以"逐晚落行 + 决策注释写明为什么"的形态回到代码里。
排查与关键证据¶
修复后的模型沉淀在预挂函数头部的决策注释里(internal/mod/billing/due_folio.go 的 PrepayStayToDue),注释本身可作一级证据,要点有三:
- 逐晚落行,各归各的营业日。未入账的业务夜逐晚落主账,营业日取各晚自己的;这样夜审按"账单 + 营业日已入"防重时自然跳过,夜审不需要知道"整段预挂"这个概念的存在。幂等锚点与夜审锚点同一套命名空间;
- 提前离店由退房流程兜底冲销(
TrueUpDuePrepaid):把预离之后才"该发生"的房费行按营业日筛出来冲回,主账以调整差额冲回、副账等额冲回——副账收敛到实际晚数; - 押金不动、口径互斥。挂账额是"余额减未冲正押金"(
MoveOutstandingToDue),押金负债留在主账;挂账副账不允许嵌套转挂;走结(账务冻结式退房)与普通挂账互斥使用。
每一步可逆是这套模型的另一半:撤销退房需要精确还原(RevertDueTransfer 按原因匹配镜像行做逆操作);追加挂账时渠道与备注用合并保留策略,防止被后续流程清掉。夜审侧配合的取舍是:不再用"排除已入"的方式防重,而是进差异校验——已入的静默跳过,金额不符的在差异备注里露出,绝不重复入账;钟点房整晚跳过,防止与退房转夜价撞车。
根因¶
两条,互为表里:
- 粒度定律:任何批量入账,粒度必须与既有防重锚点同粒度,否则幂等失效。防重逻辑与入账逻辑各自正确、合起来错误,是账务系统里最隐蔽的一类 bug;
- 可逆定律:账务窗口的每一步(预挂、冲销、撤销退房)都要能被精确冲回。可逆性要求冲销行能唯一反查原行——这正是"一行总价"模型永远给不了的:它没有可供匹配的逐晚结构。
约束与取舍¶
- 放弃了整段一行总价。写入简单、报表行少、用户看着清爽——全部让位给粒度一致性;
- 放弃了删流水式冲销。不可审计的冲销等于没冲;
- 代价一:流水行数随晚数增长,挂账清单出现负数行(冲销行),展示层要专门做引导文案,否则前台看不懂"怎么有负的";
- 代价二:逆操作的匹配条件(原因 + 关联)成为必须精心维护的契约——匹配写错一条,冲错的就是别人的账;
- 代价三:幂等锚点的命名空间变复杂,预挂、夜审、退房补差各有锚点,需要一张总表管理。
可复用检查清单¶
- 任何批量入账先问:它的粒度和既有防重锚点同粒度吗?不同粒度就改模型,不要改防重;
- 设计入账时先设计它的逆操作;逆不了的形态(直接删流水)一律不采用;
- 押金与营收的分账口径、走结与挂账的互斥关系,写代码前先用一页纸写死;
- 冲销行必须携带能唯一反查原行的信息,且冲销本身要幂等;
- 面向用户的账单出现负数行/镜像行时,展示层同步设计解释,不把内部冲销结构裸露给前台。
小结¶
这个案例最有意思的地方在于:两边的代码单看都是对的——预挂逻辑正确,夜审防重也正确——错的是它们相遇的粒度。账务一致性的一半工作量,在把"粒度"当成一等设计对象。