news 2026/10/9 2:41:48

Java采购管理系统实战:数据库设计与事务并发避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java采购管理系统实战:数据库设计与事务并发避坑指南

简介:这是一套面向Java Web初学者与课程设计开发者的企业采购管理系统完整源码,采用JSP技术构建,配合MySQL数据库,用于解决企业采购信息的高效管理问题。系统实现了用户登录与角色权限区分、供应商信息灵活增删改查、材料种类与库存管理等核心模块,普通用户与超级管理员登录后进入不同操作页面,可作为进销存类项目的学习范本或毕业设计参考。压缩包共248个文件,约22.71MB,包含32个java源文件、30个jsp页面、35个class编译文件、63个jar依赖包,以及xml配置、gif与png图片、css样式、properties配置等资源,覆盖从源码到运行依赖的完整结构。目前已有2659人学习下载。通过阅读源码,读者可掌握Action、Service分层设计思路,理解Supplier、Product、Order等业务模块的实现方式,并借鉴分页封装与数据库交互的写法,适合需要快速搭建采购管理项目或对照排错的学习者。

1. 拿到「Java实现采购管理系统(含数据库).rar」先别急着解压:这套东西到底能解决什么

很多做 Java 的人第一次接触采购管理系统,是在课程设计、毕设或者小公司内部工具的场景里。你手上可能有一个压缩包,名字就叫「Java实现采购管理系统(含数据库).rar」,里面大概率是源码加一份 SQL 脚本。但真正的问题不是「怎么打开它」,而是「这套系统能不能撑起一条真实的采购业务线」。采购管理系统的核心链路其实很固定:供应商建档、采购申请、询价比价、采购订单、到货入库、对账付款,每一步都要和数据库里的状态字段严格对应。如果只是把增删改查堆上去,跑起来能点,但订单一多、并发一上来,库存和订单状态就会对不上。这篇文章面向的是想用 Java 把采购管理真正落地的人,不管你是要复现一个可运行的系统,还是要在现有骨架上改出能用的版本,我都会把选型、建表、核心代码和踩坑点讲清楚。数据库增删改查是基础,但采购系统的难点从来不在单表 CRUD,而在多表状态流转和事务边界。

2. 采购管理系统的数据库设计:从供应商到入库的六张核心表

2.1 为什么采购系统的表不能只按界面来建

很多人拿到需求第一反应是照着页面建表:一个供应商表、一个订单表、一个入库表,看起来够了。但采购业务里最容易翻车的地方是「同一张订单分批到货」和「部分退货」。如果订单表和入库表是一对一,第二批货到了你只能改原记录,历史就丢了。所以核心表至少要拆成:供应商表、物料表、采购申请单、采购订单主表、采购订单明细、入库单、入库明细、付款记录。这里先给一个最小可用的六表结构,覆盖从申请到入库的主链路。

