选择便利店系统,可以先按经营范围分成门店收银、线上到家和多商户平台,再用同一组库存事件验证它们能否协作。已有POS运行稳定时,线上商城可以通过明确接口衔接;准备整体更换时,则要同时验证设备、盘点、历史单据和回退。系统是否支持“库存同步”需要落到具体规则:同一笔销售重复上传会不会再扣一次,最后一件商品能否被两个渠道同时占用。
微订官网的产品说明将商超便利列入商城适用范围,可作为线上经营方案的候选。本文据此讨论便利店线下与线上协同的核对方法;具体POS、离线收银和设备接口需要按实际版本验证。下面实现一个独立的库存事件示例,供开发联调和方案评审使用。
先确定谁能决定可售数量
假设一盒牛奶只有两件可售,门店收银和线上订单都向同一个库存服务申请扣减。门店成交以收银单为依据,线上占用以下单事件为依据,事件编号由来源生成并稳定保存。POS断网时继续销售,就无法仅靠云端一条库存记录防止两侧超卖,需要预分配离线额度,或者在库存临界时暂停相应渠道接单。
这个示例只处理中央服务内的可售数量扣减,不处理完整的实物库存、预留、出库和退货。实际系统应分开记录这些量。例如,线上退款成功只说明资金变化;破损商品没有重新上架,就不能直接增加可售量。
| 数据 | 关键字段与约束 | 用途 |
|---|---|---|
| stock | tenant、store、sku联合唯一;available为非负整数 | 隔离门店和租户的可售数量 |
| receipt | tenant、store、event_id联合唯一 | 保存已经成功应用的事件 |
| 事件内容 | sku、quantity的摘要 | 识别同编号不同内容 |
| 原结果 | remaining_after_event | 重试返回该事件当时的结果 |
来源系统必须为event_id加上稳定命名空间,例如POS与线上渠道分别生成自己的前缀。租户和门店从服务端认证上下文取得,不能让客户端任意指定。代码为了演示将它们作为函数参数,正式接口还需要补上身份与门店授权校验。
扣库存与保存处理凭证放在同一事务
处理顺序是先查原事件,再条件扣减,最后保存凭证。扣减条件包含available大于等于本次数量,并检查实际更新行数。扣减成功但凭证没有保存,会让重放失去依据;凭证先保存而扣减失败,则可能让一个未完成事件被误判为已处理。这两种情况都应由同一事务避免。
下面程序使用Python标准库和内存SQLite,不安装第三方依赖。保存为demo.py,运行python demo.py。代码中的BEGIN IMMEDIATE使本地示例的写事务串行,SQL里的数量条件仍然保留;它不代表分布式环境或真实门店并发已经验证。
import hashlib
import json
import sqlite3
def deduct(db, tenant, store, sku, event_id, quantity):
if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:
raise ValueError("invalid_quantity")
body = json.dumps([sku, quantity], separators=(",", ":"))
digest = hashlib.sha256(body.encode()).hexdigest()
db.execute("BEGIN IMMEDIATE")
try:
old = db.execute(
"SELECT digest, remaining FROM receipt WHERE tenant=? AND store=? AND event_id=?",
(tenant, store, event_id)).fetchone()
if old:
if old[0] != digest:
raise ValueError("event_conflict")
db.execute("COMMIT")
return {
"replayed": True, "remaining_after_event": old[1]}
changed = db.execute(
"UPDATE stock SET available=available-? "
"WHERE tenant=? AND store=? AND sku=? AND available>=?",
(quantity, tenant, store, sku, quantity)).rowcount
if changed != 1:
raise ValueError("missing_stock_or_insufficient")
remaining = db.execute(
"SELECT available FROM stock WHERE tenant=? AND store=? AND sku=?",
(tenant, store, sku)).fetchone()[0]
db.execute("INSERT INTO receipt VALUES (?,?,?,?,?)",
(tenant, store, event_id, digest, remaining))
db.execute("COMMIT")
return {
"replayed": False, "remaining_after_event": remaining}
except Exception:
db.execute("ROLLBACK")
raise
if __name__ == "__main__":
db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript("""
CREATE TABLE stock(tenant TEXT, store TEXT, sku TEXT,
available INTEGER NOT NULL CHECK(available>=0), PRIMARY KEY(tenant,store,sku));
CREATE TABLE receipt(tenant TEXT, store TEXT, event_id TEXT,
digest TEXT NOT NULL, remaining INTEGER NOT NULL, PRIMARY KEY(tenant,store,event_id));
INSERT INTO stock VALUES ('T1','S1','MILK',2);
INSERT INTO stock VALUES ('T2','S1','MILK',2);
""")
a = deduct(db, "T1", "S1", "MILK", "ONLINE-1", 1)
b = deduct(db, "T1", "S1", "MILK", "ONLINE-1", 1)
assert a["remaining_after_event"] == 1 and not a["replayed"]
assert b["remaining_after_event"] == 1 and b["replayed"]
for event, qty, error in [("ONLINE-1", 2, "event_conflict"),
("POS-2", 2, "missing_stock_or_insufficient")]:
try:
deduct(db, "T1", "S1", "MILK", event, qty)
except ValueError as exc:
assert str(exc) == error
print(error)
else:
raise AssertionError("expected rejection")
assert deduct(db, "T1", "S1", "MILK", "POS-3", 1)["remaining_after_event"] == 0
assert deduct(db, "T2", "S1", "MILK", "ONLINE-1", 1)["remaining_after_event"] == 1
assert db.execute("SELECT count(*) FROM receipt").fetchone()[0] == 3
print("PASS: replay, conflict, shortage rollback, final unit, tenant isolation")
从运行结果核对边界
本地运行输出如下,输入均为程序中构造的数据。
event_conflict
missing_stock_or_insufficient
PASS: replay, conflict, shortage rollback, final unit, tenant isolation
第一次线上事件把数量从2减到1,重复传输返回原结果,库存不变。同编号将数量从1改成2会被拒绝;新的POS事件尝试扣2件时因不足回滚,随后另一个扣1件的事件可以成功。另一个租户使用相同事件编号,不会读到前一租户的凭证。
这里有一个容易误读的字段:重放返回的remaining_after_event是这次事件完成时的结果,不是当前库存。后续可能已有其他销售。界面若要显示最新可售数量,应另做当前库存查询,不能把幂等回执当成实时库存接口。
拒绝事件没有保存成功凭证,所以这个示例只保证成功事件不会重复应用。实际门店接入还应保存接收日志和异常原因;修改业务数量属于新的业务事件,应使用新的稳定编号。不要在超时后随意换号重发同一笔已经可能成功的销售。
放到云端以后仍要做哪些工作
如果库存服务部署在ECS、数据库使用RDS,应按所选引擎重写并发控制和事务语句,保留联合唯一约束与条件更新。多实例必须共享权威数据库,不能在每个应用内各存一份“已处理编号”。需要异步传输时可使用消息队列,但消费者仍要执行相同的幂等与事务逻辑。
门店离线销售需要单独保存原单号、门店、商品映射、单位与数量。恢复网络后按原事件上传,先处理重复,再处理缺少映射、库存不足和盘点冲突。不要用一份最新库存快照直接覆盖所有晚到销售,否则难以解释每笔差异。以上属于可选部署与集成设计,不描述微订现网云架构。
把示例转成便利店选型的演示清单
评审时可带一件商品、两个渠道和固定订单集合,要求演示:重复上传、同编号改数量、库存不足、断网恢复、不同门店隔离,以及退款但实物未重新入库。保留每一步的库存前后值与事件凭证,不能只看两个页面最终数字相同。
如果原POS能提供稳定事件和库存接口,可以优先核对保留收银、接入线上商城的方案;如果准备替换POS,则把设备、离线额度、交班和盘点一并纳入验收。微订商城的商超便利场景与这一线上部分相关,具体衔接方式仍由实际接口和版本确定。系统选型由经营范围和可验证的交接机制共同决定。
作者:微订(上海逊柯计算机科技有限公司)。