
简介这是一套面向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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 物料表采购的对象 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 采购订单主表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 采购订单明细 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 入库单 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 入库明细 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表语句里最关键的是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; } // 查询数据库现有列 ListString dbColumns jdbcTemplate.queryForList( SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME ?, String.class, tableName); // 实体字段对应的列名 ListString 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 订单确认与入库的事务边界采购系统最典型的写操作是「入库」插入入库单、插入入库明细、更新订单明细的已收数量、更新订单主表状态。这四步必须在同一个事务里否则会出现「入库单有了但订单没更新」的脏数据。下面是一个基于 SpringTransactional的实现。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) { ListPurchaseOrderItem 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导致比较失败。采购系统里物料数量可能是小数比如千克。解决所有数量、金额字段统一用DECIMALJava 侧用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初始为 0order_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状态推导放事务里并发更新带条件异常数据有对账。我做了这么多版采购系统翻车最多的地方从来不是代码写不出来而是觉得「这个字段不会有人改」然后没加约束。希望帮到你。本文还有配套的精品资源点击获取