餐饮改价折扣不能只改金额:事实不可变、投影重算与结算快照设计
0. 先说结论
餐饮 POS 里的改价、单品折扣、整单折扣和付款页折扣,看起来只是“把金额改一下”。
但真正做进生产系统后,会遇到一组很麻烦的问题:
未结账前可以改价。
部分结账后还能继续加菜、退菜、赠菜。
整单固定金额折扣要按剩余未结数量继续分摊。
已确认结算单不能被后续重算改掉。
付款页还可能再给一次折扣。
多台 POS 同时操作同一桌台时,旧页面不能覆盖新账单。
如果系统只是回写订单行金额,短期能跑,长期会出现金额漂移、历史成交价被改写、部分结账后折扣说不清、收款页和订单页互相覆盖的问题。
我后来更认可的模型是:
价格操作是事实,不直接覆盖历史。
当前账单金额是投影,可以基于事实重算。
已确认结算是快照,一旦确认就不能被普通价格操作重写。
ORDER 阶段和 PAYMENT 阶段分开建模。
版本号负责并发保护,幂等键负责重复提交保护。
这篇文章拆的是一套餐饮价格操作架构:为什么不能只改金额,怎么处理手动改价、单品折扣、整单折扣、付款折扣,为什么固定金额折扣应该被看成预算,以及为什么“投影可重算”和“结算快照不可变”必须同时存在。
1. 直接改金额为什么会坏
最直觉的实现是这样的:
用户点击改价 / 折扣
-> 后端更新订单行单价或折扣金额
-> 重新计算订单总额
-> 页面刷新
如果一桌只有一次完整结账,这么做问题不大。
但餐饮场景通常不是一次性闭合的。
一个真实一点的过程可能是:
先点 3 份菜
-> 给其中一份单品折扣
-> 做一次预结
-> 客人又加菜
-> 客诉后给整单折扣
-> 部分确认结账
-> 剩余部分继续退菜或赠菜
-> 最后付款页再给一个收银折扣
这时如果系统只有“订单行当前金额”,就会出现几个问题。
第一,历史成交被改写。
已经确认结账的明细,本质上已经形成成交快照。后续加菜、退菜、重新打整单折时,如果继续回写原订单行金额,就可能把已确认部分的成交价也改掉。
第二,固定金额折扣说不清。
比如整单固定减 30。先确认结掉一部分后,这 30 元到底算已经消费了一部分,还是下一次预结又恢复成满额 30?如果没有“折扣预算”和“已确认分摊”的概念,就只能靠临时字段猜。
第三,比例折扣和金额折扣混在一起。
比例折扣本质是规则,应该随当前未结基数重算;固定金额折扣本质是预算,应该按已确认消费后的余额继续分摊。两者如果都提前固化成金额字段,后面退菜、赠菜、加菜时都会变形。
第四,并发覆盖。
A 设备打开账单准备打折,B 设备已经加菜或改价。A 继续按旧页面提交,如果没有版本保护,就可能用旧视图覆盖新账单事实。
所以价格操作不能只是“改几个金额字段”。它必须被设计成一条能解释历史、能重算当前、能保护并发的业务链路。
2. 三类东西必须分清
餐饮价格系统里至少要分清三类对象:
| 对象 | 作用 | 是否可变 |
|---|---|---|
| 价格操作事实 | 记录用户做过什么价格动作 | 已发生事实不改写,只能取消、取代或追加 |
| 当前价格投影 | 根据有效事实和当前未结数量算出的页面金额 | 可以重算 |
| 结算快照 | 已确认结账时保存的成交结果 | 确认后不可被普通价格操作重写 |
换句话说:
事实用于解释。
投影用于展示和下一步计算。
快照用于锁定历史成交。
这里的“事实不可变”不是说永远不能撤销,而是不能通过覆盖原记录来伪装历史。
用户后面又改了一次价,应该新增一次价格操作事实,旧事实可以变成已取代或已取消,但不要把旧事实直接改成新事实。
这样审计才能回答:
第一次是谁改价?
第二次是谁打折?
哪个动作取代了哪个动作?
最终结账时采用的是哪一版价格投影?

