超市进销存系统实战:用例图、单据驱动库存与事务避坑

发布时间:2026/11/4 12:17:42
超市进销存系统实战:用例图、单据驱动库存与事务避坑

简介:「超市进销存管理系统(附用例图)」是一套面向超市信息化场景的课程设计与毕业设计参考项目,围绕商品进货、销售管理、库存统计与订单管理四条业务主线展开,适合正在学习ASP.NET与数据库应用开发的学生及初级开发者对照练手。压缩包共103个文件、约2.03MB,以24个cs后台代码、18个aspx页面、4个css样式表与22个gif、24个jpg界面素材为主,另含mdf/ldf数据库文件与mdl模型文件,形成从页面到数据的完整链路。配套用例图以图形化方式梳理员工、顾客、供应商等参与者与系统功能的交互关系,便于厘清需求边界与业务流程。已有4381人学习下载,读者可据此了解表单录入、条码扫描查询、库存阈值预警与订单状态跟踪等模块的实现思路,并借鉴现成的页面组织与数据库设计快速搭建自己的实验环境。

1. 从一张用例图说起:这个超市进销存系统到底解决什么问题

小区门口那家超市的老板算过一笔账:每天流水看着不错,月底一盘点,货架上少了三千多块的货,怎么少的说不清。这种场景就是超市进销存系统的用武之地——把采购、入库、销售、退货、盘点、报损这条链上的每一次数量变动都落到单子上,让库存不再是拍脑袋的数字。这套资源的核心是一套可运行的进销存工程代码,外加一份把业务边界画清楚的用例图。用例图不是摆设,它决定了后面表怎么建、接口怎么切、权限怎么分。适合两类人:一类是刚接手课程设计或毕设,需要一套完整可跑的业务系统打底;另一类是小超市、便利店的自建需求,想拿现成骨架改成能用的东西。读懂那几条参与者和用例之间的连线,比直接翻代码更快摸到系统的骨架。

2. 系统全景与技术选型:采购、销售、库存三条线怎么拧成一股

进销存这三个字听着简单,真正写起来最容易翻车的地方在于:进、销、存不是三个独立模块,而是同一条数据在不同阶段的三种形态。采购进来是「进」,卖掉是「销」,中间躺在仓库里的是「存」,任何一次数量变动都必须同时留下单据和流水,否则月底对账就是一笔糊涂账。所以整套系统的设计重心不在页面上,在于「单据驱动库存」这条主线能不能闭环。

2.1 参与者与用例边界:用例图里那几条箭头到底指向谁

先看用例图给出的角色划分。这类系统的参与者通常不超过五个,每个角色的权限边界直接对应到后端的接口分组:

参与者主要用例对应后端模块
收银员销售开单、退货、查询商品价格sale、product(只读)
仓管员采购入库、销售出库、库存盘点、报损stock、purchase、check
采购员创建采购单、维护供应商purchase、supplier
店长审核单据、查看进销存报表、设置库存预警report、audit
系统管理员用户管理、角色权限、商品分类维护system、category

这里有个容易忽略的设计点:同一个用例可能被多个角色触发,但权限粒度不一样。比如「查看库存」,仓管员看的是实物数量,店长看的是数量加金额成本。如果后端只按 URL 做一次拦截,就会出现普通员工能查到进货价的情况。常见做法是把权限挂到「角色-菜单-接口」三级上,用例图里的每条连线在后端落成一个权限码,例如stock:query、stock:query:cost。

用例图里还有一类容易画漏的用例——包含关系(include)。像「销售开单」必然包含「扣减库存」和「生成流水」,这两个不是独立菜单,而是被主用例调用的子流程。落到代码里,它们就是 Service 层的两个方法,而不是两个 Controller。很多人照着用例图画了一堆接口,最后发现前端根本用不上,问题就出在没区分「用户可触发的用例」和「被复用的子用例」。

再一个,扩展关系(extend)值得单独说。「销售开单」可以扩展出「使用会员折扣」和「整单退货」,这两个是可选的、条件触发的。工程上的对应做法是:主流程走完,再用策略或事件的方式挂扩展逻辑,别把折扣计算和退货逻辑硬塞进开单方法里,否则这个方法三个月后就没人敢动了。

2.2 技术栈组合:为什么多数同类项目都落在 Spring Boot + MySQL + Vue 上

