做校园采购系统这几年,最深的感触是:这种项目不像互联网产品那样追求流量和转化,它拼的是“流程的完整度”和“状态的严谨性”。一个采购申请从提交到变成订单,中间要经过审批、询价、确认、下单、收货、对账好几个环节,任意一环脱链,后续的财务和资产记录就全乱了。
这个标题里的“完整流程”四个字,恰恰是校园采购系统的灵魂。我做过的几套系统里,凡是能顺利落地并长期用起来的,无外乎都是把“采购申请—审批流转—订单执行—履约入库—财务闭环”这条链路理得清清楚楚。下面就把我在实际项目里的设计思路、数据库结构、核心代码路径和一些踩坑经验拿出来,跟你拆开聊聊。
1. 项目概述与整体设计思路
1.1 这个系统到底在解决什么问题
校园场景里的采购,和普通企业采购有个明显差异:它不是一个部门闷头买,而是跨部门、跨角色的协同作业。教师提出需求,部门负责人要审核,采购部门要汇总比价,供应商要接单发货,库管要验收,财务要认账付款。任何一个环节依靠纸质单子或者微信聊天记录来衔接,都会出现“单子找不到、状态说不清、责任推不掉”的局面。
我最初接触这类项目时,需求方提得最多的就是两句话:第一,我要能随时知道这张申请单走到哪一步了;第二,我要能查到每一步是谁处理的、什么时候处理的、处理意见是什么。所以系统的核心,并不是做一个多绚丽的功能界面,而是把每张单据的状态变更轨迹完整记录下来,让全流程可追溯。
从数据流的角度看,整个系统其实只有一条主线:采购申请单 → 审批通过 → 生成采购订单 → 供应商发货 → 收货入库 → 财务确认。围绕这条主线,再延伸出供应商管理、预算额度控制、库存或资产登记、历史报表等辅助模块。主线越清晰,系统越不容易做散。
1.2 核心流程全景:一次采购的完整生命周期
拿一次典型的设备采购来举例,整个生命周期大概是这样的。申请人填写采购申请单,里面包含采购事由、预算金额、明细清单(设备名称、型号、数量、预估单价);提交后单据进入待审批状态,部门负责人先审,采购部门再审,可能还会根据金额大小追加更高级别的审批;全部通过后,采购员在系统里发起询价,维护几家供应商的报价单;选定供应商之后,系统生成采购订单;订单推送给供应商,供应商线下发货后,在系统里登记发货信息。
库管收到货后做收货验收,核对品类和数量,生成收货单;财务根据收货单、订单、发票进行付款登记;最后单据归档,整个采购流程关闭。这个流程听起来长,但每一步的状态变化和数据流转都是相对独立的,只要数据库表结构设计合理,代码实现起来并不复杂。
我之前看过一些新手做的采购系统,最大的问题就是流程砍得太短。申请通过后直接生成订单,中间的询价比价环节完全缺席。这在真实校园环境里根本没法用,因为采购部门必须留下“我为什么选这家供应商”的依据。所以流程设计上,宁可多一个环节,也不要图省事让它缺失。
2. 技术选型与模块划分
2.1 技术栈选择与版本规划
采购系统属于典型的管理信息系统,用户量不大,但逻辑链路长、数据一致性要求高。对于这类系统,我一般建议采用经典稳定的技术组合,没必要追逐太新或太复杂的框架。后端用Java Spring Boot,前端用Vue 3 + Element Plus,数据库用MySQL,缓存用Redis,文件存储用本机目录或对象存储服务。
这个选择背后是有讲究的。Spring Boot在事务管理、权限框架(Spring Security或Sa-Token)方面非常成熟,处理采购单据这类强事务场景很省心;Vue 3 + Element Plus做管理后台的效率极高,表格、表单、树形控件基本都是现成的;MySQL存结构化单据数据再合适不过;Redis则是用来处理并发锁、状态防重和多级缓存。
如果是小团队快速交付,直接用单体架构就好,没必要一上来就拆微服务。采购系统的核心数据几乎都围绕几张主表运行,拆得太碎反而得处理跨服务事务,徒增复杂度。我做过一个单体版,单机部署就已经能扛每秒钟几十次的订单操作,对校园场景完全够用。
2.2 为什么要自研状态机而不是直接上工作流引擎
这是我在多个项目里反复权衡过的点。有人一听“审批流”,第一反应就上工作流引擎,比如Activiti、Flowable。但实际做下来我发现,校园采购系统的审批逻辑远没有那么复杂,大多数就是“逐级审批 + 会签 + 退回”,用一张状态表加有限的状态迁移规则就足够了。
自研状态机的优势很明显:第一,逻辑完全掌握在手里,排查问题一目了然;第二,部署简单,没有额外的流程引擎服务和模型表;第三,状态流转可控性极高,需求方说“这个状态不允许退回时”,改一个枚举判断就行。而流程引擎虽然灵活,但学习成本高、表结构复杂,对采购这种固定流程来说属于杀鸡用牛刀。
当然,自研的前提是流程相对固定。如果你们面对的是那种“每个学院都有完全不同审批链”的场景,那还是可以考虑规则配置化的方案。我的建议是,先梳理清楚业务线的状态数量,如果不超过十个,就坚定地自研状态机;如果超过十五个且并行分支很多,再考虑引入流程引擎。
2.3 角色权限边界设计
权限设计是整个系统里最容易出问题的地方,也是最容易被忽视的地方。采购系统涉及的几类角色,权限边界一定要分清楚。
申请人只能看到和管理自己发起的申请单;部门负责人能看到本部门所有人的申请;采购员能看到所有已审批通过的申请,但看不到具体报销和财务的敏感数据;库管员只能操作收货相关的功能;财务能看到订单金额、发票和付款状态,但一般不应该有改单据的权力;系统管理员负责维护用户、角色和基础数据。
我习惯用“数据权限”和“操作权限”两个维度来切分。操作权限控制“能不能点某个按钮”,数据权限控制“列表里能看到哪些行”。前者用角色标识判断,后者统一通过SQL拦截器追加部门或用户维度的条件。这样做的好处是,前端页面不需要写太多复杂的条件判断,后端在查询时就已经把数据范围限制住了。
3. 核心数据模型与数据库设计
3.1 主单据与明细的数据结构
采购系统最核心的表是“采购申请单主表”和“采购申请明细表”。主表记录单据编号、申请人、申请部门、申请时间、采购事由、预算金额、总金额、当前状态等;明细表记录每一项要买什么、数量多少、预估单价多少。
主表和明细表用一对多的关系关联,明细表的金额合计要等于主表的总金额。这里有个实战细节:金额计算不要在前端算完后直接存,后端必须重新遍历明细做累加校验,防止有人绕过页面直接调接口提交假数据。
单据编号的生成也要讲究。我一般用“采购单号 = 年 + 月 + 部门编码 + 四位流水号”,比如202506-CS-0042。月份结束或者单号超过9999时,流水号自动重置。不要直接用数据库主键ID当前端订单号,因为ID长度偏短且容易被遍历。
CREATE TABLE purchase_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_no VARCHAR(40) NOT NULL, request_title VARCHAR(200), apply_user_id BIGINT NOT NULL, apply_dept_id BIGINT NOT NULL, apply_time DATETIME NOT NULL, budget_amount DECIMAL(12,2), total_amount DECIMAL(12,2) DEFAULT 0, status VARCHAR(30) NOT NULL, urgent_flag TINYINT DEFAULT 0, remark VARCHAR(500), create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_request_no(request_no) ); CREATE TABLE purchase_request_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id BIGINT NOT NULL, item_name VARCHAR(200) NOT NULL, item_spec VARCHAR(200), quantity DECIMAL(10,2) NOT NULL, unit_price DECIMAL(12,2), amount DECIMAL(12,2), category_code VARCHAR(50) );明细表为什么要单独建?直观原因是同一张采购单里可能有多种设备或耗材,复用主表会导致大量冗余字段。深层原因是后续订单生成时,需要按明细逐项匹配供应商报价,拆表才能做到精细对账。
3.2 状态流转表的设计思路
采购申请单的状态字段是关键,我通常用一个status字符串字段存枚举值,同时在内存里用一个状态机类统一管理状态迁移规则。状态枚举大致包含如下序列:DRAFT(草稿)、SUBMITTED(待审批)、APPROVED(审批通过)、REJECTED(审批驳回)、PRICING(询价中)、ORDER_CREATED(订单已生成)、DELIVERING(送货中)、RECEIVED(已收货)、FINISHED(已完成)、CANCELED(已取消)。
状态机的管理方式绝对不能在业务代码里散落一堆 if-else,那样后续改状态流程会想哭。正确做法是将“当前状态 + 操作动作 → 目标状态”的映射集中定义在一个配置类或一张表里。
状态流转表的好处是,需求方问“驳回之后能不能修改再提交”,你可以直接回答能,因为在配置里REJECTED + SUBMIT → SUBMITTED是合法的。所有不允许的迁移直接抛出异常,操作人员立刻知道“当前状态不允许此操作”,不用去猜代码哪里漏了判断。
3.3 订单与财务对账的关联设计
订单表是整个采购执行环节的主表,它关联的其实是采购申请单的明细聚合结果。设计时要注意:一个采购申请单可以拆分成多个订单,比如不同类型物资分别下给不同供应商;也可以多个采购申请单合并生成一个订单,比如同一供应商同日送货。
我建议订单表保留request_ids或单独建关联表,同时保留冗余的request_no字段。冗余字段虽然违背第三范式,但在订单列表展示和财务对账时能少查一张关联表,性能收益非常实在。
CREATE TABLE purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL, supplier_id BIGINT NOT NULL, order_time DATETIME NOT NULL, status VARCHAR(30) NOT NULL, total_amount DECIMAL(12,2), pay_status VARCHAR(30), receive_status VARCHAR(30), invoice_no VARCHAR(60), remark VARCHAR(500) );财务对账环节不需要单独建复杂表,在订单表上加pay_status(未付/部分付款/已付款)和invoice_no字段就够用了。付款记录如果以后要留痕,可以单建一张付款流水表。我这里倾向于建流水表,因为一笔订单分两次付款很常见,一维字段存不下这种信息。
4. 实操过程:从申请到订单执行的代码实现
4.1 工程初始化与依赖准备
如果你是从零开始搭这个项目,我建议直接用 Spring Boot 官方的脚手架生成基础工程。依赖里加上spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、spring-boot-starter-data-redis,再加一个 Sa-Token 或 Spring Security 做登录鉴权。
工程结构我这里给一个比较顺手的划分:controller层只接收参数和做参数校验,service层写业务逻辑和事务控制,mapper层负责数据库操作,entity层对应数据库表,dto层处理前端交互对象,state包专门放状态机和状态枚举。
很多新手会把大量逻辑写进 controller,导致接口越来越臃肿。采购系统这种业务链条长的项目,一定要严格分层,不然写到最后自己都找不到某段逻辑在哪。我一般是 controller 超过五十行就该警惕了,超过八十行必须重构。
4.2 采购申请模块:提交与审批的实现细节
首先是创建申请单。这个接口的核心逻辑是:把主单数据和明细数据在一个事务里插入,校验明细不能为空,金额合计必须等于主表填写金额,同时把状态置为DRAFT。这里加事务注解的目的是防止主表插入成功但明细失败,导致出现缺失明细的残废单据。
@Transactional(rollbackFor = Exception.class) public Long createRequest(PurchaseRequestDTO dto, LoginUser user) { PurchaseRequest request = new PurchaseRequest(); request.setRequestNo(generateRequestNo()); request.setApplyUserId(user.getId()); request.setApplyDeptId(user.getDeptId()); request.setApplyTime(LocalDateTime.now()); request.setStatus(PurchaseStatus.DRAFT.name()); BigDecimal total = BigDecimal.ZERO; for (PurchaseRequestItemDTO item : dto.getItems()) { BigDecimal amount = item.getUnitPrice() .multiply(item.getQuantity()); item.setAmount(amount); total = total.add(amount); } if (total.compareTo(dto.getBudgetAmount()) > 0) { throw new BusinessException("明细金额合计不能超过预算金额"); } request.setTotalAmount(total); requestMapper.insert(request); // 批量插入明细,回填requestId return request.getId(); }提交操作就是把状态从DRAFT变成SUBMITTED,同时记录提交时间。这里建议校验操作人必须是申请人本人,别让同事能替别人乱提交。
审批操作是整个流程里最容易出并发问题的地方,一定要处理好“重复审批”和“状态已变化”这类情况。我的做法是用数据库行锁把申请单锁住,再检查当前状态是否仍为待审批状态。如果别人已经先审批了,后到的操作就会因为状态不对而直接报错,而不是覆盖前一个人的结果。
@Transactional(rollbackFor = Exception.class) public void approveRequest(Long requestId, LoginUser user, String action, String comment) { PurchaseRequest request = requestMapper .selectByIdForUpdate(requestId); if (request == null) { throw new BusinessException("申请单不存在"); } if (!PurchaseStatus.SUBMITTED.name() .equals(request.getStatus())) { throw new BusinessException("申请单状态已变化,无法审批"); } ApprovalRecord record = new ApprovalRecord(); record.setRequestId(requestId); record.setApproverId(user.getId()); record.setApprovalAction(action); record.setApprovalComment(comment); record.setApprovalTime(LocalDateTime.now()); approvalMapper.insert(record); if ("AGREE".equals(action)) { request.setStatus(PurchaseStatus.APPROVED.name()); } else { request.setStatus(PurchaseStatus.REJECTED.name()); } requestMapper.updateById(request); }这里要特别强调selectByIdForUpdate这个动作,它会给数据库行加排他锁,直到事务提交才释放。这样两个审批人同时操作一张单子时,后到的人会等锁,看到的是最新状态,自然不会覆盖数据。
4.3 询价与订单生成:从多供应商报价到最终下单
审批通过后,采购员进入询价环节。询价在数据库层面可以简单设计成“采购申请明细 + 供应商 + 报价 + 交期”的记录,每次报价维护一条数据。这个环节不涉及状态机的大变化,主要是数据的收集和比较。
真正关键的是订单生成逻辑。从业务上讲,订单是询价结果的落地产物,订单金额、明细数量、供应商,都必须和某个询价结果对应得上。
@Transactional(rollbackFor = Exception.class) public PurchaseOrder createOrder(OrderCreateDTO dto) { PurchaseRequest request = requestMapper .selectByIdForUpdate(dto.getRequestId()); if (!PurchaseStatus.PRICING.name() .equals(request.getStatus())) { throw new BusinessException("申请单不处于询价中状态"); } PurchaseOrder order = new PurchaseOrder(); order.setOrderNo(generateOrderNo()); order.setSupplierId(dto.getSupplierId()); order.setOrderTime(LocalDateTime.now()); order.setStatus(OrderStatus.PENDING_SHIPMENT.name()); order.setPayStatus(PayStatus.UNPAID.name()); // 根据选中的供应商报价重新生成订单明细和总金额 List<OrderItem> items = new ArrayList<>(); BigDecimal total = BigDecimal.ZERO; for (OrderItemDTO itemDTO : dto.getItems()) { OrderItem item = new OrderItem(); item.setItemName(itemDTO.getItemName()); item.setQuantity(itemDTO.getQuantity()); item.setUnitPrice(itemDTO.getUnitPrice()); BigDecimal amount = itemDTO.getUnitPrice() .multiply(itemDTO.getQuantity()); item.setAmount(amount); total = total.add(amount); items.add(item); } order.setTotalAmount(total); orderMapper.insert(order); orderItemMapper.batchInsert(items); // 订单生成后,申请单状态推进 request.setStatus(PurchaseStatus.ORDER_CREATED.name()); requestMapper.updateById(request); return order; }这段逻辑里我特意把“申请单状态校验”放在加锁之后,就是为了避免两个采购员同时对一张单子下单,生成两笔订单。此外,订单明细里没有直接引用申请单明细表的ID,而是复制一份品类和数量,业务上解释为“订单是对当时报价的固化”,即使以后申请明细被人为修改也不会影响已生成的订单。
5. 收货入库与采购闭环的实现
5.1 收货单生成与验收流程
订单变为已发货状态后,库管员就能看到待收货的订单列表。收货操作的核心是生成一张收货单,并通过累加已收货数量来判断订单属于“部分收货”还是“全部收货”。
我把收货逻辑做成两个动作:扫码或手工选择订单号,然后逐项填写实际到货数量。如果实际到货数量小于订单数量,订单状态维持部分收货;如果全部到齐,订单状态置为已收货。
@Transactional(rollbackFor = Exception.class) public void receiveOrder(Long orderId, List<ReceiveItemDTO> items) { PurchaseOrder order = orderMapper.selectByIdForUpdate(orderId); if (!OrderStatus.DELIVERING.name().equals(order.getStatus())) { throw new BusinessException("订单不在送货状态,无法收货"); } // 更新明细收货数量 for (ReceiveItemDTO dto : items) { orderItemMapper.receiveByOrderItemId( dto.getOrderItemId(), dto.getReceiveQuantity()); } boolean allReceived = orderItemMapper .countUnreceivedItems(orderId) == 0; order.setStatus(allReceived ? OrderStatus.RECEIVED.name() : OrderStatus.PARTIALLY_RECEIVED.name()); orderMapper.updateById(order); }这里有个在实际使用中的体会是:收货数量允许大于订单实际剩余数量吗?答案是绝对不允许。库管员在赶时间的时候容易多填数量,后端的校验必须把这种异常拦截下来,宁愿报错也不要把错误数据写进系统。
5.2 预算占用、回滚与财务对账实现
预算控制是采购系统里比较微妙的一部分。我的做法是在申请提交时做“预算占用”,也就是从部门预算扣减对应的金额;审批驳回或者取消整单时做“预算回滚”。这条逻辑如果漏写,预算数最终会失真。
@Transactional(rollbackFor = Exception.class) public void cancelRequest(Long requestId, LoginUser user) { PurchaseRequest request = requestMapper.selectByIdForUpdate(requestId); if (!canCancel(request.getStatus())) { throw new BusinessException("当前状态下不允许取消"); } // 回滚预算占用 budgetMapper.addAvailableAmount( request.getApplyDeptId(), request.getBudgetAmount()); request.setStatus(PurchaseStatus.CANCELED.name()); requestMapper.updateById(request); }财务在订单收货后发起对账,实际上是确认“订单金额、收货明细、发票金额”三者的吻合度。我一般不做自动校验,因为发票和订单金额往往存在误差。我的做法是把三方数据并列展示给财务,由财务人工判断后确认。如果一个系统能让财务不需要把订单打印出来翻来覆去找数据,已经算成功了一半。
采购闭环之后,数据就进入归档与查询环节。这时候前台的“单据详情页”就显得特别重要,它要把申请单信息、审批记录、询价记录、订单信息、收货记录、付款记录全部串成一个时间轴展示出来,让任何一个人打开都能看到这张单子的完整故事。这个页面做好了,系统的可信度就起来了。
6. 常见问题与排查经验速查表
6.1 高频报错场景与解决思路
我在多个采购系统项目里遇到过不少重复的问题,下面整理成一张速查表,覆盖了前端交互到后端事务的常见坑。
| 问题现象 | 典型原因 | 排查思路 |
|---|---|---|
| 用户快速点击两次提交,生成两张申请单 | 前端缺少防重复提交,后端也没有做幂等控制 | 前端按钮提交后立即禁用;后端在创建单据前通过request_no唯一索引兜底 |
| 同一张单据被两个人同时审批,后者覆盖前者 | 审批逻辑未对单据行加锁 | 改为selectByIdForUpdate加悲观锁,并在更新前检查状态 |
| 明细金额合计与主表金额不一致 | 前端传参被篡改或计算逻辑不一致 | 后端重新遍历明细计算合计,不一致直接拒绝提交 |
| 订单生成时重复下单 | 没有校验申请单状态或未加锁 | 生成订单前用行锁锁定申请单,状态必须为询价中才允许下单 |
| 收货数量超过订单数量 | 入库接口缺少上限校验 | 在接收明细时对比已收数量,超出剩余数量直接抛异常 |
| 预算金额变成负数 | 取消或驳回时未回滚预算占用 | 审核所有会结束单据生命周期的操作,做好预算回滚 |
| 列表分页查询很慢 | 明细查询用了N+1模式 | 改为一次性查询明细再按单据号分组;给status和apply_time加联合索引 |
每次排查问题,我都会先看日志里有没有抛出“状态已变化”或“金额校验不通过”这类业务异常,因为这类异常通常意味着业务上没有处理好,而不是代码语法问题。只要状态机和数据校验这两块稳住的,绝大多数下游异常都不会发生。
6.2 状态机跳转异常排查实录
有一次线上反馈说,某张采购单明明已经生成订单,结果采购员还能去编辑询价记录,保存后订单金额和申请单金额就不一致了。我排查后发现问题出在编辑询价接口上,它没有校验申请单当前是否处于“询价中”状态,导致任何状态下都能改。
这类问题非常典型,本质就是后台接口没有走统一状态机校验。后来我增加了一个切面,凡是涉及状态变更或状态相关查询的接口,统一做一个状态合法性预校验,把散落在各业务方法里的状态判断收敛到一个地方。这个改动之后,状态错乱的工单数量明显下降。
另一个真实场景是“驳回后无法重新提交”。原因是申请单被驳回后,系统把所有编辑能力都关闭了,但需求方希望驳回后能修改明细再重新提交。如果不手工修改状态机的规则,这个问题会一直存在。最后我加了一条规则:REJECTED + SUBMIT → SUBMITTED,同时清空之前的审批记录,重新走审批流。改完之后,需求方明显感觉到系统跟他们的管理习惯贴近了。
6.3 数据一致性优化与性能避坑
小体量系统遇到的最大性能瓶颈,往往不在数据库,而在查询逻辑。采购单列表页如果每行都去查一次供应商名称和部门名称,一百条数据就是两百多次额外查询,页面慢得没法看。解决办法是列表查询时一次性把关联表的字典数据加载进内存,用Map做关联,而不是逐行调用mapper查询。
事务方面,采购系统的单据操作几乎都要保证原子性。我的建议是宁可把事务粒度放大,也不要在事务里夹杂外部调用,比如短信通知、文件上传这种耗时操作,否则数据库链接会被长时间占用,并发稍高就出现连接池耗尽。
关于乐观锁和悲观锁的选择,采购系统这种低频高价值数据的场景,我反而推荐悲观锁。理由很简单:悲观锁虽然等待时间可能长一点,但能彻底杜绝覆盖更新,而且实现代码更直白,不容易出错。商品库存那种高频操作才真正需要乐观锁的轻量特性。
7. 写在最后:我的落地心得
每次做完一个采购系统,我都会回头复盘那些“让项目从勉强能跑到真正好用”的关键点。除了流程正确和状态清晰之外,我觉得最容易被人忽略的是两个事情:第一是操作留痕,每个关键动作的时间、操作人、结果都要落到记录表里;第二是异常恢复路径,比如供应商拒单了怎么办、财务打回订单了怎么办,这些边缘路径如果不在系统里预留出来,上线后就会被零碎的线下沟通淹没掉。
另一个心得是,做这类系统一定要保持克制。采购管理本质上是一套规范流程的信息化,不是花哨功能的竞速场。那些花里胡哨的可视化大屏和数据报表,在资源不足的小团队里反而是负担。先把单据流转做到无遗漏,把审批和订单执行做到无歧义,系统就算立住了。后面有条件再慢慢加上BI分析、资产折旧联动这些增值模块,那也是水到渠成的事。
如果你正准备做或者正在做类似的校园采购系统,希望这篇梳理能给你省下一些自己摸索的时间。流程界的经验说到底就一句话:状态机定生死,事务保底线。把这两件事想透了,系统的地基就稳了。