☰
进销存系统源码核心设计:三流合一、事务与成本核算
2026/10/20 15:04:31 网站建设 项目流程

简介:这是一套基于MySQL的开源ERP进销存系统源码,面向中小企业信息化建设者、Java开发人员及毕业设计选题者。系统覆盖采购、销售、库存与财务等核心模块,清晰展示了从供应商管理到客户订单、出入库及成本核算的完整业务链路,便于理解企业资源计划的实际运转方式。资源包共1448个文件,约14.26MB,以Java后端源码、JSP/HTML/CSS/JavaScript前端页面、SQL数据库脚本及项目配置文件为主,另含大量界面截图与图标素材,可帮助快速掌握项目结构并部署运行。目前已有769人学习下载,适合用于二次开发、业务功能扩展或作为ERP系统学习参考。借助开放的源码和初始化脚本,使用者能够深入研读角色权限控制、报表统计、系统集成等关键设计,并在真实场景中锻炼数据库建模与Web应用开发能力。

1. 进销存系统源码:先分清是“能跑的代码”还是“能用的系统”

很多团队拿到一套 ERP 进销存系统源码,第一反应是“先跑起来看看”,启动脚本配好,界面完整,商品能录入、订单能审核、库存能查。但真接业务就不一样:进货单分批到货,仓库拆了又合,成本价在退货、调拨、赠送之后慢慢漂移。账上一个数,月底对账另一个数,这个差正是判断源码好坏的分水岭。

越是“跑起来容易”的源码,越让新手让新手对“系统可用”产生误判。真正的进销存价值不在菜单多,而在于单据、库存、成本三条数据链能不能对平——采购入库单审核后,库存和流水同时变;销售出库单审核后,成本和毛利同步算好。链路稳,代码才值得改。

这篇笔记顺着业务链路往下走:表结构、核心接口、关键参数、上线检查。想内部自建进销存的开发者,和正在评估源码方案的技术负责人,都可以按这个顺序读。

2. 先拆业务链路:进销存源码的“三流”内核与最小表结构

2.1 单据流、库存流、成本流:为什么“三流合一”才叫进销存

进销存跟普通订单管理系统的区别,在于它不是“记录一张单据就完事”。一张销售出库单在业务上要同时做三件事:第一,更新该 SKU 在当前仓库的现存量和可用量;第二,在库存流水表里写一条“出库”记录,记录变动前后的数量,方便追溯;第三,按当前库存成本算出这笔销售的出库成本,回写到销售单上,这样毛利报表才能直接看。

这三个动作看起来简单,但顺序和事务边界很容易写错。见过不少源码把库存扣减和流水写入分开处理,结果写流水时抛异常,整个事务回滚,库存数量却已经变了。这类问题通常要等月底对账才暴露,到时候想定位是哪张单写坏的,只能靠流水表查,而流水表本身又没记全。

判断一套进销存源码值不值得继续投入,先看它有没有这三样东西:库存流水表独立存在且带业务单号;成本算法有独立模块而不是随手写在业务类里;所有库存变更都通过统一的“库存服务”入口,不允许业务代码直接改余额字段。满足这三条,后面加功能会轻松很多。

注意:任何“直接 UPDATE inventory_balance SET quantity = xxx”的写法,都是在给系统埋雷。改库存永远要走流水引擎。

“库存服务”在做业务抽象时,还面临“同一张单的多个 SKU”的情况。一张销售单可能带几行明细,不同 SKU 的库存扣减必须放在同一个事务里,要么全部成功要么全部失败。否则会出现“主单审核通过,A 商品扣了,B 商品没扣”这种半成品数据。

2.2 选型理由:判断一套进销存源码能不能落地的两个硬指标

市面上能拿到的进销存源码,走读代码时我一般不只盯“功能菜单数”,而是看两处设计:有没有独立的“库存调整单”业务类型,以及成本核算方式是不是可配置的。

“库存调整单”承接的是那些没法定义为采购和销售的库存变动。比如仓库盘点发现短了三件、老板让拿一批样机赠送客户但不出销售单、生产领料后发现退料回仓。这些场景若没有调整单,业务人员只能让程序员改数据库,或者做“虚拟采购单”“虚拟销售单”来冲账,一来一回不仅单据语义变得看不懂,连统计报表都会被污染。市面上的源码产品大多有调整单的概念,很多自建版本反而把它砍掉,导致上线后到处打补丁。