这类项目要能跑起来、还要方便改,技术选型上没必要追新。多数同类工程采用的做法是后端 Spring Boot + MyBatis-Plus + MySQL,前端 Vue + Element UI,管理端用若依这类脚手架裁剪。理由很实在:

  • 进销存是典型的 CRUD 密集型业务,ORM 帮你省掉大量样板 SQL,MyBatis-Plus 的条件构造器写报表查询也够用;
  • 单据和库存的并发控制需要数据库事务和行锁,MySQL InnoDB 是标配,换成其他库反而要多解释;
  • 前端表单多、表格多,Element UI 的el-table和el-form直接能撑住进销存 80% 的页面;
  • 脚手架自带的用户、角色、菜单、日志模块,正好对上用例图里的「系统管理员」这一块,省掉重复劳动。

如果拿到的是更早期的版本,后端用 Servlet + JSP 或者 SSM,逻辑其实一样,只是分层没这么清爽。看代码时不用纠结框架,盯住三件事:事务边界在哪、库存字段在哪儿被改、单据状态在哪几个值之间流转。

配置文件这块,数据库连接、库存预警阈值、单据编号前缀一般会放在application.yml里:

spring: datasource: url: jdbc:mysql://localhost:3306/market_stock?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver # 事务超时时间,单据批量入库时容易踩到默认值 transaction: default-timeout: 30s market: stock: # 低于这个数量触发预警,按门店实际调整 warn-threshold: 10 # 单据号前缀,采购 CG、销售 XS、盘点 PD order-prefix: purchase: CG sale: XS check: PD

参数说明:warn-threshold是全店默认值,实际业务里更合理的是按商品单独设,这里留了全局兜底;order-prefix用于生成单据号,格式一般是「前缀 + yyyyMMdd + 4 位流水」,流水号靠 Redis 自增或者数据库序列表,别用max(id)+1,并发下必撞号。

2.3 从用例到接口:一个「销售出库」用例如何映射成一次 HTTP 调用

用例图看完,接下来要把它翻译成接口。以「销售开单」为例,从前端点击到数据落库,链路是这样的:

  1. 收银员扫码,前端调GET /api/product/scan?code=xxx拿到商品信息和当前库存;
  2. 加购若干商品,前端本地算小计,提交时调POST /api/sale/order;
  3. 后端校验库存、生成销售单主表和明细、扣减库存、写库存流水,全程一个事务;
  4. 返回单据号和实收金额,前端打印小票。

这一步的关键在于接口的输入结构要能承接用例图里的所有扩展。销售单的请求体里除了商品明细,还要预留memberId、discountType、remark这些字段,否则后面加会员功能就得改接口签名,前端跟着全改。

