多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

进销存系统开发实战:从用例图到库存扣减与对账避坑指南

进销存系统开发实战:从用例图到库存扣减与对账避坑指南 简介一套面向超市运营场景的进销存管理系统项目资源围绕商品进货、销售管理、库存统计、订单管理等核心业务流程设计并附带用例图辅助理解系统角色与功能关系。资源共103个文件以C#源文件cs和ASP.NET页面aspx为主配合CSS样式、jpg/gif界面示意图以及mdl、mdf等数据库文件压缩包约2.03MB。内容覆盖供应商与商品信息录入、采购订单生成、条形码销售、库存阈值预警、订单状态追踪等具体实现用例图清晰展现员工、顾客、供应商与各功能模块的交互流程。目前已有4379人学习下载适合用于课程设计、毕业设计或系统二次开发时快速掌握进销存业务流程与代码组织方式。1. 这套超市进销存管理系统到底解决什么问题很多刚接触管理系统开发的人第一次接到的需求就是做个进销存。听起来简单进货、卖货、改库存。可真做起来才发现进销存系统的核心根本不是增删改查而是账实一致——数据库里的库存、今天卖了多少、供应商送了多少货这些数据在一天结束的时候能不能对得上。我见过好几个开发者在自测时一切正常一上线跑一周库存负数、销售金额对不上、进货单被重复提交问题全冒出来了。这篇文章要讲的这套超市进销存管理系统是一个基于经典分层架构的Web应用代码仓库里附带完整的用例图UML用例图用来明确系统边界和角色权限。这套系统适合谁如果你是正在做课程设计、毕业设计或者刚入行想找一个业务完整的练手项目它比单纯的书店管理系统、学生管理系统更贴近真实商业场景涉及多角色、多单据、库存流水、金额计算还有并发扣库存这种经典坑。整套系统的业务范围从供应商管理、商品档案到进货入库、前台收银、库存盘点全部覆盖。用例图在这个项目里不是应付文档用的它直接决定了后台的菜单权限是怎么划分的——后面我会专门用一章讲怎么从用例图推导出权限表。下面进入正题我先从最值得参考的用例图讲起接着是数据库建模然后是库存扣减的核心逻辑最后是避坑清单和对账验证。2. 从用例图开始的角色与权限设计这张图不是画着好看的2.1 用例图里的四个角色对应着系统里的哪些操作权限这套系统的用例图一共涉及四个角色店长管理员、收银员、采购员、仓管员。店长的用例是最多的包含员工管理、商品管理、供应商管理、进货审核、销售报表查看、盘点审核几乎覆盖全系统收银员只有两个用例——前台收银和退货采购员的用例集中在供应商管理和进货申请采购员可以创建进货单但不能审核入库仓管员负责入库确认和库存盘点。这种划分符合大多数超市的日常分工。用例图在这个项目里直接引导了后端权限表的设计。看不到图的情况下我们可以推导出最核心的规则权限最小化单据创建和审核分离。采购员能建进货单但不能审核入库就是为了避免一个人既买菜又记账的漏洞。你在实现的时候不要把权限控制做成简单的管理员全部放行其他角色只能读而是要把页面按钮级别的权限做出来。比如进货审核通过后审核通过按钮对采购员不可见只能看到自己创建的进货单列表。2.2 用UML工具画出用例图步骤、边界和颗粒度画用例图我一般用两个工具简单快速用在线绘图工具比如draw.io要更规范就用UML工具比如StarUML。用例图不需要画得特别复杂关键是边界清晰。步骤如下第一步先画一个系统边界矩形框框里面的左边放置角色右边放置用例。角色用火柴人图标表示从角色到用例之间画实线。第二步把上面说的四个角色分别放在左右两侧用例按业务模块分组商品管理、进货管理、销售管理、库存管理、系统管理。第三步用include关系处理公共操作。比如前台收银这个用例必然包含查询商品信息这个子用例进货入库必然包含查询商品库存。include关系用虚线箭头加《include》字样标注。第四步处理进货申请和进货审核的关系。这两个用例虽然都叫进货但使用者不同采购员执行申请店长执行审核。在用例图上它们必须拆成两个独立的用例否则权限模型会跟着出错。我在画图的时候见过不少开发者把这两个合并成一个进货管理结果到了写权限拦截器的时候纠结半天也没法分清谁能操作哪个按钮。这里要强调一个画用例图的颗粒度问题用例图不要画到新增商品、编辑商品、删除商品这种CRUD粒度。用例图的目的是表达角色与业务目标的交互商品信息维护就是一个完整用例拆成新增/编辑/删除会让图变得琐碎而且在推导权限时没有帮助。2.3 从用例图到权限控制Spring Security或拦截器的配置思路用例图定下来之后权限控制就好写了。如果项目用的是Spring Boot我建议不需要引入完整的Spring Security那套框架对刚做中小型系统的人来说偏重、配置繁琐用拦截器加注解的方式就够用。核心是一个自定义注解RequirePermission加在Controller的方法上然后写一个HandlerInterceptor去校验当前登录用户的角色。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String[] roles() default {}; // 允许访问的角色编码如 {ADMIN, CASHIER} }public class PermissionInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; RequirePermission annotation handlerMethod.getMethodAnnotation(RequirePermission.class); if (annotation null) { return true; // 未加注解的接口不做拦截 } User currentUser (User) request.getSession().getAttribute(LOGIN_USER); String[] requiredRoles annotation.roles(); for (String requiredRole : requiredRoles) { if (currentUser.getRoleCode().equals(requiredRole)) { return true; } } response.setStatus(403); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\msg\:\无权限访问\}); return false; } return true; } }这段代码的逻辑很简单每个接口方法标注上允许访问的角色列表拦截器在请求进入Controller之前先看当前用户的角色码是否在允许列表里不在就直接返回403。这里有两个细节值得注意第一roles()是数组意味着一个接口可以同时允许多个角色访问比如查询商品列表这个接口收银员和仓管员都需要用第二注解只标在Controller方法上Service层不用加因为Service层不处理登录状态拦截器层面统一处理就够了。权限拦截器在注册时要排除登录接口和静态资源路径否则会出现死循环登录接口本身也被拦截用户永远登录不进去。注册代码如下Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new PermissionInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /logout, /static/**, /error); } }3. 数据库建模六张核心表和一张库存流水表3.1 商品表、供应商表、进货单、销售单字段怎么设计才合理超市进销存系统的数据库表设计是这套系统能不能稳定运行的关键。我见过不少把商品信息和库存数量放在同一张表里的做法也不反对但建议至少拆成商品表、进货单表、进货单明细表、销售单表、销售单明细表、供应商表六张核心表再加一张库存流水表用于对账。商品表product的核心字段是id、product_name、specification规格比如500ml/瓶、unit单位包/瓶/袋、purchase_price进货价、sale_price销售价、stock_quantity当前库存、warning_quantity库存预警阈值、category_id商品分类ID、status上架/下架状态。其中stock_quantity这个字段是冗余字段——它可以从库存流水表聚合出来保留它是为了查询效率。真正要保证数据准确的是库存流水表后面会讲。供应商表supplier比较简单id、supplier_name、contact_person、phone、address、remark。进货单表purchase_order是主表字段包括id、purchase_no进货单编号用时间戳加随机数生成、supplier_id、purchase_total_amount进货总金额、status待审核/审核通过/已入库/已作废、create_time、audit_time、create_by创建人ID、audit_by审核人ID。进货单明细表purchase_order_item用于记录每一笔进货的商品明细id、purchase_order_id关联主表、product_id、purchase_quantity、purchase_price、subtotal小计金额。为什么要拆主表和明细表因为一张进货单关联多种商品如果只建一张表存所有商品数据冗余和更新异常会非常严重。这也是三范式里第二范式的典型应用。销售单表sales_order逻辑上跟进货单对称id、sales_no、total_amount、discount_amount优惠金额、pay_amount实付金额、payment_method现金/微信/支付宝、cashier_id收银员ID、create_time。销售单明细表sales_order_item字段id、sales_order_id、product_id、sale_quantity、sale_price、subtotal。3.2 库存流水表每个库存变动都留痕迹这是对账的唯一凭据库存流水表stock_flow是整个系统最值得花时间设计的表。每一笔进货入库、销售出库、退货、盘点调整都必须在流水表里插入一条记录。字段设计如下id、product_id、change_type变动类型PURCHASE_IN入库、SALE_OUT销售出库、SALE_RETURN退货入库、CHECK_ADJUST盘点调整、change_quantity变动数量正数入库负数出库、before_quantity变动前库存、after_quantity变动后库存、order_no关联的单据编号方便追溯、operator_id操作人ID、operate_time操作时间、remark。表格库存流水表字段说明字段名类型说明change_typeVARCHAR(20)变动类型入库正数、出库负数before_quantityINT变动前的库存数值用于审计after_quantityINT变动后的库存数值必须等于 before changeorder_noVARCHAR(40)关联的进货单号或销售单号operator_idBIGINT操作人ID定位责任人的关键字段这里要说明一个原则任何情况下商品表的stock_quantity字段都不能直接被UPDATE语句修改。库存的变更只能从流水表计算出来。具体做法是在事务里先插入流水记录然后根据流水记录更新商品表的库存字段。这个顺序很重要保证操作失败时事务回滚库存和流水保持一致。3.3 创建表的SQL代码主键、外键、唯一索引和事务下面是六张核心表的建表SQL这里给出商品表、进货单主表和库存流水表的示例完整建表脚本在项目的SQL文件里-- 商品表 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(100) NOT NULL, specification VARCHAR(100), unit VARCHAR(20) NOT NULL, purchase_price DECIMAL(10,2) NOT NULL, sale_price DECIMAL(10,2) NOT NULL, stock_quantity INT NOT NULL DEFAULT 0, warning_quantity INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, category_id BIGINT, INDEX idx_category_id (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 进货单主表 CREATE TABLE purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, purchase_no VARCHAR(40) NOT NULL UNIQUE, supplier_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, create_by BIGINT NOT NULL, create_time DATETIME NOT NULL, audit_by BIGINT, audit_time DATETIME ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存流水表 CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, change_type VARCHAR(20) NOT NULL, change_quantity INT NOT NULL, before_quantity INT NOT NULL, after_quantity INT NOT NULL, order_no VARCHAR(40), operator_id BIGINT, operate_time DATETIME NOT NULL, INDEX idx_product_time (product_id, operate_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时有三个地方需要额外说明。第一金额字段必须用DECIMAL不能用DOUBLE或者FLOAT这个在避坑章节会详细解释。第二purchase_no字段加了UNIQUE约束防止并发时生成重复的进货单号。第三所有表统一使用InnoDB引擎因为进销存系统的事务要求很高MyISAM不支持事务一旦写入过程中断库存和单据会出现部分数据丢失的现象这种问题排查起来非常痛苦。4. 用 MyBatis 实现库存扣减事务和锁的并发控制4.1 进货入库和销售出库的代码实现进货入库的核心逻辑在Service层。整个流程是校验进货单状态是审核通过且未入库 → 遍历进货单明细 → 检查商品是否存在 → 修改商品库存 → 写入库存流水 → 更新进货单状态为已入库。代码如下Transactional(rollbackFor Exception.class) public void purchaseInbound(Long purchaseOrderId, Long operatorId) { PurchaseOrder order purchaseOrderMapper.findById(purchaseOrderId); if (order null || !order.getStatus().equals(OrderStatus.AUDITED)) { throw new BizException(进货单不存在或状态不允许入库); } ListPurchaseOrderItem items purchaseOrderItemMapper.findByOrderId(purchaseOrderId); for (PurchaseOrderItem item : items) { Product product productMapper.findById(item.getProductId()); if (product null) { throw new BizException(商品不存在商品ID item.getProductId()); } int beforeQty product.getStockQuantity(); int afterQty beforeQty item.getPurchaseQuantity(); // 更新商品库存注意这里使用了乐观锁 int affected productMapper.updateStockWithVersion( product.getId(), beforeQty, afterQty, product.getVersion()); if (affected 0) { throw new BizException(商品库存更新冲突请重试); } // 写入库存流水 StockFlow flow new StockFlow(); flow.setProductId(item.getProductId()); flow.setChangeType(StockFlowType.PURCHASE_IN); flow.setChangeQuantity(item.getPurchaseQuantity()); flow.setBeforeQuantity(beforeQty); flow.setAfterQuantity(afterQty); flow.setOrderNo(order.getPurchaseNo()); flow.setOperatorId(operatorId); flow.setOperateTime(new Date()); stockFlowMapper.insert(flow); } // 更新进货单状态 purchaseOrderMapper.updateStatus(purchaseOrderId, OrderStatus.INBOUND); }这段代码里的关键逻辑在于updateStockWithVersion这个方法。它对应的SQL是UPDATE product SET stock_quantity #{afterQty}, version version 1 WHERE id #{id} AND version #{version}这个更新语句的意思是说只有当商品表里的version版本号还是我查询时候的版本号时才允许更新。如果在两次查询之间另一个线程已经改过这条商品记录的库存version就变了UPDATE影响的行数为0当前事务抛出冲突异常并回滚从而避免库存错乱。这就是乐观锁——不锁住整张表而是靠记录级版本号来防止并发覆盖。进货入库并发发生的概率不如销售出库高因为进货是批次操作但销售出库的并发扣减是必须处理的问题。4.2 销售出库的库存扣减为什么用悲观锁而不是直接UPDATE销售出库的逻辑与进货类似差别在于数量的方向负值和并发强度。收银台可能有多个收银员同时结账同一件商品被两个人同时购买是常见场景。如果你只做读取库存→检查库存足够→扣减库存在并发高的时候就会翻车。举个例子A收银员和B收银员同时读到库存是5A卖出去1件库存变成4B也卖出去1件如果B在扣减时没有约束A和B都执行UPDATE product SET stock_quantity 4最后库存变成4而不是3卖出去的2件商品只扣了1件库存——这就是超卖。解决这个并发问题我推荐使用SELECT ... FOR UPDATE悲观锁。在事务内查询商品记录时直接加上行锁其他事务只能等这个事务提交之后才能查询同一行数据。实现如下Transactional(rollbackFor Exception.class) public void createSaleOrder(ListSaleItemParam params, Long cashierId) { // 1. 生成销售单号 String saleNo generateSaleNo(); // 2. 计算总金额并扣减库存 BigDecimal totalAmount BigDecimal.ZERO; for (SaleItemParam param : params) { // 关键使用FOR UPDATE锁定商品行 Product product productMapper.findByIdForUpdate(param.getProductId()); if (product null) { throw new BizException(商品不存在); } if (product.getStockQuantity() param.getQuantity()) { throw new BizException(商品库存不足 product.getProductName()); } int beforeQty product.getStockQuantity(); int afterQty beforeQty - param.getQuantity(); productMapper.updateStockQuantity(product.getId(), afterQty); // 记录库存流水 StockFlow flow new StockFlow(); flow.setProductId(product.getId()); flow.setChangeType(StockFlowType.SALE_OUT); flow.setChangeQuantity(-param.getQuantity()); flow.setBeforeQuantity(beforeQty); flow.setAfterQuantity(afterQty); flow.setOrderNo(saleNo); flow.setOperatorId(cashierId); flow.setOperateTime(new Date()); stockFlowMapper.insert(flow); // 计算小计 BigDecimal subtotal product.getSalePrice().multiply(BigDecimal.valueOf(param.getQuantity())); totalAmount totalAmount.add(subtotal); } // 3. 插入销售单和明细 SaleOrder saleOrder new SaleOrder(); saleOrder.setSaleNo(saleNo); saleOrder.setTotalAmount(totalAmount); saleOrder.setPayAmount(totalAmount); saleOrder.setCashierId(cashierId); saleOrder.setCreateTime(new Date()); saleOrderMapper.insert(saleOrder); // 插入明细略 }这里有两个选择问题需要想清楚。选悲观锁的原因在于销售出库的并发写操作频率高冲突概率大用乐观锁会导致失败的客户端不断重试而悲观锁是串行化的排队等待对用户体验更友好。进货入库的并发概率低用乐观锁就够了上面的入库示例我用了乐观锁出库示例我用悲观锁这两种锁都是好方案关键是知道自己选的是什么锁。还有一种做法是直接用原子UPDATE比如UPDATE product SET stock_quantity stock_quantity - #{qty} WHERE id #{id} AND stock_quantity #{qty}利用受影响行数判断库存是否不足。这种做法也可以但缺点是拿不到变动前的库存值库存流水里的before_quantity就没法填所以我更推荐FOR UPDATE。5. 进销存系统避坑5个让数据对不上的经典坑位5.1 用DOUBLE存金额导致库存金额对不上现象进货单的总金额加起来和明细小计对不上月底对账相差几毛几分。原因DOUBLE和FLOAT是浮点数二进制无法精确表示0.1这样的十进制小数在累加过程中会产生舍入误差。解决所有金额字段一律使用DECIMAL(10,2)实体类中对应使用BigDecimal而不是Double。在MyBatis的resultMap里金额字段的jdbcType要写DECIMALJava属性的setter接收BigDecimal不要自己转成字符串再去运算。5.2 事务不生效Service方法被同类调用库存更新一半就提交了现象方法明明标了Transactional但运行时抛异常库存流水有记录商品库存没更新数据就错乱了。原因Spring的事务基于AOP代理。同类内部调用this.method()不会经过代理类事务注解失效。比如在PurchaseService里一个方法调用同类下的另一个带Transactional的方法事务就不会开启。解决把事务方法放到另一个Service类中调用或者注入自身代理AutowiredLazy用ApplicationContext取代理最稳妥的就是事务方法必须从外部进入。5.3 库存扣减后没有回滚单据状态和库存不一致现象销售单创建成功但库存没有扣或者库存扣了销售单没生成。原因库存扣减和单据插入不在同一个事务中。比如先调用库存服务扣减再调用下单服务保存中间抛异常时库存服务已经提交了。解决在一个Transactional方法里完成扣库存 生成单据 写流水任何一个步骤抛异常全部回滚。如果你系统里服务拆分得很细那就需要引入分布式事务比如Seata但对这个单体项目一个事务方法就够了。5.4 删除商品或供应商时出现外键报错或孤儿数据现象删除一个商品时提示有子记录关联无法删除或者强行删除后进货明细里的商品名变成了空。原因商品表被进货单明细和销售单明细引用没有设计软删除字段。解决给商品表增加status字段0下架/1上架删除操作的业务含义是下架而不是物理删除。这样历史单据里的商品信息始终完整报表统计也不会丢数据。供应商同理除非确认历史没有任何关联单据否则不要物理删除。5.5 并发进货采购单审核导致一批货被入库两次现象仓管员同时打开两个浏览器标签页对同一张进货单点了两次入库库存翻倍增加。原因入库操作没有做单据状态校验也没有加锁。第一次入库后状态已经变成已入库第二次操作时没有重新检查。解决在purchaseInbound方法一开始就SELECT ... FOR UPDATE锁住进货单记录在状态判断之后、执行入库之前再次检查状态是否为可入库。只要状态更新和库存扣减在同一个事务里第二个请求会等待第一个事务提交后再读取到状态已经是已入库直接返回业务异常。6. 库存不准怎么办用对账脚本把问题定位到具体单据系统上线运行后不管你写完时多自信最终都必须面对一个现实真数据跑起来库存一定会出问题。可能是人工误操作可能是代码没走到的异常分支也可能是数据库被外部改过。所以最后一件事不是写新功能而是写一个对账脚本每天定时跑一遍把账实不一致的数据查出来。对账的核心思路是交叉验证三条独立的数据链路。第一条链路销售单明细表里每个商品的总销售数量之和进货单明细表里入库状态的总进货数量之和退货表同理。第二条链路库存流水表里每个商品的累计变动数量flow里加上初始库存假设系统上线前有过一次期初盘点初始库存记录在盘点表里。第三条链路商品表里的当前stock_quantity。这三条链路的结果应该完全相等。用SQL实现-- 对账SQL统计每个商品的流水累计变动数量 vs 商品表当前库存 SELECT p.id AS product_id, p.product_name, p.stock_quantity AS current_stock, IFNULL(SUM(sf.change_quantity), 0) IFNULL((SELECT init_quantity FROM stock_initial WHERE product_id p.id), 0) AS flow_stock FROM product p LEFT JOIN stock_flow sf ON sf.product_id p.id GROUP BY p.id, p.product_name, p.stock_quantity HAVING current_stock ! flow_stock;这条SQL的输出结果就是所有库存对不上的商品。找到了商品接下来要定位是哪个单据。做法是查询该商品的库存流水按时间排序手工检查哪一条流水的before_quantity不等于前一条流水的after_quantity中间必然有缺口或重复记录。如果流水的连续性没有问题那就检查流水操作当日有没有外部手动修改数据库的痕迹。这里分享一个血的教训我在做一个模拟项目X的时候系统上线第三周老板说某个大件商品的库存数量明显不对。跑了对账SQL发现流水连续商品表库存也和流水一致但实物库存就是多了两件。最后查出来是运营人员通过后台直接把商品库存字段改了改完库存流水没有记录。从那以后我在系统里就多做了一个保护任何商品表库存字段的变更必须通过写库存流水接口完成删掉所有直接对product表UPDATE库存的入口包含SQL管理工具的使用规范也写进了交接文档。对于这个项目你可以在此基础上扩展自动修复功能当对账发现不一致时生成一条盘点调整单把库存调成流水计算出的正确值。这一步要谨慎必须让店长人工审核后再执行。盘点调整同样要走库存流水change_type设为CHECK_ADJUST这样整条链路始终有审计记录。如果你的系统还在压测或试运行阶段还有一个更快的验证办法制造一笔极端数据。比如把某个商品库存改成1然后同时发起两笔销售请求看系统是否只允许一笔成功。用JMeter或Postman的并发功能发两个请求落库后检查库存是否是0、失败的那笔请求是否收到了库存不足的提示。这种测试跑一遍系统能不能抗住真实收银台的并发压力心里就有底了。最后养成一个习惯每次上线或变更涉及库存计算的代码跑一遍上面的对账SQL。库存不准不是什么玄学无非是并发、精度、事务三件事没做好。把这个项目做透从用例图到数据库到并发控制一条线走下来以后接任何进销存、仓储、订单类的系统核心逻辑你都不会怵。希望帮到你。本文还有配套的精品资源点击获取
返回列表