3. ORDER 阶段和 PAYMENT 阶段要分开
餐饮价格操作最关键的边界,是阶段。
我会把它分成两类:
| 阶段 | 发生位置 | 典型动作 | 影响范围 |
|---|---|---|---|
| ORDER 阶段 | 点菜、加菜、预结前 | 手动改价、单品折扣、整单折扣 | 当前主账单的未结数量 |
| PAYMENT 阶段 | 已确认结账、收款前 | 付款页折扣 | 指定的待收款结算单 |
这两个阶段不能混。
ORDER 阶段面对的是 running tab,也就是还在变化的桌台账单。它可能继续加菜、退菜、赠菜、部分结账。
PAYMENT 阶段面对的是已经确认出来的一张结算单。它不是重新改订单行,而是在收款前对这张结算快照做一次收银折扣。
所以规则应该非常硬:
手动改价:只允许 ORDER 阶段。
单品折扣:只允许 ORDER 阶段。
整单折扣:只允许 ORDER 阶段。
付款折扣:只允许 PAYMENT 阶段。
任意跨阶段混传都应该拒绝,而不是后端“帮忙理解”。
因为一旦允许含糊,前端或调用方就可能绕过状态边界:
已确认结账后继续改订单行。
付款折扣偷偷扩散到整桌未结范围。
整单折扣误伤已确认结算快照。
阶段矩阵不是为了代码好看,而是为了把业务边界锁死。

