1. 项目是干什么的:进销存与物流,为什么值得自己搭
基于Spring Boot的ERP进销存管理系统,是我最近完整梳理过的一个源码项目。它把采购、销售、库存这些核心业务串成一条单据流,同时又拉了一条物流信息管理系统的支线,把仓库发货之后的运输轨迹、签收状态接回来。市面上讲单模块的教程很多,但能把进销存单据流转和物流信息串在一个系统里的完整案例并不算多,这篇就把这个项目的设计思路、核心代码结构和实操中容易踩的坑一并捋一遍。适合正在做毕设、准备企业级后台系统、或者想了解进销存底层逻辑的开发者参考。
1.1 这套系统解决了什么问题
很多小团队的业务痛点其实是断层的。采购部门开了一张采购单,仓库入库后没有人把采购单和入库单做关联回写,销售那边又开了一张出库单,账面上的库存和实物库存永远对不上。这个项目做的第一件事,是把所有业务行为统一成“单据”这个概念——采购单、采购入库单、销售单、销售出库单、调拨单、盘点单,全部走同一套单据流转框架。
单据的状态又统一成草稿、待审核、已审核、已完成、已作废这几类。任何业务操作必须基于上一张单据的终态才能继续往下走,比如销售单必须审核通过后才能生成出库单,出库单审核后才允许扣减库存。这样整个链条是闭环的,不会出现一个业务员随手改库存的情况。
物流模块则是把视角延伸到仓库之外。出库单审核通过后,系统自动生成物流单号,后续的运输节点、签收状态、异常退回都会回写到物流信息管理系统里。销售客服不用再打开Excel去问仓库“货发到哪了”,直接在系统里就能看到整个运输轨迹。对于学习和二次开发的人来说,这套系统把“单据流”和“物流信息流”两条线都铺开了,可以作为独立模块反复拆解。
1.2 适合谁去学习和改造
如果你正在做毕业设计,这个源码项目的价值在于模块完整且链路贯通,单拎出“单据流转”或者“物流信息管理”任何一块都能写出有深度的论文。如果你在企业里做企业内部系统,这套项目可以当作脚手架,把权限、组织、字典、日志这些通用能力直接复用。我自己早期做后台系统时,最大的问题就是业务表和状态字段混在一起,状态散落在各种if-else里,而这个项目用一张状态机表统一管理状态跳转,代码清晰很多。
再说一句实话:系统里有一半的功能并不是你日常写CRUD能接触到的。比如在审核采购单的同时要锁定采购占用库存,审核销售出库单时又要释放占用、扣减可用库存,这里面的并发边界、事务边界、幂等设计,才是这类系统真正值钱的部分。
2. 技术选型与总体架构:Spring Boot为核心,模块怎么切开
2.1 后端与数据库选型
项目主框架用的是Spring Boot,这个选择几乎没有争议。Spring Boot自带的自动配置和starter机制,让项目可以快速集成Web、持久层、缓存、权限框架。持久层用MyBatis Plus,日常的单表操作不用写XML,逻辑删除、分页插件、字段自动填充都是现成的,省掉了大量样板代码。数据库用MySQL,存储过程、触发器一概不用,所有业务规则放在Java层控制,这是后期维护时最省心的方式。
缓存层用了Redis,主要用在三块:验证码存储、登录Token、单据编号生成。其中单据编号生成是最实用的,后面的章节我会专门讲。前端这部分源码项目一般配套Vue和Element UI,如果你只关注后端,那也无所谓,因为接口都是标准的RESTful风格,用Postman或者Apifox直接测就行。
整个项目的模块划分是典型的按业务域拆包:
system:用户、角色、菜单、部门、字典stock:库存主表、库存流水、盘点、调拨purchase:采购订单、采购入库单sale:销售订单、销售出库单logistics:物流单、物流轨迹、签收记录common:通用工具、常量、异常处理、注解
按业务域而不是按技术层拆包的好处是,一个需求改动只涉及一个包,不会出现改一个功能要跨四五个模块的情况。这也符合DDD里面“聚合”的思想,不用把整套DDD教条搬过来,但包结构上保持清晰是很有必要的。
2.2 单据流转模块的库表设计
进销存系统里最核心的几张表,我建议你拿到源码后先看建表脚本。以采购为例,核心是两张主表加一张流水表:purchase_order存储采购单头信息,包括采购单号、供应商、采购日期、状态、总金额;purchase_order_item存储采购明细,包含商品ID、单价、数量、税率;收货完成后写入stock_in_record,记录入库的时间、操作人、对应采购单号。
为什么要把单头和明细分两张表,而不是把商品直接塞进主表一张大宽表?因为一张采购单可能包含几十个商品,每个商品的归属仓库、批次、价格可能都不同。如果都放在一张表里,字段会有大量冗余,后续做分页查询或者修改某一行明细时也会非常痛苦。用经典的单头-明细结构,查询采购单列表时只查主表,进入详情时再查明细,性能完全可控。
销售和出库方向是镜像设计:sale_order、sale_order_item、stock_out_record。库存流水表stock_record是重中之重,它记录了每一次库存变动的来源单据、变动类型、变动前数量、变动后数量、操作人。这张表如果设计得好,所有对账、审计、异常排查都有据可依;反之,如果库存数据错了,就只能靠拍脑袋改数,整个系统的可信度就崩了。
2.3 物流信息模块的数据模型
物流模块相对独立,核心是三张表。
logistics_order是物流单主表,字段包括物流单号、关联的单据类型(销售出库单/调拨单)、关联单号、发货仓库、收货人、收货地址、物流公司编码、当前状态、预计送达时间。注意一个设计细节:物流单关联的是“单据类型+单据编号”,而不是直接存订单ID。原因在于销售出库单和调拨单都可能产生物流需求,如果直接填外键ID,这张表就要同时兼容两个业务表,耦合度很高。
logistics_track是轨迹表,记录物流节点,包括节点编码、节点名称、节点描述、操作时间、操作人、所在地。每次物流更新就是往这张表插一条记录,物流单主表同时更新最新节点。之所以轨迹和主表分开,是因为一个物流单可能有几十条轨迹记录,放主表会无限膨胀,分开后主表数据量保持很小,查询列表性能稳定。
logistics_sign是签收记录表,存放签收人、签收时间、签收方式(本人/代收/驿站)以及签收照片地址。这里要特别说明:签收是物流的终态,签收完成后物流单状态变为已完成,同时回写出库单的“发货完结”标记,这样销售模块才能确认收入可以入账了。
3. 单据流转机制:进销存的“动脉”是怎么设计的
3.1 单据类型与状态机设计
我见过很多半路出家的进销存项目,单据状态全是零散的枚举加if判断,今天加一个状态,明天删一个状态,代码越改越烂。这个项目的做法是把状态流转集中管理起来,每个单据类型对应一个状态机定义,状态跳转前统一校验,不满足流转条件的操作直接拒绝。
以销售单为例,状态机的关键路径是:草稿 -> 待审核 -> 已审核 -> 已完成,以及草稿/待审核/已审核 -> 已作废。草稿允许编辑和删除,待审核只允许审核或驳回,已审核状态下不允许修改金额和数量,只能生成后续的出库单和物流单。这些规则用一张flow_rule表维护,运行时通过通用状态机组件校验。
状态机组件核心就三个方法:canTransition(currentState, targetState, action)、doTransition(stateMachineId, currentState, targetState, operatorId)、getAllowedActions(currentState)。写业务接口时,controller里不再出现大段的“if 状态等于什么再判断能干什么”,而是统一调用状态机服务,代码量直接少一半。
我自己第一次把这个机制应用到项目里时,最大的感受是“业务边界终于清晰了”。以前销售单审核漏掉反审核流程,库存扣了后想退回特别费劲;现在状态机规定,已审核状态的销售单如果想作废,必须先走“发货拦截”动作,检查对应出库单和物流单是否已经生成,如果已经发货则拦截作废操作。这种业务规则放在状态机里,比放在service里散落各处稳妥太多。
3.2 单据编号生成:高并发下的唯一性
进销存系统的单据编号有硬性要求:唯一、可读、时间可查。常见的格式是单据类型前缀+日期+流水号,例如PO202506120001表示2025年6月12日的第一张采购单。简单实现可以用数据库自增主键,但主键ID不直观,日期信息也不在编号里,对账时还要回到表里查创建时间。
这个项目用的是Redis + Lua脚本生成编号:通过INCR对bill:po:20250612这个key自增,然后拼上日期返回。为什么用Redis不用数据库序列?因为数据库的SELECT ... FOR UPDATE在高并发下会成为瓶颈,一张采购单录入的同时,另一个仓管员也在录,两个线程同时锁同一行记录,性能一下子掉下来。Redis的单线程模型天然适合这种自增场景,配合Lua脚本保证原子性,不丢号不重号。
有一个坑必须提醒:Redis是内存数据库,一旦宕机且没有开启持久化,自增计数可能会回退。虽然Redis默认配置有RDB和AOF两种持久化机制,但这个场景下哪怕回退一个号,也只影响当日编号的连续性,不影响唯一性,因为编号规则里包含了日期。而且实际业务中单据编号很少作为订单的去重键,真正的键还是数据库主键ID,所以这个风险是可控的。
3.3 库存变动与事务一致性
进销存系统里最容易被写坏的就是库存。这个项目的库存设计分了三个概念:账面库存、可用库存、占用库存。账面库存是当前实物账面数,可用库存是能拿去卖的,占用库存是被订单预占但不能再次销售的。三个字段都放在inventory表中,每次变动都会在stock_record流水表中留下记录。
库存变动的核心要求是原子性,一组操作要么全部成功要么全部不成功。比如销售出库审核时,需要同时完成三件事:扣减可用库存、增加已出库数量、写入库存流水。这三件事必须在同一个Spring事务中完成,事务边界就写在审核service方法上,用@Transactional(rollbackFor = Exception.class)回滚所有运行时异常。
这里最常见的反模式是“先更新库存,再保存流水”,如果流水保存失败,事务回滚后库存也会跟着回滚,看起来没问题,但如果你在同一个方法内不加事务,库存更新成功而流水失败,账面和流水就对不上了。源码里把这两步放在service方法内,利用Spring的声明式事务统一管理,比手动控制Connection要可靠得多。
另外要注意MySQL的默认隔离级别是REPEATABLE READ,在高并发场景下,两个事务同时读到库存为10,然后各自扣1,最后更新回去会产生丢失更新。解决办法是采用乐观锁:库存表加version字段,更新时UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?,如果更新行数为0,说明版本冲突,可以重试或直接抛异常提示“库存不足或数据已变更”。
4. 物流信息管理:从发货到签收,轨迹怎么串起来
4.1 物流单与出库单如何关联
物流模块在设计上不直接依赖销售模块的表,而是依赖一张“关联单据”的抽象字段。出库单审核通过后,物流服务监听出库审核成功事件,根据出库单上的收货地址、联系人信息自动创建物流单。这里用的是Spring的事件机制:ApplicationEventPublisher.publishEvent(new StockOutAuditedEvent(orderId)),物流模块监听这个事件,完成物流单的创建。
为什么用事件而不是直接调用物流service?因为如果物流创建失败,不应该让出库审核也失败。出库操作的核心是库存减少,而不是立刻生成一条物流单;物流单可以稍后手动补录,也可以用定时任务补偿。解耦之后,出库模块和物流模块各自演进,哪怕将来替换第三方物流公司,也只改动物流模块,销售订单和出库单完全不受影响。
物流单创建时需要生成物流单号,这个单号有两种来源:自建物流编号,或者对接快递公司时由快递公司返回运单号。源码项目内置了“模拟物流公司”的实现,在测试环境会随机生成一个格式为EXP开头的单号,方便你跑通流程。如果要对接真实快递,只需要实现一个ShipProvider接口,把生成单号、获取轨迹、回调签收三个方法替换成真实API调用即可。
4.2 轨迹更新方案:主动上报与被动查询
物流轨迹的更新方式,直接决定了信息系统的实时性和对接成本。这个项目里提供了两条路径。
第一条是主动上报。自己派送的情况下,司机或仓管员在系统里操作“节点登记”,每次登记就往logistics_track插一条记录。这种方式的优点是数据完全自己控制,查询实时性好;缺点是操作成本高,需要人工配合,适合企业自建物流团队使用。
第二条是被动查询。对接第三方物流公司时,通常由快递公司推送轨迹回调,或者由系统定时调用轨迹查询接口。回调方式更实时,但需要在公网暴露一个接收接口,并做好验签和幂等处理;定时轮询方式实现简单,但延迟取决于轮询频率。源码里默认实现是定时任务每5分钟扫描一次状态未完结的物流单,逐个调用getTrackByWaybillNo方法,把返回的轨迹列表增量写入轨迹表,并更新主表的最新状态。
增量写入这里有个细节:轨迹接口返回的往往是全量轨迹,如果每次都把这几十条记录直接插入,就会产生大量重复数据。解决方案是先查库里该物流单已有轨迹的最大时间或节点序号,再过滤掉已存在的节点,只插入新增部分。很多入门开发者容易忽略这一点,我刚开始做物流对接时也踩过这个坑,表里三个月就积累了几十万条重复轨迹,查询越来越慢。
4.3 异常物流与回滚处理
物流系统里真正复杂的不是正常流转,而是异常场景。这个源码项目至少处理了四种异常:物流拒收、运输破损、长时间未更新、地址错误无法派送。
拒收和破损的场景,物流跟踪节点表里会写入“拒收”或“破损退回”的记录,同时物流单状态变为“异常”。异常状态下系统会在后台生成一个“退货/重发”的待办事项。如果是销售出库单对应的物流,销售模块会收到一个退回指令,生成退货入库单,同时恢复占用库存。这里的逻辑要小心,不能直接把库存加回可用库存,因为货物还没真正回到仓库,需要先做“在途退回”处理,等退货入库单审核完成后库存才实际增加。
对于长时间未更新的情况,系统提供预警任务:物流单超过48小时没有新轨迹,则在预警表中插入一条记录,并通知对应的销售运营和物流管理员。这个功能看起来简单,但做出来之后业务方满意度非常高,因为以前是客服被客户追问,现在是系统主动提醒业务方,角色彻底反转。
我做这块时的体会是,异常物流虽然只占业务量的百分之几,但占用的人工精力却超过一半。一个能自动分流、自动预警的物流信息系统,比单纯做一个轨迹展示页面有价值得多。这也是这套源码项目值得重点研究的一层,代码里对异常状态的定义和流转规则藏了不少细节。
5. 实操记录与踩坑实录
5.1 项目初始化与权限设计要点
从源码仓库拉下来之后,第一步不是急着启动,而是先建库。项目默认的初始化脚本包含erp_init.sql,里面除了建表语句,还内置了一套完整的基础数据:管理员账号、系统菜单、角色权限、基础字典。我建议把初始化脚本完整执行一遍,不要只建表不插数据,否则登录后会发现菜单一片空白,以为是程序出错了。
启动前需要确认三个配置文件:application.yml里的数据源地址、Redis地址、文件上传路径。项目里默认的数据库名、用户名、密码都要改成自己的。如果有人本机没有装Redis,启动时会直接抛连接异常,这个项目把Redis作为强依赖,不像某些项目可以降级运行。
权限设计是这套系统的加分项。它的权限模型分为用户-角色-菜单-按钮四个维度,后台角色可以勾选到按钮级的权限,比如“采购单:新增”“采购单:审核”“采购单:导出”各自独立控制。这个设计对进销存很重要,因为仓管员和采购员可能都打开采购单页面,但仓管员只能查看和入库,采购员才能修改和审核。不要小看按钮权限,很多“演示系统”只做到菜单级,实际操作时会发现所有人的按钮都是一样的,那系统的边界就形同虚设。
5.2 并发减库存的三种写法
进销存系统最经典的问题就是超卖。我在项目中试过三种写法,直接把结果列出来。
第一种,先查询库存再扣减。这种是最容易出问题的写法,两个请求同时读到库存为10,各自判断足够,然后分别扣1,最终扣完库存变8,实际却卖了两单。这种写法只能用于单线程示例,生产环境坚决不能用。
第二种,UPDATE inventory SET stock = stock - 1 WHERE id = ? AND stock >= 1。这种写法在数据库层直接判断剩余库存是否足够,MySQL的行锁保证同一行的更新串行化,库存不足时更新行数为0,程序通过这个返回值判断是库存不足还是更新成功。实现简单,性能也可接受,是这个项目里实际采用的方案。需要注意的是,扣减数量是变化值,不是固定1,所以不是用乐观锁,而是用条件更新。
第三种,Redis分布式锁加事务双重控制。锁能保证同一时刻只有一个线程进入扣减代码块,事务保证扣减和流水写入的一致性。这种方式在高并发秒杀场景下更可靠,但引入锁会带来死锁、锁超时、锁续期等一堆问题。对于大多数企业内部的进销存系统,并发量每秒几十次封顶了,第二种方案已经足够。我的建议是:不要为了炫技上复杂方案,先把条件更新写好,事后再用压测验证瓶颈。
5.3 高频问题排查
我把这套系统在部署运行时最容易遇到的问题整理成了一张表,都是我实际碰到过,或者从交流群里看到别人反复问的。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 启动时报数据源连接失败 | 数据库地址或密码配置不对,或MySQL未启动 | 检查application.yml,确认数据库版本兼容驱动 |
| 登录后验证码不显示 | Redis未启动,或验证码缓存前缀读取错误 | 启动Redis,查看日志确认captcha相关key是否正常写入 |
| 商品列表分页数据重复 | MyBatis Plus分页插件没有配置拦截器 | 检查是否添加了PaginationInnerInterceptor,且版本与MyBatis Plus匹配 |
| 单据审核报“状态不允许” | 状态机流转规则与当前单据状态不一致 | 查flow_rule表,确认该单据类型的状态定义是否被改动过 |
| 出库单审核通过后库存没减 | 事务未生效,或更新条件不满足 | 检查service方法是否被同一类内部调用,导致@Transactional失效;检查库存行是否存在 |
| 物流轨迹大量重复 | 定时任务没有做增量判断 | 在日志模块检查更新逻辑,对已有节点做时间段过滤 |
| 文件上传后访问404 | 上传路径与静态资源映射不匹配 | 检查application.yml中的file.upload-path和虚拟路径配置 |
| 报表导出Excel中文乱码 | Excel导出未设置UTF-8或字体不支持中文 | 检查导出工具类的编码设置,改用EasyExcel或POI时指定字体 |
排查这类系统问题我有个习惯:先看日志,再查数据,最后才翻代码。很多时候报错的根因不是代码逻辑,而是初始化数据被改乱了。比如说,人为把某张单据的状态从已完成改回已审核,后面的流程全部乱套,状态机里的校验会把所有后续操作都拦下来。遇到这种情况,千万别急着删数据,先查stock_record的变动历史,把状态和数据一起恢复到一致点位。
5.4 开发调试时的高效技巧
启动配置这块,我一般会把spring.jpa.show-sql或MyBatis的SQL日志级别调到DEBUG,用控制台输出每一条执行的SQL。调单据流转的时候,日志里能看到整个调用链上的SQL顺序,比单步调试快得多。这里有一个容易忽略的点:MyBatis Plus的ServiceImpl.saveOrUpdate方法默认会先查一次再决定insert或update,SQL日志里会多出一条查询,这是正常现象,不用慌张。
调试物流回调接口时,如果本地没有公网地址,可以用内网穿透工具临时把接口暴露出去,模拟第三方快递公司的回调推送。或者直接在测试类里构造一个LogisticsTrackDTO,调用trackService.registerTrack()方法写入轨迹,这样不依赖外部环境,也能跑通全流程。
权限调试同样有技巧。不要每次用admin账号测试所有功能,因为admin超级管理员的角色是跳过校验的,很多权限漏洞在admin下测不出来。正确做法是新建一个测试角色,只授予部分菜单和按钮权限,然后用对应的测试账号操作页面,这样才能真正验证权限是否生效。
6. 个人体会与扩展建议
6.1 这套源码项目最值得读的几个点
如果时间有限,我建议按照“单据编号生成 -> 库存流水 -> 状态机组件 -> 物流事件解耦”这条线去读代码。这四块分别对应了高并发唯一性、数据审计、业务规则收敛、模块解耦四个核心命题,任何一块都够你写进简历或者论文。
单据编号的Redis Lua脚本虽然只有几十行,但那种“原子操作+过期时间”的构造方式可以复用到很多场景,比如生成业务流水号、生成优惠券码、生成短链接。库存流水的写入逻辑建议仔细看看两个方向:正向的入库/出库记录,以及反向的红冲/回退记录,理解这两个方向,就对进销存系统真正的难点有了直观认识。状态机组件的代码独立性很强,可以完整抽出来放到自己的项目中使用,替换掉一坨坨的if-else判断。
物流那边的代码结构我个人觉得设计得比较清楚,listener包专门处理事件监听,provider包专门对接外部物流,service包是核心业务逻辑,三层各管各的事。如果你以后要对接真实的快递公司,按这个结构扩展不会把代码改乱。
6.2 下一步可以怎么扩展
项目本身是单体应用,后续扩展的方向很多。最实用的是加一个多租户支持,在核心业务表中增加tenant_id字段,让不同公司可以在同一套系统里数据隔离。然后是报表模块,把采购汇总、销售趋势、库存周转率、物流准时率这些指标做成看板,企业管理者最关心的就是这类数据。
再往后可以做流程引擎化,把审核流程从硬编码的节点改成可配置的审批流,比如金额超过一定界限走多级审批。这个扩展属于加工作量的大头,但架构上要提前预留接口。还有消息通知,把审核待办、物流预警、异常回退都接上邮件和站内信,体验会好很多。
6.3 最后说几句心里话
从我手上过过的进销存项目来看,代码本身反而是最简单的一层,真正复杂的是业务边界、状态流转和数据一致性。这个基于Spring Boot的ERP进销存管理系统源码项目,最难得的一点是把这些“复杂”用代码明确地表达出来了,没有躲在文档里,也没有藏在Excel里。建议你拿到源码后,先不管前端页面,直接用接口把整条采购入库再销售出库的链路调通,再逐步把物流、盘点、报表串起来。等到你能说清楚“某一张单据在某个状态下,什么操作是合法的、会引发哪些连锁反应”的时候,这套系统你就真正吃透了。