☰
基于RuoYi二次开发进销存系统:从表结构设计到库存流水实战
2026/10/18 22:26:26 网站建设 项目流程

简介:这套基于Java与RuoYi框架二次开发的KS进销存系统设计源码,瞄准小型经销商以及从手工台账向信息系统转型的团队,针对采购、销售、库存等核心业务提供了一套更贴近小公司节奏的轻量解决方案,流程简洁又不失完整性。压缩包共936个文件,体量10.94MB,核心代码以304个Java源码文件和101个Vue组件为主,另含149个JS脚本、39个XML配置、28个HTML页面、88个SVG图标等,分别支撑后端逻辑、前端交互、系统配置与界面展示,整体结构便于检索和二次开发。目前已有837人学习浏览。整套源码围绕进销存的采购、销售、库存闭环展开,模块边界清楚,既可作为Java开发者研究RuoYi二次开发与业务集成的完整样例,也可用于小型经销商快速搭建或改造现有系统,省去从零设计流程的成本,帮助团队在系统转型中少走弯路。

1. 基于 RuoYi 二次开发的 KS 进销存系统:它解决什么、适合谁

拿到 KS 进销存系统设计源码时,很多人的第一反应是“这不就是在 RuoYi 里加几张表吗”。实际上,基于 Java 和 RuoYi 二次开发的进销存系统,难点不在于增删改查,而在于把采购、销售、库存、往来单位这几条业务线理顺,并保证任何一笔单据都不会把库存和金额算错。RuoYi 提供了用户、角色、菜单、代码生成这些通用能力,真正要动手设计的是商品档案、往来单位、仓库、单据状态、库存流水这一整套业务模型。这套方案最适合两类人:一类是刚接触 RuoYi、需要交付完整进销存项目的 Java 开发者,另一类是在企业内部做信息化系统选型的技术人员。前者关心表怎么建、接口怎么写,后者关心这系统能不能扛住日常对账和审计。下面从业务建模开始,逐步拆开一条可落地的实现路径。

2. 进销存业务建模:把主数据、单据和库存流水在数据库里立稳

2.1 主数据设计:商品档案、往来单位与仓库的字段取舍

进销存系统里最容易被低估的是主数据设计。商品表看起来只有名称、编码、单位,实际跑起来会牵出条码、规格、分类、多单位转换等一堆问题。我的经验是商品表保留四个核心字段:商品编码、商品名称、规格、基本单位,再用一个分类 ID 引用分类表。商品编码必须建唯一索引,因为采购单、销售单、库存流水都靠它做业务关联。单位字段建议只存基本单位,多单位换算单独建一张转换表,不要在商品表里塞“采购单位、销售单位、库存单位”三个字段,不然做报表统计时总有人问“这批入库是箱还是件,和出库的件对不上”。

往来单位建议把供应商和客户合并成一张表,用类型字段区分,而不是拆成两张独立表。很多企业既是供应商又是客户,拆表会导致对账时反复联表,还要处理同一家单位两套编码的问题。合并表里加默认结算方式和默认联系人,日常开单能省不少事。仓库则要区分仓库和库位,小企业一个仓库就能应付,但涉及拣货和批次管理时,库位字段会很有用。

我一般会这样建商品主数据和往来单位表:

