从用例图到双账本:超市进销存系统库存一致性设计
简介:这份超市进销存管理系统源码与用例图资料,面向学习零售业务系统开发的学生、课程设计者及初级开发者,围绕商品进货、销售、库存、订单等流程提供可运行参考。压缩包共103个文件、约2.03MB,以aspx页面、cs后台代码、jpg/gif界面素材为主,辅以css样式、数据库文件与配置文档,便于观察页面交互、业务分层和数据表结构。已有4380人学习下载,在课程实践与入门项目中具备一定参考面。读者可获得采购入库、销售出库、库存预警统计、订单追踪等模块的页面组织与实现线索,并能结合用例图梳理员工、顾客、供应商等参与者与系统功能的关系,用于快速搭建同类系统框架、对照完善需求分析,或作为超市信息化项目的学习样板。
1. 一张用例图定生死:超市进销存系统为什么先画人再写码
超市进销存管理系统这个题目,网上一搜全是现成模板,但真正卡住人的往往不是代码,是那张用例图——参与者少画一个,后面表结构就得返工,库存对不平的后果还得自己承担。它的本质是把「货怎么来、放哪、怎么走、剩多少」这四件事用单据串起来:采购单进来形成批次,销售单出去扣批次,盘点单修差异,中间每一步都留流水。适合谁来动手?想给自己店里搭一套的小老板、要交课程作业但更想做出能跑的东西的同学、以及接手了一套老系统发现账实不符想推倒重做的开发者。用例图是第一张必须画对的图,它决定了后面写到哪一步会出问题。
2. 用例图先立骨架:参与者到底几个人、用例拆到多细才算合格
2.1 五个参与者,别漏掉那个「不是人」的
刚接触用例图的人习惯把「顾客」画成参与者,然后给顾客连一条「购买商品」的线,图看起来挺完整。但真到写接口时就会发现,「购买商品」这个用例既包含扫码、又包含称重、还包含付款和打小票,一个用例对应了四五个接口,验收时根本说不清做完没有。
我一般按「谁在系统里留下一条操作记录」来筛参与者。按这个标准,一家中小型超市跑不出这五类:
| 参与者 | 典型动作 | 是否需要登录 | 对应的核心用例 |
|---|---|---|---|
| 收银员 | 扫码、称重、收款、退货 | 是 | 销售结算、销售退货、挂单取单 |
| 库管员 | 收货、上架、报损、移库 | 是 | 采购收货、报损出库、库存查询 |
| 采购员 | 下采购单、维护供应商 | 是 | 采购下单、供应商维护、到货跟踪 |
| 店长 | 看报表、改售价、审批盘点差异 | 是 | 销售报表、价格调整、盘点审批 |
| 定时任务 | 低库存预警、临期提醒、日结 | 否(系统触发) | 库存预警、临期提醒、日结汇总 |
第五行是最容易漏的。低库存预警、临期商品提醒、每日销售汇总这三件事,没人会主动点按钮,但业务上确实要求系统「自己动」。如果不在用例图里把它画成参与者,后面写定时任务就没有出处,需求评审时也容易被当成可有可无的附加功能砍掉。我的做法是在图右侧单独立一个火柴人,标注「系统调度」,只连三条线,保持图面干净。
至于「供应商」要不要画,取决于系统边界。如果采购员手工录单、供应商不登录系统,那它是外部实体,画在图的右边用实线连到「采购下单」即可,不要给它连「维护商品」——那会让边界模糊,最后有人问你「供应商能不能自己改售价」就不好回答了。
参与者确定之后还有一步常被跳过:给每个参与者写一句职责边界。比如「收银员只能看到销售价,看不到进价」,这句话写进用例描述的前置条件里,后面做权限表时直接照抄,比事后补要省事得多。见过太多项目把权限留到最后一天加,结果发现进价字段在收银端接口里已经返回了,改起来涉及三四个模块。
2.2 用例粒度:一条用例必须能在一次操作里验收
用例图最典型的失败方式,是画出一堆「管理商品」「管理库存」「管理订单」这种包含式的大词。这种图看着对称好看,但对落地没有任何帮助——「管理商品」底下到底包含几个接口、要不要审核、要不要批量导入,一个字都没说。
我用的判断标准很土:一条用例,必须能被一次操作完整验收。也就是一个人坐下来、点几下、填一张表单,事情就结束了。按这个标准,「管理商品」要拆成:新增商品、修改商品资料、调整售价、停用商品、批量导入商品。「管理库存」要拆成:库存查询、库存调整(报损/报溢)、移库、盘点录入、盘点审批。
拆到这个粒度,一个中型超市的用例总数大概落在 18 到 24 条之间。少于 15 条说明拆得不够,多于 30 条说明把接口当用例了。下面是去掉界面细节之后的一份用例清单,可以直接照着往图上摆:
| 编号 | 用例名 | 主参与者 | 触发条件 | 验收标准(一句话) |
|---|---|---|---|---|
| UC-01 | 商品建档 | 采购员 | 新品到货前 | 条码、名称、单位、售价齐全,重复条码被拒 |
| UC-02 | 售价调整 | 店长 | 促销或成本变动 | 生成调价记录,旧价可追溯 |
| UC-03 | 采购下单 | 采购员 | 库存低于补货点 | 生成采购单,状态为待收货 |
| UC-04 | 采购收货 | 库管员 | 供应商送货到场 | 生成批次,库存增加,应付挂账 |
| UC-05 | 采购退货 | 库管员 | 质量或数量不符 | 按原批次退回,应付冲减 |
| UC-06 | 销售结算 | 收银员 | 顾客到收银台 | 扣减库存,生成流水,打印小票 |
| UC-07 | 称重商品结算 | 收银员 | 散称商品过秤 | 按重量计价,金额四舍五入到分 |
| UC-08 | 销售退货 | 收银员 | 顾客凭小票退货 | 回写原批次,退款金额一致 |
| UC-09 | 挂单取单 | 收银员 | 顾客临时离开 | 挂单期间库存不扣减 |
| UC-10 | 报损出库 | 库管员 | 商品破损或过期 | 库存减少,生成损失记录 |
| UC-11 | 盘点录入 | 库管员 | 月末或抽盘 | 支持盲盘,录入不影响现有库存 |
| UC-12 | 盘点审批 | 店长 | 差异超过阈值 | 审批后一次性回写库存 |
| UC-13 | 库存查询 | 全部角色 | 随时 | 按条码/名称/分类查询,含批次明细 |
| UC-14 | 销售报表 | 店长 | 每日/每周 | 按品类、时段汇总,与流水一致 |
| UC-15 | 库存预警 | 系统调度 | 每日定时 | 低于安全库存的商品进提醒列表 |
这张表最大的价值不是画图,是它同时充当了后面的验收单。每做完一个模块,回来对着「验收标准」那一列打钩,比口头说「做完了」靠谱得多。
2.3 包含、扩展、泛化:三种关系只在三处出现
用例之间的连线,新手容易画成蜘蛛网,每条线都想加个<<include>>。实际项目里,这三种关系各有各的固定用武之地,用错了反而增加理解成本。
包含(include)只用于「必然发生且被多处复用」的行为。典型例子是「销售结算」包含「计算应收金额」——不管现金还是扫码,这一步一定发生。注意方向:箭头从基础用例指向被包含用例。反过来把「会员折扣」做成 include 就错了,因为不打折的顾客根本不走这一步。
扩展(extend)用于「特定条件下才发生」的分支。会员折扣、临期特价、整单抹零,都属于这一类。扩展用例要写清楚扩展点,比如「当顾客出示会员卡时,在计算应收金额之后插入折扣计算」。很多人把扩展和包含搞混,记一句话就够:包含是必须走的路,扩展是岔路口。
泛化(generalization)一般只用在参与者或者支付方式上。现金、扫码、挂账三种结算方式,抽象出一个「支付」父用例,各自泛化它,图面立刻干净。参与者也可以泛化,比如把「收银员」和「自助收银机」泛化成一个「结算操作者」,但这个只在你确实要支持自助设备时才画,否则纯属想象。
2.4 没有图也能核对:把用例图翻成三张检查表
图画完之后,我一般会让对方做三件事自查,比盯着图看有效得多。
第一张表是参与者-用例矩阵。行是五个参与者,列是全部用例,交叉处打勾。如果某一列一个勾都没有,说明这条用例没有主人,删掉;如果某一行勾超过 12 个,说明这个角色的权限过重,考虑拆成两个角色——收银员能改售价这种事,在这个矩阵里一眼就能看出来。
第二张表是边界表。把每个用例涉及的字段列出来,标注「读」和「写」。进价字段如果出现在收银员那一行的「读」列里,权限设计就出问题了。这张表做半小时,能省掉后期改接口的半天。
第三张表是异常分支表。每条用例至少写两个失败场景:扫码枪扫不出来、库存不足、金额为零、顾客中途取消。用例图本身不画异常,但需求文档里必须有,否则开发到一半才发现「负库存怎么处理」没人拍板,就得停下来开会。
提示:用例图画完先别急着美化布局,把上面三张表填完再回头调整线条。布局好不好看是给别人看的,三张表填不填得满是给自己省事的。
3. 双账本的表结构:为什么库存不能只存一个数字
3.1 只存一个数量的三种死法
最常见的建表方式是item表里加一个stock字段,进一批加,卖一件减。教学 Demo 跑到这里就结束了,但真到店里用,第一周就会出问题,而且每次出的都是同一类问题。
第一种死法:不知道钱花在哪了。同一瓶酱油,上个月进货价 8.5 元,这个月涨到 9.2 元。只有一个stock数字时,你没法回答「现在仓库里这 30 瓶,平均成本是多少」。老板问毛利,只能拿售价减最后一次进价,算出来的数虚高,月底盘完发现利润对不上,就开始怀疑系统。
第二种死法:临期商品无法定位。保质期是超市的生命线。只有一个总数,你知道有 30 瓶酱油,但不知道其中有 5 瓶是下个月到期的。等到顾客投诉,只能整批下架,损失自己承担。
第三种死法:退货和盘点没法追溯。顾客拿小票来退一瓶酱油,你要冲回哪一批的成本?盘点缺少两瓶,是卖漏扫了还是被偷了?没有流水,这些问题只能靠猜,而猜出来的结论通常不能写进报表。
所以我一般用「批次 + 流水 + 汇总」的三层结构,行内有人叫双账本:批次表管实物和成本,流水表管每一次变动的因果,汇总表只是给查询用的缓存(中小超市数据量不大,汇总表甚至可以省掉,用视图替代)。
这三层的关系是这样:入库时先写一条批次记录,再写一条流水;出库时先按规则选中批次、扣减qty_left,再写一条流水。流水表只增不改,任何时刻的库存都等于「该商品所有批次qty_left之和」,也等于「所有流水change_qty之和」。这两个等式就是后面所有对账逻辑的基础。
3.2 五张核心表的建表 SQL 与字段取舍
下面是我在 MySQL 8.x 上常用的一套骨架,字段做过删减,只保留真正影响业务的部分。
-- 商品主档:一个 SKU 一行。散称商品走店内码,条码允许为空 CREATE TABLE item ( item_id BIGINT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NULL COMMENT '69码或店内码,散称可为空', item_name VARCHAR(128) NOT NULL, category_id INT NOT NULL, unit VARCHAR(8) NOT NULL COMMENT '个/瓶/kg', is_weight TINYINT NOT NULL DEFAULT 0 COMMENT '1=称重商品', sale_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1在售 0停用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_barcode (barcode), KEY idx_name (item_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 批次表:库存的唯一真相来源,qty_left 决定还能卖多少 CREATE TABLE stock_batch ( batch_id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL, batch_no VARCHAR(32) NOT NULL COMMENT '入库时生成,关联采购单号', qty_in DECIMAL(12,3) NOT NULL COMMENT '入库数量', qty_left DECIMAL(12,3) NOT NULL COMMENT '剩余数量,出库只扣这个', unit_cost DECIMAL(10,4) NOT NULL COMMENT '本批进价,算毛利用', expire_date DATE NULL COMMENT '无保质期留空', in_at DATETIME NOT NULL, KEY idx_item_expire (item_id, expire_date, in_at), CONSTRAINT ck_qty CHECK (qty_left >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 流水表:只增不改,biz_type + biz_no 组成幂等键 CREATE TABLE stock_flow ( flow_id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL, batch_id BIGINT NOT NULL, biz_type VARCHAR(16) NOT NULL COMMENT 'PURCHASE/SALE/CHECK/RETURN/LOSS', biz_no VARCHAR(32) NOT NULL COMMENT '关联单号,重试时靠它去重', change_qty DECIMAL(12,3) NOT NULL COMMENT '入库为正,出库为负', qty_after DECIMAL(12,3) NOT NULL COMMENT '变动后该批次余量,排查用', operator VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_type, biz_no, batch_id, item_id), KEY idx_item_time (item_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 销售单与明细:主表存金额,明细存每行扣了哪些批次 CREATE TABLE sale_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, total_amt DECIMAL(12,2) NOT NULL, pay_type VARCHAR(16) NOT NULL COMMENT 'CASH/SCAN/CREDIT', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2已退', cashier VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sale_order_item ( line_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, item_id BIGINT NOT NULL, batch_id BIGINT NOT NULL COMMENT '一行可能对应多条批次,拆行存', qty DECIMAL(12,3) NOT NULL, price DECIMAL(10,2) NOT NULL, amount DECIMAL(12,2) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段的取舍值得单独说。
barcode上建了唯一索引但允许 NULL。MySQL 的唯一索引允许多个 NULL 值,正好解决散称商品(比如散装瓜子按斤卖)没有标准条码的问题。散称商品走收银台电子秤生成的店内码,前缀固定,入库时不需要,销售时临时生成,用完即弃。
数量字段统一用DECIMAL(12,3),不用FLOAT也不用INT。称重商品的最小单位可以到 0.001 千克,浮点数累加会产生0.30000000000000004这种结果,对账时对到人怀疑人生,这条是血泪经验。
stock_flow上的uk_biz (biz_type, biz_no, batch_id, item_id)是幂等键。收银时网络抖动导致重试、消息队列重复投递,同一个单号第二次插入会直接冲突报错,程序捕获后当成成功处理即可,库存不会被扣两次。这个唯一索引比任何应用层的「先查再插」都可靠。
3.3 扣减库存的原子写法:行锁、幂等键与重试
扣库存是整个系统里唯一必须严格串行的地方。两个人同时买走最后一瓶酱油,谁先谁后决定了会不会超卖。
我一般用「按效期排序 + 行锁 + 影响行数校验」三件套:
from decimal import Decimal class InsufficientStock(Exception): pass def deduct_fifo(conn, item_id, qty, biz_no, biz_type="SALE", operator="sys"): """按近效期优先扣减批次,返回 [(batch_id, 扣减量, 成本价), ...] 任何一批扣减失败立即抛异常,由调用方统一回滚事务""" remain = Decimal(str(qty)) detail = [] with conn.cursor() as cur: # 关键 1:有保质期的排前面,无保质期垫底;同效期按入库先后 cur.execute(""" SELECT batch_id, qty_left, unit_cost FROM stock_batch WHERE item_id = %s AND qty_left > 0 ORDER BY expire_date IS NULL, expire_date, in_at FOR UPDATE """, (item_id,)) rows = cur.fetchall() for batch_id, qty_left, unit_cost in rows: if remain <= 0: break take = min(remain, Decimal(qty_left)) # 关键 2:WHERE 里再判一次余量,防止别的事务插队 cur.execute( "UPDATE stock_batch SET qty_left = qty_left - %s " "WHERE batch_id = %s AND qty_left >= %s", (take, batch_id, take)) if cur.rowcount != 1: raise InsufficientStock(f"批次 {batch_id} 扣减失败,余量已变化") cur.execute(""" INSERT INTO stock_flow (item_id, batch_id, biz_type, biz_no, change_qty, qty_after, operator) VALUES (%s, %s, %s, %s, %s, %s, %s) """, (item_id, batch_id, biz_type, biz_no, -take, Decimal(qty_left) - take, operator)) remain -= take detail.append((batch_id, take, unit_cost)) if remain > 0: raise InsufficientStock(f"商品 {item_id} 缺货 {remain}") return detailORDER BY expire_date IS NULL, expire_date, in_at这一行是核心。expire_date IS NULL在 MySQL 里返回 0 或 1,所以有保质期的商品排在前面,无保质期的沉底;有保质期的内部再按日期升序,实现先进先出兼临期优先。如果业务上就是纯 FIFO、不管效期,把日期那一段换成ORDER BY in_at即可。
FOR UPDATE锁住了这些批次行,同一商品的并发扣减会在这里排队。代价是并发度下降,但超市收银场景下同一 SKU 的并发冲突本来就低,实测没必要上更复杂的方案。如果担心锁等待,把innodb_lock_wait_timeout从默认 50 秒调到 5 秒,让冲突快速失败、由上层重试,比一直等着体验更好。
cur.rowcount != 1的校验看起来冗余,实际能挡住一类隐蔽的错误:如果隔离级别被改成读已提交以外的配置、或者有人手工把qty_left改成了负数,这行会立刻暴露问题,而不是等到月底对账才发现。
注意:整个扣减过程不要夹带任何外部调用。打印小票、调支付接口、发短信通知都必须放到事务提交之后。见过一个系统在事务里同步调打印服务,网络卡了 8 秒,那 8 秒里所有买同一瓶酱油的收银台全部排队,现场画面相当难看。
4. 三条主链路落地:采购入库、收银出库、盘点损益的代码骨架
4.1 采购入库:单据、批次、应付一次写完
收货是库存唯一的大额正向入口,也是最容易做成「只加数量」的地方。按批次走,入库函数的职责就很清楚:建批次、写流水、更新采购单状态,三件事在同一个事务里完成。
def purchase_receive(conn, po_no, lines, operator): """lines: [{"item_id":1, "qty":10, "unit_cost":9.2, "expire_date":"2026-06-30"}]""" with conn.cursor() as cur: # 幂等:同一采购单不能重复收货 cur.execute("SELECT status FROM purchase_order WHERE po_no = %s FOR UPDATE", (po_no,)) row = cur.fetchone() if row is None: raise ValueError(f"采购单 {po_no} 不存在") if row[0] != 0: raise ValueError(f"采购单 {po_no} 已收货,状态 {row[0]}") for idx, ln in enumerate(lines): batch_no = f"{po_no}-{idx:02d}" # 单号加行号,天然唯一 cur.execute(""" INSERT INTO stock_batch (item_id, batch_no, qty_in, qty_left, unit_cost, expire_date, in_at) VALUES (%s, %s, %s, %s, %s, %s, NOW()) """, (ln["item_id"], batch_no, ln["qty"], ln["qty"], ln["unit_cost"], ln.get("expire_date"))) batch_id = cur.lastrowid cur.execute(""" INSERT INTO stock_flow (item_id, batch_id, biz_type, biz_no, change_qty, qty_after, operator) VALUES (%s, %s, 'PURCHASE', %s, %s, %s, %s) """, (ln["item_id"], batch_id, po_no, ln["qty"], ln["qty"], operator)) cur.execute("UPDATE purchase_order SET status = 1 WHERE po_no = %s", (po_no,))batch_no用「采购单号 + 两位行号」生成,好处是能反查、能手写核对,不用再维护一个自增序列。qty_in和qty_left在入库时相等,后面所有出库只动qty_left,qty_in永远保留原始值,盘点时用来算「这条批次到底还剩多少比例」。
入库要同时挂应付账款,但应付不属于库存模型的核心,很多小系统直接省略。我的建议是至少留一张payable表,字段只要「供应商、采购单号、金额、已付金额、状态」,五个字段足够支撑后面按账期结算,真到要扩展时不用改库存表。
采购退货走的是反向流程,但不能简单地把qty_left减回去。正确做法是找到原批次、从qty_left里扣、写一条RETURN类型的负流水,并且校验扣减量不超过qty_in - qty_left(已经卖掉的部分不能退给供应商)。这个校验漏掉,退货退多了就会出现负库存,而对账的时候负库存最难解释。
4.2 收银出库:称重商品和条码商品必须分流
收银台是三条链路里唯一有实时性要求的。条码商品和称重商品在数据结构上的差异,比想象中更大:
| 维度 | 条码商品 | 称重商品 |
|---|---|---|
| 标识 | 厂商 69 码,全局唯一 | 店内码,电子秤当场生成 |
| 数量 | 整数件,扫码即 1 | 小数千克,来自电子秤 |
| 价格来源 | 商品主档售价 | 主档单价 × 重量 |
| 常见问题 | 一码多品、条码磨损 | 秤未归零、单位是克还是千克 |
称重商品的店内码一般有固定前缀,常见做法是用22开头加 5 位自编码,收银程序看到前缀就切到称重分支。这里最常出问题的是单位:电子秤有的输出千克,有的输出克,如果系统里按千克存、秤上按克传,一瓶 500 克的商品会被扣掉 500 千克,第二天库存直接归零,谁也说不清。
WEIGHT_PREFIX = "22" def checkout(conn, items, pay_type, order_no, cashier): """items: [{"barcode":"6901234567890","qty":1}, {"barcode":"2200012","qty":0.486}] qty 单位统一为件或千克""" total = Decimal("0") with conn.cursor() as cur: for ln in items: cur.execute( "SELECT item_id, sale_price, is_weight FROM item " "WHERE barcode = %s AND status = 1", (ln["barcode"],)) it = cur.fetchone() if it is None: raise ValueError(f"条码 {ln['barcode']} 不存在或已停用") item_id, price, is_weight = it qty = Decimal(str(ln["qty"])) # 称重商品必须两位以上小数,整数件商品不允许小数 if is_weight and qty <= 0: raise ValueError("称重数量必须大于 0") if not is_weight and qty != qty.to_integral_value(): raise ValueError(f"非称重商品数量非法:{qty}") amount = (price * qty).quantize(Decimal("0.01")) detail = deduct_fifo(conn, item_id, qty, order_no, biz_type="SALE", operator=cashier) for batch_id, take, cost in detail: cur.execute(""" INSERT INTO sale_order_item (order_no, item_id, batch_id, qty, price, amount) VALUES (%s, %s, %s, %s, %s, %s) """, (order_no, item_id, batch_id, take, price, (price * take).quantize(Decimal("0.01")))) total += amount cur.execute(""" INSERT INTO sale_order (order_no, total_amt, pay_type, status, cashier) VALUES (%s, %s, %s, 1, %s) """, (order_no, total, pay_type, cashier)) return total金额用quantize(Decimal("0.01"))强制四舍五入到分,是为了避免收银小票上出现19.900000000000002。sale_order_item按批次拆行存储,一条销售明细可能对应多条批次记录,这样退货时才能精确回到原批次、原成本。
这里有个业务判断要提前拍板:库存不足时是拒绝交易还是允许负库存先卖。我的默认做法是拒绝,但给店长留一个「强制出库」的开关,走单独的LOSS流水并记录操作人。允许负库存的口子一旦放开,不加限制,三个月后你会得到一张到处是负数的库存表。
4.3 盘点损益:盲盘、阈值和差异回写
盘点是唯一会「覆盖」库存的操作,做错一次,前后账全乱。
第一步是生成盘点任务,冻结范围:按分类冻结,或者按货架冻结。冻结的意思是,盘点单生成之后,该范围内的商品不允许再做采购收货和报损出库(销售是否冻结看业务,一般小店不冻结,靠时间差和复核弥补)。
第二步是盲盘。录入界面不显示系统账面数量,只让录入实盘数量。这一点在实操中非常重要——明盘时人会下意识地往系统数字上凑,盘出来的差异永远偏小。盲盘录完之后再并排显示账面数、实盘数、差异。
第三步是审批回写。差异不能一条条改库存,要在审批时一次性处理:
def apply_check(conn, check_no, approver, threshold=Decimal("0.02")): """把盘点差异回写成盘盈/盘亏流水;差异率超过阈值需二次确认""" with conn.cursor() as cur: cur.execute(""" SELECT l.item_id, l.book_qty, l.real_qty FROM stock_check_line l JOIN stock_check h ON h.check_no = l.check_no WHERE l.check_no = %s AND h.status = 1 """, (check_no,)) lines = cur.fetchall() for item_id, book_qty, real_qty in lines: diff = Decimal(real_qty) - Decimal(book_qty) if diff == 0: continue rate = abs(diff) / max(Decimal(book_qty), Decimal("0.001")) if rate > threshold: # 交给人工二次确认,不直接回写,避免录错一位数就改掉整批库存 cur.execute("UPDATE stock_check_line SET need_review = 1 " "WHERE check_no = %s AND item_id = %s", (check_no, item_id)) continue if diff > 0: # 盘盈:新建一个盘点批次,成本按最近一次进价 cur.execute(""" INSERT INTO stock_batch (item_id, batch_no, qty_in, qty_left, unit_cost, in_at) SELECT %s, CONCAT(%s, '-P'), %s, %s, COALESCE((SELECT unit_cost FROM stock_batch WHERE item_id = %s ORDER BY in_at DESC LIMIT 1), 0), NOW() """, (item_id, check_no, diff, diff, item_id)) else: # 盘亏:直接从现有批次里扣,走 LOSS 流水 deduct_fifo(conn, item_id, -diff, check_no, biz_type="LOSS", operator=approver) cur.execute("UPDATE stock_check SET status = 2 WHERE check_no = %s", (check_no,))阈值0.02是我常用的默认值,也就是差异率超过 2% 就转人工复核。这个值的来源是经验:正常损耗(水分流失、称重误差、零星破损)很少超过 2%,超过这个比例的差异更可能是录错数或者串码,直接回写会把错误固化进库存。
盘盈走「新建批次」而不是「加到原批次」,是因为盘盈的商品往往不知道是哪一批进来的,硬塞进老批次会让成本失真。新建批次的成本取最近一次进价,是一个可解释的近似。
提示:盘点单据一旦审批,就不要再允许修改。需要修正时生成一张新的调整单,把两次调整都留在流水里。事后改历史单据,是最容易把账做乱的操作。
本文还有配套的精品资源,点击获取