餐饮改价折扣不能只改金额:事实不可变、投影重算与结算快照设计

简介: 餐饮 POS 的改价、单品折扣、整单折扣和付款页折扣,不能只靠回写金额字段解决。本文从部分结账、加菜退菜、固定金额折扣余额、已确认结算快照和多设备并发切入,拆解一套“价格事实不可变、当前投影可重算、已确认结算快照不可变”的价格操作架构,并进一步说明 ORDER/PAYMENT 阶段矩阵、版本号、幂等键和策略模式在落地中的边界。

餐饮改价折扣不能只改金额:事实不可变、投影重算与结算快照设计

0. 先说结论

餐饮 POS 里的改价、单品折扣、整单折扣和付款页折扣,看起来只是“把金额改一下”。

但真正做进生产系统后,会遇到一组很麻烦的问题:

未结账前可以改价。
部分结账后还能继续加菜、退菜、赠菜。
整单固定金额折扣要按剩余未结数量继续分摊。
已确认结算单不能被后续重算改掉。
付款页还可能再给一次折扣。
多台 POS 同时操作同一桌台时,旧页面不能覆盖新账单。

如果系统只是回写订单行金额,短期能跑,长期会出现金额漂移、历史成交价被改写、部分结账后折扣说不清、收款页和订单页互相覆盖的问题。

我后来更认可的模型是:

价格操作是事实,不直接覆盖历史。
当前账单金额是投影,可以基于事实重算。
已确认结算是快照,一旦确认就不能被普通价格操作重写。
ORDER 阶段和 PAYMENT 阶段分开建模。
版本号负责并发保护,幂等键负责重复提交保护。

这篇文章拆的是一套餐饮价格操作架构:为什么不能只改金额,怎么处理手动改价、单品折扣、整单折扣、付款折扣,为什么固定金额折扣应该被看成预算,以及为什么“投影可重算”和“结算快照不可变”必须同时存在。

1. 直接改金额为什么会坏

最直觉的实现是这样的:

用户点击改价 / 折扣
-> 后端更新订单行单价或折扣金额
-> 重新计算订单总额
-> 页面刷新

如果一桌只有一次完整结账,这么做问题不大。

但餐饮场景通常不是一次性闭合的。

一个真实一点的过程可能是:

先点 3 份菜
-> 给其中一份单品折扣
-> 做一次预结
-> 客人又加菜
-> 客诉后给整单折扣
-> 部分确认结账
-> 剩余部分继续退菜或赠菜
-> 最后付款页再给一个收银折扣

这时如果系统只有“订单行当前金额”,就会出现几个问题。

第一,历史成交被改写。

已经确认结账的明细,本质上已经形成成交快照。后续加菜、退菜、重新打整单折时,如果继续回写原订单行金额,就可能把已确认部分的成交价也改掉。

第二,固定金额折扣说不清。

比如整单固定减 30。先确认结掉一部分后,这 30 元到底算已经消费了一部分,还是下一次预结又恢复成满额 30?如果没有“折扣预算”和“已确认分摊”的概念,就只能靠临时字段猜。

第三,比例折扣和金额折扣混在一起。

比例折扣本质是规则,应该随当前未结基数重算;固定金额折扣本质是预算,应该按已确认消费后的余额继续分摊。两者如果都提前固化成金额字段,后面退菜、赠菜、加菜时都会变形。

第四,并发覆盖。

A 设备打开账单准备打折,B 设备已经加菜或改价。A 继续按旧页面提交,如果没有版本保护,就可能用旧视图覆盖新账单事实。

所以价格操作不能只是“改几个金额字段”。它必须被设计成一条能解释历史、能重算当前、能保护并发的业务链路。

2. 三类东西必须分清

餐饮价格系统里至少要分清三类对象:

对象 作用 是否可变
价格操作事实 记录用户做过什么价格动作 已发生事实不改写,只能取消、取代或追加
当前价格投影 根据有效事实和当前未结数量算出的页面金额 可以重算
结算快照 已确认结账时保存的成交结果 确认后不可被普通价格操作重写

换句话说:

事实用于解释。
投影用于展示和下一步计算。
快照用于锁定历史成交。