成本核算可配置则决定这套源码能不能适配不同生意。做服装零售的看重移动加权平均,算的是持续变动的实时毛利;做食品医药的必须先进先出,采购批次和保质期直接挂钩;做电子产品组装的往往还需要批次号、序列号追踪。源码如果只实现了一种成本算法且写死在 SQL 里,代价远不是改一个函数那么简单——它意味着库存余额表需要重算、历史报表需要重跑,仓库模块也要跟着调。

所以我的选型总结是:结构清晰比功能多更值钱。宁可要一个余额表、流水表结构干净的小代码包,也别要一个界面花哨但所有计算逻辑都堆在业务类里的老古董。看完源码后让团队回答三个问题:一张销售单审核后,数据会在几张表里发生变化?月底结存怎么算?盘点差异怎么处理?答不清楚,就说明结构还不够好。

2.3 最小表集合:一张进销存源码至少需要这九张表

进销存的核心表一定是“主表 + 明细表”结构。采购单、销售单不能只有一张大表,否则一个单子拉了十个商品,修改其中一条明细要整表更新,历史版本也留不下来。

表名类别职责
sku主数据商品主数据,唯一编码、单位、分类
warehouse主数据仓库维表,多仓业务必须
supplier / customer主数据供应商和客户档案
purchase_order / purchase_order_item单据采购主表 + 明细表
sale_order / sale_order_item单据销售主表 + 明细表
inventory_balance汇总按“仓 + SKU”维护现存量、可用量、成本价
inventory_flow流水每笔库存变动形成一行,带业务单号、变动前后数量
snapshot_stock报表每日库存快照,月结与审计报表只读它

主表存单据头(单据编号、往来单位、日期、金额合计、审核状态),明细表存商品行(商品、数量、含税单价、金额、批次号、仓库),两张表通过单据号关联。个人练习项目常常简化成单表,看起来能用,等做多对多商品结构改动时完全走不动。

库存余额表里还应该有一个字段容易被忽略:可用量。现存量代表仓库里有多少,可用量代表现在能卖多少。两者之差是“已锁定库存”——被订单占用但还没出库的部分。很多售罄、超卖问题,根源不是算错,而是没区分这两个数。

从这一章开始,后面所有的接口和参数调整,都围绕这里定下的表结构展开。表的结构定了,接口怎么写、参数怎么调,都只是在这个骨架上加肉。

3. 把源码落到本地:建表、出库接口与幂等控制的实现

3.1 最小建表 DDL:库存余额和库存流水怎么建才不算错

如果让我从零搭一套进销存,第一个动作永远是先把两张表建出来:库存余额表和流水表。下面的 DDL 是生产环境里很常见的最小写法。

CREATE TABLE `inventory_balance` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `sku_id` BIGINT NOT NULL COMMENT '商品ID', `warehouse_id` BIGINT NOT NULL COMMENT '仓库ID', `quantity` INT NOT NULL DEFAULT 0 COMMENT '现存量', `available_quantity` INT NOT NULL DEFAULT 0 COMMENT '可用量(现存量-锁定)', `cost_price` DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '当前移动加权成本价', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_sku_wh` (`sku_id`, `warehouse_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存余额表';

这里最关键的是几个地方:

  • sku_id + warehouse_id唯一键严格防止同一商品在同一个仓库出现多条余额记录。若有重复,成本计算会混乱;
  • cost_price用 DECIMAL(20,6),不能用 float/double,很多报表对不上的根因就是浮点运算累加误差;
  • update_time用 ON UPDATE CURRENT_TIMESTAMP,排查问题时能大致判断这一行的最后变动时间。

流水表的最小结构如下:

