ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

超市进销存管理系统实现:用例图、数据库建模与事务并发控制

超市进销存管理系统实现:用例图、数据库建模与事务并发控制

简介:超市进销存管理系统配套资源,面向超市运营人员、管理信息系统学习者及.NET开发工程师,覆盖商品进货、销售管理、库存统计、订单管理等核心业务环节。压缩包共103个文件,约2.03MB,包含24个C#源码文件(业务逻辑)、18个aspx页面(界面层)、CSS样式与图片素材(界面设计),以及mdf/ldf数据库文件、mdl模型和用例图,可直观了解系统功能与用户交互。已有4379人学习浏览,资源通过用例图清晰展示了员工、顾客、供应商与系统的协作关系,并提供了库存预警、销售趋势分析等模块的实现思路。适合作为课程设计参考或中小型超市信息化改造的基础模板,便于在此基础上扩展会员管理、报表统计等功能。

1. 先从不靠谱的库存账本说起:为什么动手前必须画用例图

带过太多人写超市进销存管理系统,见过最典型的翻车现场是:系统上线演示时,库存表里剩 5 件商品,实际货架上有 12 件,老板一盘点就露馅;又或者是收银台卖出去的货没有扣减库存,后台库存越跑越多,最后成了黑匣子,谁都不知道哪笔数据是对的。这类问题的根源不是 SQL 写得差、不是数据库选错了,而是项目从一开始就没有把「谁、在什么场景下、对商品做什么操作」这件事梳理清楚。超市进销存管理系统听起来就是进货、卖货、管库存三件事,但加上退货、报损、盘点、调拨、供应商结算、会员价之后,功能边界立刻糊成一团。用例图的价值恰恰在这里:它用一张图把参与者、功能模块和使用场景之间的关系钉死,让开发照着图写代码,让测试照着图验证,让老板照着图确认需求。这篇文章从用例图出发,一路拆到数据库表结构、后端事务代码和库存一致性校验,最后落在一套能直接抄作业的实现路径上。

2. 用例拆分与用例描述:把业务写成能让开发直接开工的需求规格

2.1 三类参与者与十三个核心用例

画超市进销存管理系统的用例图,第一步不是画椭圆,而是先定参与者。常见的做法是让系统范围涵盖日常运营的三类人物角色:负责进货和供应商往来的采购员、在前台处理销售和退货的收银员、以及拥有最高数据权限的店长。有人会把「供应商」也画成参与者,这在用例图里是允许的——供应商发起送货、接收退货单确实是一个外部交互场景——但对大部分课设和内部管理系统来说,把供应商相关操作收敛到采购员用例里,系统的边界会清爽很多。我一般维护三个参与者,把供应商当作档案数据而不是外部角色来管理。

参与者核心职责关联用例
采购员补货、退货给供应商、查询库存进货入库、采购退货、库存查询、供应商档案维护
收银员前台卖货、处理顾客退货销售开单、销售退货、小票打印、会员价核算
店长全局掌控、纠错、调价盘点录入、报损报溢、调拨管理、统计报表、库存预警、基础档案维护

用例图里每个椭圆就是一个用例,但椭圆与椭圆之间不要乱连线。很多初学者会把「进货入库」和「库存查询」之间画一条 include 关系,理由是入库后库存变了、查询结果也会变——这是逻辑上的先后关联,不是用例图上的关系。用例图的关系只有 include(一个用例必然包含另一个用例)、extend(一个用例在特定条件下扩展另一个用例)、以及参与者和用例之间的关联线。举个例子,「销售开单」在扫码枪识别不出条码时扩展出「手动输入条码」,这就是一条典型的 extend;「进货入库」必然需要「登录系统」就可以用 include 表达,但登录这种公共用例,我一般干脆画在系统边界之外,不污染业务图。

2.2 用例描述的字段长什么样

用例图本身是有极限的,椭圆只能告诉你有这件事,不能告诉系统该返回什么异常。真正让开发照着写代码的是用例描述(use case specification),通常一张用例图配一个描述表格。关键的字段是:前置条件、主流程(编号步骤)、扩展流程、后置条件。这在课设文档里是必写的,在真实项目里更是评审核心。下面把「进货入库」这个最核心的用例写成可执行的描述。

