简介:面向超市进销存场景的完整源码与设计资料包,适合计算机课程设计、毕业设计及中小型超市管理系统二次开发参考。内容围绕商品进货、销售管理、库存统计与订单管理展开,包含供应商录入、采购入库、条码销售、库存预警、订单状态追踪等业务流程,并附用例图,以员工、顾客、供应商等参与者及其交互关系展示系统功能边界,便于理解需求与后续优化。压缩包共103个文件,以24个C#源码、18个ASPX页面、24张JPG与22张GIF界面及流程素材、4个CSS样式和SQL Server数据库文件为主,整体约2.03MB,目录可直接用于运行与查阅。已有4381人学习下载。读者可从中获取可运行的Web系统结构、页面组织方式、数据库表关系及用例建模思路,适合对照实现进销存核心模块、完善库存分析或开展功能扩展。
1. 盘点差了三箱货, 问题往往不在人
月底盘点差了三千多块钱的货, 店长第一反应是员工手脚不干净, 查了三天监控, 什么都没查出来。真正的原因很朴素: 收货时手工记在纸上, 晚上再录进电脑, 中间漏了两笔退货, 库存数字从此就一直是错的。做超市进销存的人对这一幕不陌生——问题不在人, 在于“进货、库存、销售”三条流水没有记录在同一条事务里。这份超市进销存管理系统源码配了完整的用例图和数据表设计, 覆盖商品档案、采购入库、销售出库、库存盘点、供应商管理与报表统计。拿它当课设、毕设够用; 对想完整练一遍“单据 + 明细 + 库存余额”建模的后端开发者, 用例图那几页比代码更值钱。
2. 业务拆解:六个业务域怎么映射成一张用例图和一套接口
2.1 六个业务域:从商品档案到日结报表
拿到这类系统, 我的习惯是先不看代码, 先看它管了几件事。超市进销存看着功能多, 拆下来就是六个域: 基础资料(商品、分类、供应商、仓库、员工)、采购(采购单、收货入库、退货)、销售(收银开单、销售退货、挂单)、库存(库存余额、出入库流水、盘点、调拨)、结算(应付、应收、日结)、系统(用户、角色、菜单权限)。六个域里, 前五个是业务, 最后一个是壳。
判断一套进销存写得糙不糙, 有个很直接的标准: 看它有没有“流水表”。只存库存余额的系统, 数字一旦错了就永远查不出错在哪一步; 有流水的系统, 任何时刻都能用“余额 = 流水累加”这条等式反查。这份资源里既有stock余额表也有stock_flow流水表, 说明设计者是按正经单据思路做的, 不是那种一个goods表加个num字段糊出来的演示。
六个域对应到页面, 后台大概十来个菜单: 商品管理、分类管理、供应商管理、采购入库、采购退货、销售开单、销售退货、库存查询、库存盘点、报表中心、用户角色。如果你只是要跑起来看效果, 前四个菜单就够; 要改造成真能用的店务系统, 结算和盘点这两块是重点, 后面第 6 章会讲怎么动。
提示:先跑通“商品 → 采购入库 → 销售出库 → 库存查询”这条最短闭环, 再去看盘点和对账。顺序反了容易在权限和报表上耗掉一整天。
2.2 技术栈与分层:算库存的代码不该写在 Controller 里
这类项目常见的形态是前后端分离: 后端 SpringBoot + MyBatis-Plus + MySQL 8, 前端 Vue3 + Element Plus + Axios, 登录用 JWT, 权限用简单的角色 - 菜单两级。也有不少是 SSM + JSP + Layui 的老结构, 分层思路是一样的, 只是模板换成了 JSP。不管拿到哪个版本, 你要盯的是三层的职责边界有没有乱。
正常的分层是这样:controller只做参数校验和返回包装, 一行业务逻辑都不该有;service承担事务、单据状态流转、库存计算;mapper只写 SQL, 越薄越好。翻车最多的地方是把“扣库存”写进 Controller, 结果一个接口里既改单据状态又改库存, 事务注解加在 Controller 上——Spring 的@Transactional在同类内部调用时失效, 这个坑每年都有人踩。
我给这类项目定的规矩是: 凡是会改库存、改金额的方法, 必须落在service层, 方法上带@Transactional(rollbackFor = Exception.class), 并且方法名统一叫xxxAndUpdateStock这种一看就知道动了两张表的。命名土一点没关系, 半年后回来改的人会感谢你。
// 典型的 service 层骨架:事务只在这一层开 @Service public class SaleService { @Resource private SaleOrderMapper orderMapper; @Resource private StockMapper stockMapper; // rollbackFor 必须写 Exception.class,默认只回滚 RuntimeException @Transactional(rollbackFor = Exception.class) public void createAndOut(SaleOrderForm form, Long operatorId) { // 1. 写单据主表 + 明细表 SaleOrder order = SaleOrder.from(form, operatorId); orderMapper.insert(order); // 2. 逐行扣库存,任何一行失败整单回滚 for (SaleItem item : form.getItems()) { int affected = stockMapper.decrease(item.getGoodsId(), item.getQty()); if (affected == 0) { throw new BizException("商品[" + item.getGoodsName() + "]库存不足"); } } } }这段没写完整的 SQL, 重点在rollbackFor和throw。默认事务只对RuntimeException回滚, 你自定义的BizException如果继承的是Exception, 不加这个参数就会“库存扣了、单据没生成”。参数operatorId一路传到流水表里, 出问题能追到是哪个人开的单。
2.3 用例图落到接口:五个参与者对应哪几个 API
资源里附的用例图不是装饰。把参与者捋一遍, 接口清单基本就出来了。常见五类参与者: 收银员(只开单、查价、退货需授权)、库管(收货、盘点、调拨)、采购员(建采购单、查供应商)、店长(看报表、审单据、改价格)、系统管理员(账号、角色、参数)。
| 参与者 | 核心用例 | 对应接口 | 权限点 |
|---|---|---|---|
| 收银员 | 扫码开单、挂单、小额退货 | POST /api/sale/order、POST /api/sale/hold | sale:create |
| 库管 | 采购收货、盘点录入、库存查询 | POST /api/purchase/in、POST /api/check/submit | stock:in、stock:check |
| 采购员 | 建采购单、供应商维护 | POST /api/purchase/order、GET /api/supplier/list | purchase:edit |
| 店长 | 日结报表、单据审核、改价 | GET /api/report/daily、POST /api/purchase/audit | report:view、order:audit |
| 管理员 | 用户、角色、菜单 | POST /api/user、GET /api/role/menu | sys:* |
这张表是我从用例图翻译过来的, 你手上的版本字段名可能不同, 但结构一定一致。改造的时候, 新增一个菜单就等于新增一个权限点, 别图省事直接复用sys:*, 后面做店长和收银员的分权时会很难受。
接口风格上, 查询用 GET 带分页参数pageNum/pageSize, 写操作统一 POST + JSON body, 返回体固定{code, msg, data}。这种约定俗成的包装在课设项目里到处都是, 好处是前端拦截器好写, 坏处是 HTTP 状态码全变成 200, 排查时要靠code字段。我个人更倾向保留 401/403 的真实状态码, 拦截器里少写一层判断。
2.4 跑起来:从建库到登录后台的四步
环境上没什么特别的: JDK 8 或 17(看 pom 里的spring-boot-starter-parent版本)、MySQL 8、Node 16+, 前端包管理器用 npm 或 pnpm 都行。数据库字符集一定要选utf8mb4, 商品名里出现生僻字或者 emoji 的时候,utf8会直接报错。
# 1. 建库并导入脚本,脚本一般在 sql/ 目录下 mysql -uroot -p -e "CREATE DATABASE supermarket DEFAULT CHARSET utf8mb4;" mysql -uroot -p supermarket < sql/supermarket.sql # 2. 改后端配置:数据库账号、端口、JWT 密钥 # src/main/resources/application-dev.yml # spring.datasource.url / username / password # server.port: 8080 # 3. 起后端 mvn clean package -DskipTests java -jar target/supermarket-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev # 4. 起前端,开发环境走 vite 代理到 8080 cd web && npm install && npm run dev四个步骤里最容易卡住的是第三步:application-dev.yml里数据库地址写的是localhost:3306, 如果你用 Docker 起 MySQL, 端口映射没做对就会一直连不上; 另外mvn package报找不到符号, 多半是 Lombok 版本和 JDK 不匹配, 在 pom 里把lombok提到 1.18.30 以上基本能解。
启动成功后默认账号一般在 SQL 脚本的sys_user表里, 密码是加密存的, 常见做法是 MD5 或 BCrypt。忘了密码不用慌, 直接改库里那条记录, 用在线工具生成一个 BCrypt 串贴进去就行——生产环境当然不能这么干, 本地调试图省事。
前端vite.config.js里有一段proxy配置, 把/api转到http://localhost:8080。如果你把后端端口改成了别的, 记得两边一起改, 不然页面能打开但所有请求 404, 新手最容易在这里怀疑人生。
3. 工程与数据:目录分层和四张核心表怎么定
3.1 后端目录:按业务域分包, 不按技术分层分包
课设项目常见的目录是controller/、service/、mapper/三个大包平铺, 里面塞三四十个类。这种分法在十几个接口的时候没问题, 加到五十个就开始找不着北。这套资源的结构更接近实战一点: 先按域分, 域内再分层。
src/main/java/com/example/supermarket/ ├── common/ # 统一返回、异常、常量、工具 │ ├── R.java # {code,msg,data} 包装 │ └── BizException.java ├── security/ # JWT 过滤器、登录、权限注解 ├── goods/ # 商品域 │ ├── controller/GoodsController.java │ ├── service/GoodsService.java │ ├── mapper/GoodsMapper.java │ └── entity/Goods.java ├── purchase/ # 采购域:单据 + 收货 ├── sale/ # 销售域:开单 + 退货 ├── stock/ # 库存域:余额、流水、盘点 └── report/ # 报表域:只读,不写库好处很实在: 你要改盘点逻辑, 只翻stock/一个目录; 要给别人交接, 说一句“库存在 stock 包”就行。坏处是 Mapper 的 XML 分散在各域下,mybatis-plus.mapper-locations得配成classpath*:mapper/**/*.xml这种通配写法, 少一个星号就报Invalid bound statement。
report/单独拎出来是我很推荐的一点。报表是只读的, 不该复用业务域的 Service, 否则很容易在报表查询里顺手写了个更新。把它隔离出来, 加个只读数据源或者干脆走从库, 后面做性能优化时省事。
3.2 四张核心表:商品、单据、明细、库存余额
库里表不少, 但真正决定这套系统能不能用的只有四类: 商品档案、单据主表、单据明细、库存余额。剩下的供应商、分类、员工都是附属。
-- 商品档案:goods_no 是内部编码,barcode 是厂商条码,两者不要混 CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_no VARCHAR(32) NOT NULL COMMENT '内部编码,扫码枪可读', barcode VARCHAR(64) DEFAULT NULL COMMENT '厂商条码,允许为空允许重复', goods_name VARCHAR(128) NOT NULL, category_id BIGINT NOT NULL, unit VARCHAR(16) NOT NULL COMMENT '件/斤/瓶,称重商品用斤', purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_goods_no (goods_no), KEY idx_barcode (barcode) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存余额:一个商品一个仓库一条记录,靠唯一键兜住 CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL DEFAULT 1, qty INT NOT NULL DEFAULT 0 COMMENT '可用库存', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_goods_wh (goods_id, warehouse_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存流水:只增不改,对账的唯一依据 CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT 'PURCHASE_IN/SALE_OUT/CHECK_ADJUST', biz_no VARCHAR(64) NOT NULL COMMENT '来源单号,幂等靠它', goods_id BIGINT NOT NULL, change_qty INT NOT NULL COMMENT '正数入负数出', before_qty INT NOT NULL, after_qty INT NOT NULL, operator_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_type, biz_no, goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三个点值得展开说。第一,goods_no和barcode必须分开: 同一个商品可能换过包装、条码变过, 但内部编码不该变; 称重商品(散装糖果、生鲜)根本没有条码, 只能靠内部编码。第二,stock上的uk_goods_wh唯一键是防重复入库的底牌, 没有它, 并发下会出现同一商品两条库存记录, 查库存时SUM出来翻倍。第三,stock_flow上的uk_biz唯一键让“同一张单据同一商品只能入一次”变成数据库层面的约束, 业务代码写漏了也捅不出大娄子。
金额字段一律DECIMAL(10,2), 别用DOUBLE。超市里 0.99 乘以 3 这种计算, 用浮点会出现2.9699999999999998, 四舍五入到分之后跟手工算的差一分钱, 对账时能让人抓到头发掉。这是一个我见过太多次的血泪教训, 建表时多打几个字就能避免。
3.3 约束与索引:唯一键和条件更新才是防重的底牌
新手做进销存, 防重复提交的第一反应是前端按钮置灰。这招能挡住手抖, 挡不住网络重试、浏览器回退、并发双击。真正管用的是三道防线叠起来: 前端置灰 + 后端幂等键 + 数据库唯一索引。
索引上要按查询习惯来。收银台最常用的两个查询是“按条码查商品”和“按单号查单据”, 前者给goods.barcode加普通索引(注意条码可能重复, 不能加唯一), 后者给sale_order.order_no加唯一索引。报表那边常按时间范围查,create_time上加索引, 但如果数据量不大(单店一天几百单), 这个索引收益有限, 别为了它牺牲写入速度。
慢查询里最常见的是库存查询页: 一次性JOIN商品、分类、库存、供应商四张表再LIKE '%关键词%'。LIKE前置百分号走不了索引, 商品上千条的时候页面会明显卡。常见的改法是先按分类或首字母筛选, 把模糊匹配限制在小范围里, 或者干脆上 ES——单店场景没必要, 分页 + 条件筛选就够了。
注意:
stock_flow这张表只增不删。做数据清理时千万别DELETE, 归档也得先导出一份。余额对不上时, 它是你唯一的后悔药。
4. 核心链路:一次入库和一次出库, 库存数字是怎么被扣准的
4.1 采购入库:一次确认写四张表
采购收货这条链路, 输入是“一张采购单 + 每行实收数量”, 输出是“库存涨了、流水多了一条、单据状态变了”。四张表要在一个事务里改完, 缺一步就会出现“单据已确认但库存没加”这种查起来极难受的脏数据。
@Service public class PurchaseInService { @Resource private PurchaseOrderMapper orderMapper; @Resource private PurchaseItemMapper itemMapper; @Resource private StockMapper stockMapper; @Resource private StockFlowMapper flowMapper; @Transactional(rollbackFor = Exception.class) public void confirmIn(Long orderId, Long operatorId) { PurchaseOrder order = orderMapper.selectById(orderId); // 状态机卡一道:只有草稿态能确认,防止重复点 if (order == null || !"DRAFT".equals(order.getStatus())) { throw new BizException("单据不存在或已确认"); } List<PurchaseItem> items = itemMapper.listByOrderId(orderId); if (items.isEmpty()) { throw new BizException("单据没有明细"); } for (PurchaseItem item : items) { // 幂等:同一单号同一商品,流水已存在就跳过 int dup = flowMapper.countByBiz("PURCHASE_IN", order.getOrderNo(), item.getGoodsId()); if (dup > 0) { continue; } // 先尝试累加,返回 0 说明库存行还不存在 int affected = stockMapper.increase(item.getGoodsId(), item.getQty()); if (affected == 0) { stockMapper.insertIgnore(item.getGoodsId(), item.getQty()); } // 流水记录变动前后的值,方便事后追 StockFlow flow = StockFlow.of("PURCHASE_IN", order.getOrderNo(), item, operatorId); flowMapper.insert(flow); } orderMapper.updateStatus(orderId, "FINISHED"); } }对应的两条 SQL 很短, 但省不得:
<!-- 有库存行就累加 --> <update id="increase"> UPDATE stock SET qty = qty + #{qty}, version = version + 1 WHERE goods_id = #{goodsId} AND warehouse_id = 1 </update> <!-- 没有库存行就插一条,靠唯一键兜住并发 --> <insert id="insertIgnore"> INSERT IGNORE INTO stock (goods_id, warehouse_id, qty, version) VALUES (#{goodsId}, 1, #{qty}, 0) </insert>INSERT IGNORE配上uk_goods_wh唯一键, 是这个写法成立的关键。两个线程同时给一个新品入库, 一个插入成功一个被忽略, 不会出现两条库存行。如果你不喜欢INSERT IGNORE(它会吞掉其他类型的错误, 比如字段超长), 可以换成ON DUPLICATE KEY UPDATE qty = qty + VALUES(qty), 一条语句搞定插入和累加, 我更常用后者。
参数上要留意warehouse_id。单店场景写死 1 没问题, 但建议做成配置项或者从单据上取, 后面扩展成多门店时不用回头改 SQL。
4.2 销售出库:用带条件的 UPDATE 挡住负库存
销售出库是最容易出事的地方, 因为它是高并发的: 一台收银机上连扫十件商品, 后台可能是十个并发请求。库存 3 件, 两个人同时买, 先查后改的写法必然超卖。
<!-- 关键在 WHERE 里的 qty >= #{qty}:库存不够时影响行数为 0 --> <update id="decrease"> UPDATE stock SET qty = qty - #{qty}, version = version + 1 WHERE goods_id = #{goodsId} AND warehouse_id = 1 AND qty >= #{qty} </update>@Transactional(rollbackFor = Exception.class) public Long createSale(SaleForm form, Long operatorId) { // 生成单号:日期 + 4 位序号,实际项目里建议用 Redis 自增或号段 String orderNo = "S" + DateUtil.format(new Date(), "yyyyMMdd") + Seq.next(); SaleOrder order = SaleOrder.draft(orderNo, form, operatorId); orderMapper.insert(order); for (SaleItem item : form.getItems()) { // 影响行数为 0 只有两种可能:库存不足,或商品库存行不存在 int affected = stockMapper.decrease(item.getGoodsId(), item.getQty()); if (affected == 0) { throw new BizException("商品 " + item.getGoodsName() + " 库存不足"); } flowMapper.insert(StockFlow.of("SALE_OUT", orderNo, item, operatorId)); } orderMapper.updateStatus(order.getId(), "PAID"); return order.getId(); }不用SELECT ... FOR UPDATE也能防超卖, 靠的就是qty >= #{qty}这个条件和 InnoDB 的行锁。UPDATE语句在命中索引的情况下会给那一行加排他锁, 两个并发请求排队执行, 后到的那个发现qty已经不够, 影响行数为 0, 直接抛异常回滚整单。这比“先查库存再判断再更新”少一次往返, 也少一个竞态窗口。
有个细节容易被忽略: 抛出BizException后整单回滚, 但如果前端是“一次扫一件、每件单独提交”的模式, 前面几件已经扣了。这时候要么改成整单提交, 要么在异常时回滚已生成的部分——所以我在方法上直接标了整单事务, 加购物车在前端做, 点结算才发一次请求。
version字段在这个写法里其实是冗余的(条件更新已经够了), 但保留它有价值: 排查时看一眼版本号变了多少次, 就知道这个商品被操作了多少回。
4.3 盘点与冲红:差异流水怎么回写
盘点是进销存的“校准器”。流程一般是: 生成盘点单 → 录入实盘数 → 提交 → 系统算差异 → 以实盘为准改余额, 同时写一条CHECK_ADJUST流水。注意是“以实盘为准”, 不是“加减差异”, 这两个在并发下结果完全不同。
-- 提交盘点:直接以实盘数为准覆盖余额 UPDATE stock s JOIN stock_check_item i ON i.goods_id = s.goods_id SET s.qty = i.real_qty, s.version = s.version + 1 WHERE i.check_id = #{checkId} AND s.warehouse_id = 1; -- 差异流水:change_qty = 实盘 - 账面,正数盘盈负数盘亏 INSERT INTO stock_flow (biz_type, biz_no, goods_id, change_qty, before_qty, after_qty, operator_id) SELECT 'CHECK_ADJUST', c.check_no, i.goods_id, i.real_qty - s.qty, s.qty, i.real_qty, #{operatorId} FROM stock_check_item i JOIN stock s ON s.goods_id = i.goods_id JOIN stock_check c ON c.id = i.check_id WHERE i.check_id = #{checkId} AND s.warehouse_id = 1;这两条 SQL 的执行顺序不能反。先写流水再改余额, 取到的before_qty是旧的, 是对的; 先改余额再写流水,before_qty就变成了新值, 差异算出来是 0, 流水等于白记。这种顺序依赖在存储过程里更明显, 纯 SQL 写的时候反而容易看漏。
冲红(销售退货)是另一个方向。退货单确认时, 库存加回去, 流水记一条SALE_RETURN, 金额按原单单价退, 不做重新定价。这里有个业务上的坑: 如果原单已经参与过日结, 退货要不要改历史报表? 常见做法是不改历史, 在退货发生的当天记一笔负数, 报表按发生日汇总。这样财务口径对得上, 也符合“账不能随便改”的直觉。
4.4 报表口径:日结快照还是实时聚合
报表这块, 课设项目一般直接GROUP BY实时算, 数据量小看不出问题。真放到店里用, 一天几百单、几千行明细, 收银高峰期点一下“今日销售”要等三秒, 体验就崩了。
我的做法是分两层: 明细层实时查, 汇总层每天凌晨跑一个定时任务, 把当日各商品的销量、销售额、毛利算好落进report_daily表。页面默认读快照, 想看当天实时的再点“刷新”走实时查询。快照表结构简单到不能再简单:stat_date + goods_id + sale_qty + sale_amount + cost_amount, 唯一键(stat_date, goods_id)。
定时任务用 Spring 的@Scheduled(cron = "0 30 2 * * ?")就够, 单店场景不需要引入调度中心。要注意的是补数: 如果某天任务失败了, 得有手动重跑的入口, 否则那天报表永远是空的。我在report域里留了一个POST /api/report/rebuild?date=yyyy-MM-dd的接口, 只允许店长角色调, 重跑前先DELETE当天快照再重算, 保证幂等。
5. 排查与避坑:库存对不上时, 先查这五个地方
5.1 五个高频翻车现场
① 页面库存显示 0, 数据库里明明有货
现象: 商品列表里某个商品库存一直显示 0, 但直接查stock表能看到qty = 58。 原因: 库存查询 SQL 里写死了warehouse_id = 1, 而入库时取的仓库是从单据上带的, 单据上的warehouse_id是 2(或者前端传了个空, 落库成了 0)。两边条件对不上,LEFT JOIN出来就是NULL, 前端格式化成 0。 解决: 把仓库条件统一收到一个上下文里(登录用户的默认仓库, 或者单据上取), 别在 SQL 里硬编码。顺手在stock表按(goods_id)建个索引, 排查时SELECT * FROM stock WHERE goods_id = ?能立刻看到究竟有几条记录。
② 同一张采购单点了两次确认, 库存翻倍
现象: 库管说只点了一次, 库存涨了两倍。 原因: 前端按钮没置灰 + 后端只校验了“单据存在”, 没校验状态, 两次请求几乎同时到达, 都通过了校验。事务隔离级别是默认的REPEATABLE READ, 两个事务各自读到的状态都是DRAFT。 解决: 三道防线一起上。前端点击后立刻disabled; 后端把状态更新改成条件更新UPDATE purchase_order SET status='FINISHED' WHERE id=? AND status='DRAFT', 影响行数为 0 就直接返回“已确认”; 最外层再用stock_flow的uk_biz唯一键兜底, 重复插入直接报错。
③ 金额差一分钱, 对账永远对不平
现象: 单据明细加起来和主表总额差 0.01。 原因: 单价用DOUBLE存, 或者 Java 里用double算单价 * 数量, 累加之后精度丢失。也可能是每行四舍五入到分之后再求和, 与“总和再四舍五入”差了分。 解决: 数据库DECIMAL(10,2), Java 一律用BigDecimal, 并且显式指定setScale(2, RoundingMode.HALF_UP)。主表总额由明细行BigDecimal累加得出, 不要另算一遍。这个坑没有捷径, 只能靠代码规范卡住。
④ 删了一个商品, 历史单据详情打不开
现象: 商品管理里删除一个下架商品, 结果半年前的销售单点进去 500。 原因: 商品用了物理删除, 而单据明细里存的是goods_id, 详情页JOIN goods查不到记录, 或者代码里goods.getName()空指针。 解决: 所有主数据一律逻辑删除(deleted字段), 查询统一加deleted = 0。更好的做法是明细表里冗余存一份goods_name和sale_price, 单据一旦生成就不依赖主数据的变化——商品改名、改价都不该影响历史单据。
⑤ 盘点提交后报表没变
现象: 盘点单显示已提交,stock表的数字也变了, 但日报表还是老数据。 原因: 日报表读的是report_daily快照, 而盘点没有触发快照重算。 解决: 盘点、退货、冲红这类会改历史数据的操作, 提交后统一调一次快照重建, 或者干脆把快照的生成时间放到凌晨, 当天数据走实时查询。两种都行, 别混着来。
5.2 定位手段:一条对账 SQL 和三个日志断点
库存对不上时, 别急着翻代码, 先跑一条对账 SQL。核心等式是“余额 = 所有流水累加”, 不成立就说明中间有一步没写流水, 或者写了但没改余额。
-- 找出余额与流水累计不一致的商品 SELECT s.goods_id, g.goods_name, s.qty AS stock_qty, IFNULL(SUM(f.change_qty), 0) AS flow_qty, s.qty - IFNULL(SUM(f.change_qty), 0) AS diff FROM stock s LEFT JOIN stock_flow f ON f.goods_id = s.goods_id LEFT JOIN goods g ON g.id = s.goods_id GROUP BY s.goods_id, g.goods_name, s.qty HAVING diff <> 0 ORDER BY ABS(diff) DESC;查出来有差异, 再按biz_type分组看是哪类业务出的问题:
-- 按业务类型看变动量,异常类型一眼能看出来 SELECT biz_type, COUNT(*) AS cnt, SUM(change_qty) AS total_qty FROM stock_flow WHERE goods_id = ? AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY biz_type;日志这边我一般埋三个点: 库存变更前后各打一行(带goods_id、before、after、biz_no), 事务提交后打一行“单据 X 确认成功”, 异常捕获处打完整堆栈。日志里必须带biz_no, 这是把日志和流水串起来的唯一线索。见过太多项目日志打得热闹, 但全是“进入方法”“参数为 xxx”, 真出事了一句有用的都没有。
提示:本地复现并发问题, 可以用
ab -n 100 -c 10压一下出库接口, 比手点快得多。压完立刻跑对账 SQL, 有问题当场就能看出来。
6. 二次开发:把课设改成店里能用的收银台, 要动哪几刀
课程设计到能用之间, 差的不是功能数量, 是几个具体的落地细节。下面三个改造方向, 是我见过的需求里出现频率最高的。
第一刀: 扫码枪接入。扫码枪本质是个键盘设备, 扫一次等于快速敲入一串字符再回车。不需要任何驱动, 前端只要保证有个隐藏输入框始终聚焦, 监听到回车就触发查询。
// 扫码枪监听:全局聚焦 + 回车触发,注意别和搜索框抢焦点 let buffer = '' let lastTime = 0 window.addEventListener('keydown', (e) => { const now = Date.now() // 两次按键间隔超过 50ms 视为手打,清空缓冲 if (now - lastTime > 50) buffer = '' lastTime = now if (e.key === 'Enter' && buffer.length >= 6) { queryGoodsByBarcode(buffer) // 调 /api/goods/barcode/{code} buffer = '' e.preventDefault() } else if (e.key.length === 1) { buffer += e.key } })50ms这个阈值是经验值, 手打速度一般到不了, 扫码枪基本都在 20ms 以内。buffer.length >= 6是过滤误触, 太短的串当条码查没意义。真正要注意的是焦点管理: 页面上有搜索框时, 用户的输入会被这个全局监听吃掉, 所以要么在输入框聚焦时暂停监听, 要么把扫码区做成独立组件。
第二刀: 称重商品。散装商品按斤卖, 数量是小数,qty字段就得从INT改成DECIMAL(10,3)。这一改会牵连所有库存计算, Java 侧全部换成BigDecimal, 加减的时候注意setScale(3, RoundingMode.DOWN)——库存宁可少算也别多算。收银时称重商品不能扫条码, 得有个“称重”按钮弹框输重量, 或者接电子秤的串口读数。串口这块浏览器做不了, 常见做法是本地起个小服务读串口再往页面推, 复杂度不低, 单店可以先手动输入。
第三刀: 单据打印。小票打印用window.print()配合一段@media print样式就够了, 不需要引第三方库。热敏纸宽度一般是 58mm 或 80mm, 布局按 58mm 设计, 打印时把侧边栏、按钮全部display: none。
@media print { .no-print { display: none !important; } .receipt { width: 58mm; font-size: 12px; font-family: monospace; } @page { margin: 0; } }改造完怎么验证? 我一般准备三份数据: 一份正常流程(采购 100 件, 卖 30 件, 剩 70), 一份并发场景(库存 5 件, 同时下 10 单, 只该成功 5 单), 一份异常回滚(单据里混一个不存在的商品 ID, 整单该回滚)。三份跑完再跑一遍对账 SQL, 差异为 0 才算过。
回归的时候有个小技巧: 把对账 SQL 挂成定时任务, 每十分钟跑一次, 有差异就往一个专门的告警表里插一条。开发阶段看到告警表里冒出记录, 比事后翻日志快十倍。
从那以后我每次接手这类单据型系统, 都先把“库存余额 = 流水累加”这条等式写成 SQL 挂上去, 跑通了才敢动业务代码。数字对了, 功能再丑都能慢慢补; 数字不对, 界面再漂亮也是白搭。希望帮到你。
本文还有配套的精品资源,点击获取