这里的“事实不可变”不是说永远不能撤销,而是不能通过覆盖原记录来伪装历史。

用户后面又改了一次价,应该新增一次价格操作事实,旧事实可以变成已取代或已取消,但不要把旧事实直接改成新事实。

这样审计才能回答:

第一次是谁改价?
第二次是谁打折?
哪个动作取代了哪个动作?
最终结账时采用的是哪一版价格投影?

pricing-fact-projection-snapshot.png

3. ORDER 阶段和 PAYMENT 阶段要分开

餐饮价格操作最关键的边界,是阶段。

我会把它分成两类:

阶段 发生位置 典型动作 影响范围
ORDER 阶段 点菜、加菜、预结前 手动改价、单品折扣、整单折扣 当前主账单的未结数量
PAYMENT 阶段 已确认结账、收款前 付款页折扣 指定的待收款结算单

这两个阶段不能混。

ORDER 阶段面对的是 running tab,也就是还在变化的桌台账单。它可能继续加菜、退菜、赠菜、部分结账。

PAYMENT 阶段面对的是已经确认出来的一张结算单。它不是重新改订单行,而是在收款前对这张结算快照做一次收银折扣。

所以规则应该非常硬:

手动改价:只允许 ORDER 阶段。
单品折扣:只允许 ORDER 阶段。
整单折扣:只允许 ORDER 阶段。
付款折扣:只允许 PAYMENT 阶段。

任意跨阶段混传都应该拒绝,而不是后端“帮忙理解”。

因为一旦允许含糊,前端或调用方就可能绕过状态边界:

已确认结账后继续改订单行。
付款折扣偷偷扩散到整桌未结范围。
整单折扣误伤已确认结算快照。

阶段矩阵不是为了代码好看,而是为了把业务边界锁死。

pricing-stage-matrix.png

4. ORDER 阶段:当前金额来自投影

ORDER 阶段的金额最好不要直接理解成“订单行金额字段”。

它应该是一个投影结果:

标准价 / 历史兼容价
-> 手动改价
-> 单品折扣
-> 整单折扣分摊
-> 当前未结应付金额

这个顺序很重要。

手动改价改变的是该行参与后续折扣计算的成交基准。

单品折扣作用在行上。

整单折扣作用在当前未结范围上,再分摊回行。

例如:

标准单价:66
手动改价:60
单品 9 折
最终行应付:54

如果顺序错了,结果就会完全不同。

再比如整单折扣。它可以有两种范围:

全桌当前未结范围。
指定明细的未结范围。

这两种必须分清。

如果用户选择了指定明细整单折,后续加菜不应该自动参与这次折扣;如果用户选择的是全桌整单折,后续加菜是否参与,就要按产品策略明确。

公开文章里不需要暴露字段,但模型上要有这个概念:

折扣事实必须知道自己的作用范围。
投影时必须按作用范围计算。

5. 固定金额折扣应该被看成预算

固定金额整单折扣最容易做错。

假设一桌菜当前未结金额 100,给了整单固定减 30。

如果马上全额结账,很简单:

应付 = 100 - 30 = 70

但如果只结掉其中一部分呢?

比如当前确认了 40 元基数,对应分摊了 12 元折扣。剩余未结基数还有 60。

下一次预结时,整单固定减 30 应该怎么处理?

更稳的口径是:

固定金额折扣 = 原始预算。
已确认结算快照消费掉一部分预算。
剩余预算继续作用于剩余未结数量。

也就是:

原始折扣预算:30
已确认消费:12
剩余预算:18
剩余未结基数:60
下一次最多再分摊:18

这样才能同时满足两个目标:

  1. 已确认部分不被后续重算改写。
  2. 剩余未结部分还能继续使用剩余额度。

如果产品希望“后续再给满额 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 阶段:

只处理指定已确认但未收款结算单。
不接受行列表扩散。
不回写商品成交单价。
不影响其他未结账单。

pricing-operation-flow.png

16. 常见反模式

16.1 把折扣余额回写到订单行

这会让订单行同时承担“原始业务事实”和“投影计算结果”两种职责。后面一旦部分结账、退菜、加菜,字段语义会越来越乱。

