最近刚配合一家连锁客户上线完一套《多公司多门店·仓储式超市·POS收银系统》,趁着项目细节还热乎,把整套方案从头到尾复盘一遍。先给结论:这类项目真正难的,从来不是收银机上那几个按钮,而是藏在收银背后的组织架构、库存模型和资金归集。很多团队把精力全砸在前端交互上,结果第二家门店一接入,就发现库存对不上、报表算不对、账目混成一锅粥。我这边客户的实际情况很典型:老板手里两个公司主体,一个做批发,一个做零售,下面挂着多家仓储式超市门店,共用一个大仓库,但每家门店又要独立核算。这套系统要解决的核心问题,说白了就三件事:多公司怎么各自独立核算、多门店怎么共享库存和协同补货、POS收银台在复杂促销和断网环境下怎么稳定收银。这篇文章适合被多公司、多门店复杂度折磨的产品经理和开发,也适合准备做零售数字化的业主,以及想系统搞懂POS系统背后逻辑的同行。我会按“需求拆解—数据建模—收银设计—库存协同—分账结算—问题排查”这条路线走,每一步的取舍都会讲清楚为什么这么做,直接可落地。1. 项目定位:先想清楚这是一套“零售业务中台”,不是一台收银机1.1 拆解“多公司多门店·仓储式超市”背后的真实需求标题里每个词都不是装饰,背后都有明确的业务压力。先说“多公司”。仓储式超市的老板们很少只有一个营业执照。常见的是:一个批发公司,负责大宗采购和批发业务;一个零售公司,运营所有超市门店;一个物业或管理公司,负责收租金、管理费;甚至按区域再拆几个公司来享受政策和隔离风险。这些公司之间是真实的法人关系,财务必须独立核算,互不掺和。但在业务上,它们又共享商品、共享供应商、共享仓库。系统如果从数据库层面就用一棵简单的“公司-门店”树去建模,后面跨公司调拨、成本流转会直接卡死。再说“多门店”。门店是独立的经营单元,每个门店有销售目标、毛利考核、独立库存和独立资金流水。但门店和门店之间不是孤岛,会互相调拨商品,可能共用同一个中央大仓。仓储式超市这个定语还带来几个独特要求:SKU数量大,通常一两万起步;大包装整箱卖,也拆零卖;生鲜和散称商品占比高,要接电子秤;批发客户多,会出现整单挂账、月结这种支付方式;客单价高,购物车经常几十件商品,收银速度要求极高。如果就按“单店版收银+进销存”来做,一上线就会出现一个死循环:A门店缺货时不知道B门店有货;批发公司给零售公司供货没法做内部结算;总部想看全盘库存,只能靠Excel。所以这套系统的正确姿势是:以“多公司、多门店”为核心约束,把组织、商品、库存、交易、结算全部抽象成平台级能力,POS只是最末端的一个交互终端。1.2 为什么架构上选择“模块化单体”,而不是微服务我在这个项目里最强调的一点:别一上来就微服务。以常规仓储式超市的体量,一台好一点的服务器,支撑几个公司、十来个门店、日均几千单是完全没问题的。微服务的思路在这里会引入分布式事务、服务间调用链、重复数据同步等问题,每一个都会成为稳定性黑洞。但这个项目又确实需要模块边界,否则后面维护会越来越痛苦。我的落地方式是“模块化单体”:组织与主数据模块:公司、门店、仓库、人员、权限;商品中心:SPU、SKU、条码、单位换算、分类、品牌;价格与促销模块:多套价格、促销规则、优惠分摊;库存中心:实时库存、流水、预占、补货、盘点;交易中心:订单、支付、退款、小票、挂账;结算中心:日结、对账、内部结算单、导出凭证。模块之间通过内部接口调用,数据库可以共享一个实例,但是每张表归属模块必须清晰。后期如果真的门店量爆发式增长,直接按库存中心、交易中心拆服务出来即可,不需要推倒重来。部署形态上,我建议门店端用“B/S收银台+本地缓存”模式,中心端部署一个核心服务加一个后台管理站点。门店端浏览器打开收银页面,日常在线操作,离线时自动切换本地模式。比起纯C/S桌面客户端,省去了每台收银机装驱动和升级的运维成本。1.3 多租户数据隔离:怎么处理“多公司”的数据边界多公司最容易出事的一点,就是A公司的报表里混进B公司的数据。所以数据隔离在架构第一天就要定清楚。我当时对比过三种方式:方案优点缺点适用场景每公司独立数据库隔离最彻底,财务审计省心维护成本高,跨公司报表要聚合公司间完全独立,几乎无业务往来共享库、独立Schema隔离较好,可跨库join迁移成本稍高公司间有协同,但需要严格边界共享库、共享表,带公司字段开发最方便,报表聚合容易容易漏条件查到别家公司数据公司间业务协同紧密最终我建议用第三种,共享库共享表,每张业务表都带company_id,并在所有写操作里强制注入公司上下文,不允许开发人员手写查询时不带company_id。这个约束不要靠团队自觉,要在ORM层面做一个拦截器,或者写统一的BaseRepository,所有查询自动拼上当前上下文的company_id条件。还有一点很关键:主键不能用简单的自增ID,跨公司、跨端合并数据时会冲突。我这边全部用雪花ID,保证多门店断网本地生成订单ID也不会重复。2. 数据模型:把公司、门店、仓库、商品、库存的关系理顺再写代码2.1 组织模型设计:公司、门店、仓库必须解耦很多单店版系统的数据结构是:公司下面挂门店,门店下面挂仓库。这种设计在做多公司多门店时,马上会遇到一个问题:一个仓库同时给多个公司用,怎么办?我项目里的建模是这样的,四个核心表:company公司表:公司ID、名称、type(批发公司/零售公司/管理公司)、税号、结算货币;store门店表:门店ID、名称、所属公司ID、地址、店长、营业状态;warehouse仓库表:仓库ID、名称、类型(中央大仓/门店后仓/前置仓)、所属公司ID、地址;store_warehouse_relation门店与仓库关系表:门店ID、仓库ID、是否默认出货仓。门店和仓库是多对多关系,允许一个门店对应多个仓库(比如卖场库存和后仓库存分属两个物理区域),但一定要有一个默认出货仓,POS下单扣库存时优先扣默认仓。这样设计的好处是:批发公司的大仓可以同时给零售公司的门店供货,按“调拨”走内部结算,库存不会乱;未来加前置仓、加盟店,不需要改表结构。建表时我习惯把“公司”和“经营组织”分开,因为现实中管理公司并不直接经营门店,它只是控股。财务报表按公司走,经营报表按门店走,两者可以交叉,但不能耦合。2.2 商品主数据:一物多码、多单位换算不能省仓储式超市的商品主数据比普通便利店复杂得多。最典型的问题:同一个商品,在批发时按箱卖,在门店零售时按瓶卖。货架上的条码是单瓶的,但整箱外包装上印的又是另一个条码。所以商品模型不能只建一张“商品表”。我当时设计了这样一套结构:spu商品SPU表:代表一个抽象商品,比如“某品牌纯净水550ml”;sku规格SKU表:代表具体售卖规格,比如“某品牌纯净水550ml×12瓶整箱”和“某品牌纯净水550ml×1瓶”是两个SKU;barcode条码表:一个SKU可以有多个条码,一个条码也可以关联多个SKU(这种坑后面讲);unit_conversion单位换算表:SKU的销售单位、采购单位、库存单位以及换算率。核心换算逻辑示例:SKU条码销售单位单价库存单位换算率纯净水550ml×1瓶690000000001瓶2.00瓶1纯净水550ml×12瓶整箱690000000002箱21.60瓶12这样设计和“一物一码”思路最大的差别:POS收银时,扫码扫到整箱条码,系统能自动算出这箱在库存上占用的是12瓶。同时报表分析销量时,可以把整箱销售换算成瓶数来统计,不会出现“销量单位混乱”的问题。另外,仓储式超市一定有生鲜和散称商品,这类商品没有固定条码,或者一个条码对应一个类目但价格随重量变化。要预留PLU码(计量商品码)字段,POS端接电子秤时,称重机直接传重量和PLU码过来,系统按“单价×重量”生成行。2.3 库存模型:为什么不能只有“库存数量”一列很多进销存系统的库存表就一个字段:qty。销售扣一下,入库加一下,看起来简单,但一遇到并发、断网补传、手工调整,账就花了。我在这套方案里坚持用“库存余额 + 库存流水”双表结构。库存余额表inventory:CREATE TABLE inventory ( id BIGINT PRIMARY KEY, company_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, onhand_qty DECIMAL(18,3) NOT NULL DEFAULT 0, available_qty DECIMAL(18,3) NOT NULL DEFAULT 0, allocated_qty DECIMAL(18,3) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, updated_at DATETIME );库存流水表inventory_flow:CREATE TABLE inventory_flow ( id BIGINT PRIMARY KEY, company_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, change_qty DECIMAL(18,3) NOT NULL, before_qty DECIMAL(18,3) NOT NULL, after_qty DECIMAL(18,3) NOT NULL, biz_type VARCHAR(32) NOT NULL, biz_id VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_biz (biz_type, biz_id) );两个表的关键点:库存流水是append-only追加写入,只允许新增,不允许修改和删除。每一条流水都要带业务单据号和业务类型(销售、采购、调拨、盘点、报损、退货等)。库存余额表不是根据流水实时计算的,它只是一份“缓存”,用来快速查询和并发扣减。如果出现账实不符,通过重放流水可以找到差异在哪笔单据上。allocated_qty表示预占库存,也就是订单已创建但还没支付成功时占用的数量。三种数量的关系:物理库存=在途+在库,可用库存=物理库存-预占。这里说一个很多人忽略的原则:任何库存变动都要先写流水,再更新余额表。顺序反了,一旦余额表更新成功但流水写入失败,系统永远都查不出差异来源。我在项目中把这个写进统一库存服务,业务代码没有权限直接操作inventory表。3. 收银流程与促销:POS端最容易出问题的地方全在这里3.1 收银界面与操作流程:仓储式超市要的是“快”仓储式超市的收银场景和奶茶店、便利店完全不同。顾客推着一大车东西,高峰期排队,收银员必须尽可能在几十秒内完成一单。这个场景要求POS界面做到:默认光标在扫码框,扫码枪扫完一段条码直接回车,商品自动加入购物车,不需要再点按钮;支持连续扫码,同一商品扫两次自动叠加数量,不会弹两次窗口问;支持“数量×条码”模式,比如顾客拿了10箱饮料,收银员可以直接输入“10*690000000002”;生鲜/称重商品显示在单独区域,支持手动改重量和单价,并打印第二联标签;结算时一次展示所有行项目,支持部分取消、临时挂单、整单删除。会员识别也放首位。仓储式超市基本都是会员制,结账前先识别会员(手机号、会员卡、动态码),会员价和积分在整单上结算。顾客身份直接决定价格体系,所以会员识别必须在选品之前完成,而不是最后补。界面布局上,左侧是大列表展示已扫码商品,右侧是当前会员信息和常用操作按钮,底部是合计和收款区域。为了方便高峰期使用,所有操作都要支持快捷键,比如F2快速收款,F4拆单,F8挂单。我当时实测过,老练收银员用快捷键操作,单笔结账能控制在15秒内,比鼠标点选快一半以上。3.2 价格与促销引擎:行级优惠分摊是重点仓储式超市的价格体系特别复杂。同一件商品,面对不同顾客有完全不同的价格:普通零售价、会员价、批发价、促销价,促销价又分直降、满减、买赠、组合捆绑。如果价格逻辑全写在订单逻辑里,每次加一个促销,整个系统都要重新发版。我的做法是把价格和促销做成一个独立模块,订单服务只调一个统一接口:“给定顾客、商品、数量、时间,返回成交单价和优惠明细”。价格计算顺序固定为:先定基础价:根据顾客类型选择价格带;再判行级促销:第N件折扣、数量满减;再判单级促销:整单满减、整单折扣;最后分摊:把整单优惠按行金额比例分摊到每一行。为什么一定要做“分摊”?因为退货时只知道整单退了多少钱,不知道每一行该退多少,就出大问题。分摊后每一行都有一个discount_amount字段,退货金额直接等于行原价 - 行分摊优惠 - 行已退金额,清晰安全。促销规则的另一个坑是快照。订单生成时,必须把促销规则的关键信息(折扣率、满减值、赠品SKU)持久化到订单明细里。如果只存一个促销ID,引用当前规则表,那促销一结束、规则一改,历史订单的报表统计和退货退款全都会跟着乱。3.3 断网与容灾:收银台永远不能停仓储式超市POS最怕的不是卡顿,是断网后收银台直接瘫痪。高峰排队只要断3分钟,场面就会失控。所以断网容灾是硬需求,不是可选功能。我的方案是在门店端做一个轻量级本地缓存和离线队列:门店启动时,从中心同步商品主数据、会员基础信息、促销规则快照到本地浏览器存储或本地数据库;收银时正常在线操作,所有订单实时提交中心服务;如果提交失败或者网络不通,订单自动写入本地队列,状态置为“待上传”,小票正常打印;网络恢复后,收银台自动按顺序补传订单,每条订单带一个客户端生成UUID;服务端幂等表按UUID去重,重复提交只返回原订单号,不会重复扣库存。这里有个很现实的矛盾:断网期间,门店库存是准的,但中心库存可能已经被其他门店卖完了。于是会出现在线时明明没货,断网你照卖的情况。我的处理方式是允许断网订单产生“负库存”标记,补传成功后库存按实际扣减,当天日结时把这个门店的负库存项单列出来,提醒后台做调拨或修改可售状态。硬要禁止负库存,反而会让收银台在断网状态下彻底卡死,业务损失更大。断网期间涉及支付也一样:微信支付宝这类在线支付没法授权,可以引导顾客走现金或“挂账”,挂账单在补传时同步生成应收记录。4. 库存协同:大仓、门店、调拨与盘点要闭环4.1 中央大仓与门店库存:卖场和后仓要分清对于仓储式超市,库存不是只有门店卖场一个概念,它至少分三个物理层:中央大仓:总部统一进货、统一储备的区域;门店卖场:顾客能看到能直接买的货架库存;门店后仓:门店自己的备用库存,卖场货架空了就补。POS收银直接扣减“门店卖场”库存。门店后仓到卖场的补货动作,在系统里做成“库内移库”,同一门店不同仓库ID之间做内部调拨。如果不分开,盘点时永远分不清货架上的和仓库里的差异出在哪。补货逻辑我做成这个链路:门店销量分析生成补货建议单(参考最低库存、日均销量、在途数量)—店长确认—总部审核—大仓出库—门店收货入库。整个过程单据完整,每一段都能追踪到人。这里面有一个常见误区:中央大仓给门店补货,要不要立刻增加门店库存?答案是:不能。大仓出库后,这批货是“调拨在途”状态,既不在大仓库存里,也还没进门店库存。必须等门店确认收货,库存才真正加到门店。中间任何一步不确认,账都会差一层。4.2 调拨流程与在途库存:跨公司调拨的本质是买卖门店之间互相借货,看起来只是移库存,但涉及财务时必须分两种情况:同一个公司内部两个门店调拨:按成本价调拨,不计收入,只影响两个门店的库存和成本归属;不同公司之间的调拨:本质是内部销售,调出公司确认收入,调入公司确认成本,生成内部结算单。我设计的调拨单状态机是:草稿 — 调出方审核 — 出库确认 — 调入方收货确认 — 完成。只要不是“完成”状态,库存都挂在in_transit_qty字段上。这样总部看库存报表时,能清楚看到每一批货卡在哪个环节,不会只说一句“货不见了”。实际操作中最容易出现的问题,是调拨差异。比如大仓发出去100箱,门店收货只收到98箱。这个差异不能默默改库存数量,要在“收货差异”里记录,并生成报损/溢余待审单。至于缺失的2箱是仓库漏发还是运输丢损,由责任人线下处理完毕后在系统里补单据。4.3 盘点机制与库存差异处理盘点是最能检验库存模型是否合格的功能。仓储式超市SKU多,不能动不动就闭店盘点。我的方案是循环盘点:后台可按区域/货架/品类生成盘点任务,每次只盘一部分区域;盘点时,该区域库存先冻结(锁定快照),终端录入实盘数;后台自动比对账面库存与实盘数,差异行单独列出;确认无误后生成盘盈盘亏单,审核通过后写入库存流水。这里有个很重要的经验:盘点差异单据不要直接改库存余额。要先把盘点任务里的盘点前数量和实盘数量作为两条流水记录,一正一负或一负一正,保证流水账是连续的,后面追责才有依据。实际项目里,盘点查到差异后,如果差异率超过一个阈值(比如1%),我建议先不急着盘盈盘亏,而是去查流水:有没有订单取消但库存没回滚,有没有退货只退钱没退库存,有没有条码串号。把根因找出来再调账,否则本月调平了,下个月又会冒出更大的差异。5. 分账与结算:多公司多门店的钱怎么算清楚5.1 收款渠道与资金归集:门店独立收款优先收银端会接很多支付渠道:微信、支付宝、云闪付、现金、银行卡、预存卡、挂账。最怕的是所有渠道都收到总部一个商户号下面,然后月底再手工切分到各门店。手续费怎么摊、退款时怎么区分,全是麻烦。我这边强烈建议:每个门店申请自己独立的微信/支付宝商户号。资金直接T+1到门店对应的公司账户,财务核算时天然按门店归集,总部只需要在系统里做报表聚合,不涉及二次清分。支付渠道的手续费也直接跟着商户号走,每一笔订单都能对应到支付渠道扣费记录。如果某些连锁客户总部为了拿到更低的支付费率,坚持用一个统一商户号,那系统必须能下载渠道对账单,按门店维度拆分明细,并按比例分摊手续费。这个功能单独做一个“渠道对账”模块,不要塞进订单模块里,否则后面对账时查询会越来越慢。5.2 多公司独立核算:日结、内部结算单与凭证导出多公司独立核算,核心是把每笔业务在发生时就把归属公司定清楚:销售订单通过门店归属公司,生成收入凭证到门店所属公司;采购订单通过仓库归属公司,生成成本凭证到仓库所属公司;调拨单跨公司时,自动生成“内部销售单+内部采购单”两张单据,金额按调拨结算价计算。每日日结时,每个门店独立做:销售汇总、支付汇总、优惠汇总、退款汇总、交接班差异。这几个汇总数要和渠道对账单、现金盘库单三方核对,全部对上才能日结完成。有任何一分钱差异,收银主管都要在系统里写备注留痕。举个例子,A批发公司调拨一批货给B零售公司门店,系统生成两张单:单据方向金额影响内部销售单(A)A确认收入按调拨结算价10000元A公司收入+10000,成本+8000内部采购单(B)B确认成本按调拨结算价10000元B公司库存成本+10000月底两个公司的往来余额对不上时,直接查两边的内部结算单号关联,效率比以前翻倍。这套设计不要求系统自带完整财务总账,但数据结构要能导出标准凭证到财务软件。每个模块在业务单据上预留account_entry_id,方便关联凭证号。等以后要上财务模块,直接在现有单据上补分录即可,不用动业务表。6. 常见故障与排查实录:上线半年我踩过的坑6.1 并发扣减导致超卖:为什么库存会变负数多门店同时卖同一个SKU时,最容易出的bug就是并发扣减超卖。典型的错误代码是这样:inv = Inventory.objects.get(sku_id=sku_id, warehouse_id=warehouse_id) if inv.available_qty >= qty: inv.available_qty -= qty inv.save()两个请求同时读到available_qty=10,都判断10>=1成立,各自扣1,最后available_qty还是9,实际上已经卖了2件。修复方式是用数据库原子更新:UPDATE inventory SET available_qty = available_qty - 1, version = version + 1 WHERE sku_id = ? AND warehouse_id = ? AND available_qty >= 1;更新影响行数为0,说明库存不够,立即返回“库存不足”。不需要引入Redis分布式锁,数据库行锁在这个量级完全够用。判断标准很简单:只要单库TPS不超过几千,行级原子更新是最可靠、最简单、性能最好的方案。6.2 库存对不上账:先别急着调账,按流水重放“账实不符”是零售系统的头号投诉。排查思路要固定成套路,否则每次从头查,太累:对现有库存余额快照和库存流水余额做比对,找出SKU和仓库;按时段重新计算该SKU的流水累计值,与余额表比对,找出是哪一天开始分叉;把当天所有单据(销售、退货、采购、调拨、盘点、报损)明细拉出来,逐项核对;找到差异单据后,看是否是重复补传、单据状态和库存状态不一致、或手工改动过流水。我这边工具箱里长期备着一个对账SQL:按biz_type分组汇总change_qty再与inventory对拍,一跑就能锁定异常单据。做一个定期(每天凌晨)自动执行的库存对账任务,差异超过阈值自动告警,比等门店投诉再查强得多。6.3 退货与促销优惠分摊:退得少顾客闹,退得多门店亏满100减20这种整单促销,顾客退其中一件商品,到底退多少钱?如果订单没有行级分摊优惠,就只能按比例手工算,POS收银员根本算不过来。我的办法是创建订单时直接做好行级分摊:行分摊优惠 = 整单优惠 ×(该行商品金额 / 整单商品总金额)行可退金额 = 行商品金额 - 行分摊优惠 - 该行已退金额保留两位小数,舍入误差单独放到订单头“分摊尾差”字段里,保证所有行分摊优惠之和等于整单优惠。另外一个容易漏的坑:退货不只是退钱,还要回补库存。如果退货单只生成退款单没生成库存回补流水,卖场库存就会越退越少,最后对账对不上。我规定退货流程必须同时走“退款单+入库单”两步,缺一不可。6.4 硬件外设与系统运维的实战经验仓储式超市门店有很多外设:标签打印机、小票打印机、扫码枪、钱箱、电子秤、自助收银机。硬件问题虽然不复杂,但会非常消耗实施精力。标签打印机和小票打印机,我建议优先选支持ESC/POS指令的型号,直接通过打印指令控制排版,比依赖Windows驱动稳定得多。尤其是POS前台,驱动一更新就可能打印乱码,而指令模式基本不受系统升级影响。电子秤的连接方式,首选网口,次选串口,尽量避免USB口。称重数据要实时传到POS端,USB连接容易受驱动影响突然断掉。串口只要参数配好,稳定性尚可。买称重机时一定先确认协议是否开放,有些品牌的数据协议要额外买接口授权,合同里没写后期会很被动。数据库备份我这边用“每日全量+实时binlog”,流水表按月分区。POS前台产生的数据量不算大,但库存流水和操作日志增长很快,分区能显著提升月报查询速度。系统升级一律先灰度到一家门店,稳定三天后再全量推广,避免一次升级把所有门店都带崩。6.5 多公司多门店项目的实施顺序最后说说整个项目的落地顺序。第一次做这类系统时,我最容易犯的错是一上来就写代码,结果发现需求根本没谈清楚。这套系统的正确顺序应该是:先梳理组织架构:有哪几个公司,公司之间什么关系,哪些门店归哪个公司,仓库归属谁;再梳理商品和库存规则:哪些商品共享库存,哪些商品只能由某个公司采购,内部调拨价怎么定;然后才是功能开发:基础资料、商品中心、库存中心、POS交易、结算分账;最后是联调和试点:先跑一家门店试营业,跑顺了再扩到所有门店。资料收集阶段永远比想象中慢。我一个客户光整理商品主数据就花了两周,但这些基础数据不整理清楚,后面每上一个功能都要回头补。宁可前两周慢一点,也不要后期返工。整套系统做完,我的体会是:多公司多门店的POS项目,并不是技术难点有多高,而是业务语义必须在一开始就对得非常细。公司之间怎么分账、门店之间怎么调拨、断网怎么兜底,这些想透了,代码只是照着实现而已。它后面还能继续扩展,比如接入自助收银、移动手持PDA、供应商对账后台,骨架只要没歪,功能越加越顺。
最近刚配合一家连锁客户上线完一套《多公司多门店·仓储式超市·POS收银系统》,趁着项目细节还热乎,把整套方案从头到尾复盘一遍。先给结论:这类项目真正难的,从来不是收银机上那几个按钮,而是藏在收银背后的组织架构、库存模型和资金归集。很多团队把精力全砸在前端交互上,结果第二家门店一接入,就发现库存对不上、报表算不对、账目混成一锅粥。我这边客户的实际情况很典型:老板手里两个公司主体,一个做批发,一个做零售,下面挂着多家仓储式超市门店,共用一个大仓库,但每家门店又要独立核算。这套系统要解决的核心问题,说白了就三件事:多公司怎么各自独立核算、多门店怎么共享库存和协同补货、POS收银台在复杂促销和断网环境下怎么稳定收银。这篇文章适合被多公司、多门店复杂度折磨的产品经理和开发,也适合准备做零售数字化的业主,以及想系统搞懂POS系统背后逻辑的同行。我会按“需求拆解—数据建模—收银设计—库存协同—分账结算—问题排查”这条路线走,每一步的取舍都会讲清楚为什么这么做,直接可落地。1. 项目定位:先想清楚这是一套“零售业务中台”,不是一台收银机1.1 拆解“多公司多门店·仓储式超市”背后的真实需求标题里每个词都不是装饰,背后都有明确的业务压力。先说“多公司”。仓储式超市的老板们很少只有一个营业执照。常见的是:一个批发公司,负责大宗采购和批发业务;一个零售公司,运营所有超市门店;一个物业或管理公司,负责收租金、管理费;甚至按区域再拆几个公司来享受政策和隔离风险。这些公司之间是真实的法人关系,财务必须独立核算,互不掺和。但在业务上,它们又共享商品、共享供应商、共享仓库。系统如果从数据库层面就用一棵简单的“公司-门店”树去建模,后面跨公司调拨、成本流转会直接卡死。再说“多门店”。门店是独立的经营单元,每个门店有销售目标、毛利考核、独立库存和独立资金流水。但门店和门店之间不是孤岛,会互相调拨商品,可能共用同一个中央大仓。仓储式超市这个定语还带来几个独特要求:SKU数量大,通常一两万起步;大包装整箱卖,也拆零卖;生鲜和散称商品占比高,要接电子秤;批发客户多,会出现整单挂账、月结这种支付方式;客单价高,购物车经常几十件商品,收银速度要求极高。如果就按“单店版收银+进销存”来做,一上线就会出现一个死循环:A门店缺货时不知道B门店有货;批发公司给零售公司供货没法做内部结算;总部想看全盘库存,只能靠Excel。所以这套系统的正确姿势是:以“多公司、多门店”为核心约束,把组织、商品、库存、交易、结算全部抽象成平台级能力,POS只是最末端的一个交互终端。1.2 为什么架构上选择“模块化单体”,而不是微服务我在这个项目里最强调的一点:别一上来就微服务。以常规仓储式超市的体量,一台好一点的服务器,支撑几个公司、十来个门店、日均几千单是完全没问题的。微服务的思路在这里会引入分布式事务、服务间调用链、重复数据同步等问题,每一个都会成为稳定性黑洞。但这个项目又确实需要模块边界,否则后面维护会越来越痛苦。我的落地方式是“模块化单体”:组织与主数据模块:公司、门店、仓库、人员、权限;商品中心:SPU、SKU、条码、单位换算、分类、品牌;价格与促销模块:多套价格、促销规则、优惠分摊;库存中心:实时库存、流水、预占、补货、盘点;交易中心:订单、支付、退款、小票、挂账;结算中心:日结、对账、内部结算单、导出凭证。模块之间通过内部接口调用,数据库可以共享一个实例,但是每张表归属模块必须清晰。后期如果真的门店量爆发式增长,直接按库存中心、交易中心拆服务出来即可,不需要推倒重来。部署形态上,我建议门店端用“B/S收银台+本地缓存”模式,中心端部署一个核心服务加一个后台管理站点。门店端浏览器打开收银页面,日常在线操作,离线时自动切换本地模式。比起纯C/S桌面客户端,省去了每台收银机装驱动和升级的运维成本。1.3 多租户数据隔离:怎么处理“多公司”的数据边界多公司最容易出事的一点,就是A公司的报表里混进B公司的数据。所以数据隔离在架构第一天就要定清楚。我当时对比过三种方式:方案优点缺点适用场景每公司独立数据库隔离最彻底,财务审计省心维护成本高,跨公司报表要聚合公司间完全独立,几乎无业务往来共享库、独立Schema隔离较好,可跨库join迁移成本稍高公司间有协同,但需要严格边界共享库、共享表,带公司字段开发最方便,报表聚合容易容易漏条件查到别家公司数据公司间业务协同紧密最终我建议用第三种,共享库共享表,每张业务表都带company_id,并在所有写操作里强制注入公司上下文,不允许开发人员手写查询时不带company_id。这个约束不要靠团队自觉,要在ORM层面做一个拦截器,或者写统一的BaseRepository,所有查询自动拼上当前上下文的company_id条件。还有一点很关键:主键不能用简单的自增ID,跨公司、跨端合并数据时会冲突。我这边全部用雪花ID,保证多门店断网本地生成订单ID也不会重复。2. 数据模型:把公司、门店、仓库、商品、库存的关系理顺再写代码2.1 组织模型设计:公司、门店、仓库必须解耦很多单店版系统的数据结构是:公司下面挂门店,门店下面挂仓库。这种设计在做多公司多门店时,马上会遇到一个问题:一个仓库同时给多个公司用,怎么办?我项目里的建模是这样的,四个核心表:company公司表:公司ID、名称、type(批发公司/零售公司/管理公司)、税号、结算货币;store门店表:门店ID、名称、所属公司ID、地址、店长、营业状态;warehouse仓库表:仓库ID、名称、类型(中央大仓/门店后仓/前置仓)、所属公司ID、地址;store_warehouse_relation门店与仓库关系表:门店ID、仓库ID、是否默认出货仓。门店和仓库是多对多关系,允许一个门店对应多个仓库(比如卖场库存和后仓库存分属两个物理区域),但一定要有一个默认出货仓,POS下单扣库存时优先扣默认仓。这样设计的好处是:批发公司的大仓可以同时给零售公司的门店供货,按“调拨”走内部结算,库存不会乱;未来加前置仓、加盟店,不需要改表结构。建表时我习惯把“公司”和“经营组织”分开,因为现实中管理公司并不直接经营门店,它只是控股。财务报表按公司走,经营报表按门店走,两者可以交叉,但不能耦合。2.2 商品主数据:一物多码、多单位换算不能省仓储式超市的商品主数据比普通便利店复杂得多。最典型的问题:同一个商品,在批发时按箱卖,在门店零售时按瓶卖。货架上的条码是单瓶的,但整箱外包装上印的又是另一个条码。所以商品模型不能只建一张“商品表”。我当时设计了这样一套结构:spu商品SPU表:代表一个抽象商品,比如“某品牌纯净水550ml”;sku规格SKU表:代表具体售卖规格,比如“某品牌纯净水550ml×12瓶整箱”和“某品牌纯净水550ml×1瓶”是两个SKU;barcode条码表:一个SKU可以有多个条码,一个条码也可以关联多个SKU(这种坑后面讲);unit_conversion单位换算表:SKU的销售单位、采购单位、库存单位以及换算率。核心换算逻辑示例:SKU条码销售单位单价库存单位换算率纯净水550ml×1瓶690000000001瓶2.00瓶1纯净水550ml×12瓶整箱690000000002箱21.60瓶12这样设计和“一物一码”思路最大的差别:POS收银时,扫码扫到整箱条码,系统能自动算出这箱在库存上占用的是12瓶。同时报表分析销量时,可以把整箱销售换算成瓶数来统计,不会出现“销量单位混乱”的问题。另外,仓储式超市一定有生鲜和散称商品,这类商品没有固定条码,或者一个条码对应一个类目但价格随重量变化。要预留PLU码(计量商品码)字段,POS端接电子秤时,称重机直接传重量和PLU码过来,系统按“单价×重量”生成行。2.3 库存模型:为什么不能只有“库存数量”一列很多进销存系统的库存表就一个字段:qty。销售扣一下,入库加一下,看起来简单,但一遇到并发、断网补传、手工调整,账就花了。我在这套方案里坚持用“库存余额 + 库存流水”双表结构。库存余额表inventory:CREATE TABLE inventory ( id BIGINT PRIMARY KEY, company_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, onhand_qty DECIMAL(18,3) NOT NULL DEFAULT 0, available_qty DECIMAL(18,3) NOT NULL DEFAULT 0, allocated_qty DECIMAL(18,3) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, updated_at DATETIME );库存流水表inventory_flow:CREATE TABLE inventory_flow ( id BIGINT PRIMARY KEY, company_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, change_qty DECIMAL(18,3) NOT NULL, before_qty DECIMAL(18,3) NOT NULL, after_qty DECIMAL(18,3) NOT NULL, biz_type VARCHAR(32) NOT NULL, biz_id VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_biz (biz_type, biz_id) );两个表的关键点:库存流水是append-only追加写入,只允许新增,不允许修改和删除。每一条流水都要带业务单据号和业务类型(销售、采购、调拨、盘点、报损、退货等)。库存余额表不是根据流水实时计算的,它只是一份“缓存”,用来快速查询和并发扣减。如果出现账实不符,通过重放流水可以找到差异在哪笔单据上。allocated_qty表示预占库存,也就是订单已创建但还没支付成功时占用的数量。三种数量的关系:物理库存=在途+在库,可用库存=物理库存-预占。这里说一个很多人忽略的原则:任何库存变动都要先写流水,再更新余额表。顺序反了,一旦余额表更新成功但流水写入失败,系统永远都查不出差异来源。我在项目中把这个写进统一库存服务,业务代码没有权限直接操作inventory表。3. 收银流程与促销:POS端最容易出问题的地方全在这里3.1 收银界面与操作流程:仓储式超市要的是“快”仓储式超市的收银场景和奶茶店、便利店完全不同。顾客推着一大车东西,高峰期排队,收银员必须尽可能在几十秒内完成一单。这个场景要求POS界面做到:默认光标在扫码框,扫码枪扫完一段条码直接回车,商品自动加入购物车,不需要再点按钮;支持连续扫码,同一商品扫两次自动叠加数量,不会弹两次窗口问;支持“数量×条码”模式,比如顾客拿了10箱饮料,收银员可以直接输入“10*690000000002”;生鲜/称重商品显示在单独区域,支持手动改重量和单价,并打印第二联标签;结算时一次展示所有行项目,支持部分取消、临时挂单、整单删除。会员识别也放首位。仓储式超市基本都是会员制,结账前先识别会员(手机号、会员卡、动态码),会员价和积分在整单上结算。顾客身份直接决定价格体系,所以会员识别必须在选品之前完成,而不是最后补。界面布局上,左侧是大列表展示已扫码商品,右侧是当前会员信息和常用操作按钮,底部是合计和收款区域。为了方便高峰期使用,所有操作都要支持快捷键,比如F2快速收款,F4拆单,F8挂单。我当时实测过,老练收银员用快捷键操作,单笔结账能控制在15秒内,比鼠标点选快一半以上。3.2 价格与促销引擎:行级优惠分摊是重点仓储式超市的价格体系特别复杂。同一件商品,面对不同顾客有完全不同的价格:普通零售价、会员价、批发价、促销价,促销价又分直降、满减、买赠、组合捆绑。如果价格逻辑全写在订单逻辑里,每次加一个促销,整个系统都要重新发版。我的做法是把价格和促销做成一个独立模块,订单服务只调一个统一接口:“给定顾客、商品、数量、时间,返回成交单价和优惠明细”。价格计算顺序固定为:先定基础价:根据顾客类型选择价格带;再判行级促销:第N件折扣、数量满减;再判单级促销:整单满减、整单折扣;最后分摊:把整单优惠按行金额比例分摊到每一行。为什么一定要做“分摊”?因为退货时只知道整单退了多少钱,不知道每一行该退多少,就出大问题。分摊后每一行都有一个discount_amount字段,退货金额直接等于行原价 - 行分摊优惠 - 行已退金额,清晰安全。促销规则的另一个坑是快照。订单生成时,必须把促销规则的关键信息(折扣率、满减值、赠品SKU)持久化到订单明细里。如果只存一个促销ID,引用当前规则表,那促销一结束、规则一改,历史订单的报表统计和退货退款全都会跟着乱。3.3 断网与容灾:收银台永远不能停仓储式超市POS最怕的不是卡顿,是断网后收银台直接瘫痪。高峰排队只要断3分钟,场面就会失控。所以断网容灾是硬需求,不是可选功能。我的方案是在门店端做一个轻量级本地缓存和离线队列:门店启动时,从中心同步商品主数据、会员基础信息、促销规则快照到本地浏览器存储或本地数据库;收银时正常在线操作,所有订单实时提交中心服务;如果提交失败或者网络不通,订单自动写入本地队列,状态置为“待上传”,小票正常打印;网络恢复后,收银台自动按顺序补传订单,每条订单带一个客户端生成UUID;服务端幂等表按UUID去重,重复提交只返回原订单号,不会重复扣库存。这里有个很现实的矛盾:断网期间,门店库存是准的,但中心库存可能已经被其他门店卖完了。于是会出现在线时明明没货,断网你照卖的情况。我的处理方式是允许断网订单产生“负库存”标记,补传成功后库存按实际扣减,当天日结时把这个门店的负库存项单列出来,提醒后台做调拨或修改可售状态。硬要禁止负库存,反而会让收银台在断网状态下彻底卡死,业务损失更大。断网期间涉及支付也一样:微信支付宝这类在线支付没法授权,可以引导顾客走现金或“挂账”,挂账单在补传时同步生成应收记录。4. 库存协同:大仓、门店、调拨与盘点要闭环4.1 中央大仓与门店库存:卖场和后仓要分清对于仓储式超市,库存不是只有门店卖场一个概念,它至少分三个物理层:中央大仓:总部统一进货、统一储备的区域;门店卖场:顾客能看到能直接买的货架库存;门店后仓:门店自己的备用库存,卖场货架空了就补。POS收银直接扣减“门店卖场”库存。门店后仓到卖场的补货动作,在系统里做成“库内移库”,同一门店不同仓库ID之间做内部调拨。如果不分开,盘点时永远分不清货架上的和仓库里的差异出在哪。补货逻辑我做成这个链路:门店销量分析生成补货建议单(参考最低库存、日均销量、在途数量)—店长确认—总部审核—大仓出库—门店收货入库。整个过程单据完整,每一段都能追踪到人。这里面有一个常见误区:中央大仓给门店补货,要不要立刻增加门店库存?答案是:不能。大仓出库后,这批货是“调拨在途”状态,既不在大仓库存里,也还没进门店库存。必须等门店确认收货,库存才真正加到门店。中间任何一步不确认,账都会差一层。4.2 调拨流程与在途库存:跨公司调拨的本质是买卖门店之间互相借货,看起来只是移库存,但涉及财务时必须分两种情况:同一个公司内部两个门店调拨:按成本价调拨,不计收入,只影响两个门店的库存和成本归属;不同公司之间的调拨:本质是内部销售,调出公司确认收入,调入公司确认成本,生成内部结算单。我设计的调拨单状态机是:草稿 — 调出方审核 — 出库确认 — 调入方收货确认 — 完成。只要不是“完成”状态,库存都挂在in_transit_qty字段上。这样总部看库存报表时,能清楚看到每一批货卡在哪个环节,不会只说一句“货不见了”。实际操作中最容易出现的问题,是调拨差异。比如大仓发出去100箱,门店收货只收到98箱。这个差异不能默默改库存数量,要在“收货差异”里记录,并生成报损/溢余待审单。至于缺失的2箱是仓库漏发还是运输丢损,由责任人线下处理完毕后在系统里补单据。4.3 盘点机制与库存差异处理盘点是最能检验库存模型是否合格的功能。仓储式超市SKU多,不能动不动就闭店盘点。我的方案是循环盘点:后台可按区域/货架/品类生成盘点任务,每次只盘一部分区域;盘点时,该区域库存先冻结(锁定快照),终端录入实盘数;后台自动比对账面库存与实盘数,差异行单独列出;确认无误后生成盘盈盘亏单,审核通过后写入库存流水。这里有个很重要的经验:盘点差异单据不要直接改库存余额。要先把盘点任务里的盘点前数量和实盘数量作为两条流水记录,一正一负或一负一正,保证流水账是连续的,后面追责才有依据。实际项目里,盘点查到差异后,如果差异率超过一个阈值(比如1%),我建议先不急着盘盈盘亏,而是去查流水:有没有订单取消但库存没回滚,有没有退货只退钱没退库存,有没有条码串号。把根因找出来再调账,否则本月调平了,下个月又会冒出更大的差异。5. 分账与结算:多公司多门店的钱怎么算清楚5.1 收款渠道与资金归集:门店独立收款优先收银端会接很多支付渠道:微信、支付宝、云闪付、现金、银行卡、预存卡、挂账。最怕的是所有渠道都收到总部一个商户号下面,然后月底再手工切分到各门店。手续费怎么摊、退款时怎么区分,全是麻烦。我这边强烈建议:每个门店申请自己独立的微信/支付宝商户号。资金直接T+1到门店对应的公司账户,财务核算时天然按门店归集,总部只需要在系统里做报表聚合,不涉及二次清分。支付渠道的手续费也直接跟着商户号走,每一笔订单都能对应到支付渠道扣费记录。如果某些连锁客户总部为了拿到更低的支付费率,坚持用一个统一商户号,那系统必须能下载渠道对账单,按门店维度拆分明细,并按比例分摊手续费。这个功能单独做一个“渠道对账”模块,不要塞进订单模块里,否则后面对账时查询会越来越慢。5.2 多公司独立核算:日结、内部结算单与凭证导出多公司独立核算,核心是把每笔业务在发生时就把归属公司定清楚:销售订单通过门店归属公司,生成收入凭证到门店所属公司;采购订单通过仓库归属公司,生成成本凭证到仓库所属公司;调拨单跨公司时,自动生成“内部销售单+内部采购单”两张单据,金额按调拨结算价计算。每日日结时,每个门店独立做:销售汇总、支付汇总、优惠汇总、退款汇总、交接班差异。这几个汇总数要和渠道对账单、现金盘库单三方核对,全部对上才能日结完成。有任何一分钱差异,收银主管都要在系统里写备注留痕。举个例子,A批发公司调拨一批货给B零售公司门店,系统生成两张单:单据方向金额影响内部销售单(A)A确认收入按调拨结算价10000元A公司收入+10000,成本+8000内部采购单(B)B确认成本按调拨结算价10000元B公司库存成本+10000月底两个公司的往来余额对不上时,直接查两边的内部结算单号关联,效率比以前翻倍。这套设计不要求系统自带完整财务总账,但数据结构要能导出标准凭证到财务软件。每个模块在业务单据上预留account_entry_id,方便关联凭证号。等以后要上财务模块,直接在现有单据上补分录即可,不用动业务表。6. 常见故障与排查实录:上线半年我踩过的坑6.1 并发扣减导致超卖:为什么库存会变负数多门店同时卖同一个SKU时,最容易出的bug就是并发扣减超卖。典型的错误代码是这样:inv = Inventory.objects.get(sku_id=sku_id, warehouse_id=warehouse_id) if inv.available_qty >= qty: inv.available_qty -= qty inv.save()两个请求同时读到available_qty=10,都判断10>=1成立,各自扣1,最后available_qty还是9,实际上已经卖了2件。修复方式是用数据库原子更新:UPDATE inventory SET available_qty = available_qty - 1, version = version + 1 WHERE sku_id = ? AND warehouse_id = ? AND available_qty >= 1;更新影响行数为0,说明库存不够,立即返回“库存不足”。不需要引入Redis分布式锁,数据库行锁在这个量级完全够用。判断标准很简单:只要单库TPS不超过几千,行级原子更新是最可靠、最简单、性能最好的方案。6.2 库存对不上账:先别急着调账,按流水重放“账实不符”是零售系统的头号投诉。排查思路要固定成套路,否则每次从头查,太累:对现有库存余额快照和库存流水余额做比对,找出SKU和仓库;按时段重新计算该SKU的流水累计值,与余额表比对,找出是哪一天开始分叉;把当天所有单据(销售、退货、采购、调拨、盘点、报损)明细拉出来,逐项核对;找到差异单据后,看是否是重复补传、单据状态和库存状态不一致、或手工改动过流水。我这边工具箱里长期备着一个对账SQL:按biz_type分组汇总change_qty再与inventory对拍,一跑就能锁定异常单据。做一个定期(每天凌晨)自动执行的库存对账任务,差异超过阈值自动告警,比等门店投诉再查强得多。6.3 退货与促销优惠分摊:退得少顾客闹,退得多门店亏满100减20这种整单促销,顾客退其中一件商品,到底退多少钱?如果订单没有行级分摊优惠,就只能按比例手工算,POS收银员根本算不过来。我的办法是创建订单时直接做好行级分摊:行分摊优惠 = 整单优惠 ×(该行商品金额 / 整单商品总金额)行可退金额 = 行商品金额 - 行分摊优惠 - 该行已退金额保留两位小数,舍入误差单独放到订单头“分摊尾差”字段里,保证所有行分摊优惠之和等于整单优惠。另外一个容易漏的坑:退货不只是退钱,还要回补库存。如果退货单只生成退款单没生成库存回补流水,卖场库存就会越退越少,最后对账对不上。我规定退货流程必须同时走“退款单+入库单”两步,缺一不可。6.4 硬件外设与系统运维的实战经验仓储式超市门店有很多外设:标签打印机、小票打印机、扫码枪、钱箱、电子秤、自助收银机。硬件问题虽然不复杂,但会非常消耗实施精力。标签打印机和小票打印机,我建议优先选支持ESC/POS指令的型号,直接通过打印指令控制排版,比依赖Windows驱动稳定得多。尤其是POS前台,驱动一更新就可能打印乱码,而指令模式基本不受系统升级影响。电子秤的连接方式,首选网口,次选串口,尽量避免USB口。称重数据要实时传到POS端,USB连接容易受驱动影响突然断掉。串口只要参数配好,稳定性尚可。买称重机时一定先确认协议是否开放,有些品牌的数据协议要额外买接口授权,合同里没写后期会很被动。数据库备份我这边用“每日全量+实时binlog”,流水表按月分区。POS前台产生的数据量不算大,但库存流水和操作日志增长很快,分区能显著提升月报查询速度。系统升级一律先灰度到一家门店,稳定三天后再全量推广,避免一次升级把所有门店都带崩。6.5 多公司多门店项目的实施顺序最后说说整个项目的落地顺序。第一次做这类系统时,我最容易犯的错是一上来就写代码,结果发现需求根本没谈清楚。这套系统的正确顺序应该是:先梳理组织架构:有哪几个公司,公司之间什么关系,哪些门店归哪个公司,仓库归属谁;再梳理商品和库存规则:哪些商品共享库存,哪些商品只能由某个公司采购,内部调拨价怎么定;然后才是功能开发:基础资料、商品中心、库存中心、POS交易、结算分账;最后是联调和试点:先跑一家门店试营业,跑顺了再扩到所有门店。资料收集阶段永远比想象中慢。我一个客户光整理商品主数据就花了两周,但这些基础数据不整理清楚,后面每上一个功能都要回头补。宁可前两周慢一点,也不要后期返工。整套系统做完,我的体会是:多公司多门店的POS项目,并不是技术难点有多高,而是业务语义必须在一开始就对得非常细。公司之间怎么分账、门店之间怎么调拨、断网怎么兜底,这些想透了,代码只是照着实现而已。它后面还能继续扩展,比如接入自助收银、移动手持PDA、供应商对账后台,骨架只要没歪,功能越加越顺。