简介:面向超市运营与信息化管理学习者的进销存管理系统资料包,围绕商品进货、销售管理、库存统计与订单管理展开,并附带系统用例图,适合课程设计、毕业设计及中小型超市数字化管理方案参考。压缩包共103个文件,约2.03MB,主要由aspx页面、cs代码、css样式、jpg/gif/png/bmp界面图及mdl、mdf、ldf数据库文件构成,另有config配置与说明文档,能够呈现从前台页面到后台数据存储的较完整结构。已有4380人学习下载。读者可从中了解采购入库、销售出库、库存预警、订单跟踪等模块的实现思路,结合用例图梳理员工、顾客、供应商与系统之间的交互关系,并参考数据库模型和页面组织方式,为进销存系统的功能设计、课程实践或二次开发提供素材。
1. 从一张用例图开始:超市进销存到底要管几件事
接手一个超市进销存管理系统时,多数人第一反应是打开编辑器先建表。我一般会先停下来,要一张用例图。原因很实际:超市这门生意里,进货、销售、盘点三件事互相咬合,任何一方少写一条异常流,上线后就是账实不符。用例图不是给评审看的装饰画,它直接决定了后面建几张表、开几个接口、留几个权限位。这篇笔记按我做过的一个中型门店项目复盘,8000 多个 SKU,日单量 3000 上下,两条收银线加一个后台库房。从用例图怎么拆参与者,到进货销售盘点三条链路怎么落代码,再到月底盘亏时先查哪张表。适合正要动手写第一版的开发者,也适合系统已经跑了半年、总在盘点时对不上数的人。
2. 把用例图翻译成数据模型:商品、批次、单据怎么分层
用例图画完,第一件事不是写接口,而是把图上的椭圆逐个翻译成“谁写数据、写进哪张表”。这一步偷懒,后面所有对账问题都会变成玄学。
2.1 四类参与者倒推权限位
一个门店级进销存,用例图上的参与者通常不超过四类:收银员、库管员、采购员、店长。参与者不是画着好看的,每个参与者背后对应一组可触发的用例,以及一组数据可见范围。我习惯在用例图旁边列一张对照表,这张表后面会直接变成角色权限表的初始数据。
| 参与者 | 可触发用例 | 数据可见范围 | 落库角色 |
|---|---|---|---|
| 收银员 | 销售出库、退货、库存查询 | 仅本门店、仅当前班次单据 | CASHIER |
| 库管员 | 采购入库、报损、盘点、调拨 | 本门店全量商品与批次 | STOCK_KEEPER |
| 采购员 | 采购下单、供应商维护、进货价查询 | 全门店商品,看不到销售明细 | PURCHASER |
| 店长 | 全部用例 + 报表 + 权限分配 | 全部门店 | STORE_MANAGER |
很多项目在这里翻车,原因是把权限写成了代码里的if role == 'admin'。用例图上有几个参与者,代码里就该有几组权限点,而且权限点要绑定用例编号,不是绑定菜单。比如“盘点”这个用例,必然同时包含“生成盘点单”和“审核盘点差异”两个动作,只给库管员生成权限、不给审核权限,盘点单就永远卡在待审核状态。常见做法是建三张表:sys_role、sys_permission(字段里存用例编号,如UC-STOCK-03)、sys_role_permission。收银员点“退货”按钮时,后端校验的是UC-SALE-02这个权限点,而不是判断这个人是不是店长。这样做的好处是,用例图改一版,权限表跟着改一行,代码不用动。
2.2 商品主档与批次表:为什么不能只有一张 goods 表
新手最容易犯的错,是只建一张goods表,字段里塞stock_qty和cost_price。门店规模小的时候能跑,一旦出现“同一条码、两次进货价格不同”,成本就再也算不准了。
原因在于成本核算方式。超市常用移动加权平均法,入库时按“原库存金额 + 本次入库金额”除以总数量,重算一次成本价,销售时按当前成本价结转。这个算法要求每次入库都能拿到“本次入库价”和“入库前库存”,所以库存和成本必须能按批次追溯。生鲜、乳品这类还有效期,出库要按先到期先出,没有批次表根本做不到。
所以数据模型至少分三层:商品主档goods只管条码、名称、规格、分类、单位这些不随库存变化的信息;批次表goods_batch管某个商品某次进货的生产日期、到期日、入库成本、剩余数量;库存汇总stock管某个商品当前总数量、当前成本价,供收银端快速查询。三层之间用goods_id关联,批次表再加一个batch_no做幂等键,防止同一张入库单重复导入。
提示:商品主档里不要放
stock_qty。一旦放了,就会出现“主档数量”和“批次数量之和”两份真相,对不上时你不知道该信谁。
2.3 建表脚本与字段参数
下面是这套模型的核心 DDL(MySQL 8 写法,其他库把AUTO_INCREMENT换掉即可)。金额统一用DECIMAL(12,4),数量用DECIMAL(12,3),因为散装称重商品会出现 0.5 千克这种小数。
-- 商品主档:只放稳定属性 CREATE TABLE goods ( goods_id BIGINT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NOT NULL COMMENT '条码,门店内唯一', goods_name VARCHAR(128) NOT NULL, category_id BIGINT NOT NULL, unit VARCHAR(16) NOT NULL COMMENT '个/千克/箱', spec VARCHAR(64) DEFAULT NULL, is_batch_ctrl TINYINT NOT NULL DEFAULT 0 COMMENT '1=按批次管理,生鲜/乳品置1', status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_barcode (barcode), KEY idx_category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 批次表:成本与效期的唯一真相 CREATE TABLE goods_batch ( batch_id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, batch_no VARCHAR(64) NOT NULL COMMENT '入库单号+行号,做幂等', production_d DATE DEFAULT NULL, expire_d DATE DEFAULT NULL, in_price DECIMAL(12,4) NOT NULL COMMENT '本次入库单价', qty_left DECIMAL(12,3) NOT NULL DEFAULT 0, UNIQUE KEY uk_batch (goods_id, batch_no), KEY idx_expire (goods_id, expire_d) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存汇总:收银端高频读 CREATE TABLE stock ( goods_id BIGINT PRIMARY KEY, qty DECIMAL(12,3) NOT NULL DEFAULT 0, cost_price DECIMAL(12,4) NOT NULL DEFAULT 0 COMMENT '移动加权平均成本', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 入库单主表 + 明细 CREATE TABLE inbound_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, supplier_id BIGINT NOT NULL, total_amt DECIMAL(14,4) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已审核 2已冲红', created_by BIGINT NOT NULL, UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE inbound_item ( item_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, qty DECIMAL(12,3) NOT NULL, in_price DECIMAL(12,4) NOT NULL, batch_no VARCHAR(64) NOT NULL, KEY idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段里有几个参数值得单独说。uk_batch (goods_id, batch_no)这个唯一键是整个系统的防重复闸门,同一张入库单被误提交两次,第二次会直接报唯一键冲突,而不是把库存加两遍。idx_expire (goods_id, expire_d)是给出库用的,生鲜拣货按ORDER BY expire_d ASC走这个索引,几千个批次也不会慢。is_batch_ctrl这个开关是给非批次商品留的后路,矿泉水、纸巾这类不需要追溯的商品置 0,入库时自动生成一个虚拟批次,逻辑上统一,实现上不必为两类商品写两套代码。
stock.cost_price不设默认值 0 之外的含义,它只由入库和退货两种动作改写,盘点、报损只改数量不改成本价。这条约定写进表注释,能省掉后面无数次“这个字段到底谁在写”的争论。
3. 用例图落到接口:进货、销售、盘点三条主链路的实现
用例图上的椭圆是业务语言,接口是机器语言,中间的翻译工作有三处最容易出问题:入库怎么改成本价、出库怎么防超卖、盘点怎么处理差异。下面按三条链路分别落代码。
3.1 采购入库:一张入库单如何同时改库存和成本价
入库要在一个事务里做完四件事:写主表、写明细、写批次、更新汇总库存与成本价。顺序不能乱,先写明细和批次,再更新汇总,最后回写主表状态。移动加权平均的公式是:
新成本价 = (原库存数量 × 原成本价 + 本次入库数量 × 本次入库价) ÷ (原库存数量 + 本次入库数量)
def receive_inbound(conn, order_no, items): """items: [{'goods_id':1,'qty':10,'in_price':3.5,'batch_no':'B001','expire_d':'2025-06-01'}]""" with conn.transaction(): order_id = insert_inbound_order(conn, order_no, items) # 主表,状态0 for it in items: insert_inbound_item(conn, order_id, it) # 明细 insert_batch(conn, it) # 批次,唯一键防重 # 行锁读当前库存,避免并发入库算错成本 row = conn.execute( "SELECT qty, cost_price FROM stock WHERE goods_id=%s FOR UPDATE", (it['goods_id'],) ).fetchone() old_qty = row.qty if row else 0 old_cost = row.cost_price if row else 0 new_qty = old_qty + it['qty'] new_cost = ((old_qty * old_cost) + (it['qty'] * it['in_price'])) / new_qty upsert_stock(conn, it['goods_id'], new_qty, round(new_cost, 4)) update_order_status(conn, order_id, 1) # 审核通过 return order_id逻辑上有两个关键点。第一,SELECT ... FOR UPDATE必须在更新汇总之前执行,把这一行锁住,否则两个库管同时入库同一商品,成本价一定算错。第二,new_qty为 0 的边界要单独判断,虽然业务上不会出现零数量入库,但导入脏数据时可能发生,除以零会让整个事务回滚,日志里只看到一条除零异常,排查半天才发现是数据问题。
参数方面,in_price传的是不含税单价还是含税单价,必须在接口层定死。我的做法是统一传不含税价,税率在供应商档案里存,入库时算一次税额写进明细表,报表要用含税金额时现算。这样做的代价是明细表多两个字段,好处是税率调整时历史单据不受影响。
3.2 销售出库:并发扣库存的两种写法与选择
收银端是并发最高的地方,两条收银线同时卖最后一瓶水,库存扣成负数就是事故。常见有两种写法:悲观锁和条件更新。
悲观锁就是上面入库用的SELECT ... FOR UPDATE,读出来判断够不够,够再更新。适合一次要扣多个商品的场景,比如组合套餐。缺点是锁持有时间长,高峰期容易排队。
条件更新更适合单品扣减,一条 SQL 搞定:
UPDATE stock SET qty = qty - %(qty)s, updated_at = NOW() WHERE goods_id = %(goods_id)s AND qty >= %(qty)s;执行后看rowcount,为 0 说明库存不足或商品不存在,直接给收银端返回“库存不足”,不要先查再改。这种写法在 MySQL 的 InnoDB 下靠行锁保证原子性,并发 500 单每秒的压测里没出现过超卖。代价是拿不到扣减前的成本价,需要另起一条查询读cost_price做毛利计算。
def sale_out(conn, sale_no, items, cashier_id): with conn.transaction(): sale_id = insert_sale_order(conn, sale_no, cashier_id) for it in items: cur = conn.execute( "UPDATE stock SET qty=qty-%s WHERE goods_id=%s AND qty>=%s", (it['qty'], it['goods_id'], it['qty']) ) if cur.rowcount == 0: raise BizError(f"商品{it['goods_id']}库存不足") # 批次商品走 FEFO:按到期日升序扣 if it.get('is_batch_ctrl'): deduct_batch_fefo(conn, it['goods_id'], it['qty']) insert_sale_item(conn, sale_id, it) return sale_id有个坑要说清楚:批次扣减和汇总扣减必须在同一事务里,且顺序是先扣汇总再扣批次,还是反过来?我试过先扣批次,出现过“批次够了但汇总不够”导致事务回滚、批次表留下脏数据的情况(某些库的隔离级别下)。统一改成先扣汇总、rowcount校验通过后再扣批次,回滚时两边一起回滚,行为可预期。deduct_batch_fefo内部按expire_d ASC逐批扣,扣到数量为 0 为止,扣不满就抛异常回滚。
3.3 盘点单:快照、差异、调整三步走
盘点是进销存里最容易引起争议的功能,因为盘点期间还在营业,一边数数一边卖货,数出来的数永远对不上。我的做法是把盘点拆成三个明确的阶段,每个阶段落一个状态位。
第一步生成盘点单时,把当前stock.qty复制一份到stock_check_item.book_qty,这个字段叫账面快照,生成后不再变。库管拿着打印单去数,数出来填real_qty。第二步提交时算差异diff_qty = real_qty - book_qty,只写差异表,不动库存。第三步由店长审核,审核通过才按差异调整stock和批次表,同时写一条stock_adjust_log记录调整前后数量、操作人、审核人。
-- 生成盘点单:账面快照一次性写入 INSERT INTO stock_check_item (check_id, goods_id, book_qty, real_qty) SELECT %(check_id)s, goods_id, qty, NULL FROM stock WHERE goods_id IN (SELECT goods_id FROM goods WHERE status = 1); -- 审核后调整:只调差异,且加条件防重复审核 UPDATE stock s JOIN stock_check_item i ON s.goods_id = i.goods_id SET s.qty = s.qty + (i.real_qty - i.book_qty) WHERE i.check_id = %(check_id)s AND i.real_qty IS NOT NULL AND EXISTS (SELECT 1 FROM stock_check c WHERE c.check_id = i.check_id AND c.status = 1);WHERE c.status = 1这个条件是防重复审核的后悔药。审核接口被点两次、或者前端重试,第二次执行时状态已经不是 1,rowcount为 0,直接返回“单据已审核”。很多盘亏事故追到最后,不是数错了,是差异被调了两遍。
4. 用例规约怎么写才不返工:从参与者到异常流
用例图画出椭圆只是开始,真正决定开发返工率的是规约。我见过太多项目,图上画了“退货”这个用例,开发照着做了个“原路退款”功能,上线才发现门店要的是“换货补差”,两边理解完全不同。
4.1 用例图的三层结构与 include/extend 的边界
一张能用的用例图,结构上分三层:最外层是系统边界矩形,里面放用例椭圆;边界外画参与者小人;边界之间用关联线连。系统边界不是越大越好,它应该只框住“本系统负责的动作”,供应商管理、支付通道这些外部系统放在边界外,用参与者或者外部系统框表示。
真正容易画错的是 include 和 extend 这两个关系。include是“必然发生”的抽取,比如“销售出库”必然包含“扣减库存”,箭头从销售指向扣库存。extend是“条件触发”的扩展,比如“销售出库”在顾客要求退货时扩展出“退货”,箭头从退货指向销售,且要标注扩展条件。画反了会直接导致代码结构错:把退货画成 include,开发就会在每次销售时都执行一遍退货逻辑;把扣库存画成 extend,开发就会漏掉扣库存。
注意:extend 的箭头方向是从扩展用例指向基础用例,很多人画成反的。记不住就记一句话——扩展是“偶尔插一脚”,箭头从插脚的一方指回主流程。
4.2 一个用例至少写五段
用例规约我坚持写五段,少一段后面就要补沟通成本。以“采购入库”为例:
第一段写编号与名称,UC-STOCK-01 采购入库,编号要和权限表、测试用例、接口文档三处对齐,全程可追溯。第二段写参与者与目标,主参与者是库管员,目标是把供应商送来的货登记进系统并让库存可用。第三段写前置条件,采购单已审核、供应商已建档、操作人有入库权限。第四段写主成功场景,按编号步骤列:扫码或手输条码 → 系统带出商品与建议进货价 → 输入实收数量与生产日期 → 提交 → 系统写单据、写批次、更新库存与成本价 → 打印入库单。第五段写扩展流,每条都要带触发条件:条码不存在则提示建档;实收数量与采购数量不一致则标记差异并通知采购员;批次重复提交则提示“该入库单已处理”。
五段里最容易被省略的是第五段。省略的后果是,开发只写了 happy path,库管遇到差异数量时系统不让提交,只能手工改数据库,改完就再也没人知道真实库存是多少。
4.3 用表格把规约固化成验收项
规约写完,我习惯再压成一张验收表,每行对应用例图上的一个椭圆,测试直接照这张表点。这张表同时也是给店长的说明书,比任何操作手册都好用。
| 用例编号 | 用例名 | 主参与者 | 关键异常流 | 验收判定 |
|---|---|---|---|---|
| UC-STOCK-01 | 采购入库 | 库管员 | 数量差异、批次重复、条码不存在 | 入库后库存增加、成本价重算正确 |
| UC-STOCK-02 | 报损出库 | 库管员 | 报损数量超库存、审批人缺失 | 库存减少,成本价不变 |
| UC-STOCK-03 | 库存盘点 | 库管员+店长 | 差异超阈值需二次审批、重复审核 | 快照不变,差异只调一次 |
| UC-SALE-01 | 销售出库 | 收银员 | 库存不足、批次过期 | 不出现负库存,过期批次不出库 |
| UC-SALE-02 | 销售退货 | 收银员+店长 | 原单不存在、超期退货 | 库存回补,金额按原单退 |
阈值这个参数值得单独定。差异数量占账面数量的比例超过 5%,或者差异金额超过 200 元,强制走店长二次审批。这个阈值写进配置表,不同门店可以调,不要硬编码在代码里,否则新开一家店就要改一次代码重新发版。
5. 排查:用例图画得漂亮,上线还是对不上账
系统跑起来之后,账实不符的原因通常不在业务逻辑,而在几个固定位置。下面五条是我这几年重复遇到最多的。
5.1 库存扣成负数
现象:盘点时发现某商品库存是 -3,但系统日志里没有任何报错。
原因:扣减语句用了“先查再改”的写法,查的时候库存够,改的时候被另一个事务抢先扣掉了。或者用了UPDATE stock SET qty = qty - 1但没加AND qty >= 1条件,数据库不会拦。
解决:所有扣减统一改成条件更新,校验rowcount。已经出现负库存的,先跑一条核查 SQL 找出所有qty < 0的商品,人工核对最近三个月的出入库流水,把差额用盘点单调平,不要直接UPDATE改数,否则审计日志断链。
5.2 金额用 float,月底差几毛
现象:销售日报的金额合计和收银机汇总差 0.3 元,查单据每一笔都对。
原因:金额字段用了FLOAT或DOUBLE,累加时二进制精度丢失。单品价格 3.3 元这种数在 float 里存不精确,几万笔累加后误差被放大。
解决:所有金额字段改DECIMAL(12,4),Python 侧用decimal.Decimal,不要用float中转。已经上线的库改字段类型要做数据迁移,迁移脚本里用CAST(amt AS DECIMAL(12,4)),迁完跑一遍对账,把历史误差用调整单补上。
5.3 成本价漂移
现象:某商品这个月没进过货,成本价却变了,毛利报表跟着跳。
原因:退货逻辑写错了。销售退货时按“当前成本价”回补库存,但这批货当时是按两周前的入库价卖出的,价格已经变过。正确做法是按原销售单关联的批次成本回补,找不到原批次才退化到当前成本价,并记一条警告日志。
解决:退货明细表加origin_batch_id字段,销售时把批次 ID 写进销售明细,退货时顺着读。历史单据补不了,只能从上线日之后开始准确,之前的月份在报表里标注“成本口径变更”。
5.4 用例图画了 extend,代码里没实现
现象:店长反馈“顾客要退货,收银员说系统里没有这个按钮”。
原因:用例图上退货用 extend 挂在销售出库下面,开发只做了主流程,扩展流被当成“二期需求”漏掉了。规约里的第五段(扩展流)是空的。
解决:上线前拿验收表逐行走查,每个用例至少点一次异常流。更彻底的做法是,接口文档里每个主接口必须对应列出异常码,没有异常码的接口不给过评审。
5.5 盘点期间收银卡死
现象:门店做月度盘点,前台收银录入时转圈十几秒,最后超时。
原因:盘点生成快照时用了SELECT ... FOR UPDATE锁全表,几十万行锁住,收银的扣减语句全部排队。
解决:快照生成用普通查询,不加锁,允许盘点期间库存变动,差异按“盘点开始时间点”和“审核时间点”两个维度分别记录。真要冻结,只冻结被盘的商品,按分类分批生成快照,一次不超过 500 个 SKU。这个改动上线后,盘点当天的收银超时投诉基本归零。
6. 让用例图变成验收清单:三个自检动作
系统做到能跑不难,做到月底敢让财务对账才算过关。我现在交付前固定做三个动作。
第一个动作是把用例图打印出来,拿红笔在每个椭圆上标一个数字,代表这个用例对应的接口数。数字为 0 的直接标红,说明图上有、代码里没有。第二个动作是拿验收表随机抽三个用例,手动制造异常流,比如入库时故意填一个不存在的条码,看系统是给出明确提示还是抛 500。第三个动作是跑一遍“三天对账”:第一天正常进销,第二天做一次退货和一次报损,第三天做一次小盘点,然后核对stock.qty是否等于期初 + 入库 - 出库 - 报损 + 退货 + 盘盈盘亏。三个动作加起来不到两小时,能挡掉大部分上线后的对账事故。
| 自检动作 | 检查对象 | 判定标准 | 耗时 |
|---|---|---|---|
| 椭圆标数字 | 用例图 vs 接口文档 | 每个椭圆接口数 ≥ 1 | 30 分钟 |
| 抽三个用例造异常 | 错误处理 | 返回业务错误码,非 500 | 40 分钟 |
| 三天对账 | 库存汇总 vs 流水 | 差额为 0 或已登记差异 | 45 分钟 |
还有个习惯值得提:每次改动涉及库存或金额的代码,我都会在本地库先灌 10 万条模拟流水跑一遍对账脚本,确认差额为 0 再提交。这个脚本只做一件事,把所有流水按商品汇总,和stock表逐行比。血泪经验是,这个脚本帮我抓到过两次“事务提交顺序写反”的问题,都是那种压测发现不了、只有跑真实数据量才会暴露的坑。
用例图画得再规范,也只是把需求说清楚的第一步。真正决定这套超市进销存管理系统能不能长期用下去的,是每一条能扣库存的语句后面那个rowcount判断,和每一张单据上那个不会被重复执行的幂等键。希望帮到你。
本文还有配套的精品资源,点击获取