public class SaleOrderDTO { private Long memberId; // 会员ID,散客传 null private Integer discountType; // 0 无折扣 1 会员价 2 整单折扣 private BigDecimal discountRate;// 折扣率,配合 discountType 使用 private List<SaleItemDTO> items; private String remark; public static class SaleItemDTO { private Long productId; private Integer quantity; private BigDecimal salePrice; // 前端传的售价,后端必须再校验一次 } }

这里有个反直觉的点:售价不要信任前端。DTO 里带salePrice是为了记录实际成交价(可能人工改价),但后端必须拿商品表里的价格做上限校验,超过就拒绝。用例图里「修改售价」这个用例通常只开放给店长,普通收银员没有这个权限码。这一段如果漏了,就是收银环节最常见的内控漏洞。

3. 目录结构与数据模型:商品、库存、单据三张表撑起整个系统

3.1 工程目录长什么样:分层与模块边界

工程按标准的分层结构组织,模块划分直接对应用例图里的几大块:

market-stock/ ├── market-common/ # 通用工具、返回体、异常、常量 ├── market-system/ # 用户、角色、菜单、字典(脚手架自带) ├── market-business/ # 业务核心 │ ├── src/main/java/com/market/business/ │ │ ├── controller/ # 只做参数校验和结果包装 │ │ ├── service/ # 业务编排,事务在这里 │ │ ├── service/impl/ │ │ ├── mapper/ # MyBatis 接口 │ │ ├── domain/ # 实体 │ │ └── dto/ # 入参出参 │ └── src/main/resources/ │ ├── mapper/ # XML,复杂报表 SQL 放这里 │ └── application-business.yml ├── market-admin/ # 启动模块 + 前端打包产物 └── sql/ ├── schema.sql # 建表 └── data.sql # 初始数据、菜单权限

分层的边界要守住:Controller 不写业务判断,Service 不直接拼 SQL,Mapper 只管数据。进销存项目最容易烂的地方是「库存扣减」被写在 Controller 里,因为那里能直接拿到请求参数,写起来顺手,后面加个批量出库就全乱套了。

sql/目录里的两个脚本是复现的关键,schema.sql建表、data.sql灌初始数据(管理员账号、菜单、基础商品分类)。导入顺序不能反,外键和菜单父子关系都依赖前者。

3.2 表结构设计:商品表、库存表、单据主表/明细表

进销存的表看着多,骨架其实只有三类。第一类是主数据:商品、供应商、分类、客户。第二类是单据:采购单、销售单、盘点单、报损单,每张单据都是主表 + 明细表的结构。第三类是库存与流水:库存表和库存流水表。

库存单独建表而不是塞进商品表,是个关键决定。理由是库存变动极其频繁,而商品的价格、名称、分类基本不动,放一起会导致商品表被反复更新,缓存和锁都不好做。库存表结构大致如下:

CREATE TABLE `biz_stock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `product_id` BIGINT NOT NULL COMMENT '商品ID', `quantity` INT NOT NULL DEFAULT 0 COMMENT '可用库存', `lock_quantity` INT NOT NULL DEFAULT 0 COMMENT '锁定库存,下单未出库时占用', `warn_quantity` INT NOT NULL DEFAULT 10 COMMENT '预警阈值,按商品覆盖', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_product` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';

参数说明:quantity是可用量,lock_quantity是已下单未出库的量,实际可售 =quantity - lock_quantity。加这个字段是为了处理「线上下单、门店自提」这类场景,纯线下超市可以不用,但留着不亏,删字段比加字段麻烦。version用于乐观锁,后面第 4 章会讲什么时候用它、什么时候必须换成悲观锁。

库存流水表是查账的唯一依据:

CREATE TABLE `biz_stock_flow` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `product_id` BIGINT NOT NULL, `biz_type` VARCHAR(20) NOT NULL COMMENT 'PURCHASE_IN/SALE_OUT/RETURN_IN/CHECK_ADJUST/LOSS', `biz_no` VARCHAR(40) NOT NULL COMMENT '关联单据号', `change_qty` INT NOT NULL COMMENT '变动数量,入库正出库负', `before_qty` INT NOT NULL COMMENT '变动前数量', `after_qty` INT NOT NULL COMMENT '变动后数量', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_product_time` (`product_id`, `create_time`), KEY `idx_biz_no` (`biz_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存变动流水';

before_qty和after_qty这两个快照字段看着冗余,实际上是排查库存问题的后悔药。库存数对不上时,直接按商品和时间排序拉一遍流水,看哪一笔的before_qty和上一笔的after_qty对不上,问题立刻定位。少了这两个字段,就只能靠数量加减反推,遇到退货冲正就彻底抓瞎。

单据主表和明细表的关系不用多说,采购单和销售单结构几乎对称:

CREATE TABLE `biz_purchase_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(40) NOT NULL COMMENT '单据号 CG202401010001', `supplier_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已审核 2已入库 3已作废', `total_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00, `creator_id` BIGINT NOT NULL, `audit_by` BIGINT DEFAULT NULL, `audit_time` DATETIME DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_time` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购单主表';

索引idx_status_time是给「待审核单据列表」这个高频查询用的。进销存系统的列表页基本都带状态筛选加时间排序,缺这个索引,单据量上万后翻页会明显卡。

3.3 单据状态机:从待审核到已入库的状态流转

用例图里「审核采购单」这个用例背后是一个状态机。状态值和允许的迁移必须写死在代码里,不能靠前端按钮控制。

当前状态允许操作目标状态触发角色
0 待审核审核通过1 已审核店长
0 待审核作废3 已作废采购员
1 已审核确认入库2 已入库仓管员
1 已审核反审核0 待审核店长

关键点:只有「确认入库」这一步才动库存,审核只是冻结单据。很多人把库存扣减放在审核环节,结果入库时发现货不对版要改数量,库存已经加过了,只能再做一张冲正单,账越滚越乱。正确的做法是把「审核」和「入库」拆成两个动作,审核管流程,入库管实物。

状态迁移建议用枚举加校验方法统一收口:

public enum PurchaseStatus { PENDING(0, "待审核"), AUDITED(1, "已审核"), STORED(2, "已入库"), CANCELED(3, "已作废"); private final int code; private final String desc; // 允许的迁移:key 当前状态,value 可到达的状态 private static final Map<Integer, List<Integer>> TRANSFER = Map.of( 0, List.of(1, 3), 1, List.of(0, 2) ); public static void checkTransfer(int from, int to) { if (!TRANSFER.getOrDefault(from, List.of()).contains(to)) { throw new BizException("单据状态不允许从 " + from + " 变更为 " + to); } } }

这段枚举的作用是拦截非法操作,比如前端用旧页面提交了一次「确认入库」,而单据已被别人作废,没这个校验就会把已作废单据的货再入一遍。参数上from取自数据库当前值而不是请求参数,避免前端伪造。

4. 核心链路拆解:一次采购入库从点击到落库的完整事务

4.1 入库服务的主流程代码

这是整套系统最值得逐行看的方法。仓管员点「确认入库」,后端要做四件事:校验单据状态、写库存、写流水、更新单据状态。四件事必须在一个事务里,缺一件就会出现「库存加了但单据还是已审核」这种脏数据。

@Service public class PurchaseServiceImpl { @Autowired private PurchaseOrderMapper orderMapper; @Autowired private PurchaseItemMapper itemMapper; @Autowired private StockMapper stockMapper; @Autowired private StockFlowMapper flowMapper; /** * 确认入库 * @param orderId 采购单ID * @param operatorId 操作人,用于流水留痕 */ @Transactional(rollbackFor = Exception.class) public void confirmInbound(Long orderId, Long operatorId) { // 1. 查单据并加行锁,防止两个人同时点入库 PurchaseOrder order = orderMapper.selectForUpdate(orderId); if (order == null) { throw new BizException("采购单不存在"); } // 2. 状态校验,只有已审核的才能入库 PurchaseStatus.checkTransfer(order.getStatus(), PurchaseStatus.STORED.getCode()); List<PurchaseItem> items = itemMapper.selectByOrderId(orderId); if (items.isEmpty()) { throw new BizException("采购单没有明细,不能入库"); } for (PurchaseItem item : items) { // 3. 锁定库存行,拿到变动前数量 Stock stock = stockMapper.selectForUpdate(item.getProductId()); if (stock == null) { // 首次入库的商品,先插一条库存记录 stock = Stock.init(item.getProductId(), order.getStoreId()); stockMapper.insert(stock); } int before = stock.getQuantity(); int after = before + item.getQuantity(); // 4. 更新库存 stock.setQuantity(after); stockMapper.updateById(stock); // 5. 写流水,前后快照都落库 StockFlow flow = new StockFlow(); flow.setProductId(item.getProductId()); flow.setBizType("PURCHASE_IN"); flow.setBizNo(order.getOrderNo()); flow.setChangeQty(item.getQuantity()); flow.setBeforeQty(before); flow.setAfterQty(after); flowMapper.insert(flow); } // 6. 最后更新单据状态 order.setStatus(PurchaseStatus.STORED.getCode()); order.setAuditBy(operatorId); order.setAuditTime(new Date()); orderMapper.updateById(order); } }

逻辑说明:selectForUpdate是整段代码的核心,它在事务内对单据行和库存行加了排他锁,第二个人点同一个单据时会阻塞,等第一个人提交后发现状态已经是「已入库」,状态校验直接抛异常,不会重复加库存。循环内逐条处理明细而不是批量update,是为了保证每条流水都能拿到准确的before_qty,商品数量不多时性能完全够用。

参数说明:rollbackFor = Exception.class必须显式写,Spring 默认只对RuntimeException回滚,业务里抛的受检异常会导致部分写入生效。orderId用 Long 而不是 int,商品量大后单据 ID 增长很快。

4.2 库存扣减为什么要用行锁而不是先查后改

销售出库是反向操作,但坑更多。最常见的错误写法是先select查库存、在 Java 里判断够不够、再update减掉:

// 错误示范,两件商品同时卖最后一件时会超卖 Stock stock = stockMapper.selectByProductId(pid); if (stock.getQuantity() < qty) { throw new BizException("库存不足"); } stock.setQuantity(stock.getQuantity() - qty); stockMapper.updateById(stock);

两个收银台同时卖最后一件商品,两个线程都在判断时读到 1,都判断通过,最后库存变成 -1。这就是典型的超卖。解决方式有三种,选哪种取决于并发强度:

方案写法适用场景代价
悲观锁select ... for update单据操作,并发不高但要绝对准确锁等待,事务必须短
乐观锁update ... where version = ?高并发扣减,冲突可重试失败要重试,逻辑变复杂
数据库条件更新update ... set qty = qty - ? where qty >= ?最简单,单条 SQL 搞定拿不到变动前数量,流水要另查

我一般这么选:单据类的库存变动走悲观锁,因为一次入库涉及多条明细,本来就要在一个事务里跑完,锁的粒度是商品行,冲突概率低;秒杀式的纯扣减走条件更新,一条 SQL 解决,靠影响行数判断成功与否:

<update id="deductStock"> UPDATE biz_stock SET quantity = quantity - #{qty} WHERE product_id = #{productId} AND quantity >= #{qty} </update>

调用侧判断返回值,0表示库存不足直接抛异常,1表示扣减成功。这个写法不需要先查后改,天然原子。代价是拿不到before_qty,所以流水得改成先查一次再写,或者干脆用after_qty = quantity反查。取舍看你对流水完整性的要求。

4.3 销售出库与退货的反向补偿

销售出库和采购入库结构对称,方向相反:库存减、流水change_qty记负数。真正需要单独设计的是退货。

退货不能简单地把销售单删掉、库存加回去。原因有三个:单据要留痕、财务报表要能追溯、退货可能是部分退。正确做法是生成一张独立的退货单,关联原销售单号,库存加回去,流水记RETURN_IN。

@Transactional(rollbackFor = Exception.class) public void returnGoods(ReturnOrderDTO dto, Long operatorId) { SaleOrder origin = saleOrderMapper.selectByOrderNo(dto.getOriginOrderNo()); if (origin == null || origin.getStatus() != SALE_FINISHED) { throw new BizException("原销售单不存在或状态异常"); } // 校验累计退货数量不能超过原单数量 for (ReturnItemDTO item : dto.getItems()) { int returned = returnItemMapper.sumReturnedQty(origin.getId(), item.getProductId()); int originQty = saleItemMapper.selectQty(origin.getId(), item.getProductId()); if (returned + item.getQuantity() > originQty) { throw new BizException("退货数量超过原购买数量:" + item.getProductId()); } } // 生成退货单 + 加库存 + 写流水,逻辑同入库 doReturnInbound(dto, operatorId); }

这里的关键校验是sumReturnedQty,统计该商品在这张原单上已经退过多少。少了这一步,一张单可以反复退,库存凭空多出来,月底盘点就会发现账实不符,而且很难查是哪张单重复退了。

退货还有个金额问题:原单可能有整单折扣,退其中一件时该退多少钱?常见做法是按原单明细的实付金额比例分摊,而不是按吊牌价退。这个逻辑建议单独抽一个RefundCalculator,别混在退货主流程里,后面调规则时好改。

5. 避坑与排查:库存数对不上、金额算错、并发超卖的五条血泪记录

5.1 盘点后库存翻倍:事务没覆盖流水写入

现象:盘点调整完成后,库存表数量正确,但流水表里同一个单据出现了两条一样的记录,第二天再查库存变成了两倍。

原因:盘点逻辑里先更新库存、再写流水,写流水那一步抛了异常被上层try-catch吞掉,只打了日志没往外抛,事务没回滚。库存更新提交了,流水没写,后续的补偿任务又按流水重算了一次。

解决:所有涉及库存变动的方法统一加@Transactional(rollbackFor = Exception.class),并且 catch 块里要么重新抛出,要么标记TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。排查时按biz_no查流水,同一单据号出现多条同类型记录基本就是这个毛病。

5.2 单据号重复导致入库失败

现象:早上开门高峰,两个仓管同时点入库,报Duplicate entry 'CG202401010003' for key 'uk_order_no'。

原因:单据号生成用的是select max(order_no) + 1,两个请求同时查到同一个最大值。

解决:换成 Redis 的INCR按日期做 key,或者建一张序列表用update ... set seq = seq + 1加行锁。已经上线的话,把uk_order_no唯一索引保留着,捕获DuplicateKeyException后重试一次生成,兜底用。别图省事把唯一索引删了,那才是真的灾难。

5.3 金额出现 0.01 的误差

现象:销售单明细金额加起来和主单总额差一分钱,对账时财务不认。

原因:明细金额用double算,累加时精度丢失。

解决:金额字段一律用BigDecimal,建表用DECIMAL(12,2),加减用add/subtract,乘法后必须setScale(2, RoundingMode.HALF_UP)。整单折扣的分摊更要注意,最后一条明细的金额用「总额 - 前面明细之和」倒挤,保证加起来严格等于总额,不然永远差一分。

5.4 列表页越翻越慢

现象:单据量到五万条后,采购单列表打开要五六秒。

原因:查询条件里带了DATE_FORMAT(create_time,'%Y-%m-%d') = ?,索引直接失效,全表扫描。

解决:改成范围查询create_time >= ? and create_time < ?,前端传日期后由后端算出次日零点。另外确认idx_status_time这个联合索引存在,explain看一眼type是不是range。进销存系统的列表页十有八九都栽在这个函数包字段上。

5.5 权限漏配导致收银员看到成本价

现象:收银端商品查询接口返回了purchasePrice,被店员看到进价。

原因:DTO 直接返回了实体对象,实体里有成本字段,前端虽然没展示但接口能抓到。

解决:出参一律用 VO,按角色组装字段。管理端 VO 带成本,收银端 VO 只有售价和库存。这类问题排查时直接抓接口返回值最快,别只盯着页面看。

6. 二次开发:加批次管理、加多门店、改用例图的动手路径

拿到这套代码,多数人的第一个需求是「加个保质期/批次」。改法有讲究,别一上来就动库存表主键。

第一步,在biz_stock上加batch_no和expire_date,把唯一索引从product_id改成product_id + batch_no。第二步,所有出入库的 DTO 加批次字段,入库时必填,出库时按先进先出选批次。第三步,也是最容易漏的一步:库存查询的聚合层要改。原来按商品查一条记录,现在一个商品可能有多条批次记录,列表页要sum(quantity) group by product_id,否则页面会重复展示同一个商品。

多门店的改法类似,但影响面更大。核心是给所有业务表加store_id,并且库存表的唯一索引变成product_id + store_id + batch_no。这里有个血泪教训:加字段容易,加完之后所有查询都得带上store_id,漏一个就是跨店串数据。稳妥的做法是在 MyBatis 拦截器里统一注入store_id条件,业务代码不手写,避免遗漏。验证方法是造两家店的数据,用 A 店账号登录,搜 B 店的商品条码,搜不到才算过。

用例图也要跟着改。加了多门店,参与者里会多出「门店店长」和「区域经理」,前者只能看本店报表,后者能看汇总。「查看报表」这个用例要拆成两个,权限码分开。改完图之后回头对一遍后端接口,确保每个新用例都有对应的权限码,别只改图不改码,那就是自欺欺人。

加字段的 SQL 建议单独放一个sql/upgrade_v2.sql,别去改原始的schema.sql,不然老环境升级时没法区分是初始化还是增量。脚本里带上判断:

-- 幂等升级脚本,重复执行不报错 ALTER TABLE biz_stock ADD COLUMN IF NOT EXISTS batch_no VARCHAR(40) DEFAULT '' COMMENT '批次号'; ALTER TABLE biz_stock ADD COLUMN IF NOT EXISTS expire_date DATE DEFAULT NULL COMMENT '有效期'; -- 老数据回填默认批次 UPDATE biz_stock SET batch_no = 'DEFAULT' WHERE batch_no = '' OR batch_no IS NULL;

验证节奏我是这么走的:改完表结构先跑一遍schema.sql+upgrade_v2.sql在空库上验证能否从零建起来,再拿一份带数据的备份库跑升级脚本验证老数据不丢,最后走一遍「采购入库 → 销售出库 → 退货 → 盘点」的完整链路,每一步都去biz_stock_flow里核对before_qty和上一笔的after_qty是否衔接。这套流程看着笨,但能挡住 90% 的低级错误。

从那以后我每次动库存相关的表,不管改多小,都强制走一遍「空库初始化 + 老库升级 + 全链路对账」这三步,再也不敢图快直接在生产库上改字段。希望帮到你。

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