字段内容
用例名称进货入库
参与者采购员
前置条件采购员已登录;商品档案已维护(商品存在);
该订单对应的采购单状态为「待入库」
主流程1. 采购员录入或扫描商品编码
2. 系统校验商品存在且允许采购
3. 采购员输入进货数量、进价、生产日期与保质期
4. 系统校验数量为正数、进价不低于商品档案中的最低进价
5. 系统写入进货明细、更新库存表、生成入出库流水
6. 如该商品有保质期信息,同步生成批次库存记录
7. 系统更新采购单状态为「已完成」
扩展流程3a. 商品已过保质期或临期,系统给出警告并要求二次确认
4a. 进价低于最低进价时,系统校验当前用户是否有店长授权
5a. 库存表并发更新失败,回滚整个事务并提示重试
后置条件库存表数量增加;流水表新增一条入库记录;采购单状态置为已完成

写完这张表,后端开发的工作量基本能估出来:一个入库接口、一个库存更新事务、一个流水的插入逻辑、一个状态流转判断。用例图让你看清边界,用例描述让边界变成实现细节。这一步省下来,后面写事务代码基本靠猜——猜出来的代码上线就跑不了账。

2.3 公共用例与系统边界的取舍

还有一类让人反复纠结的用例:登录、修改密码、操作日志、数据备份。它们不属于超市业务,但每个角色都要用。我习惯把它们收进一个「系统管理」分组,在用例图里单独用包(package)框起来。这么做的好处是,核心用例图不会因为塞入登录这种公共用例而变得拥挤,文档评审时也能一眼看出系统的业务主链。另有一个容易踩的坑:把「库存查询」当作和「进货入库」平级的用例画在采购员下面,但收银员也要查库存。正确的画法是把「库存查询」独立画在系统内部,分别拉两条关联线连到采购员和收银员;而不是复制两个同名椭圆。一次性把用例名称和维护矩阵定准,数据库表设计时自然就有参照。

用例分组包含用例适用参与者
采购管理进货入库、采购退货、供应商档案维护采购员
销售管理销售开单、销售退货、小票打印收银员
库存管理库存查询、盘点录入、报损报溢、调拨管理店长、采购员
系统管理登录、用户权限维护、操作日志查询店长

3. 数据库建模:库存表与流水表的主次关系怎么定

3.1 表结构设计的关键决策

数据库设计是进销存的重灾区。新手看进销存,第一反应是建一张库存表,字段是商品名、数量、进价、供应商,然后所有逻辑都在这张表上硬算。这套设计只能撑住单机演示,只要出现一次退货、一次盘点盈亏、一次报损,数据就乱得没法对账。做进销存的通用方案是把账和物分开:库存表(当前快照)负责快速读,入出库流水表(流水明细)负责追根溯源。流水表才是老大,库存表是它的增量投影。库存数量要能从流水表重新算出来,这件事是进销存系统的账实相符的地基。

拿商品档案、库存快照、入出库流水、进货单、销售单、盘点单这六类主体来说,核心表就可以按以下结构设计。字段选用是多年下来最省心的一套,不刻意追求范式最整洁,但每张表都能交代清楚「这笔数据从哪来」。

3.2 核心建表 SQL 与初始化语句

-- 商品档案表 CREATE TABLE t_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_code VARCHAR(32) NOT NULL COMMENT '商品编码,扫码枪读取的条码', product_name VARCHAR(64) NOT NULL COMMENT '商品名称', category VARCHAR(16) COMMENT '分类,如饮料/日化/生鲜', sale_price DECIMAL(10,2) NOT NULL COMMENT '标准售价', low_stock INT NOT NULL DEFAULT 0 COMMENT '库存预警下限', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', UNIQUE KEY uk_code (product_code) ) COMMENT '商品档案'; -- 库存快照表 CREATE TABLE t_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL COMMENT '商品档案id', quantity INT NOT NULL DEFAULT 0 COMMENT '实时库存数量', lock_version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_product (product_id) ) COMMENT '库存快照';

注意上面这段 SQL 里的 lock_version,是专门留给并发控制的乐观锁字段。对库存这种高频写字段,直接用 update t_stock set quantity = quantity + 1 在低并发下没毛病;但秒杀、促销场景下会出现超卖,乐观锁可以让冲突的写请求直接失败并让用户重试,而不是把库存扣成负数。