4. ORDER 阶段:当前金额来自投影
ORDER 阶段的金额最好不要直接理解成“订单行金额字段”。
它应该是一个投影结果:
标准价 / 历史兼容价
-> 手动改价
-> 单品折扣
-> 整单折扣分摊
-> 当前未结应付金额
这个顺序很重要。
手动改价改变的是该行参与后续折扣计算的成交基准。
单品折扣作用在行上。
整单折扣作用在当前未结范围上,再分摊回行。
例如:
标准单价:66
手动改价:60
单品 9 折
最终行应付:54
如果顺序错了,结果就会完全不同。
再比如整单折扣。它可以有两种范围:
全桌当前未结范围。
指定明细的未结范围。
这两种必须分清。
如果用户选择了指定明细整单折,后续加菜不应该自动参与这次折扣;如果用户选择的是全桌整单折,后续加菜是否参与,就要按产品策略明确。
公开文章里不需要暴露字段,但模型上要有这个概念:
折扣事实必须知道自己的作用范围。
投影时必须按作用范围计算。
5. 固定金额折扣应该被看成预算
固定金额整单折扣最容易做错。
假设一桌菜当前未结金额 100,给了整单固定减 30。
如果马上全额结账,很简单:
应付 = 100 - 30 = 70
但如果只结掉其中一部分呢?
比如当前确认了 40 元基数,对应分摊了 12 元折扣。剩余未结基数还有 60。
下一次预结时,整单固定减 30 应该怎么处理?
更稳的口径是:
固定金额折扣 = 原始预算。
已确认结算快照消费掉一部分预算。
剩余预算继续作用于剩余未结数量。
也就是:
原始折扣预算:30
已确认消费:12
剩余预算:18
剩余未结基数:60
下一次最多再分摊:18
这样才能同时满足两个目标:
- 已确认部分不被后续重算改写。
- 剩余未结部分还能继续使用剩余额度。
如果产品希望“后续再给满额 30”,那就不应该偷偷恢复旧折扣,而应该新增一次新的整单折扣事实。
比例折扣则不同。
比例折扣更像规则:
当前未结基数是多少,就按比例重新计算。
退菜、赠菜、加菜后,比例折扣随当前基数变化;固定额折扣则要看原始预算、已确认消费和剩余额度。
这就是为什么不能把所有折扣都提前固化成金额字段。
6. 结算快照确认后不可变
餐饮系统里有一个非常重要的边界:
预结可以重建,确认结账不能被普通价格操作重写。
预结只是给顾客看账单、打预结单或做付款前确认。这个阶段如果又加菜、退菜、改价、折扣,旧预结可以失效,然后重新预结。
但确认结账不同。确认后系统应该保存结算明细快照:
当时结了哪些菜。
每个菜结了多少数量。
当时的成交单价是多少。
单品折扣是多少。
整单折扣分摊是多少。
本次应付是多少。
后续 ORDER 阶段的投影必须排除这部分已确认数量,不能回头改它。
这条边界如果不守住,报表会出问题,小票会出问题,收款会出问题,审计也会出问题。
已确认结算快照可以被退款、冲销、作废等更高阶业务流程影响,但那应该是新的业务事实,不是普通改价和折扣继续覆盖旧快照。
7. PAYMENT 阶段:付款折扣不是重新改订单行
付款页折扣经常被误做成“给订单再打一次整单折”。
这会让边界变得很乱。
我更建议把它定义成 PAYMENT 阶段价格操作:
它只允许发生在结算单已确认、但还没有收款成功之前。
它只影响指定结算单。
它不回写商品成交单价。
它在付款区域体现为收银折扣或付款折扣。
这和 ORDER 阶段整单折扣不一样。
ORDER 阶段的整单折扣还会参与当前未结投影,可能影响后续预结。
PAYMENT 阶段的付款折扣面对的是一个已确认结算快照。它是在收款前对这一张待收款单做最后优惠。
因此 PAYMENT 折扣的允许条件要收紧:
结算单已确认。
关联收款单仍处于待收款。
没有成功收款。
没有收款占用或支付处理中状态。
本次折扣只处理指定结算单。
一旦已经部分支付或支付成功,就不应该继续做付款折扣。后续优惠应该进入退款、差额调整或财务处理流程,而不是继续改价格。
8. 版本号:并发保护不是前端自己 +1
餐饮账单天然会被多端同时操作。
典型场景:
A 设备打开桌台详情,看到版本 6。
B 设备加菜,账单变成版本 7。
A 设备仍按旧页面提交单品折扣。
如果后端不校验版本,A 的旧请求可能覆盖 B 的新事实。
所以价格操作需要版本号。
但版本号要按阶段拆:
| 版本 | 保护范围 |
|---|---|
| 主账单价格版本 | ORDER 阶段,加菜、退菜、赠菜、改价、单品折、整单折 |
| 结算单价格版本 | PAYMENT 阶段,付款页折扣 |
为什么要拆?
因为 ORDER 阶段和 PAYMENT 阶段保护的是不同对象。如果只用一个版本,付款页折扣可能被无关的桌台加菜阻塞;反过来,订单改价也可能被某个待收款结算单的小动作误伤。
前端只做一件事:
保存后端返回的版本。
提交时带上期望版本。
成功后用后端返回的新版本覆盖本地版本。
冲突后刷新账单,不要自己 +1 重试。
版本冲突不是系统异常,而是业务并发提示:
账单已经更新,请刷新后重新确认。
不要在冲突后静默重放旧请求。因为菜品、数量、折扣基数、可结数量、应付金额都可能已经变化。
9. 幂等:重复提交和并发冲突不是一回事
版本号解决的是“我看到的账单是不是最新”。
幂等键解决的是“同一个操作是不是重复提交”。
这两个不能互相替代。
比如收银员点了单品折扣,网络抖动,前端没拿到响应,于是重试同一个请求。这时应该用同一个幂等键返回同一个结果,而不是重复创建折扣事实。
但如果用户刷新页面后,基于新账单重新做了一次折扣,那就是新的业务操作,需要新的幂等键。
推荐规则:
同一次用户意图重试:同一个幂等键。
新的用户意图:新的幂等键。
同一个幂等键但请求摘要变化:拒绝,提示幂等冲突。
幂等命中时,最好返回上一次成功保存的响应快照。不要只返回“已处理”,否则前端无法恢复页面金额。
10. 策略模式:不要让价格操作变成大 if/else
手动改价、单品折扣、整单折扣、付款折扣,看起来都属于价格操作,但规则完全不同。
| 操作 | 阶段 | 规则差异 |
|---|---|---|
| 手动改价 | ORDER | 改变行成交基准,可能清理同一行旧折扣 |
| 单品折扣 | ORDER | 作用于行,按比例或固定额重算 |
| 整单折扣 | ORDER | 作用于未结范围,需要分摊 |
| 付款折扣 | PAYMENT | 只作用于指定结算快照 |
如果全部塞进一个方法,后面会变成:
if 手动改价
else if 单品折扣
else if 整单折扣
else if 付款折扣
再嵌套状态、授权、幂等、版本、分摊、预结失效
这个方法一定会越来越难改。
更稳的是用策略处理:
价格操作入口负责编排:
校验阶段矩阵
-> 校验授权
-> 校验版本
-> 校验幂等
-> 找到对应操作策略
-> 生成价格事实
-> 刷新投影或结算快照
具体策略负责本操作的业务规则:
手动改价策略
单品折扣策略
整单折扣策略
付款折扣策略
策略模式在这里不是为了套设计模式,而是因为这些操作的变更频率和规则边界不同。
后续如果增加会员折扣、员工餐、优惠券、服务补偿、抹零、税前税后折扣,也可以继续走策略扩展,而不是继续堆条件分支。
11. 预结失效:旧账单不能继续付款
ORDER 阶段发生价格变化后,旧的预结单一般应该失效。
原因很简单:预结单展示的是某个时间点的账单金额。如果之后又加菜、退菜、改价或折扣,旧预结金额已经不可信。
推荐规则:
ORDER 阶段价格操作成功
-> 当前未结投影刷新
-> 已存在的预结单失效
-> 前端丢弃旧预结结果
-> 重新预结或直接重新结账
但已确认结算不能这么处理。
预结是可重建的展示结果。
确认结算是历史成交快照。
这两者边界不能混。
12. 历史兼容:旧数据没有价格事实怎么办
新架构上线前,老订单可能没有价格操作事实。
如果投影服务强制要求所有金额都从价格事实推导,历史数据就可能被重算回标准价,造成严重事故。
所以需要历史兼容基准:
新订单:手动改价、折扣等都生成价格事实。
老订单:如果没有价格事实,使用订单行当前成交价作为兼容基准。
这不是理想模型,但它是上线现实。
系统演进时经常会遇到这种情况:新模型更干净,但旧数据不完整。正确做法不是假装历史也符合新模型,而是给旧数据一个明确、收敛、可解释的兼容入口。
13. 查询展示统一走投影
价格操作落地后,最容易遗漏的是查询侧。
如果写操作已经进入投影模型,但桌台大厅、订单详情、预结、结算、小票、历史查询还在各自按“单价 * 数量”算金额,页面一定会不一致。
推荐口径:
POS 桌台金额:读当前价格投影。
订单明细展示:读当前价格投影。
预结:读 ORDER 阶段投影。
确认结账:把投影保存成结算快照。
付款页:读结算快照,再叠加 PAYMENT 阶段折扣。
小票:读结算快照和折扣汇总。
价格历史:读价格事实和分摊结果。
这也是为什么“投影服务”应该成为明确模块,而不是散落在各个查询里临时算。
14. 小票展示也要尊重模型
小票不是简单把数据库字段打印出来。
不同价格操作在小票上的表达也不同:
| 操作 | 小票建议 |
|---|---|
| 手动改价 | 默认展示改后价,原价是否展示可配置 |
| 单品折扣 | 商品行展示折扣或划线信息 |
| 整单折扣 | 底部展示整单折扣汇总,不强制拆成每行划线价 |
| 付款折扣 | 付款区域展示收银折扣,不回写商品成交单价 |
这样展示更符合用户认知:
商品本身多少钱。
单品优惠了多少。
整单优惠了多少。
付款前又优惠了多少。
最终应付多少。
如果付款折扣回写商品行,后续顾客和收银员都会疑惑:为什么这个菜的成交单价又变了?
15. 一套可落地的执行链路
综合上面的边界,一次价格操作可以按这个顺序执行:
读取当前业务对象
-> 校验阶段与操作类型矩阵
-> 校验状态是否允许操作
-> 校验授权
-> 校验期望版本
-> 校验幂等键
-> 生成价格操作事实
-> 根据阶段刷新 ORDER 投影或 PAYMENT 快照
-> 推进对应版本号
-> 保存响应快照用于幂等回放
-> 返回最新金额与最新版本
ORDER 阶段:
只处理未结数量。
刷新当前价格投影。
必要时失效旧预结。
不改已确认结算快照。
PAYMENT 阶段:
只处理指定已确认但未收款结算单。
不接受行列表扩散。
不回写商品成交单价。
不影响其他未结账单。