16.2 已确认结算继续被普通改价重算

已确认结算是历史成交快照。普通价格操作不应该回头修改它。要处理已确认后的变化,应走退款、冲销、作废、差额调整等更高阶业务动作。

16.3 比例折扣提前固化成金额事实

比例折扣应该跟随当前未结基数重算。提前固化后,退菜、赠菜、加菜都会导致折扣金额漂移。

16.4 PAYMENT 折扣改回订单行

付款页折扣是收款前针对结算单的优惠,不是重新改变商品成交单价。回写订单行会污染历史价格和小票解释。

16.5 版本冲突后自动重放旧请求

版本冲突说明账单已经变了。应该刷新后让用户重新确认,而不是偷偷拿旧请求重试。

17. 我会怎么分期

如果让我做分期,我会这样切:

第一期只做主链路:

价格操作事实
ORDER 阶段投影
手动改价
单品折扣
整单折扣
预结失效
确认结算快照
版本号
幂等回放

第二期补 PAYMENT:

付款页折扣
结算单价格版本
未收款状态校验
付款折扣小票展示

第三期再扩展:

Staff PIN 高风险授权
原因码
优惠券 / 会员折扣
税前税后折扣
复杂抹零
退款与财务差异处理
价格操作报表

如果团队资源允许,也可以一期做完整。但我不建议在没有投影模型和快照边界的情况下,先把所有折扣入口铺开。

入口越多,后面修金额口径越痛。

18. 小结

餐饮改价和折扣不是简单金额字段。

它背后真正要解决的是:

价格操作如何成为可审计事实。
当前账单金额如何可重算。
已确认结算如何不被重写。
固定金额折扣如何在部分结账后继续解释。
ORDER 阶段和 PAYMENT 阶段如何互不污染。
多设备并发和网络重试如何不破坏金额事实。

我认为比较稳的架构是:

事实不可变
+ 投影可重算
+ 结算快照不可变
+ 阶段矩阵
+ 版本号
+ 幂等键
+ 策略处理器

这套东西看起来比“直接改金额”复杂,但它解决的是餐饮 POS 最难缠的一类问题:现场操作可以很快,后台账要能解释,历史成交不能漂移。

本文为作者原创,首发于掘金,阿里巴巴开发者社区 为同步发布版本。