-- 入出库流水表:账实相符的审计基础 CREATE TABLE t_stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, change_type TINYINT NOT NULL COMMENT '1入库 2销售出库 3采购退货 4盘点盘盈 5盘点盘亏 6报损', change_qty INT NOT NULL COMMENT '正数增加,负数减少', before_qty INT NOT NULL COMMENT '操作前快照库存', after_qty INT NOT NULL COMMENT '操作后快照库存', ref_bill_no VARCHAR(32) NOT NULL COMMENT '关联单号,如PO20250101001', operator_id BIGINT NOT NULL COMMENT '操作人', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_product_time (product_id, create_time) ) COMMENT '库存流水表';

流水表里 before_qty 和 after_qty 两个字段很关键。当天对账、排障时,直接看某件商品的操作前后值就能定位是哪一笔操作把数量改错了;没有这两个字段,出了问题只能靠猜。ref_bill_no 把流水和业务单据挂钩,这是审计和核对单据一致性的唯一线索。

各表之间还有引用完整性:商品档案被库存快照引用、流水表引用商品 id,这些外键在演示项目里可以不用建物理外键,用逻辑外键(冗余 product_id)就行。物理外键在分批导入和删单时会带来连锁阻塞,一个最典型的问题是:删商品档案时如果被流水表引用,外键约束会让删除失败且报错不够直观,实际踩一次就知道多难受。

3.3 库存初始化和幂等约束

库存初始化最常见的方法是:盘点当前仓库实际商品数量,为每个商品 INSERT 一条 t_stock 记录,同时在 t_stock_flow 里写一条 type=4(盘盈)的初始记录。这比单独把初始库存置为 0 更合理,因为从第一天起库存表就能和流水表相互审计。

-- 初始化库存并写入流水(注意必须在一个事务里) START TRANSACTION; INSERT INTO t_stock (product_id, quantity, lock_version) VALUES (1, 50, 0); INSERT INTO t_stock_flow (product_id, change_type, change_qty, before_qty, after_qty, ref_bill_no, operator_id) VALUES (1, 4, 50, 0, 50, 'INIT20250101', 1); COMMIT;

这个事务的时序是有讲究的:先写流水、后写快照,或者两者同时提交。顺序无所谓,但必须保证要么都成功、要么都失败。另一个要提防的是重复入库:同一张采购单被提交两次,库存加了两遍。最稳妥的方案是在采购单表加一个 status 字段,入库时用「UPDATE t_purchase_order SET status = 2 WHERE id = ? AND status = 1」这种条件更新来夺锁。受影响行数为 0,说明这张单已经被处理过,直接拒绝第二次入库请求。这就是幂等控制。

4. 后端落地:从用例图到 Service 方法的实现路径

4.1 分层结构与用例的映射关系

用例图到代码的映射不需要任何高深设计模式,常规做法是分层:Controller 接收参数,Service 落业务逻辑,Mapper 操作数据库。复杂项目还会加 Manager 层处理跨模块事务,但超市进销存管理系统的边界足够清晰,三层就够用。Controller、Service、Mapper 的文件名尽量从用例名翻译过来,这样以后回看项目,对照用例图找人问代码,效率会高不少。

用例名称ControllerService 核心方法
进货入库PurchaseInControllerdoPurchaseIn(PurchaseInCommand)
销售开单SaleOrderControllercreateSaleOrder(SaleOrderCommand)
盘点录入StockTakeControllersubmitStockTake(StockTakeCommand)
库存查询StockQueryControllerqueryStock(productCode, page, size)

4.2 进货入库的 Service 实现与事务边界

进货入库的完整逻辑是:校验采购单状态、写进货明细、更新库存、写流水、更新采购单状态。这五步必须在同一个事务里,任何一个环节失败都要全部回滚。下面给出核心 Service 方法的骨架,代码可以说明事务和并发锁的实际落点。