16. 常见反模式
16.1 把折扣余额回写到订单行
这会让订单行同时承担“原始业务事实”和“投影计算结果”两种职责。后面一旦部分结账、退菜、加菜,字段语义会越来越乱。
16.2 已确认结算继续被普通改价重算
已确认结算是历史成交快照。普通价格操作不应该回头修改它。要处理已确认后的变化,应走退款、冲销、作废、差额调整等更高阶业务动作。
16.3 比例折扣提前固化成金额事实
比例折扣应该跟随当前未结基数重算。提前固化后,退菜、赠菜、加菜都会导致折扣金额漂移。
16.4 PAYMENT 折扣改回订单行
付款页折扣是收款前针对结算单的优惠,不是重新改变商品成交单价。回写订单行会污染历史价格和小票解释。
16.5 版本冲突后自动重放旧请求
版本冲突说明账单已经变了。应该刷新后让用户重新确认,而不是偷偷拿旧请求重试。
17. 我会怎么分期
如果让我做分期,我会这样切:
第一期只做主链路:
价格操作事实
ORDER 阶段投影
手动改价
单品折扣
整单折扣
预结失效
确认结算快照
版本号
幂等回放
第二期补 PAYMENT:
付款页折扣
结算单价格版本
未收款状态校验
付款折扣小票展示
第三期再扩展:
Staff PIN 高风险授权
原因码
优惠券 / 会员折扣
税前税后折扣
复杂抹零
退款与财务差异处理
价格操作报表
如果团队资源允许,也可以一期做完整。但我不建议在没有投影模型和快照边界的情况下,先把所有折扣入口铺开。
入口越多,后面修金额口径越痛。
18. 小结
餐饮改价和折扣不是简单金额字段。
它背后真正要解决的是:
价格操作如何成为可审计事实。
当前账单金额如何可重算。
已确认结算如何不被重写。
固定金额折扣如何在部分结账后继续解释。
ORDER 阶段和 PAYMENT 阶段如何互不污染。
多设备并发和网络重试如何不破坏金额事实。
我认为比较稳的架构是:
事实不可变
+ 投影可重算
+ 结算快照不可变
+ 阶段矩阵
+ 版本号
+ 幂等键
+ 策略处理器
这套东西看起来比“直接改金额”复杂,但它解决的是餐饮 POS 最难缠的一类问题:现场操作可以很快,后台账要能解释,历史成交不能漂移。
本文为作者原创,首发于掘金,阿里巴巴开发者社区 为同步发布版本。