相关文章
|
9天前
|
缓存 JSON 前端开发
别把 ThreadLocal 写进 Serializer:一次 SaaS 多租户 Jackson 上下文污染排查
一次 SaaS 多租户场景下 Jackson 自定义序列化器的上下文污染排查。文章从页面金额与订单金额不一致切入,沿数据库、Service、响应 Model、JSON 序列化链路定位到 `ContextualSerializer` 生命周期错配:请求级租户金额精度被复制进 Jackson 长期缓存的属性 serializer,导致后续租户复用错误精度。最终给出“缓存结构,不缓存租户”的修复思路,以及 A -> B、B -> A、无上下文和并发场景的回归测试矩阵。
|
2天前
|
前端开发 安全 物联网
多人共用 POS 怎么追责:Staff PIN 工作会话与高风险动作授权建模
餐饮 POS 里的 Staff PIN,不应该只是“退菜时输主管密码”。真正要解决的是多人共用设备下的现场责任人识别、高风险动作授权、原因码和业务事实审计。本文从设备账号为什么不够讲起,拆解 Staff PIN 工作会话、工作台权限视图、主管授权证明、授权摘要、幂等消费和最终业务事实的边界,给出一套适合 SaaS 餐饮 POS 逐步落地的产品架构方案。
|
3天前
|
缓存 前端开发 JavaScript
别再给主数据只做删除:从停用、归档到 ReasonCode 策略引擎
主数据不是简单配置,商品、单位、客户、账户一旦被库存、订单、报表、财务或审计引用,就成为历史事实的一部分。本文从“主数据不能随便删”切入,拆解 remove、inactive/disable、archive、block 的业务语义,说明删除、停用、归档、阻断分别解决什么问题;再进一步讲 PreCheck、Policy、Executor、ReasonCode 的策略引擎设计:统一预检返回结构化决策,领域策略负责各自引用规则,执行仍回到领域接口并做最终校验,前端基于 reasonCode 和 suggestAction 做国际化与引导。文章也补充了新业务选择器过滤、历史回显保留、行级锁定、批量预检
|
5天前
|
SQL NoSQL BI
库存不是一个数字:从 On Hand 到 Available、Reserved 的销售订单库存预留设计
库存不是一个简单数字。本文从销售订单长周期履约场景切入,拆解 On Hand、Reserved、Available 三个库存口径,说明“库存预留 != 库存扣减”:预留保护订单承诺,只影响可售库存;扣减表达真实出库,才写库存流水和成本口径。文章进一步讨论待支付订单创建/编辑/取消/支付的预留生命周期、并发超占防护、订单编辑全量释放重建、本单预留在支付前校验中如何加回、历史待支付单回填与开关门禁,以及库存报表为什么必须展示现存、已承诺和可售三个口径。
|
6天前
|
缓存 前端开发 数据库
热敏打印不是 exactly-once:餐饮 POS 打印任务的 Claim、兜底与结果未知治理
餐饮 POS 打印不是简单的“接口成功就出纸”,局域网热敏打印也不能承诺物理 exactly-once。本文从下单/结算打印任务切入,拆解 PENDING -> PRINTING -> SUCCESS/UNCERTAIN 状态机、printBatchNo/jobNo/claimToken 的职责边界、业务 POS 即时执行、主 POS 后台兜底、BEFORE_SEND 与 AFTER_SEND 的失败分级、租约超时进入 UNCERTAIN、不自动重试以及人工确认/补打/取消的治理策略,说明如何把“可能重复出纸、可能漏上报、结果未知”的现场问题纳入可追踪的工程模型。
|
7天前
|
人工智能 自然语言处理 Java
AI 时代 SaaS ERP 自动化测试路线:先补接口级黄金流程
AI Coding 让代码产出速度变快,也放大了复杂 SaaS ERP 的回归风险。本文从“不要先选工具”切入,梳理自动化测试分层,说明为什么第一阶段最该补接口级黄金流程,而不是测试平台、UI 自动化或报告美化。正文重点拆解 TestNG / RestAssured 接口测试、P0/P1/P2 用例分级、专用测试租户与 runId 数据策略、登录验签、CI 接入、AI 在测试体系中的边界,以及如何把业务不变量和历史 bug 沉淀成可重复执行的回归安全网。
|
8天前
|
存储 安全 BI
业务操作日志不是字段审计:快照 Diff、幂等账本与业务语义建模
一次业务操作日志体系设计与落地复盘。文章从“字段审计为什么不够”切入,说明业务操作日志应该是 ERP 业务动作账本,而不是数据库字段流水。正文重点拆解业务语义建模、Service 显式快照、受控 Snapshot Diff、afterCommit 投递、异步 MQ、幂等账本、查询时渲染、租户隔离和敏感字段治理,给出一套适合 SaaS / ERP / 业财系统的操作日志设计方法。
|
8天前
|
JavaScript 前端开发 安全
从库存黑盒到生产闭环:轻制造 SaaS 的 MRP Lite 架构实践
本文基于新兴市场中小工厂的轻制造场景,拆解一套 MRP Lite 生产闭环的设计方法:如何用 BOM、工单、领退料、报工质检、入库、预占和在制台账,把黑盒生产转化为可追溯的库存与责任闭环。文章重点讨论业务建模、库存一致性、幂等事务和可扩展架构,而不是简单复刻 MES 功能清单。
|
开发者
阿里云开发者社区Markdown语法
阿里云开发者社区Markdown语法
18502 4
|
19天前
|
人工智能 缓存 前端开发
DeepSeek Harness 首发实测 + 入门教程,夯爆了!梁神我错了
DeepSeek Harness + DeepSeek V4 Pro 项目实战保姆级教程!手把手带你从零安装开源 AI 编程工具,开发架构图、知识讲解网站、3D 网页游戏、全栈 AI 应用 4 个项目,覆盖运行模式选择、插件安装与开发,看看能不能对标 Claude。
13114 84
DeepSeek Harness 首发实测 + 入门教程,夯爆了!梁神我错了