Web ERP采购订单管理实战:业务逻辑、数据库设计与代码实现
很多企业在用 ERP 时最先接触到的就是进销存模块而进销存里最容易被忽视、却又最影响成本的就是采购订单管理。如果采购流程没有控制好后续的入库、付款、成本核算都会跟着出问题。本文基于 Web ERP 的使用场景完整拆解采购订单管理的业务逻辑、数据库设计、后端接口、前端操作流程并给出可复用的实战代码和常见报错排查思路。无论你是 ERP 实施顾问、企业 IT 人员还是正在开发进销存系统的开发者这篇教程都能直接用到实际项目中。1. 背景与核心概念1.1 什么是 ERP 进销存ERPEnterprise Resource Planning企业资源计划是一套把企业的采购、销售、库存、财务、生产等环节整合在一起的信息系统。而进销存通常指其中的三个核心业务域采购向供应商买货涉及询价、下单、入库、退货、对账。销售向客户卖货涉及报价、下单、出库、退货、收款。库存管理仓库中的货物数量、批次、位置、成本。进销存系统这三个环节相互依赖而采购订单是整条供应链的起点。因为一旦采购决策错了库存会积压资金会被占用后续销售也会被影响。1.2 采购订单在进销存中的位置从业务顺序来看采购订单处在“采购申请 → 采购订单 → 采购入库 → 采购发票 → 采购付款”这条主链路的前端。它的作用可以概括为三点明确采购需求什么时间、向哪家供应商、购买哪些商品、数量多少、单价多少。作为后续业务的依据采购入库单需要有采购订单号财务对账需要采购订单金额库存预占也需要参考在途数量。记录业务承诺采购订单是企业和供应商之间的业务约定也是审计追踪的关键凭证。在 Web ERP 系统中采购订单管理通常具备新增、编辑、审核、关闭、查询、打印、导入导出等基础能力。除此之外还会与库存、应收应付、审批流模块联动。1.3 采购订单与采购入库单、采购退货单的区别很多初学者会把采购订单和采购入库单混淆这里做一个简单区分单据类型业务含义是否影响库存是否影响往来采购订单计划采购供应商确认不影响库存不影响往来采购入库单货物实际到货并验收入库增加库存增加应付采购退货单把不合格或多余货物退回供应商减少库存减少应付也就是说采购订单只是“计划”真正改变库存数量和财务数据的是入库单和退货单。采购订单管理要解决的就是把“计划”到“执行”的过程管控起来防止出现超量采购、重复采购、无依据入库等问题。1.4 Web ERP 与传统桌面 ERP 的差异Web ERP 指通过浏览器访问的 ERP 系统它与传统桌面 ERP 的主要区别在于免安装客户端用户只需要浏览器即可访问电脑、平板都可以用。集中部署服务和数据集中在服务器更新维护更方便。多端协同采购员、仓管员、财务可以在不同地点同时操作。权限控制更加灵活可以针对按钮、接口、页面做细粒度授权。不过Web ERP 也带来了一些新的问题比如浏览器兼容性、网络不稳定时的操作体验、多用户并发时的数据一致性。本教程在实战部分会针对这些场景给到对应的设计建议。2. 采购订单管理业务梳理2.1 典型采购业务流程要设计好采购订单管理功能先要梳理清楚业务流程。一个典型的采购流程如下业务部门提出采购申请说明需求商品、数量、期望到货日期。采购员汇总需求向供应商询价比对价格和交期。采购员根据选定的供应商创建采购订单。订单提交审批由采购主管或经理审核价格、数量、供应商。审核通过后发送订单给供应商确认。供应商发货仓库收货并在系统中创建采购入库单。财务根据入库单、订单和发票进行应付结算。在这个流程中采购订单是核心凭证。每一个环节都需要围绕订单数据展开。2.2 采购订单的完整生命周期一个采购订单从创建到关闭通常经历以下状态草稿采购员填单数据尚未提交审核可修改。待审核已提交审批等待审批人处理。已审核审批通过供应商确认可以执行收货。部分收货订单中部分商品已入库。已完成全部商品都已入库订单完成。已取消订单被作废不再执行。状态机的核心作用是避免越权操作和流程混乱。例如草稿状态的订单不能直接入库已完成的订单不能再入库已取消的订单不能再提交审核。2.3 采购订单管理涉及的角色在实际系统中不同角色对采购订单的操作权限是不同的角色主要操作采购员创建订单、编辑草稿、提交审核、修改备注采购主管审核订单、退回订单、关闭订单仓管员根据订单收货生成入库单财务查看订单价格、金额用于对账和应付系统管理员配置审批流、数据权限、编号规则Web ERP 的权限设计需要覆盖到单据级和按钮级即“谁能看哪些供应商的数据谁能审核哪个金额区间的订单”这类控制。2.4 关键业务字段说明在设计采购订单功能时核心字段包括订单编号系统自动生成保证唯一。供应商必须来自基础资料中的供应商档案。采购日期业务发生日期影响统计报表。预计到货日期用于跟单和交期管理。仓库采购入库的目标仓库。商品明细商品编码、名称、规格、单位、数量、单价。金额信息不含税金额、税额、价税合计。状态当前所处业务流程节点。备注记录补充说明比如付款条件、运输方式等。字段的定义直接影响后续报表和财务对账所以在建模阶段就要考虑清楚。3. 环境准备与项目结构3.1 技术栈说明本教程的实战部分以常见的 Web ERP 技术栈为例后端Java 8 Spring Boot持久层MyBatis-Plus数据库MySQL 8.x前端Vue.js Element Plus构建工具Maven需要说明的是不同企业的 ERP 技术栈可能不同有的是 Java有的是 PHP有的是 Python但采购订单管理的业务逻辑是通用的。你可以把本文中的设计思路和数据模型迁移到任何语言和框架中。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路和代码结构。3.2 演示环境假设我们要实现一个采购订单管理模块包含以下功能采购订单列表查询创建采购订单提交审核审核通过审核驳回关闭订单根据订单生成采购入库单为了突出重点示例项目会简化部分模块例如不单独做供应商管理而是使用供应商 ID 供应商名称的冗余字段。3.3 项目目录结构一个典型的后端项目结构如下erp-demo ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/erp/demo │ │ │ ├── ErpDemoApplication.java │ │ │ ├── controller │ │ │ │ └── PurchaseOrderController.java │ │ │ ├── service │ │ │ │ ├── PurchaseOrderService.java │ │ │ │ └── impl │ │ │ │ └── PurchaseOrderServiceImpl.java │ │ │ ├── mapper │ │ │ │ ├── PurchaseOrderMapper.java │ │ │ │ └── PurchaseOrderItemMapper.java │ │ │ ├── entity │ │ │ │ ├── PurchaseOrder.java │ │ │ │ └── PurchaseOrderItem.java │ │ │ ├── dto │ │ │ │ ├── PurOrderCreateRequest.java │ │ │ │ ├── PurOrderUpdateRequest.java │ │ │ │ └── PurOrderAuditRequest.java │ │ │ └── common │ │ │ ├── Result.java │ │ │ └── BusinessException.java │ │ └── resources │ │ ├── application.yml │ │ └── mapper │ │ ├── PurchaseOrderMapper.xml │ │ └── PurchaseOrderItemMapper.xml │ └── sql │ └── erp_demo.sql └── front └── src ├── api │ └── purchaseOrder.js └── views └── purchaseOrder └── index.vue前端部分在真实项目中会用 Web 工程单独维护这里为了说明完整流程也会给出一段 Vue 页面示例。3.4 数据表准备新项目可以先创建一个数据库CREATE DATABASE IF NOT EXISTS erp_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE erp_demo;在本教程第 4 章会给出完整的建表 SQL。4. 核心功能设计与数据库建模4.1 采购订单主表设计采购订单主表存储一笔采购订单的汇总信息设计原则是“一单一主表一行一明细”。主表字段需要满足唯一订单号供应商信息订单金额汇总业务状态审计字段创建人、创建时间、审核人、审核时间等不建议把供应商的完整信息都存到订单表通常只存供应商 ID 和供应商名称快照。这样在供应商档案修改后历史订单仍能保留当时名称。4.2 采购订单明细表设计明细表记录订单中的每一行商品通过order_id与主表关联。关键点在于记录以下内容商品快照商品编码、商品名称、规格、单位。商品资料后续可能修改所以要冗余快照字段。采购数量业务核心数量。已收货数量用于校验是否可继续入库防止超收。单价与金额价格快照后续价格变更不影响历史订单。这里特别说明“已收货数量”字段的作用。如果不记录该字段每次收货都要临时统计入库明细性能不够且逻辑容易出错。直接在订单明细上维护累计已收数量是目前进销存系统最常见的做法。4.3 数据库 DDL 示例-- 采购订单主表 CREATE TABLE pur_order ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_no varchar(32) NOT NULL COMMENT 采购订单号, supplier_id bigint NOT NULL COMMENT 供应商ID, supplier_name varchar(128) NOT NULL COMMENT 供应商名称快照, order_date date NOT NULL COMMENT 采购日期, expected_date date DEFAULT NULL COMMENT 预计到货日期, warehouse_id bigint DEFAULT NULL COMMENT 收货仓库ID, warehouse_name varchar(128) DEFAULT NULL COMMENT 收货仓库名称, total_quantity int NOT NULL DEFAULT 0 COMMENT 订单总数量, total_amount decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 价税合计, discount_amount decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 优惠金额, paid_amount decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 已付款金额, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0草稿 1待审核 2已审核 3部分收货 4已完成 5已取消, audit_by varchar(64) DEFAULT NULL COMMENT 审核人, audit_time datetime DEFAULT NULL COMMENT 审核时间, remark varchar(500) DEFAULT NULL COMMENT 备注, created_by varchar(64) NOT NULL COMMENT 创建人, created_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_supplier_id (supplier_id), KEY idx_status (status), KEY idx_order_date (order_date) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 采购订单主表;-- 采购订单明细表 CREATE TABLE pur_order_item ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_id bigint NOT NULL COMMENT 采购订单主表ID, order_no varchar(32) NOT NULL COMMENT 采购订单号, line_no int NOT NULL COMMENT 行号, sku_code varchar(64) NOT NULL COMMENT 商品编码, sku_name varchar(128) NOT NULL COMMENT 商品名称快照, spec varchar(128) DEFAULT NULL COMMENT 规格型号, unit varchar(32) DEFAULT NULL COMMENT 单位, quantity int NOT NULL DEFAULT 0 COMMENT 采购数量, received_qty int NOT NULL DEFAULT 0 COMMENT 已收货数量, price decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 采购单价, tax_rate decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT 税率(%), tax_amount decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 税额, amount decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 行金额(不含税), total_amount decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 行金额(价税合计), remark varchar(255) DEFAULT NULL COMMENT 行备注, PRIMARY KEY (id), KEY idx_order_id (order_id), KEY idx_sku_code (sku_code) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 采购订单明细表;这里使用order_no冗余字段是为了在按订单号查询明细时减少一次关联。实际项目中可以根据查询习惯决定是否保留。4.4 状态机设计状态机是采购订单开发中很容易写乱的部分。不建议在 Service 的每个方法里用 if-else 判断状态更好的方式是把状态流转统一收敛。草稿(0) - 待审核(1) - 已审核(2) - 部分收货(3) - 已完成(4) | | v v 已取消(5) 已取消(5)对应到代码中可以维护一个状态流转表当前状态允许操作目标状态草稿提交审核待审核草稿修改草稿草稿取消已取消待审核审核通过已审核待审核审核驳回草稿已审核开始收货部分收货/已完成部分收货继续收货部分收货/已完成状态校验的核心代码会在第 5 章给出。5. 完整实战案例采购订单创建与审批本章通过一个完整的示例演示采购订单从创建到审核的闭环流程。5.1 前端页面创建表单与明细编辑在 Web ERP 中创建采购订单页面通常由两部分组成上方是订单头信息供应商、日期、仓库下方是商品明细表格。这里以 Vue Element Plus 为例展示核心模板结构。文件路径为front/src/views/purchaseOrder/index.vuetemplate div classorder-container el-card shadownever el-form :modelorderForm label-width100px el-row :gutter20 el-col :span8 el-form-item label供应商 required el-select v-modelorderForm.supplierId filterable placeholder请选择供应商 changehandleSupplierChange el-option v-foritem in supplierList :keyitem.id :labelitem.supplierName :valueitem.id / /el-select /el-form-item /el-col el-col :span8 el-form-item label采购日期 required el-date-picker v-modelorderForm.orderDate typedate value-formatYYYY-MM-DD placeholder选择日期 / /el-form-item /el-col el-col :span8 el-form-item label预计到货 el-date-picker v-modelorderForm.expectedDate typedate value-formatYYYY-MM-DD placeholder选择到货日期 / /el-form-item /el-col /el-row /el-form el-table :dataorderForm.items border el-table-column label商品编码 width140 template #default{ row } el-input v-modelrow.skuCode placeholder选择商品 / /template /el-table-column el-table-column label商品名称 min-width160 template #default{ row } el-input v-modelrow.skuName placeholder自动带出或手动填写 / /template /el-table-column el-table-column label数量 width120 template #default{ row } el-input-number v-modelrow.quantity :min1 / /template /el-table-column el-table-column label单价 width140 template #default{ row } el-input-number v-modelrow.price :min0 :precision2 / /template /el-table-column el-table-column label行金额 width120 template #default{ row } span{{ (row.quantity * row.price).toFixed(2) }}/span /template /el-table-column el-table-column label操作 width80 template #default{ $index } el-button typedanger link clickremoveItem($index)删除/el-button /template /el-table-column /el-table div stylemargin: 12px 0 el-button clickaddItem添加商品/el-button /div div styletext-align: right el-button typeprimary clicksubmitOrder保存草稿/el-button el-button typesuccess clicksubmitAndAudit提交审核/el-button /div /el-card /div /template对应的脚本部分import { getSupplierList, createPurchaseOrder, submitAudit } from /api/purchaseOrder export default { name: PurchaseOrderCreate, data() { return { supplierList: [], orderForm: { supplierId: undefined, supplierName: , orderDate: new Date().toISOString().slice(0, 10), expectedDate: , warehouseId: undefined, remark: , items: [] } } }, created() { this.loadSuppliers() }, methods: { async loadSuppliers() { const res await getSupplierList() this.supplierList res.data }, handleSupplierChange(val) { const found this.supplierList.find(item item.id val) this.orderForm.supplierName found ? found.supplierName : }, addItem() { this.orderForm.items.push({ skuCode: , skuName: , spec: , unit: , quantity: 1, price: 0 }) }, removeItem(index) { this.orderForm.items.splice(index, 1) }, async submitOrder() { const params this.buildParams() await createPurchaseOrder(params) this.$message.success(保存成功) this.$router.push(/purchase/order/list) }, async submitAndAudit() { const params this.buildParams() const res await createPurchaseOrder(params) await submitAudit({ orderId: res.data, action: SUBMIT }) this.$message.success(已提交审核) }, buildParams() { return { supplierId: this.orderForm.supplierId, supplierName: this.orderForm.supplierName, orderDate: this.orderForm.orderDate, expectedDate: this.orderForm.expectedDate, warehouseId: this.orderForm.warehouseId, remark: this.orderForm.remark, items: this.orderForm.items.map((item, index) ({ lineNo: index 1, skuCode: item.skuCode, skuName: item.skuName, spec: item.spec, unit: item.unit, quantity: item.quantity, price: item.price })) } } } }这里需要提醒的是真实项目中“商品编码”通常会通过商品选择器选择而不是直接手输。手输会导致商品名称、规格不准确。本文为了控制示例长度保留了手输方式实际开发时建议接入商品档案选择器。5.2 后端接口创建订单后端创建订单的接口收到前端传来的 JSON 数据后需要做以下几件事参数校验必填字段校验、明细是否为空、数量是否大于 0。生成订单号。循环明细计算金额。插入主表和明细表。返回订单 ID。先定义一个统一返回体// 文件路径src/main/java/com/erp/demo/common/Result.java package com.erp.demo.common; public class ResultT { private int code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } // getter / setter 省略 }再定义用户购买订单的创建请求对象// 文件路径src/main/java/com/erp/demo/dto/PurOrderCreateRequest.java package com.erp.demo.dto; import javax.validation.Valid; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; import java.util.List; public class PurOrderCreateRequest { NotNull(message 供应商不能为空) private Long supplierId; private String supplierName; private String orderDate; private String expectedDate; private Long warehouseId; private String remark; Valid NotEmpty(message 商品明细不能为空) Size(max 500, message 商品明细不能超过500行) private ListItemRequest items; public static class ItemRequest { NotEmpty(message 商品编码不能为空) private String skuCode; NotEmpty(message 商品名称不能为空) private String skuName; private String spec; private String unit; NotNull(message 数量不能为空) private Integer quantity; NotNull(message 单价不能为空) private java.math.BigDecimal price; private java.math.BigDecimal taxRate; // getter / setter 省略 } // getter / setter 省略 }核心的 Service 实现// 文件路径src/main/java/com/erp/demo/service/impl/PurchaseOrderServiceImpl.java package com.erp.demo.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.erp.demo.common.BusinessException; import com.erp.demo.dto.PurOrderCreateRequest; import com.erp.demo.entity.PurchaseOrder; import com.erp.demo.entity.PurchaseOrderItem; import com.erp.demo.mapper.PurchaseOrderItemMapper; import com.erp.demo.mapper.PurchaseOrderMapper; import com.erp.demo.service.PurchaseOrderService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.UUID; Service public class PurchaseOrderServiceImpl implements PurchaseOrderService { Autowired private PurchaseOrderMapper purchaseOrderMapper; Autowired private PurchaseOrderItemMapper purchaseOrderItemMapper; Value(${erp.order-prefix:PO}) private String orderPrefix; Override Transactional(rollbackFor Exception.class) public Long createOrder(PurOrderCreateRequest request) { // 1. 参数补充与校验 if (request.getItems() null || request.getItems().isEmpty()) { throw new BusinessException(采购订单至少需要一条商品明细); } // 2. 构建主表 PurchaseOrder order new PurchaseOrder(); order.setOrderNo(generateOrderNo()); order.setSupplierId(request.getSupplierId()); order.setSupplierName(request.getSupplierName()); order.setOrderDate(LocalDate.parse(request.getOrderDate(), DateTimeFormatter.ISO_LOCAL_DATE)); if (request.getExpectedDate() ! null !request.getExpectedDate().isEmpty()) { order.setExpectedDate(LocalDate.parse(request.getExpectedDate(), DateTimeFormatter.ISO_LOCAL_DATE)); } order.setWarehouseId(request.getWarehouseId()); order.setRemark(request.getRemark()); order.setStatus(0); order.setCreatedBy(admin); BigDecimal totalAmount BigDecimal.ZERO; int totalQuantity 0; // 3. 构建明细计算金额 ListPurchaseOrderItem items new ArrayList(); for (PurOrderCreateRequest.ItemRequest itemReq : request.getItems()) { if (itemReq.getQuantity() 0) { throw new BusinessException(商品 itemReq.getSkuCode() 的数量必须大于 0); } if (itemReq.getPrice().compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(商品 itemReq.getSkuCode() 的单价不能为负数); } PurchaseOrderItem item new PurchaseOrderItem(); item.setSkuCode(itemReq.getSkuCode()); item.setSkuName(itemReq.getSkuName()); item.setSpec(itemReq.getSpec()); item.setUnit(itemReq.getUnit()); item.setQuantity(itemReq.getQuantity()); item.setReceivedQty(0); item.setPrice(itemReq.getPrice()); BigDecimal taxRate itemReq.getTaxRate() null ? BigDecimal.ZERO : itemReq.getTaxRate(); item.setTaxRate(taxRate); // 不含税金额 数量 * 单价 BigDecimal amount itemReq.getPrice() .multiply(BigDecimal.valueOf(itemReq.getQuantity())) .setScale(2, RoundingMode.HALF_UP); // 税额 不含税金额 * 税率 BigDecimal taxAmount amount .multiply(taxRate.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)) .setScale(2, RoundingMode.HALF_UP); // 价税合计 BigDecimal totalLineAmount amount.add(taxAmount).setScale(2, RoundingMode.HALF_UP); item.setAmount(amount); item.setTaxAmount(taxAmount); item.setTotalAmount(totalLineAmount); item.setLineNo(items.size() 1); items.add(item); totalQuantity itemReq.getQuantity(); totalAmount totalAmount.add(totalLineAmount); } order.setTotalQuantity(totalQuantity); order.setTotalAmount(totalAmount.setScale(2, RoundingMode.HALF_UP)); // 4. 先保存主表再保存明细 purchaseOrderMapper.insert(order); for (PurchaseOrderItem item : items) { item.setOrderId(order.getId()); item.setOrderNo(order.getOrderNo()); purchaseOrderItemMapper.insert(item); } return order.getId(); } private String generateOrderNo() { String datePart LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String uuidPart UUID.randomUUID().toString().replace(-, ).substring(0, 8).toUpperCase(); return orderPrefix - datePart - uuidPart; } }这段代码的关键点Transactional保证主表和明细表同时写入或同时回滚避免产生主表存在但明细缺失的脏数据。金额计算在服务端统一完成前端计算金额只用于实时展示不能作为最终数据来源。订单号使用“前缀 日期 随机串”的方式生成既保证可读性又避免高并发下重复。5.3 审批流程实现采购订单提交审核和审核通过本质是状态更新。但为了避免状态被随意跳转需要在校验当前状态后才允许更新。下面是提交审核和审核通过的 Service 方法Override Transactional(rollbackFor Exception.class) public void submitAudit(Long orderId, String operator) { PurchaseOrder order purchaseOrderMapper.selectById(orderId); if (order null) { throw new BusinessException(采购订单不存在); } if (order.getStatus() ! 0) { throw new BusinessException(只有草稿状态的订单才能提交审核); } PurchaseOrder update new PurchaseOrder(); update.setId(orderId); update.setStatus(1); purchaseOrderMapper.updateById(update); } Override Transactional(rollbackFor Exception.class) public void auditOrder(Long orderId, boolean passed, String operator, String auditRemark) { PurchaseOrder order purchaseOrderMapper.selectById(orderId); if (order null) { throw new BusinessException(采购订单不存在); } if (order.getStatus() ! 1) { throw new BusinessException(只有待审核状态的订单才能审核); } PurchaseOrder update new PurchaseOrder(); update.setId(orderId); update.setAuditBy(operator); update.setAuditTime(LocalDateTime.now()); if (passed) { update.setStatus(2); } else { update.setStatus(0); update.setRemark((order.getRemark() null ? : order.getRemark()) [驳回原因] (auditRemark null ? : auditRemark)); } purchaseOrderMapper.updateById(update); }这里需要特别强调的是在真实企业 ERP 中审批通常不是简单一个字段而是一张审批记录表CREATE TABLE pur_order_audit_log ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL, action varchar(32) NOT NULL COMMENT SUBMIT/AUDIT_PASS/AUDIT_REJECT, operator varchar(64) NOT NULL, comment varchar(500) DEFAULT NULL, created_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 采购订单审批日志;加审批日志的好处是后续可以追踪完整操作历史出现争议时能定位到具体操作人。5.4 控制器接口控制器层将前端请求映射到 Service并做统一异常处理// 文件路径src/main/java/com/erp/demo/controller/PurchaseOrderController.java package com.erp.demo.controller; import com.erp.demo.common.Result; import com.erp.demo.dto.PurOrderAuditRequest; import com.erp.demo.dto.PurOrderCreateRequest; import com.erp.demo.service.PurchaseOrderService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; RestController RequestMapping(/api/pur/order) public class PurchaseOrderController { Autowired private PurchaseOrderService purchaseOrderService; PostMapping(/create) public ResultLong create(RequestBody Valid PurOrderCreateRequest request) { return Result.ok(purchaseOrderService.createOrder(request)); } PostMapping(/submitAudit) public ResultVoid submitAudit(RequestBody PurOrderAuditRequest request) { purchaseOrderService.submitAudit(request.getOrderId(), admin); return Result.ok(null); } PostMapping(/audit) public ResultVoid audit(RequestBody PurOrderAuditRequest request) { purchaseOrderService.auditOrder( request.getOrderId(), request.isPassed(), admin, request.getComment() ); return Result.ok(null); } }5.5 运行与验证项目启动后可以使用以下 curl 命令模拟创建订单curl -X POST http://localhost:8080/api/pur/order/create \ -H Content-Type: application/json \ -d { supplierId: 1, supplierName: 测试供应商, orderDate: 2025-02-10, expectedDate: 2025-02-15, warehouseId: 1, items: [ {skuCode: SKU001, skuName: 键盘, quantity: 10, price: 99.00, taxRate: 13}, {skuCode: SKU002, skuName: 鼠标, quantity: 20, price: 39.50, taxRate: 13} ] }预期返回结果{ code: 200, message: success, data: 1 }数据库中可以查询到对应数据SELECT * FROM pur_order WHERE id 1; SELECT * FROM pur_order_item WHERE order_id 1;整个创建、提交审核、审核通过的流程可以通过接口文档或前端页面完成。6. 常见问题与排查思路在 Web ERP 采购订单管理功能的开发和使用过程中下面这些问题是出现频率较高的。问题现象常见原因解决思路创建订单时分录丢失前端提交字段名和后端实体不一致检查 JSON 字段命名是否使用items、skuCode等约定订单金额精度错误使用了浮点数double计算金额金额一律使用BigDecimal数据库使用DECIMAL提交审核后状态没变化接口返回成功但状态未更新检查事务是否提交查看日志确认 SQL 是否执行审核状态混乱草稿可入库状态枚举判断缺失统一使用状态机公共方法禁止在业务代码里散落状态判断重复点击保存产生多条订单前端重复提交前端按钮增加 loading 状态后端做幂等控制采购数量超出订单数量仍能入库收货逻辑未校验received_qty入库事务里使用乐观锁或行锁检查累计收货数量订单作废后仍显示在报表查询 SQL 未过滤状态报表查询默认排除已取消状态或加状态筛选高并发下订单号重复订单号生成策略简单使用数据库序列、Redis 自增或日期 随机串针对“重复点击保存”的问题最简单的方案是前端控制按钮状态例如在提交方法里加锁async submitOrder() { if (this.submitting) return this.submitting true try { // 调用接口 } finally { this.submitting false } }如果要求更高可以在后端接口增加业务幂等键。前端生成一个requestId后端判断该requestId是否已处理过。幂等设计更适合高并发场景普通的进销存系统先用前端按钮锁即可满足需求。关于超收问题生产环境应该使用SELECT ... FOR UPDATE锁住订单明细记录再判断累计收货数量最后更新订单明细的received_qty。这里不再展开完整代码。7. 最佳实践与工程建议7.1 价格与库存联动采购订单管理不只是记录单据还需要和基础资料、库存联动。创建采购订单时建议自动带出供应商上次采购价格或最近报价避免每次手工输入。带出后允许采购员修改但要记录“价格修改历史”方便后期对账时确认价格来源。库存联动方面采购订单审核通过后可以在库存表中记录“在途数量”。在途数量是指已经下单但还没入库的商品数量。如果系统不记录在途数量采购员很容易重复采购。这个概念在很多开源 ERP 中没有实现但业务价值很高。7.2 审批与权限控制采购订单涉及资金支出权限必须细分。建议采用“数据权限 操作权限”双维度设计。数据权限采购员只能看到自己创建的订单采购主管可以看到全部订单。操作权限只有被授权的用户才能审核订单、取消订单、修改单价。在 Web ERP 中按钮级权限可以通过后端接口鉴权实现PreAuthorize(hasPermission(purchase:order:audit)) PostMapping(/audit) public ResultVoid audit(RequestBody PurOrderAuditRequest request) { ... }如果不想使用 Spring Security也可以自己维护“角色-菜单-按钮权限”关系表在拦截器中判断当前用户是否拥有对应权限标识。7.3 数据安全与操作审计采购订单数据属于企业核心业务数据需要注意以下几点数据库备份采购订单表数据量大且重要需要定期备份。操作审计记录谁在什么时间创建、修改、审核了什么订单。审计日志不要轻易覆盖。防止 SQL 注入使用 MyBatis 的#{}参数占位符不要拼接 SQL。防止越权访问查询订单接口必须校验数据权限不能只传orderId就返回任意订单。删除订单数据要格外谨慎。生产环境建议使用“软删除 关闭”代替物理删除即增加deleted字段。这样即使操作错误也能恢复数据。7.4 Web ERP 性能与部署建议采购订单列表查询要建立联合索引比如(supplier_id, order_date)。明细查询使用order_id索引避免全表扫描。Web 端接口要控制返回字段数量列表页不要返回所有明细。大促或月底集中录入期间数据库连接池配置需要足够。前端打包后通过 Nginx 部署接口地址使用反向代理避免跨域问题。Web ERP 的会话保持建议使用 Redis保证多实例部署时登录状态不丢失。7.5 采购订单的导入导出实际业务中采购员往往需要从 Excel 导入商品明细。导入功能需要注意以下几点导入模板必须包含必填列如商品编码、数量、单价。导入前先做数据校验返回错误行号和错误原因。导入过程使用异步任务避免大批量数据导致接口超时。导出使用数据库流式查询防止内存溢出。下面是一个简单的导入校验示例思路public void checkImportRow(int rowNum, ListString rowData) { if (rowData.size() 3) { throw new BusinessException(第 rowNum 行列数不完整); } if (StringUtils.isBlank(rowData.get(0))) { throw new BusinessException(第 rowNum 行商品编码为空); } // 其他校验... }Web ERP 的导入功能不能只追求“能导进去”还要保证错误提示清晰。否则用户拿到一堆看不懂的报错实施效率会非常低。8. 总结与下一步方向本文围绕 ERP 进销存中的采购订单管理从业务概念、生命周期、数据库建模、后端接口实现到常见问题排查完整走了一遍流程。重点掌握的内容包括采购订单在整个进销存主链路中的位置以及它与采购入库单、采购退货单的边界。采购订单主表和明细表的设计要点尤其是商品快照和已收货数量的作用。状态机的设计思想避免业务状态被随意跳转。创建订单和审批流程的后端实现方式以及事务、金额精度等开发细节。Web ERP 场景下的权限控制、操作审计、性能优化和导入导出注意事项。如果是在真实企业项目中实施下一步建议优先完善三个方向采购订单与库存在途数量的联动、供应商对账功能、以及移动端审批。采购订单管理是进销存系统里性价比最高、最值得优先做扎实的模块。你先把这个模块跑通后续的入库、库存、付款功能都会顺畅很多。如果本文对你有帮助可以收藏备用。后面我会继续更新 ERP 进销存系列的其他模块比如采购入库、库存管理、销售订单和财务报表。有任何疑问欢迎在评论区交流。