@Service public class PurchaseInService { @Transactional(rollbackFor = Exception.class) public void doPurchaseIn(PurchaseInCommand cmd) { // 1. 幂等校验:只有状态为“待入库”的采购单才能入库 PurchaseOrder order = purchaseOrderMapper .selectByIdForUpdate(cmd.getOrderId()); if (order == null || !order.isPending()) { throw new BizException("采购单不存在或已入库,禁止重复提交"); } // 2. 对库存行加悲观锁,锁住商品对应的库存记录 Stock stock = stockMapper.selectByProductIdForUpdate(cmd.getProductId()); if (stock == null) { throw new BizException("该商品未初始化库存"); } int beforeQty = stock.getQuantity(); int afterQty = beforeQty + cmd.getQuantity(); stock.setQuantity(afterQty); stockMapper.updateById(stock); // 3. 写入库存流水,保留操作前后快照 stockFlowMapper.insert(StockFlow.builder() .productId(cmd.getProductId()) .changeType(StockFlowType.PURCHASE_IN.getCode()) .changeQty(cmd.getQuantity()) .beforeQty(beforeQty) .afterQty(afterQty) .refBillNo(cmd.getOrderNo()) .operatorId(cmd.getOperatorId()) .build()); // 4. 更新采购单状态,完成入库流程 purchaseOrderMapper.updateStatus(cmd.getOrderId(), PurchaseOrderStatus.COMPLETED.getCode()); } }

这段代码里的 selectByIdForUpdate 是悲观锁的核心。它把采购单行锁住,防止两个入员同时对同一张单操作;selectByProductIdForUpdate 锁住库存行,保证 beforeQty 读到的是最新值。悲观锁适合进销存后台这种低并发、强一致的场景。缺点是锁的粒度粗、并发能力弱,但对超市内部系统来说,完全够用且逻辑简单。

4.3 并发控制:乐观锁与悲观锁怎么选

销售场景与进货场景的锁选择不同。收银台是高频操作,如果每个销售单都用 select for update,数据库的锁竞争相当大;而且销售出库的核心矛盾是库存不能为负,可以采用乐观锁改造。下面是一条典型的变更 SQL,它在扣减时校验版本号,冲突就让业务层抛异常重试。

-- 乐观锁扣减库存,返回0表示版本冲突,本次扣减失败 UPDATE t_stock SET quantity = quantity - #{qty}, lock_version = lock_version + 1, update_time = NOW() WHERE product_id = #{productId} AND quantity >= #{qty} AND lock_version = #{expectedVersion}

这条 SQL 的两个条件:quantity >= qty 是业务约束,保证不能扣成负数;lock_version = expectedVersion 是并发控制,保证没有人改过库存快照。进销存项目里我偏好把数量约束放在 SQL 里而不是只放在 Java 代码里,原因是 Java 的 if 判断有并发间隙,而 SQL 的条件判断和更新是一个原子操作。

方案适用场景优点缺点
悲观锁(for update)采购入库、盘点审核逻辑简单,读到的值一定最新并发低,锁等待可能拖慢请求
乐观锁(version)销售出库、促销扣减吞吐高,不会阻塞读冲突多时用户体验差,需要重试
条件更新(quantity >= qty)所有库存扣减天然防负库存无法单独处理并发,须配合锁或版本号

5. 进销存避坑:盘点轧差、负数库存、删单断链四类经典故障

5.1 盘点单轧差后账实不符

现象:盘点单录入实际数量后,系统的库存数没有变成盘点数,反而是盘点单保存了差异、库存表纹丝不动。第二天盘点差异报表里差异数越滚越大。原因:盘点单审核时只更新了盘点单自身的状态,没有把盈亏数同步到库存表。解决:盘点审核必须是一个事务,先根据盘点数减账面数算出盈亏,再调用库存更新逻辑,同时写入盘盈盘亏流水。血泪经验是盘点流程一定要有一张盘盈盘亏单做中间载体,直接改库存和在盘点单上记录差异是两种完全不同的说法,前者没有审计痕迹。

5.2 收银并发导致库存变成负数

现象:促销期间同一个 SKU 被两个收银台同时下单,库存剩余 1 件,两单都成功了,库存变成 -1。原因:Service 层先查库存再判断再更新,查和更新之间隔了一条网络 I/O,另一个请求趁虚而入。解决:把库存判断下推到 SQL 的 where 条件里,配合受影响行数判断是否超卖。如果项目已经用了乐观锁,让失败请求重新读取库存并提示用户减少购买数量。

5.3 一键删单导致流水断链

现象:发现某张销售单开错了,开发图省事直接 delete 掉销售单和对应流水。一个月后对账时,流水金额与进货记录对不上,又找不到是谁删的。原因:单据删除属于物理删除,破坏了一单一流水的审计链。解决:零容忍物理删单。所有单据只做状态流转,比如销售单增加「作废」状态,作废时库存回补,并写一条 type=作废回补 的流水。平时只允许对未审核单据做反审核,已审核的单据必须走红冲流程。

5.4 收银员直接改数据库

现象:某收银员权限配置失误,前端能访问库存调整接口,把 0 库存改成 1000。原因:用例图里画的权限边界没有落到接口鉴权上。解决:权限校验放在网关或拦截器层,每个接口都绑定角色码。库存调整和盘点审核这类敏感操作,在日志里记录 operator_id、操作时间和变更前后的数值。数据权限和功能权限要同时配置,某员工既能看销售单又能改库存,这在权限模型里是不允许出现的组合。

5.5 库存表与流水表对不齐时怎么定位

现象:后台界面显示库存 35,把流水表 SUM 一遍却是 42。原因:大概率是某条业务逻辑只更新了库存表或只写了流水,典型场景是定时任务回补库存时漏写了流水。解决的顺序是先订正、后复盘。订正 SQL 一般这样写:先把库存表数量归零,再用流水表分组求和回填。但必须强调,这只是对账缝合手段,不会解决业务漏洞;根因在 Service 层没有把库存更新和流水插入放进同一事务。

-- 对账缝合:以流水表为准重建库存快照(仅在排查期使用) UPDATE t_stock s LEFT JOIN ( SELECT product_id, SUM(change_qty) AS net_qty FROM t_stock_flow GROUP BY product_id ) f ON s.product_id = f.product_id SET s.quantity = COALESCE(f.net_qty, 0) WHERE s.id IS NOT NULL;

这条 SQL 跑完必须立刻做一次全量盘点复核,因为如果流水表本身少了一笔,缝合后库存会保持错误的正确。定期对账的方式是每天凌晨跑库存快照校验,把异常项推进告警队列。

6. 验证方法:用用例图反推测试用例,给系统上一道保险

6.1 用例描述转测试用例:每个用例至少两条路径

用例描述表里的主流程和扩展流程,就是测试用例的来源。拿「进货入库」来演:主流程测正向入库,验证库存增加、流水生成、采购单状态变化;扩展流程测超量入库(数量超采购单剩余量)、过期商品、进价越权、重复提交。每一条扩展流程都对应一个异常分支,这些分支覆盖得越全,协作方交付的时候就越硬气。

6.2 库存一致性自动化校验脚本

如果系统已经有测试环境,可以写一段 Python 脚本定期对账,逻辑是:连接数据库分别查询库存快照和流水结果,对比每个商品的两个数字。这个脚本跑在凌晨任务里,任何一次差异都会把商品编码和差异数打进日志,第二天上班第一件事就是排查。

import pymysql conn = pymysql.connect(host="10.0.0.5", user="store_reader", password="****", database="store_db") cur = conn.cursor() # 两侧来源数据查询(只读操作,不会污染业务库) cur.execute("SELECT product_id, quantity FROM t_stock") stock_map = {pid: qty for pid, qty in cur.fetchall()} cur.execute("""SELECT product_id, SUM(change_qty) FROM t_stock_flow GROUP BY product_id""") flow_map = {pid: qty for pid, qty in cur.fetchall()} # 对比两个数据源,找出不一致的商品 diff = [] for pid in set(stock_map) | set(flow_map): a, b = stock_map.get(pid, 0), flow_map.get(pid, 0) if a != b: diff.append((pid, a, b)) for pid, a, b in diff: print(f"商品id={pid}, 库存快照={a}, 流水净变化={b}")

脚本里的两个字典分别快照了两个数据源,diff 列表就是差异清单。这个方案跑在只读账号上,不会对业务数据产生副作用。对账如果天天有差异,优先查 Service 层有没有漏掉流水的分支,而不是怀疑脚本。

6.3 进阶方向:缓存与消息队列的边界在哪里

系统跑顺畅之后,有人会问要不要上 Redis 缓存库存、用 MQ 异步扣减。我的看法是:超市进销存管理系统的量级完全不需要。Redis 缓存库存适合高并发秒杀,但会把库存一致性变成一个分布式问题,处理补偿的复杂度远超收益。正确进阶路线是先把数据库层的索引、锁、事务边界打磨干净,再考虑把「库存查询」类只读接口做短 TTL 缓存,而把「扣减库存」永远留在数据库事务里。

我在真实项目里吃过一次亏,某个订单系统图省事把扣减逻辑放到了异步队列,结果服务重启丢了消息,库存直接腐化。从此养成一个习惯:凡是涉及进销存数字变动的操作,一律同步事务处理;异步只做通知和报表。用例图上的每一个用例,落到代码里都要能回答「谁在什么条件下调用了哪个方法」,这个项目才能睡得着觉。

对新手最有用的一句建议是:先花半天时间把用例图改到逻辑闭环,再花两天把数据库表和流水字段设计清楚,写代码反而是其中最机械的一步。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进