CREATE TABLE ks_product ( product_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品ID', product_code VARCHAR(32) NOT NULL COMMENT '商品编码', product_name VARCHAR(128) NOT NULL COMMENT '商品名称', spec VARCHAR(64) DEFAULT '' COMMENT '规格型号', unit VARCHAR(16) NOT NULL COMMENT '基本单位', category_id BIGINT DEFAULT NULL COMMENT '分类ID', barcode VARCHAR(64) DEFAULT NULL COMMENT '条码', enable_flag CHAR(1) DEFAULT '0' COMMENT '启用标志(0正常 1停用)', create_by VARCHAR(64) DEFAULT '' COMMENT '创建者', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)', PRIMARY KEY (product_id), UNIQUE KEY uk_product_code (product_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品档案表'; CREATE TABLE ks_partner ( partner_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '往来单位ID', partner_code VARCHAR(32) NOT NULL COMMENT '单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '单位名称', partner_type CHAR(1) NOT NULL DEFAULT '1' COMMENT '单位类型(1供应商 2客户 3两者都是)', contact VARCHAR(32) DEFAULT NULL COMMENT '联系人', phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话', address VARCHAR(255) DEFAULT NULL COMMENT '地址', settle_type VARCHAR(32) DEFAULT '1' COMMENT '默认结算方式(1现结 2月结)', PRIMARY KEY (partner_id), UNIQUE KEY uk_partner_code (partner_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='往来单位表';

这里有几个字段值得细说。product_code唯一索引不只是防重复,更是为 Excel 导入和扫码录入时按编码走 upsert 逻辑预留的。partner_type用 1、2、3 三个值而不是两个布尔字段,是为了保证同一条记录只能表达一种类型,避免“既是供应商又是客户”变成两行脏数据。del_flag是逻辑删除的通用做法,所有业务表都建议带上,因为进销存单据一旦物理删除,关联的流水和报表会全部乱掉,删错数据连后悔药都没有。字段注释一定要写完整,因为 RuoYi 的代码生成器会读取注释来生成页面标签、查询条件和列表表头,这是整个二次开发里性价比最高的一步。

2.2 单据与明细:采购、销售共用的单头单行结构

进销存的单据几乎都是“一张单头加多行明细”。单头记录单据编号、往来单位、仓库、业务日期、经办人、总金额和状态;明细记录商品、数量、单价、行金额、批次号。不要把明细里的商品字段冗余进一张大宽表,否则后续改单价、做红冲、对账都会非常痛苦。

我常用的方案是抽出单据主表和单据明细表,用单据类型字段区分采购、销售、其它入库、其它出库。直接贴核心建表 SQL,后面的接口和流水逻辑都会围绕它展开:

CREATE TABLE ks_bill_main ( bill_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '单据ID', bill_no VARCHAR(32) NOT NULL COMMENT '单据编号', bill_type CHAR(2) NOT NULL COMMENT '单据类型(10采购 20销售 30其它入库 40其它出库)', bill_status CHAR(1) NOT NULL DEFAULT '0' COMMENT '状态(0草稿 1已审核 2红冲)', partner_id BIGINT DEFAULT NULL COMMENT '往来单位ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID', bill_date DATE NOT NULL COMMENT '业务日期', total_amount DECIMAL(20,4) NOT NULL DEFAULT 0 COMMENT '单据总金额', remark VARCHAR(255) DEFAULT NULL COMMENT '备注', create_by VARCHAR(64) DEFAULT '' COMMENT '创建者', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', dept_id BIGINT DEFAULT NULL COMMENT '创建部门(用于数据权限)', PRIMARY KEY (bill_id), UNIQUE KEY uk_bill_no (bill_no), KEY idx_bill_date (bill_date), KEY idx_bill_type_status (bill_type, bill_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='进销存单据主表'; CREATE TABLE ks_bill_item ( item_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '明细ID', bill_id BIGINT NOT NULL COMMENT '单据ID', product_id BIGINT NOT NULL COMMENT '商品ID', quantity DECIMAL(20,4) NOT NULL COMMENT '数量', price DECIMAL(20,4) NOT NULL DEFAULT 0 COMMENT '成交单价', amount DECIMAL(20,4) NOT NULL DEFAULT 0 COMMENT '行金额', batch_no VARCHAR(64) DEFAULT NULL COMMENT '批次号', PRIMARY KEY (item_id), KEY idx_bill_id (bill_id), KEY idx_product_id (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='进销存单据明细表';

数量和金额定义成DECIMAL(20,4),而不是 double,这是吸取了对账差几毛钱的教训。Java 端用 BigDecimal 接收,入库前做精度校验,数据库端做兜底,能避免九成金额相关的低级问题。bill_no弄成业务可见的唯一编号,而不是直接用自增主键,是因为采购部、财务部拿着纸质单子核对时,需要可读、可念的编号。bill_type + bill_status联合索引对应系统里最频繁的查询“某类单据待审核列表”,单表数据量过百万以后效果明显。

2.3 库存模型:当前库存表加流水表,而不是只有一个库存字段

库存设计是进销存和普通 CRUD 应用最大的分水岭。很多从零开始做系统的开发者喜欢在商品表上维护一个库存字段,每次开单就 update。问题在于:一旦出现漏单、重复审核或按业务日期追溯历史库存,商品表上没有任何操作痕迹,连“这一刻的库存为什么是这个数”都回答不了。

正确做法是拆成两张表:一张当前库存表,记录每个商品在每个仓库的实时数量;一张库存流水表,记录每一次导致库存变化的动作。当前库存表用(product_id, warehouse_id)做唯一键,加可用数量和锁定数量两个字段。可用数量是能直接开单出库的数量,锁定数量是销售订单审核后占用、但还没实际出库的数量,防止别的单先把货抢走。库存流水表记录变动前后的数量、关联单据号和业务类型,财务审计时能完整还原库存轨迹。

CREATE TABLE ks_stock ( stock_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '库存ID', product_id BIGINT NOT NULL COMMENT '商品ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID', available_qty DECIMAL(20,4) NOT NULL DEFAULT 0 COMMENT '可用数量', locked_qty DECIMAL(20,4) NOT NULL DEFAULT 0 COMMENT '锁定数量', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (stock_id), UNIQUE KEY uk_product_warehouse (product_id, warehouse_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前库存表'; CREATE TABLE ks_stock_flow ( flow_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '流水ID', product_id BIGINT NOT NULL COMMENT '商品ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID', bill_id BIGINT NOT NULL COMMENT '来源单据ID', bill_type CHAR(2) NOT NULL COMMENT '来源单据类型', before_qty DECIMAL(20,4) NOT NULL COMMENT '变动前数量', change_qty DECIMAL(20,4) NOT NULL COMMENT '变动数量(正入负出)', after_qty DECIMAL(20,4) NOT NULL COMMENT '变动后数量', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (flow_id), KEY idx_product_warehouse_time (product_id, warehouse_id, create_time), KEY idx_bill_id (bill_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';

before_qty和after_qty这两列,能让你从任意一天的流水反推库存变化,不用去翻原始单据。业务侧写流水时,必须和库存表更新放在同一个数据库事务里,而且顺序不能乱:先锁库存行,再写流水,最后提交事务。不少进销存系统翻车,都是因为库存表更新成功了、流水没写进去,查账时账面和实物对不上又找不到原因。后面第四章会专门讲这段代码具体怎么写。

2.4 建表前先确认的四个细节:主键、逻辑删除、部门字段和金额精度

进销存设计源码能不能顺利跑起来,往往取决于建表时有没有提前照顾 RuoYi 的工程习惯。第一,主键统一用 BIGINT 自增,不要用 VARCHAR 业务编码做主键,否则所有外键关联和分页插件都要跟着难受。第二,业务表统一加del_flag和create_by、create_time,既有审计价值,又符合 RuoYi 代码生成器的默认模板。第三,需要被数据权限管控的表,比如单据主表,必须显式设计dept_id字段,否则 RuoYi 的部门过滤会在查询时把不该过滤的数据也拦掉。第四,所有金额和数量字段统一DECIMAL(20,4),Java 端属性类型用 BigDecimal,避免后续出现精度转换的隐性 bug。

3. 在 RuoYi 里把表结构变成可用的进销存模块:生成、改造、验证

3.1 用代码生成器生成基础 CRUD,先检查包路径和字段映射

RuoYi 内置了基于 MyBatis 的代码生成器,读取表结构后能生成 controller、service、mapper、domain 和一套基于 Vue 的列表、表单页面。实际操作时,进入“代码生成”菜单,导入ks_product、ks_partner、ks_bill_main等表,Ruoyi 会根据字段注释生成对应代码。这里有个容易被忽略的前置动作:在生成前要修改生成配置,把包名改成业务包路径,比如com.ks.business,避免和框架自带的com.ruoyi代码混在一起,后期升级框架更容易维护。

生成完不要急着跑,先检查三样东西:第一,主键属性是否被识别为 Long,自增主键如果被生成成 Integer,数据量一大就会溢出;第二,del_flag字段要确认逻辑删除注解或查询条件已经生效;第三,时间字段DATETIME生成后,前端的日期组件要选择正确的格式,否则列表页查询时间会传成字符串导致条件失效。代码生成器只是起点,真正的进销存逻辑要从改造接口开始。

3.2 采购入库单接口:从默认 CRUD 改成带库存更新的业务闭环

代码生成器给出的接口只能做单表增删改查,进销存的核心业务逻辑都要在 service 层重新写。以采购入库单为例,基本规则是:新增单据时生成可读的单号,保存后默认是草稿状态;审核通过后,商品库存才真正增加;红冲时库存原路扣减。把审核和保存拆成两个接口,代码会清晰很多。

/** * 采购入库单审核 * 只有状态为草稿(0)的单据才能审核 */ @PreAuthorize("@ss.hasPermi('ks:purchase:audit')") @PutMapping("/audit/{billId}") @Transactional(rollbackFor = Exception.class) public AjaxResult audit(@PathVariable Long billId) { KsBillMain bill = billMainMapper.selectById(billId); if (bill == null) { return error("单据不存在"); } if (!"0".equals(bill.getBillStatus())) { return error("只有草稿状态的单据才能审核"); } // 逐条商品增加库存,并写流水 List<KsBillItem> items = billItemMapper.selectByBillId(billId); if (items == null || items.isEmpty()) { return error("单据明细为空,不能审核"); } for (KsBillItem item : items) { int rows = stockMapper.addAvailableQty( item.getProductId(), bill.getWarehouseId(), item.getQuantity()); if (rows == 0) { throw new ServiceException("商品 " + item.getProductId() + " 库存扣减失败"); } stockFlowMapper.insert(buildFlow(bill, item, "IN")); } int updated = billMainMapper.updateStatus(billId, "0", "1"); if (updated != 1) { throw new ServiceException("单据状态更新失败,请重试"); } return success(); }

这段代码的核心在@Transactional上。进销存的库存操作必须保证原子性,任何一个商品库存更新失败,前面所有更新都要回滚,不能出现半张单生效的情况。注意stockMapper.addAvailableQty是带条件判断的原子 update,不是先查询再在内存里做加减,这两者的差别在并发场景下是隔离级别的差异。updateStatus(billId, "0", "1")是最后一道并发防线:两个用户同时点审核,第一个事务把状态从 0 改成 1,第二个事务执行到这里因为影响行数为 0 而失败,事务回滚,不会产生重复入库。这就是进销存状态机里最常用的“乐观状态校验”。

3.3 前端页面改造:商品选择联动、金额计算和按钮状态

后端接口跑通后,前端要在 RuoYi 生成的 Vue 页面基础上做改造。最典型的改法是给明细行加商品选择器,用户选完商品后自动带出规格、单位和参考价,再根据数量和单价实时计算行金额。

// 选择商品后联动带出规格、单位和参考价 handleProductSelect(row, product) { if (!product) { this.$set(row, 'productId', null); return; } this.$set(row, 'productCode', product.productCode); this.$set(row, 'productName', product.productName); this.$set(row, 'spec', product.spec); this.$set(row, 'unit', product.unit); this.$set(row, 'price', product.lastPrice ? Number(product.lastPrice) : 0); this.$set(row, 'amount', this.calcAmount(row)); }, calcAmount(row) { const price = Number(row.price || 0); const qty = Number(row.quantity || 0); const amount = this.mul(price, qty); this.$set(row, 'amount', amount); return amount; }, mul(a, b) { const factor = 10000; return Math.round(a * factor) * Math.round(b * factor) / (factor * factor); }

这里用this.$set而不是直接row.xxx = value,是因为 RuoYi 分离版基于 Vue 2,直接给对象新增未声明字段不会触发视图更新,列表上会显示 undefined。这个属于典型的“页面看起来没反应,数据其实已经变了”,排查时先看控制台报错,再看字段有没有在 data 里声明。金额计算没有直接用a * b,而是先放大成整数再相乘,避免 0.1 乘 0.2 不等于 0.02 的精度问题。后端最终会用 BigDecimal 再算一遍并覆盖,但前端先算错的话,用户提交时看到的价格和最终入库的价格对不上,信任感会瞬间崩掉。

4. 单据状态机、原子库存更新与报表统计:进销存的核心逻辑

4.1 单据状态流转:草稿、审核、红冲的完整周期

进销存单据不能一删了之。一张已审核的采购单,如果直接物理删除,库存流水留在表里,月底对账会变成一团浆糊。因此几乎所有单据都要有状态机:草稿可以编辑和删除;审核后不能直接改明细,只能红冲;红冲生成一张负数单据抵消原单。状态字段取值约定如下:

状态含义可操作
0草稿编辑、删除、审核
1已审核打印、红冲、查看
2已红冲只读,不允许任何修改

红冲的本质不是删除原单,而是生成一张新的负数单据,把库存和往来余额反向冲掉。审核草稿时写正数流水,红冲时写负数流水,两条流水都关联同一个原始单号。这比物理删除安全得多,财务审计时能看到“这单录错了、后来被冲掉了”的完整过程。业务上,红冲单必须引用原单号,所以单据主表最好加一个source_bill_id字段,否则两张单之间没有关联,月底查往来账时根本说不清冲的是哪一单。

4.2 为什么不能先查后改:原子 SQL 与防负库存约束

很多进销存系统出现负数库存,十有八九是库存更新写成了“先 select 当前库存,在内存里加好数字,再 update 回去”。这在单线程下没问题,并发场景下两个请求同时读到同一个旧值,后提交的覆盖掉先提交的,库存就错了。正确写法是把加减操作写进一条 update,交给数据库行锁保证原子性:

@Update(""" UPDATE ks_stock SET available_qty = available_qty + #{changeQty} WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} AND available_qty + #{changeQty} >= 0 """) int addAvailableQty(@Param("productId") Long productId, @Param("warehouseId") Long warehouseId, @Param("changeQty") BigDecimal changeQty);

末尾那个available_qty + #{changeQty} >= 0是防负库存的兜底条件。如果 changeQty 是负数且可用库存不够,这条 update 返回 0,Java 端就能感知并抛异常回滚整个事务。条件必须写在 update 里,而不是先 if 判断再更新,否则两个并发请求都通过 if 判断后,还是会覆盖出负数。另外,这个 update 会自动对命中的库存行加排他锁,后到的请求会等待前一个事务提交后再执行。这比在 Java 代码里加 synchronized 可靠,因为 synchronized 只能管住单台应用服务,管不住数据库层多实例并发。

4.3 库存报表:基于 stock_flow 建汇总查询

进销存上线后,最常用的不是开单页面,而是报表。报表不需要复杂设计,从流水表 group by 就能覆盖九成需求。下面这段 SQL 是进销存月报核心,按月份和单据类型聚合,输出采购入库、销售出库的数量和单量。对数据量在百万级以内的中小系统,(product_id, warehouse_id, create_time)索引能把执行时间控制在秒内。

SELECT DATE_FORMAT(flow.create_time, '%Y-%m') AS month, SUM(CASE WHEN flow.bill_type = '10' THEN flow.change_qty ELSE 0 END) AS purchase_qty, SUM(CASE WHEN flow.bill_type = '20' THEN ABS(flow.change_qty) ELSE 0 END) AS sale_qty, COUNT(DISTINCT flow.bill_id) AS bill_count FROM ks_stock_flow flow WHERE flow.create_time >= #{startTime} AND flow.create_time < #{endTime} GROUP BY DATE_FORMAT(flow.create_time, '%Y-%m') ORDER BY month DESC

注意销售出库在流水里存的是负数,统计销售数量要用ABS()。如果业务上要单独看销售退货,可以在 GROUP BY 里再加bill_type,或者把红冲标识纳入查询条件。报表口径越到后期越容易漂移,建议把这类统计 SQL 收敛到一个 ReportMapper 里,不要散落在各个 service 里,否则每人加一个条件,同一张表能对出七八个版本的数字。

5. 避坑指南:KS 进销存二次开发中的高频排查与踩坑记录

5.1 单据重复提交导致库存双扣

现象:前端双击保存按钮,后台出现两条相同单号的插入记录,库存被扣了两次。原因:新增接口没有做幂等控制,两次请求都通过了草稿状态校验。解决:前端提交后立即把按钮置灰并禁用,后端在 service 入口做单号唯一约束捕捉。数据库的uk_bill_no唯一索引是最后一道防线,重复插入会直接报 DuplicateKeyException,整个事务回滚。三层缺一不可,只靠前端置灰挡不住并发和恶意请求。

5.2 代码生成器重新生成覆盖了手工改造的 Mapper XML

现象:手工调整好的采购明细查询 SQL,在某次重新生成后全部消失,启动时报 “Invalid bound statement (not found)”。原因:RuoYi 代码生成器默认把生成的 mapper.xml 直接写入源码目录,重新生成时不会保留原文件的手工改动。解决:生成器只用于第一版,后续业务改造放到自定义的 Mapper 文件里;如果需要重新生成,先把已改造的文件备份。更好的做法是在代码生成配置里把输出目录指向独立业务包,和框架自动生成的目录分开,再配合 SVN/Git 的提交记录回退误覆盖。记住一条:不要对已经进入开发状态的表反复全量生成,那是给自己埋雷。

5.3 数据权限把采购单查没了

现象:某个用户明明配置了全部数据权限,打开采购单列表却只能看到自己创建的几条,导出也只导出一小部分。原因:RuoYi 的数据权限通过@DataScope注解在 SQL 上追加部门过滤条件,如果业务表没有dept_id字段,或创建单据时没有给dept_id赋值,过滤条件会把从其它部门流转来的单据全部拦掉。解决:ks_bill_main保留dept_id字段,插入时从当前登录用户取部门 ID,查询时依托 RuoYi 的@DataScope完成部门及子部门过滤。遇到跨部门协同的单据,比如 A 部门开单、B 部门发货,就不要依赖默认的部门过滤,改成基于单据归属人的自定义数据权限 SQL,并在文档里写明这个例外。

5.4 DECIMAL 精度和外键字段类型对不上

现象:单据头和明细金额加起来差几毛钱,或者按商品 ID join 查不到数据。原因:建表时把金额字段设成 DOUBLE,或者商品 ID 在两张表里分别用了 BIGINT 和 VARCHAR。DOUBLE 在 MySQL 里是近似值存储,对账时会出现 0.1 加 0.2 不等于 0.3 的情况;外键类型不一致直接导致 join 查不到或查出重复行。解决:金额和数量全部统一DECIMAL(20,4),所有主外键统一BIGINT。进销存系统大量 join 发生在单据、商品、往来单位之间,只要一张表的类型不一致,后续所有报表都会出问题。写建表 SQL 时把字段类型复制过来用,不要手打。

5.5 多线程审核同一张单,流水重复写入

现象:两个人同时点一张草稿单的审核按钮,一个人成功,另一个人日志里没有明确业务错误。原因:库存流水插入和单据状态更新没有放在同一事务里,或者状态更新没有条件校验。解决:service 方法加@Transactional(rollbackFor = Exception.class),状态更新必须是UPDATE ks_bill_main SET bill_status = '1' WHERE bill_id = ? AND bill_status = '0',通过影响行数判断是否成功。同时在库存流水表上建立(bill_id, bill_type, product_id)的唯一索引,代码层面万一被绕过,数据库索引也能挡住重复流水。

6. 上线前的三个优化:锁粒度、单据号生成和异步导出

6.1 库存并发从“对”到“快”:行锁、乐观锁与可用/锁定分离

前面讲的原子 update 保证了正确性,但真正高并发的仓库场景里,同一件商品被多个营业员同时开单出库,还是会出现行锁等待。我的做法是先用带条件的原子 update 做乐观控制;如果单个商品每日出库笔数超过几百笔,再把库存表拆成可用数量和锁定数量,开单时先加锁定量,不阻塞可用数量的读操作,实际出库时再减可用数量。锁粒度越小并发越高,但实现复杂度也会上升,所以只在碰到锁等待过多的时候做,不要一开始就上复杂方案。

6.2 单据号用日期加流水号,让业务人员一眼看懂

单据号是进销存的门面,采购部拿着一张单子,凭单号就能判断是哪天开的、属于哪类业务。不要用 UUID,太长了念不出来;也不要在 Java 里拼时间戳,做不到递增还会重复。我一般用 Redis 的 INCR 生成按天序列,再补零到固定长度,单号形如20240512-PD-000123。如果项目没接 Redis,可以用数据库单号序列表,但每次取号要SELECT ... FOR UPDATE,会让开单接口并发能力下降一半。前端保存单据前先请求一次取号接口,保证用户打开单据时就能看到单号,而不是保存时才生成。

6.3 报表导出改异步任务,避免 Tomcat 线程被大查询占死

进销存列表页导出经常是扫整张流水表,同步导出会让页面卡住几十秒,期间其它用户接口也跟着变慢。我的习惯是引入一个简单的异步任务表,前端点导出时只插入一条任务记录,后台线程池执行真正的查询和 Excel 生成,前端轮询任务状态,完成后显示下载链接。这样既不会让慢查询拖垮所有人,也方便导出失败时保留日志和重试入口。实现时注意线程池要独立配置核心线程数和队列上限,别直接复用业务线程池。

做完这三个优化,KS 进销存才算从“能跑”变成“能用、敢用”。我印象最深的一次翻车,是早期没加负数库存条件,门店盘点时发现库存成了负数,查到最后是两个店员同时给同款商品开单造成的。从那以后,所有库存更新都改成带条件判断的原子 SQL,每次上线前专门做一次并发审核演练。选 RuoYi 做底座确实省了不少基础功夫,但进销存这种业务,库存、金额、单据的一致性才是真正的底线,框架替代不了这几条约束。希望帮到你。

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

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

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

立即咨询