CREATE TABLE `inventory_flow` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `sku_id` BIGINT NOT NULL, `warehouse_id` BIGINT NOT NULL, `change_type` VARCHAR(20) NOT NULL COMMENT 'IN/OUT/ADJUST_IN/ADJUST_OUT', `change_qty` INT NOT NULL COMMENT '变动数量,出库为负数', `before_qty` INT NOT NULL COMMENT '变动前余量', `after_qty` INT NOT NULL COMMENT '变动后余量', `biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号', `biz_type` VARCHAR(20) NOT NULL COMMENT 'SALE/PURCHASE/SHIFT/ADJUST', `remark` VARCHAR(255) DEFAULT '', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_biz_sku` (`biz_no`, `sku_id`, `change_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';

uk_biz_sku这个唯一键就是幂等关键,技术上保证“同一张单、同一个 SKU、同一种变动类型”只有一条流水。重复提交时直接抛业务异常,不会让库存被扣两次。

还有一个注意点:biz_no不能空,它是和业务单据对账的连接点。月底做进销存汇总时,所有报表都是从流水表按biz_type分组聚合出来的,如果流水表里某几行单号没填,这些单据的库存变动就会在对账时变成“无主账”,极难排查。

3.2 出库接口的事务实现:先锁行,再校验,后写流水

在完成建表后我们来写第一个核心接口:出库。

@Transactional(rollbackFor = Exception.class) public void outbound(OutboundCommand cmd) { // 1. 在同一事务内锁定余额行,同一SKU并发出库时后到的请求会等待 InventoryBalance balance = balanceMapper.selectBySkuAndWhForUpdate( cmd.getSkuId(), cmd.getWarehouseId()); if (balance == null) { throw new BizException("该仓库没有这个商品的库存记录"); } // 2. 扣减前校验可用量,防止超卖 if (balance.getAvailableQuantity() < cmd.getQuantity()) { throw new BizException("库存不足,可用量为" + balance.getAvailableQuantity()); } int beforeQuantity = balance.getQuantity(); int afterQuantity = beforeQuantity - cmd.getQuantity(); // 3. 扣减现存量与可用量 balance.setQuantity(afterQuantity); balance.setAvailableQuantity(balance.getAvailableQuantity() - cmd.getQuantity()); balanceMapper.updateById(balance); // 4. 写流水,重复提交会被唯一键拦下 inventoryFlowMapper.insert(InventoryFlow.builder() .skuId(cmd.getSkuId()) .warehouseId(cmd.getWarehouseId()) .changeType(ChangeType.OUT) .changeQty(-cmd.getQuantity()) .beforeQty(beforeQuantity) .afterQty(afterQuantity) .bizNo(cmd.getOrderNo()) .bizType(BizType.SALE) .build()); // 5. 反写销售单的出库成本 saleOrderMapper.updateCostAmount(cmd.getOrderNo(), balance.getCostPrice() * cmd.getQuantity()); }

selectBySkuAndWhForUpdate是整段代码防超卖的命根子。FOR UPDATE会给这条余额记录加行级锁,在事务提交前,其他操作同一个 SKU 的请求会阻塞等待。没有这一步,两个请求同时读到 quantity = 3,各自扣 2,最后余额变成 1,实际却已经卖出了 4 件。

@Transactional的边界也要注意:锁、校验、扣减、流水、回写成本必须全在一个事务里。任何一步抛异常,前面的数据全部回滚。如果事务只包到扣减为止,流水写失败时库存还是会漏。

3.3 并发与幂等:重复点击、超时重试、同单并发三连问

业务执行操作时永远会遇到三个问题:用户双击审核按钮、客户端超时后重发、两个同事同时处理同一张单。三种情况都会导致同一个biz_no调两次出库接口。如果只靠流水表唯一键拦截,报错信息会很不友好,而且插入失败会浪费一次数据库往返。更稳妥的方式是在接口入口加一道分布式锁:

// 入口处先用单据号加分布式锁,兜底放在流水唯一键上 String lockKey = "stock:lock:" + cmd.getOrderNo(); boolean locked = redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException("订单正在处理中,请勿重复提交"); } try { outbound(cmd); } finally { redisLock.unlock(lockKey); }

tryLock的第三个参数 10 是等待锁的超时秒数。这个值要结合出库接口平均耗时来设:正常出库事务在几百毫秒内完成,设 10 秒足够;如果业务里还连带做了远程单据回传,就适当加大,否则会误拦截。

分布式锁并非银弹,它保证的是“同一时间只有同一个单据进入接口”,而不是“这个单永远只处理一次”。真正不可绕过的防线还是流水表的唯一键。锁的目的是友好提示,唯一键是最后兜底,两者配合后才能放心上线。

单机部署时可以用 JVM 的并发包替代 Redis,但一旦服务扩到两个实例,本地锁就失效。生产环境建议直接用 Redis 版本,代码侵入不大,成本也低。

4. 进销存源码的必调参数:成本算法、安全库存、多仓可用量

4.1 成本核算参数:移动加权平均与先进先出的选用边界

拿到一套源码,第一件事是翻配置中心里“成本核算方式”是写死的还是可配置的。可配置的情况下,最常见的两个取值是移动加权平均和先进先出。

移动加权平均的计算公式是:

新成本价 = (旧数量 × 旧成本价 + 新入库数量 × 新入库单价) / (旧数量 + 新入库数量)

实现逻辑一般长这样:

def recalc_moving_avg_cost(old_qty, old_cost, in_qty, in_unit_cost): if old_qty + in_qty == 0: return Decimal('0') total_cost = old_qty * old_cost + in_qty * in_unit_cost return total_cost / (old_qty + in_qty)

这个公式简单,但有一个隐藏前提:每次入库后都要立即重算,而且所有历史流水必须完整。如果中间某次入库没有走成本重算逻辑,后面所有的毛利都会带着一个错误基数滚动下去。这就是为什么成本算法不能放在“保存单据”的普通逻辑里,而要放在库存服务内、和余额更新绑在同一个事务里。

先进先出则完全依赖批次表。出库时不是按当前余额的成本价算,而是从最早一个未扣完的批次里取。代码一般长这样:

SELECT * FROM inventory_batch WHERE sku_id = ? AND warehouse_id = ? AND remaining_qty > 0 ORDER BY in_date ASC FOR UPDATE;

按in_date升序取出最早批次,扣减其remaining_qty,直到本次出库数量被扣满。批次表里剩下的数量如果不够,就继续往下取下一个批次。这个逻辑对食品、医药、电子产品尤其重要,因为它们有保质期和可追溯要求;如果业务只是卖标准件、不关心批次来源,就没必要上先进先出,会增加不少管理成本。

4.2 安全库存与补货建议:三个参数算出“什么时候该补”

进销存源码里最常见的智能补货逻辑其实不复杂,核心是三个数:日均出库量、供货提前天数、安全系数。它们共同算出安全库存和最大库存。

安全库存 = 日均出库量 × 供货提前天数 × 安全系数 最大库存 = 日均出库量 × (补货周期天数 + 供货提前天数) + 安全库存 建议采购量 = max(0, 最大库存 - 现存量 - 在途量)

安全系数的取值范围一般在 1.0 到 1.5 之间。需求平稳的行业设 1.0,销售波动大的行业建议拉到 1.2 以上。系数设太高会导致囤货压资金,设太低又会频繁触发补货,这需要拿历史三个月的出库数据先做一次回放验证。

配套的参数表一般长这样:

参数名示例值说明
avg_daily_sales按 SKU 维表可由近 90 天数据自动计算
lead_time_days7从下单到入库的供货周期
safety_factor1.2波动系数
order_cycle_days10每次补货的周期
in_transit_qty按单据采购已审核未入库的数量

在途量这个数要特别注意,它不是库存余额表里的字段,而是从采购订单明细里聚合出来的。计算公式是“采购单已审核且未完全入库的数量”。如果源码把在途量也放在余额表里,会极大地增加锁冲突和出错概率。

4.3 多仓可用量参数:现存量、在途量和锁定量的关系

多仓是进销存源码很容易被低估的模块。小规模业务一仓跑得很顺,一旦扩展到两三个仓库,问题立刻暴露:总部仓给门店调拨了一半的货,这笔调拨在途期间,两个仓的可用量怎么算?

一个稳妥的配置是:

可用量 = 现存量 - 锁定量 + 在途量

但不同业务对“可用”的定义不同。零售电商一般把在途量计入可用,因为消费者愿意等调货;门店销售则相反,一旦调拨出库,源仓的在途量不计入可售,不然会出现“仓库没货但系统显示可卖”的情况。

源码里可配置的参数一般是reserve_strategy:设置成order,表示销售单审核时就锁定库存;设置成shipped,表示真正出库才扣减。前者更保守,能防止超卖,但会造成库存“看上去被占住”,影响其他订单的可用判断;后者库存流转快,但遇到两个订单抢最后一件货时,后审核的会直接在出库时失败,体验较差。

生产环境我一般默认用order锁定,并把出库校验放在审核事务里一起完成。库存不足的单子在审核阶段就被提示,而不是等仓库发货时再说,业务反馈好得多。

5. 进销存源码上线的五个避坑记录:每一项都值得对照检查

5.1 负库存翻车:账上越卖越少,钱也平不了

现象:某 SKU 当天正常出库,第二天盘点一看,结转成了负数。仓库实际没货,系统却还能继续审核出库单,月底库存金额直接对不上现金流。

原因:出库接口没有校验可用量,或者校验和扣减分在两个事务里。很多源码为了让业务“先跑起来”,默认开启了允许负库存的开关,出库变成了纯记录,库存逻辑彻底失守。

解决:把出库逻辑改写成“先锁行、再校验、后扣减”,并把允许负库存开关默认关掉。已经产生的负库存不能用 UPDATE 直接改,要新建一张盘点单或报损单冲正,走一遍正常的库存流水。

5.2 采购退货后成本价“跳水”,毛利数据没法信

现象:一批高价采购的货后来退给了供应商,系统里这个 SKU 的成本价一夜之间变得很低,接下来的毛利报表全部失真。

原因:退货入库时,流水里回写的成本用了原采购单价,而不是当前库存的移动加权成本。余额表的成本价被“按错的价格”重算了,一次退货污染了后续所有毛利数据。

解决:采购退货的流水change_type单独标识,不走普通采购入库的成本计算路由;退货时按当前余额表的移动加权成本来算金额,而不是原采购单价。改完后要写个一次性脚本,把历史退货涉及的 SKU 按流水重算一遍余额成本。

5.3 同一张销售单按了两次审核,库存就扣了两遍

现象:用户双击审核按钮,或者页面卡顿后重试,结果一张销售单在系统里只看到一条记录,但库存被扣除了两次。

原因:接口没有做幂等控制,也没有唯一键兜底。同一个biz_no进来两次,库存引擎执行了两次扣减,流水表也多了两条一模一样的记录。

解决:给inventory_flow增加(biz_no, sku_id, change_type)唯一索引,同时在接口入口加 Redis 单据锁。出现重复请求时直接提示“订单正在处理中”,而不是让它进入正常扣减流程。

5.4 按序列号查不到批次,追溯断在调拨单上

现象:售后拿到一台设备,想查它的入库时间、出库时间和卖给了谁,系统里只查到“该序列号不存在”。

原因:源码只对 SKU 数量做了流水,没有把序列号和批次的流转关系记录清楚。调拨时只调整了数量,没有把“该 SN 从哪个仓流向哪个仓”的关联线走下去。

解决:在有序列号需求的场景,单独建一张sn_inventory表,字段包含 sn、sku_id、warehouse_id、batch_no、in_order_no、out_order_no、status。调拨不再直接改 SN 的状态,而是生成一条调拨流水,保证 SN 在任何时点都能追溯到最新归属。

5.5 月末结存和实时库存对不上,快照表没有跑

现象:平时实时库存怎么查都对,一到月末对账,报表里的库存金额和财务账本差了几千块,而且怎么都对不平。

原因:报表直接读实时余额表生成,而本月发生过盘点差异、报损、调拨,这些变动都在单据上体现了,但报表逻辑没把这些差异纳入进去。实时余额一直在变,历史报表却需要“那一刻的结存”。

解决:加一张snapshot_stock每日快照表,由定时任务在每天业务结束后生成,记录每个仓每个 SKU 的结存数量和结存金额。所有历史报表、月结单只读快照表,不再读实时余额表。这样即使当天数据后期被调整,历史月报也不会被“篡改”。

6. 上线前第一件事:用流水回放给库存“找茬”

6.1 两段 SQL 找出所有对不上的账

进销存源码上线前,除了跑通主流程外,我习惯做一次“流水回放”:把库存流水表里的所有变动按 SKU 和仓库重新汇总,再去比对余额表的现存量。两者差多少,就说明有多少库存变动没有被流水化。

SELECT b.sku_id, b.warehouse_id, b.quantity AS balance_qty, COALESCE(f.flow_qty, 0) AS flow_qty, (b.quantity - COALESCE(f.flow_qty, 0)) AS diff FROM inventory_balance b LEFT JOIN ( SELECT sku_id, warehouse_id, SUM(CASE WHEN change_type IN ('IN', 'ADJUST_IN') THEN change_qty WHEN change_type IN ('OUT', 'ADJUST_OUT') THEN -change_qty ELSE 0 END) AS flow_qty FROM inventory_flow GROUP BY sku_id, warehouse_id ) f ON b.sku_id = f.sku_id AND b.warehouse_id = f.warehouse_id WHERE ABS(b.quantity - COALESCE(f.flow_qty, 0)) > 0;

第二段 SQL 用来查“单据审核了但库存没动”的孤儿单据:

SELECT d.order_no, d.sku_id, d.quantity FROM sale_order_detail d LEFT JOIN inventory_flow f ON f.biz_no = d.order_no AND f.sku_id = d.sku_id AND f.change_type = 'OUT' WHERE d.audit_status = 'audited' AND f.id IS NULL;

这段 SQL 查出的是已审核但没有任何出库流水的销售明细。出现这种记录,说明出库事务里“更新单据状态”和“扣库存”没有绑定在同一个事务里。

如果这两段 SQL 上线前跑出来不是空集,先别接历史数据,也别急着让业务开始录入。把缺口找出来,是先补流水还是写调整单,再定方案。有一年我接手一套别人写坏的进销存源码,上线前数据导好了,业务也录了一周的单,月底对不上账才知道,最早导入那批历史数据全是直接 UPDATE 余额,根本没走流水引擎。从那以后这个习惯再没丢过:凡是经过我手落地的进销存,第一件事永远是先跑流水回放,把每一件商品的余额和流水对齐,再谈上线。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询