Java C/S进销存实战:库存流水驱动的系统设计与并发控制 简介这是一份面向Java C/S架构进销存系统开发者的完整工程与设计资料适合正在做课程设计、毕业设计或企业级进销存项目的人员参考。包内共有252个文件包含166个class编译文件、68个java源码文件、4个doc需求与设计文档以及sql数据库脚本、properties配置、jar依赖和myumldata模型文件等压缩包仅1.63MB轻量但资料齐全。资源围绕采购、销售、库存、财务四大核心业务配套需求文档中的业务流程、用例图以及详细设计中的架构、模块、接口与数据库表结构设计可帮助读者快速理解系统从设计到落地的完整链路。目前已有195人学习下载适合作为进销存系统开发的参考模板便于对照源码、文档进行二次开发与功能扩展。 最近在技术群里被问得最多的一个项目就是进销存。尤其是指定“用Java做、C/S客户端/服务器结构”的那一类——新手想拿它练手中小企业想找套能部署在内网的方案两种需求经常被混在一起聊。我前后参与过三套进销存系统的开发踩过的坑足够写一篇长文。最典型的问题不是代码写不出来而是把进销存做成了“会漏账的订单本”——库存越查越不对月底一盘点负数一大片最后只能靠手工改数据勉强收场。这篇文章我打算把一套Java C/S进销存从业务梳理、技术选型、数据库设计到核心代码实现完整讲一遍。重点说清楚哪些地方必须一开始就做对哪些问题要等系统上线跑一段时间才会陆续暴露。内容偏向实战适合刚接触管理系统的Java开发者也适合正准备给别人做进销存外包的团队作参考。1. 进销存系统的业务本质先搞懂管的是“流水”不是“库存”很多新手拿到“进销存”这个需求第一反应是设计商品表、库存表然后把增删改查一套写完就交差。这种系统上线三个月内大概率出问题商品明明没卖出去几件库存却对不上财务问“这批货的成本到底是多少”系统答不上来。问题出在根上——你把进销存理解成了“管库存”而它真正管的是“商品的进出流水”。1.1 三张单据定乾坤进销存业务看起来种类繁多拆开来看其实就是三种钱货动作进货、销售、库存内部调整。进货向供应商采购对应“进货入库单”库存增加。销售卖给客户对应“销售出库单”库存减少。库存调整盘点差异、报损、调拨、期初录入对应“库存调整单”。系统里所有库存变化都必须来自这三类单据不能有任何“游离在单据之外”的库存变动。这句话是整个系统的宪法级约束。比如盘点发现实物少了5件你不能直接写SQL把库存减5而是要先做一张“盘亏调整单”审核通过后系统自动扣减库存。这样月底对账时才能回答一个问题那5件去哪了1.2 库存表只是“结果表”不是“账本”新手最容易犯的错就是拿库存表当账本用。库存不对了直接改库存字段客户退了一瓶水不生成退货单直接给库存加1。短期看很痛快等单据多了之后库存数据就成了一笔糊涂账没人能说清楚当前数字是怎么来的。正确的设计思路是库存表里的数字永远是由流水汇总出来的结果库存表本身只是一个查询缓存。每一笔入库、出库、调整都会先写入“库存流水表”然后根据流水重新计算出最新的库存余额回写到库存余额表。库存流水只增不改、只增不删。这样你随时可以回答“这个商品三天前的库存是多少”“昨天一共出了多少货”“这批货的成本按什么价格算的”。我见过做得比较讲究的系统连“用户登录后直接改SKU名称”这种操作都要记录审计日志更不用说库存这种核心数据。库存流水表就是整个业务的事实来源这一点想通了后面很多设计决策就顺理成章了。2. 技术选型为什么我坚持用C/S而不是B/S提到进销存不少人的第一反应是做成网页版——浏览器打开就能用多方便。但如果你深入过门店、仓库、批发部的实际使用场景会发现在局域网内跑一个Java C/S桌面客户端往往比网页版更贴合真实业务节奏。2.1 C/S在真实门店场景里的优势进销存的用户是店长、库管、收银员他们一天要录入大量单据。网页版的体验是“鼠标点一下等页面刷新再点下一个输入框”而桌面客户端的体验是“回车自动跳下一个输入框F1开单、F2保存、F8查询键盘就能完成大部分操作”。以销售开单为例收银员扫一瓶水的条码直接回车数量自动加1再扫一瓶可乐回车继续录下一行。桌面端可以做到全程不碰鼠标。B/S架构当然也能做键盘联动但要处理浏览器的兼容性、焦点管理、页面刷新状态丢失等问题投入成本明显更高体验还不一定稳定。网络环境也是关键因素。门店的宽带不稳定是常态如果外网一断整个收银系统就瘫了老板站在收银台边上看着你重启路由器的场面经历一次就再也不想经历。C/S结构内网通信只要局域网通系统就能跑断外网最多是影响云端同步不影响开单收银。对比维度C/S桌面客户端B/S网页版录入效率高快捷键/回车流设计灵活中依赖浏览器控件和焦点管理网络依赖只需内网外网断开可继续用强依赖服务器可用性部署成本客户端需安装但可用一键安装包免安装但要维护Web服务器数据安全数据不暴露公网接口面小需额外做防火墙、WAF、认证等防护开发调试桌面端问题易复现日志本地化浏览器兼容性坑多问题复现路径长当然如果你的业务场景需要多门店异地协同、老板在外地用手机看报表那C/S就不合适了那属于B/S或移动端的事。进销存选C/S还是B/S从来不是技术栈的优劣问题而是场景匹配度的问题。2.2 客户端界面框架Swing还是JavaFXJava桌面端的UI框架现实选择基本就是Swing和JavaFX两选一。JavaFX的优势是现代化、自带CSS样式支持、TableView组件功能比Swing的JTable强不少适合高分屏、界面颜值要求高的场景。但它有一个很现实的麻烦打包分发。JavaFX本身不在JDK里需要用jpackage或第三方工具打成独立安装包JDK版本、操作系统位数、显卡驱动都可能成为坑。如果你客户的电脑是几年前的老机器还在用Win7JavaFX的高版本兼容性会让你很头疼。Swing的优势是稳。它跟着JDK走一个包含JRE的一键安装包就能跑老机器、旧系统兼容性都很好。界面是丑了点但进销存最大的诉求是“录入效率”和“不出错”不是“界面多好看”。我做过的C/S进销存客户端设计上优先保证键盘流操作顺畅界面风格朴素、信息密度高客户反而觉得很专业。如果要在两者之间选我的建议很直接客户电脑情况不可控、团队时间紧选Swing客户电脑比较新、团队有人熟悉JavaFX选JavaFX。别为颜值冲动UI框架换了业务逻辑该写的功夫一点不少。2.3 服务端与通信方案服务端我推荐直接用Spring Boot内嵌Tomcat打包成独立JAR。部署时在服务器上装个JDK把JAR注册成Windows服务或Linux systemd服务开机自启即可运维成本很低。通信协议不要自己设计TCP二进制协议也不要为了“高并发”上Netty。进销存这种系统一天的并发请求量可能还不如一个中型网站高峰期的零头。客户端和服务端之间用HTTPJSON最省事Spring MVC天然支持Java客户端用现成的HTTP客户端库就能对接排错也方便直接在浏览器里就能调试接口。数据存储用MySQL就够。单机版小体量用SQLite也行但一旦门店数量超过五家还是统一用MySQL后面接报表、做数据迁移都更省心。3. 数据库设计对不上账的毒瘤都在表结构里进销存系统的数据量不大单表撑死几十万行性能上几乎没有压力。它的难点在内核——表结构设计如果不合理逻辑越写越绕最终对不上账。下面这张表格基本覆盖了一套标准进销存系统需要的核心业务表表名用途关键字段t_goods商品档案商品编码、名称、规格、条码、单位t_category商品分类分类编码、名称、上级分类t_supplier供应商名称、联系人、电话、应付余额t_customer客户名称、联系人、电话、应收余额t_warehouse仓库/门店仓库编码、名称t_stock库存余额商品ID、仓库ID、库存数量、均价t_purchase进货入库单头单号、供应商ID、仓库ID、单据状态、日期t_purchase_item入库单明细单据ID、商品ID、数量、进价、金额t_sale销售出库单头单号、客户ID、仓库ID、单据状态、日期t_sale_item出库单明细单据ID、商品ID、数量、售价、金额t_stock_flow库存流水业务类型、业务单号、商品ID、仓库ID、变动方向、变动数量、变动前库存、变动后库存3.1 库存余额表与流水表的关系最容易理解错的是库存余额表。t_stock表里只存“当前剩余数量”和“当前移动加权平均成本价”它是一个可以被流水重新推导出来的结果表。而t_stock_flow流水表才是整个系统的账本。每一次库存变化要么是入库单审核要么是出库单审核要么是调整单审核都会在流水表里产生一条记录。流水表不删除、不修改出了任何账务问题第一件事就是查流水往回追溯。3.2 库存流水表怎么设计才够用很多系统设计流水表时都偷懒只存“商品ID、变更数量、备注”。等真正对账的时候就会发现信息不够——这张单是哪位操作员审的变动之前的余额是多少对应的是哪张业务单——全都查不到。我的建议是流水表至少包含这些字段CREATE TABLE t_stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(20) NOT NULL COMMENT 业务类型PURCHASE/SALE/ADJUST, biz_no VARCHAR(32) NOT NULL COMMENT 业务单号比如采购单号/销售单号, goods_id BIGINT NOT NULL COMMENT 商品ID, warehouse_id BIGINT NOT NULL COMMENT 仓库ID, direction TINYINT NOT NULL COMMENT 方向1入库 -1出库, quantity DECIMAL(14,2) NOT NULL COMMENT 变动数量, unit_price DECIMAL(14,4) NOT NULL COMMENT 当时成本/售价, before_qty DECIMAL(14,2) NOT NULL COMMENT 变动前库存, after_qty DECIMAL(14,2) NOT NULL COMMENT 变动后库存, operator_id BIGINT NOT NULL COMMENT 操作员, biz_time DATETIME NOT NULL COMMENT 业务时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_goods_time (goods_id, biz_time), KEY idx_biz_no (biz_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;before_qty和after_qty这两个字段看着冗余实际价值极高。出了争议时你可以直接看出“这笔操作把库存从47变成了42”而不是再累加计算一遍。biz_no能把流水精确关联回原单据财务查账时顺着单号一路点过去两三分钟就能解释清楚一笔差异。3.3 金额精度与单据状态约束金额相关的字段一律用DECIMALJava代码里一律用BigDecimal。进销存系统最忌讳用double或float存金额原因后面第5章会详细讲。数据库建表时单价建议用DECIMAL(14,4)——有些商品进价会到小数点后三四位核算成本时精度多一些没坏处金额合计用DECIMAL(14,2)符合国内财务习惯。单据状态字段也很关键。一张入库单可能经历“草稿、已保存、已审核、已作废”这几个状态。只有“已审核”状态才能影响库存草稿和作废单都不能。作废不等于删除它要把之前已经产生的库存流水冲掉并在单子上保留作废痕迹方便后期追责和审计。如果开始就设计“删除单据”而不是“作废单据”后面你的财务数据一定会被删得稀碎。4. 核心业务流程的实现与并发控制进销存系统的业务逻辑不算复杂但有一个点必须认真对待库存扣减的并发控制。仓库库管员同时打两张出库单、扫码枪连续扫货、盘点单和销售单同时在跑——这种并发场景很常见处理不好就是超卖或库存负数。4.1 入库单提交一个事务内完成所有事服务端保存入库单的接口业务链路是收到请求保存单据头和明细接着扣加库存然后写流水。整套动作必须在同一个数据库事务里完成任何一个环节失败都不允许留下半截数据。核心代码逻辑可以参考下面的写法Transactional(rollbackFor Exception.class) public void auditPurchaseOrder(Long orderId, Long operatorId) { // 1. 锁定单据防止同一张单被重复审核 PurchaseOrder order purchaseOrderMapper.selectByIdForUpdate(orderId); if (order null || !SAVED.equals(order.getStatus())) { throw new BusinessException(单据不存在或状态不正确); } // 2. 对涉及库存的商品行逐行加锁 ListPurchaseOrderItem items purchaseItemMapper.selectByOrderId(orderId); for (PurchaseOrderItem item : items) { Stock stock stockMapper.selectByGoodsAndWarehouseForUpdate( item.getGoodsId(), order.getWarehouseId()); if (stock null) { // 首次入库创建一条新的库存记录 stock createStock(item.getGoodsId(), order.getWarehouseId()); } BigDecimal beforeQty stock.getQuantity(); BigDecimal afterQty beforeQty.add(item.getQuantity()); // 3. 更新库存余额数量 移动加权平均成本 stock.setQuantity(afterQty); stock.setAvgCost(calcNewAvgCost(stock, item, beforeQty)); stockMapper.update(stock); // 4. 插入库存流水 stockFlowMapper.insert(buildFlow( PURCHASE, order.getOrderNo(), item.getGoodsId(), order.getWarehouseId(), 1, item.getQuantity(), item.getPurchasePrice(), beforeQty, afterQty, operatorId, order.getBizTime())); } // 5. 更新单据状态 order.setStatus(AUDITED); purchaseOrderMapper.update(order); }重点看第二步的ForUpdate——在扣减库存前先锁住对应商品的行。同一时刻如果另一张单也想动这个商品的库存它必须等前一个事务提交后才能继续。这是防止并发问题的关键。4.2 出库防超卖出库和入库最大的不同在于入库可以随便加出库却要“先判断库存够不够”。最危险的做法是先查库存有多少判断库存 出库数量执行库存扣减在并发场景下两个线程可能同时查到库存是10同时判断“10 5”然后同时扣减最终库存变成0——虽然理论上已经卖了10件出去。这就是经典的超卖问题。解决办法与入库一样对库存行加悲观锁后再判断Transactional(rollbackFor Exception.class) public void auditSaleOrder(Long orderId, Long operatorId) { SaleOrder order saleOrderMapper.selectByIdForUpdate(orderId); if (order null || !SAVED.equals(order.getStatus())) { throw new BusinessException(单据不存在或状态不正确); } ListSaleOrderItem items saleItemMapper.selectByOrderId(orderId); for (SaleOrderItem item : items) { Stock stock stockMapper.selectByGoodsAndWarehouseForUpdate( item.getGoodsId(), order.getWarehouseId()); if (stock null || stock.getQuantity().compareTo(item.getQuantity()) 0) { throw new BusinessException(商品[ item.getGoodsName() ]库存不足); } BigDecimal beforeQty stock.getQuantity(); BigDecimal afterQty beforeQty.subtract(item.getQuantity()); stock.setQuantity(afterQty); stockMapper.update(stock); stockFlowMapper.insert(buildFlow( SALE, order.getOrderNo(), item.getGoodsId(), order.getWarehouseId(), -1, item.getQuantity(), item.getSalePrice(), beforeQty, afterQty, operatorId, order.getBizTime())); } order.setStatus(AUDITED); saleOrderMapper.update(order); }注意那个compareTo判断。库存是BigDecimal不能用或直接比较更不能用。这是进销存系统里最常见的低级错误之一。判断必须放在“拿到行锁之后”也就是ForUpdate之后否则这个判断就是无效的——你查到的数据可能是前一个事务已经改了但还没提交的旧值。4.3 库存查询与对账前台查询库存时直接查t_stock余额表秒回。但如果用户要看“某个时间段内的出入库明细”就要走流水表ListStockFlow flows stockFlowMapper.selectRange( goodsId, warehouseId, startTime, endTime);有了流水表之后月底对账就变得很简单拿系统的“账面库存”和“实物盘点”做差异差异不为零就生成一张库存调整单调整数量和原因审核后自动更新库存、写流水。整个过程环环相扣每个数字都有来源。5. 实测中踩过的坑与完整排查过程理论设计说清楚了接下来是实践中真实踩过的坑。每一条都对应着真金白银的教训我尽量把当时的排查思路完整写出来。5.1 double导致金额精度错乱一分钱去哪了第一次做进销存时数据库里金额字段用的是doubleJava代码里也直接用double做金额运算。表面上一切正常直到财务月底对账发现应收账款差了几分钱。排查过程很折磨。先怀疑是折扣算错了把折扣逻辑翻了几遍又怀疑是四舍五入的问题把数据库里所有金额相关的字段都导出来人工比对。最后写了个脚本逐单累加销售金额和财务的报表一对比发现差异集中在那些“单价带小数点、数量又多的单据”上。根因是浮点数存储精度。0.1 0.2在二进制浮点里不等于0.3而等于一个很长的小数。单笔差异也许只有0.00000000000000001元但几千笔订单累加起来差异就显出来了。修复办法简单粗暴全局替换。数据库所有金额字段改成DECIMALJava代码里所有金额类型改BigDecimal所有运算改add、subtract、multiply禁止直接使用 - * /。修改量不小但改完之后这类问题就再也没出现过。5.2 有人直接改库存表账实不符的最隐蔽原因系统上线跑了大半年后客户反馈某几个商品的库存对不上。我调出流水记录发现从某一天开始这几样商品的库存流水和单据完全能对上但余额表的数字却对不上——这说明有人绕过了单据和流水直接改了库存表。当时第一反应是不相信因为后台管理界面里并没有开放直接修改库存的功能。最终用数据库的binlog查到了改数据的SQL时间戳对应的是客户老板的账号。原来是他们嫌“走单审核”麻烦盘点发现差了几件货直接找人进数据库改了数字。遇到这种情况光靠沟通提醒没用。我给库存表加了一层数据库拦截逻辑对t_stock表做审计一旦检测到绕过业务接口的UPDATE语句就记录告警同时把后台管理员账号的操作日志打开谁在什么时间改了什么数据全部留痕。更重要的是代码层面把“修改库存”这个操作的入口封死——所有库存变更必须经过业务Service层除了系统本身任何人都不应该能手动修改库存余额表。5.3 并发出库超卖日志时间线还原真相这个问题是我在给一家批发客户做压力测试时发现的。当时开了一个线程池模拟20个客户端同时提交出库单商品库存只有10件结果20单里有3单成功了库存变成了负数。刚开始怀疑是事务没生效。检查Transactional注解没问题又怀疑是锁粒度问题查了代码逻辑发现问题出在一个很隐蔽的地方第一次查询库存时没有加锁。页面展示数量用的是一次查询用户提交订单时又是一次查询而提交订单时的ForUpdate锁是在“判断库存”之后才加的——我先查了库存够不够再去锁行锁完没重新查直接用了之前查到的旧数量。修复方案很清晰先锁行锁住之后再重新查库存再判断再更新。判断、扣减、写流水必须在拿到锁之后进行否则判断的数据就是脏数据。改完之后再用20个线程并发压测只有库存够的单能成功不够的会直接报“库存不足”。问题现象根因修复方案月末金额差几分钱使用了double存储和计算金额全局改为BigDecimal DECIMAL账面库存与流水对不上有人绕过业务接口直接改库权限控制 操作审计 数据库拦截并发出库出现负数先判断库存后才加锁先锁库存行再查询判断单据日期混乱客户端与服务器时间不一致统一以服务端时间为准删除销售单导致流水丢失用了物理删除改为作废/冲红流程5.4 客户端与服务器时间不一致这个坑不算难但很烦。客户的服务器主板电池出问题系统时间慢了一天。结果库管晚上8点做的销售单系统里显示的日期却是前一天。月底报表一出当天的销售数据全部少了一天。修复办法所有业务时间统一以服务器时间为准。客户端不做任何时间相关判断客户端提交单据时只传业务内容时间由服务端LocalDateTime.now()生成。这条规则从前端到接口层全部统一从那以后日期相关的对账问题再没出现过。6. 系统上线之后我才想明白的几件事第一件事进销存系统真正的复杂度从来不在能不能写出来而在业务的边界条件够不够清晰。比如负库存到底允不允许客户先拿货后补单的情况要不要支持退换货是走红冲重新开单还是直接改原单这些问题不跟客户确认清楚系统做得再漂亮也会被业务规则打回原形。我后来做需求调研时第一件事就是拿出三张空白的入库单、出库单、调整单让客户把所有能想到的流程一个字段一个字段过一遍。第二件事系统要留“后悔药”。所谓后悔药就是单据的作废和冲红机制。客户操作错了不能要求他删单重录——权限不能这么放。正确做法是允许作废单据作废会自动生成反向流水把库存冲回来同时保留作废记录。这样既保证数据链条完整又给了业务人员容错空间。第三件事进销存系统不只是一套软件它本质上是一套“数据规则”。谁有权开单、谁有权审核、谁能看成本价、谁能改供应商结算价这些权限边界必须明确。我之前帮客户部署完系统后还会专门花半天时间教他们“怎么从流水反推问题”——这套思维比软件本身更值钱。如果你正准备用Java做C/S进销存我建议你先把这篇文章第3章和第4章的表结构和事务逻辑吃透把账务链条设计复习一遍再动手写代码。前端界面做丑了可以改表结构设计错了改起来就是扒一层皮的事。照着流水驱动的思路做这套系统即便后面加功能、接报表、对接电子发票都会顺很多。本文还有配套的精品资源点击获取