-- 供应商表:一个供应商可能有多个联系人,这里先做单联系人简化版 CREATE TABLE supplier ( id BIGINT PRIMARY KEY AUTO_INCREMENT, supplier_code VARCHAR(32) NOT NULL UNIQUE COMMENT '供应商编码', supplier_name VARCHAR(128) NOT NULL, contact_person VARCHAR(64), contact_phone VARCHAR(32), status TINYINT DEFAULT 1 COMMENT '1启用 0停用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 物料表:采购的对象 CREATE TABLE material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, material_code VARCHAR(32) NOT NULL UNIQUE, material_name VARCHAR(128) NOT NULL, unit VARCHAR(16) COMMENT '单位:个/箱/千克', reference_price DECIMAL(12,2) COMMENT '参考单价', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 采购订单主表 CREATE TABLE purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, supplier_id BIGINT NOT NULL, order_status TINYINT DEFAULT 10 COMMENT '10待确认 20已确认 30部分入库 40已完成 50已取消', total_amount DECIMAL(14,2) DEFAULT 0, expect_date DATE COMMENT '期望到货日期', created_by VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_supplier (supplier_id), INDEX idx_status (order_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 采购订单明细 CREATE TABLE purchase_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity DECIMAL(12,2) NOT NULL, unit_price DECIMAL(12,2) NOT NULL, received_qty DECIMAL(12,2) DEFAULT 0 COMMENT '已入库数量', INDEX idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 入库单 CREATE TABLE stock_in ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stock_in_no VARCHAR(32) NOT NULL UNIQUE, order_id BIGINT NOT NULL, warehouse VARCHAR(64), operator VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 入库明细 CREATE TABLE stock_in_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stock_in_id BIGINT NOT NULL, order_item_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity DECIMAL(12,2) NOT NULL, INDEX idx_stock_in (stock_in_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段建表语句里最关键的是purchase_order_item.received_qty和purchase_order.order_status的配合。订单状态不是手动改的,而是每次入库后根据明细的已收数量自动推导:全部收满变 40,收了一部分变 30。这样即使分三批到货,历史入库单都在,订单主表只反映当前汇总状态。参数上,金额统一用DECIMAL(14,2),不要用 float,采购对账差一分钱都是事故。字符集用utf8mb4,供应商名称里出现生僻字或特殊符号不会乱码。

2.2 用 MyBatis-Plus 根据实体类反向校验表结构

热词里有人搜「mybatisplus根据java实体类生成创建表的sql语句」,这个能力在采购系统里其实很有用:当你改了实体类字段,可以快速比对数据库表是不是漏了列。MyBatis-Plus 本身不直接生成 DDL,但可以通过TableInfoHelper拿到实体映射的字段信息,再拼出建表语句做校验。下面是一个校验脚本的核心逻辑。

// 依赖:mybatis-plus-boot-starter // 作用:扫描实体类,输出字段与数据库列的差异,避免上线才发现漏字段 @Component public class EntitySchemaChecker { @Autowired private JdbcTemplate jdbcTemplate; public void checkTable(Class<?> entityClass, String tableName) { // 拿到 MyBatis-Plus 解析后的表信息 TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass); if (tableInfo == null) { System.out.println("实体未被 MyBatis-Plus 解析:" + entityClass.getName()); return; } // 查询数据库现有列 List<String> dbColumns = jdbcTemplate.queryForList( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = ?", String.class, tableName); // 实体字段对应的列名 List<String> entityColumns = tableInfo.getFieldList().stream() .map(FieldInfo::getColumn) .collect(Collectors.toList()); entityColumns.add(tableInfo.getKeyColumn()); for (String col : entityColumns) { if (!dbColumns.contains(col)) { System.out.println("数据库缺少列:" + tableName + "." + col); } } } }

逻辑说明:TableInfoHelper.getTableInfo是 MyBatis-Plus 启动后缓存的实体映射信息,能拿到@TableField注解解析后的真实列名。参数上,information_schema.COLUMNS在 MySQL 里查当前库的表结构,换成其他数据库要改系统表名。这个校验建议放在单元测试里跑,不要放在生产启动流程里,否则数据库权限不够会直接启动失败。采购系统字段变动频繁,尤其是审批流相关的字段,上线前跑一次能省掉很多「Unknown column」的报错。

3. 采购订单状态流转的 Java 实现:事务和并发扣减怎么处理

3.1 订单确认与入库的事务边界

采购系统最典型的写操作是「入库」:插入入库单、插入入库明细、更新订单明细的已收数量、更新订单主表状态。这四步必须在同一个事务里,否则会出现「入库单有了但订单没更新」的脏数据。下面是一个基于 Spring@Transactional的实现。

@Service public class StockInService { @Autowired private StockInMapper stockInMapper; @Autowired private StockInItemMapper stockInItemMapper; @Autowired private PurchaseOrderItemMapper orderItemMapper; @Autowired private PurchaseOrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public void createStockIn(StockInDTO dto) { // 1. 插入入库单主表 StockIn stockIn = new StockIn(); stockIn.setStockInNo(generateNo("IN")); stockIn.setOrderId(dto.getOrderId()); stockIn.setWarehouse(dto.getWarehouse()); stockIn.setOperator(dto.getOperator()); stockInMapper.insert(stockIn); // 2. 逐条插入入库明细,并累加订单明细已收数量 for (StockInItemDTO item : dto.getItems()) { StockInItem entity = new StockInItem(); entity.setStockInId(stockIn.getId()); entity.setOrderItemId(item.getOrderItemId()); entity.setMaterialId(item.getMaterialId()); entity.setQuantity(item.getQuantity()); stockInItemMapper.insert(entity); // 带条件的更新,防止超收 int updated = orderItemMapper.increaseReceivedQty( item.getOrderItemId(), item.getQuantity()); if (updated == 0) { throw new BizException("入库数量超过订单剩余数量,明细ID:" + item.getOrderItemId()); } } // 3. 重新计算订单状态 refreshOrderStatus(dto.getOrderId()); } private void refreshOrderStatus(Long orderId) { List<PurchaseOrderItem> items = orderItemMapper.selectByOrderId(orderId); boolean allDone = items.stream() .allMatch(i -> i.getReceivedQty().compareTo(i.getQuantity()) >= 0); boolean anyReceived = items.stream() .anyMatch(i -> i.getReceivedQty().compareTo(BigDecimal.ZERO) > 0); PurchaseOrder order = new PurchaseOrder(); order.setId(orderId); if (allDone) { order.setOrderStatus(40); } else if (anyReceived) { order.setOrderStatus(30); } orderMapper.updateById(order); } }

逻辑说明:increaseReceivedQty对应的 SQL 必须带条件,不能直接set received_qty = received_qty + ?,否则并发下会超收。参数上,rollbackFor = Exception.class保证任何异常都回滚,包括业务异常。refreshOrderStatus放在事务内,保证状态和明细数量一致。这里没有用数据库触发器,因为触发器在采购系统里调试成本太高,出问题很难定位,血泪经验是能放应用层就放应用层。

3.2 并发入库时用乐观锁还是悲观锁

采购系统里同一个订单可能被两个仓管同时操作入库,这时候received_qty的更新就是并发点。常见做法有两种:悲观锁select ... for update,或者乐观锁版本号。采购场景我更推荐乐观锁,因为入库操作频率不高,冲突概率低,悲观锁会拖慢整个订单的查询。

-- 乐观锁方式:在 purchase_order_item 加 version 字段 ALTER TABLE purchase_order_item ADD COLUMN version INT DEFAULT 0; -- 更新时带版本号 UPDATE purchase_order_item SET received_qty = received_qty + #{qty}, version = version + 1 WHERE id = #{id} AND received_qty + #{qty} <= quantity AND version = #{version};

参数说明:received_qty + #{qty} <= quantity这个条件同时完成了「防超收」和「乐观锁校验」。如果返回影响行数为 0,说明要么版本变了,要么超收了,应用层统一抛异常让用户重试。注意version字段要映射到实体里,MyBatis-Plus 的@Version注解可以自动处理,但这里因为条件里还带了数量校验,手写 SQL 更直观。采购系统里不要用「先查再改」的两步操作,中间的时间窗口足够另一个请求插进来。

4. 采购管理系统避坑:这五个问题我几乎每个项目都遇到过

4.1 现象:订单状态和入库数量对不上,查日志发现事务没回滚

原因:@Transactional默认只对RuntimeException回滚,如果业务里抛的是受检异常,或者异常被 catch 后没重新抛出,事务不会回滚。采购系统里经常有人把BizException定义成受检异常,结果入库失败但入库单已经插进去了。

解决:统一用@Transactional(rollbackFor = Exception.class),并且业务异常继承RuntimeException。另外注意自调用问题:同一个类里 A 方法调 B 方法,B 上的事务注解不生效,因为没走代理。采购系统的 Service 拆分要清晰,入库和状态刷新不要写在同一个类的私有方法里互相调。

4.2 现象:供应商编码重复插入报唯一键冲突,但前端提示「系统异常」

原因:数据库唯一索引报DuplicateKeyException,全局异常处理没捕获,直接冒泡成 500。采购系统里供应商编码、订单号都是唯一键,重复提交很常见。

解决:在全局异常处理器里单独捕获DuplicateKeyException,返回「编码已存在,请刷新后重试」。更稳妥的做法是前端提交时先做一次幂等校验,用订单号或请求 token 做去重。数据库唯一索引是最后一道防线,但不能让它成为唯一防线。

4.3 现象:分页查询采购订单时,总数对但列表数据重复

原因:订单主表和明细表 join 后分页,一个订单有多条明细,导致主表记录被放大。MyBatis-Plus 的分页插件在 join 场景下如果没做去重,就会出现总数和列表不一致。

解决:分页查询主表,明细单独查一次再在内存里组装。或者用子查询先分页主表 ID,再根据 ID 查明细。采购系统的列表页通常只需要展示订单主信息,明细在详情页看,所以主表和明细分页分开是最省事的做法。

4.4 现象:入库时提示「入库数量超过订单剩余数量」,但实际剩余足够

原因:received_qty字段用了 float 或 double,累加后出现精度误差,比如 0.1 + 0.2 = 0.30000000000000004,导致比较失败。采购系统里物料数量可能是小数,比如千克。

解决:所有数量、金额字段统一用DECIMAL,Java 侧用BigDecimal,比较用compareTo不要用equals。BigDecimal的equals会比较精度,compareTo只比较数值,采购系统里一律用compareTo。

4.5 现象:数据库连接池耗尽,采购系统高峰期卡死

原因:入库事务里做了远程调用或者文件操作,事务持有时间过长,连接被占满。采购系统里常见的是入库后要发通知、生成 PDF 对账单,这些操作如果放在事务里,连接池很快就不够用。

解决:事务里只做数据库操作,通知和对账单生成放到事务提交后,用TransactionSynchronizationManager.registerSynchronization或者 Spring 的@TransactionalEventListener。连接池参数上,maximumPoolSize不要拍脑袋设很大,先看数据库最大连接数,采购系统一般 20 到 50 足够,设太大反而会把数据库拖垮。

5. 把采购管理系统跑起来之后,怎么验证它真的能用

5.1 用一组边界数据做入库全链路验证

系统能启动不代表业务正确。我一般会准备一组边界数据:一个订单有三条明细,数量分别是 10、20、30,然后分三次入库,第一次入 5、10、30,第二次入 5、10、0,第三次入 0、0、0。预期结果是:第一次入库后订单状态变 30,第二条明细收满;第二次入库后第一条明细收满,订单状态仍是 30;第三次入库数量为 0 的明细应该被拒绝或者跳过,订单状态变 40。这个用例能同时验证部分入库、超收拦截和状态推导。

// 验证订单状态推导的单元测试片段 @Test public void testPartialStockIn() { // 构造订单:三条明细,数量 10/20/30 Long orderId = createTestOrder(); // 第一次入库:5/10/30 stockInService.createStockIn(buildDto(orderId, 5, 10, 30)); PurchaseOrder order = orderMapper.selectById(orderId); assertEquals(30, order.getOrderStatus()); // 部分入库 // 第二次入库:5/10/0 stockInService.createStockIn(buildDto(orderId, 5, 10, 0)); order = orderMapper.selectById(orderId); assertEquals(40, order.getOrderStatus()); // 全部收满 // 第三次入库:尝试再入 1,应该抛异常 assertThrows(BizException.class, () -> stockInService.createStockIn(buildDto(orderId, 1, 0, 0))); }

参数说明:createTestOrder里要确保received_qty初始为 0,order_status初始为 20。断言用assertEquals比较状态码,不要比较字符串。这个测试跑通,说明事务、状态推导、超收拦截三条线都是通的。采购系统的验收不要只看页面能不能点,一定要用这种边界数据把状态机跑一遍。

5.2 数据库层面加两个监控查询

系统上线后,我习惯在数据库里留两个查询,方便快速定位问题。第一个是查「订单状态和明细数量不一致」的异常数据:

-- 找出状态为已完成但明细未收满的订单 SELECT o.order_no, i.id AS item_id, i.quantity, i.received_qty FROM purchase_order o JOIN purchase_order_item i ON i.order_id = o.id WHERE o.order_status = 40 AND i.received_qty < i.quantity;

第二个是查「入库明细数量之和与订单明细已收数量不一致」:

-- 找出入库明细汇总与订单明细已收数量对不上的记录 SELECT i.id, i.received_qty, IFNULL(SUM(si.quantity), 0) AS actual_received FROM purchase_order_item i LEFT JOIN stock_in_item si ON si.order_item_id = i.id GROUP BY i.id, i.received_qty HAVING i.received_qty <> IFNULL(SUM(si.quantity), 0);

这两个查询建议做成定时任务,每天跑一次,有异常就告警。采购系统的数据一旦对不上,对账时就是灾难,后悔药没地方买。我自己的习惯是,任何涉及数量累加的系统,都要有一个「汇总对账」的查询兜底,不能只信应用层的逻辑。

5.3 一个具体技巧:用数据库死锁日志反推事务顺序

采购系统并发入库时如果出现死锁,MySQL 的SHOW ENGINE INNODB STATUS会输出最近一次死锁的详细信息。我一般会重点看两个事务的加锁顺序:如果事务 A 先锁订单明细 1 再锁明细 2,事务 B 先锁明细 2 再锁明细 1,就会死锁。解决办法是在应用层对入库明细按order_item_id排序后再处理,保证所有事务的加锁顺序一致。这个技巧在采购系统里特别实用,因为一个订单的明细数量不多,排序成本几乎为零,但能消掉大部分死锁。数据库死锁不是玄学,日志里写得清清楚楚,关键是愿不愿意去看。

这套采购管理系统从建表到状态流转,再到验证和排查,核心就一句话:数量字段用 DECIMAL,状态推导放事务里,并发更新带条件,异常数据有对账。我做了这么多版采购系统,翻车最多的地方从来不是代码写不出来,而是觉得「这个字段不会有人改」然后没加约束。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 2:40:41

U盘重装Windows系统保姆级教程:从启动盘制作到安装避坑

1. 重装系统的真实痛点&#xff1a;为什么很多人宁愿卡顿也不碰U盘电脑用了两三年&#xff0c;开机转圈一分钟&#xff0c;打开任务管理器看到磁盘占用率100%&#xff0c;后台偷偷更新的进程把内存吃光&#xff0c;风扇转得像要起飞——这个时候大部分人都会冒出同一个念头&…

作者头像 李华
网站建设 2026/10/9 2:38:12

【ArkUI 练中学】第2课:ArkTS 基础语法

本节目标掌握 ArkTS 中变量与常量的声明方式&#xff0c;理解 let 与 const 的区别熟悉 ArkTS 的基本数据类型&#xff1a;number、string、boolean&#xff0c;以及数组类型学会函数声明、可选参数、默认参数和箭头函数的用法掌握 if / else if / else 条件渲染&#…

作者头像 李华
网站建设 2026/10/9 2:37:11

OptiScaler 快速上手:三步让只认 DLSS 的游戏跑上 AMD 显卡

OptiScaler 快速上手&#xff1a;三步让只认 DLSS 的游戏跑上 AMD 显卡 【免费下载链接】OptiScaler OptiScaler bridges upscaling/frame gen across GPUs. Supports DLSS2/XeSS/FSR2 inputs, replaces native upscalers, enables FSR-FG/XeFG on non-FG titles. Supports Nuk…

作者头像 李华
网站建设 2026/10/9 2:36:19

Git Reset完全指南:三种模式、误操作恢复与团队协作避坑

我见过太多人在 Git 里"点错一个按钮就心态爆炸"的场景。最常见的翻车操作就是git reset&#xff0c;尤其是git reset --hard。群里喊"代码没了怎么找回"的人&#xff0c;十有八九都是先执行了git reset --hard&#xff0c;随后才发现工作区里那些没提交的…

作者头像 李华