看到这个标题的时候我第一反应是愣了一下——Spring Boot是Java生态里的框架,Django是Python生态里的框架,这俩怎么还"基于"上了?但干这行时间长了就明白了,这种命名在毕设项目和中小企业内部系统里其实非常常见。它通常不是指一个框架内部基于另一个框架,而是指这套系统本身是一个混合架构:核心业务用Spring Boot承接,Django作为辅助服务处理某些特定模块。而标题里的"供应商"则透露了该系统最核心的业务方向——进销存管理里的供应商管理与采购链路。
这篇博文我想从这套系统的技术选型逻辑讲起,把仓库进销存系统的功能拆解、数据模型设计、采购入库与销售出库的完整链路、并发扣库存的关键难点全部过一遍,最后给出可以直接落地的部署步骤和排坑经验。无论你是正在做类似课题的学生,还是公司里需要快速搭一套库存系统的开发者,这篇都能给你省下不少摸索时间。
1. 这个"Spring Boot基于Django"的混合架构,到底在表达什么
1.1 为什么会出现这种看起来矛盾的组合
先把这个概念说清楚。Spring Boot和Django不存在谁基于谁的底层依赖关系,这一点懂技术的朋友都知道。但在实际项目里,尤其是课题设计和中小型公司内部系统里,这种标题表达其实是说:这套仓库进销存管理系统的主后端是Spring Boot,而整个项目里面还有一个用Django开发的辅助服务。
为什么有人会这么设计?我见过三种比较典型的落地方式。
第一种,Django负责数据采集与外部对接。进销存系统不是孤立的,它需要对接供应商的报价信息、物流单号、甚至电商平台的数据。Python生态在爬虫、开放接口对接、Excel批量处理上确实方便,用Django起一个数据同步服务,定时抓取或接收外部数据,再通过HTTP接口或消息队列喂给Spring Boot主系统,是一个很顺的思路。
第二种,Django负责后台管理与报表服务。Spring Boot做核心进销存业务接口,Django带着自带的Admin后台和ORM做数据维护、报表导出、权限配置。Django Admin对于内部运营人员来说相当友好,几乎零开发成本就能生成一套可用的管理界面。
第三种,也是毕设项目里最常见的——Spring Boot是主程序,Django是某个功能模块的演示实现。有的课题会要求同时体现两种技术,于是"Spring Boot为主、Django为辅"就成了一个兼顾评分和演示的方案。
1.2 混合架构的三种常见落地方式
不管哪种方式,架构上都绕不开模块边界和通信方式这两个问题。我在实际落地时习惯这样划分:
| 模块 | 承担框架 | 职责说明 |
|---|---|---|
| 核心进销存API | Spring Boot | 供应商管理、商品信息、采购单、销售单、库存增减、权限认证 |
| 数据同步服务 | Django | 供应商价格同步、物流状态回写、批量Excel导入 |
| 报表服务 | Django | 库存周转报表、采购汇总、销售趋势图 |
| 管理后台 | Django Admin | 内部运营人员维护基础数据、手动调整异常单据 |
通信方式上,最简单的就是用HTTP接口互相调用。Spring Boot把需要同步的数据封装成接口,Django侧定时拉取;反过来Django处理完的数据也通过POST回传。数据量不大时完全够用,不用一上来就引入消息队列,那反而是过度设计。
1.3 我给这套系统的技术栈建议
如果你是从零起一个同类系统,我的建议是:Spring Boot主工程用Maven管理,Java版本选8或11;MyBatis-Plus做ORM,数据库用MySQL 8.0;Django侧用3.2以上版本,Python 3.8以上。Django部分不需要做太重的功能,提供一个数据上报接口和Admin后台就足够了。
这里想多说一句:不要因为标题是"基于Django"就把业务逻辑往Django里塞。进销存最核心的库存扣减、单据流水、事务一致性,一定放在Spring Boot侧处理,因为Java生态对事务和并发控制的方案最成熟,而这些恰恰是库存系统最容易出问题的地方。Django作为辅助角色,管好"数据输入"和"数据展示"就够了。
2. 进销存的核心业务模型:先想清楚再写代码
2.1 五个核心模块的职责边界
仓库进销存系统听起来复杂,但剥开看就是五个模块:供应商、商品、采购入库、销售出库、库存。每个模块的职责边界必须一开始就划清楚,否则后面写起代码来就是一团乱麻。
供应商模块管的是"货从哪来",包括供应商名称、联系人、电话、地址、结算方式、信用额度、合作状态。商品模块管的是"货是什么",包括商品编码、名称、规格、单位、分类、条码、成本价、零售价。采购入库管的是"货怎么进来",采购单从供应商发起,到货后验收入库。销售出库管的是"货怎么出去",销售订单确认后扣减库存并发货。库存模块管的是"现在还有多少",它不是一个独立录入的模块,而是每一次采购和销售自动计算出来的结果。
这个职责边界很重要。很多新手会把"库存"做成一张可以手工改的表,这是非常危险的。库存必须是单据驱动的结果,任何库存变动都要能溯源到一张采购单或销售单上。
2.2 库存流水表是整个系统的"账本"
设计进销存系统时,最核心的表不是库存表,而是库存流水表。库存表只是当前快照,流水表才是完整账本。这个账本要记录每一件商品每一次的库存变动,变动类型包含采购入库、销售出库、退货入库、盘盈盘亏、成本调整。
为什么要强调流水?因为库存表只能回答"现在有多少",回答不了"为什么变成这么多"。实际运营中,供应商对账、内部审计、追溯商品去向,都离不开流水。库存对不上时,流水就是排查的原始依据。
库存流水表至少需要这些字段:流水号、商品ID、变动类型、变动前数量、变动数量、变动后数量、关联单据号、操作人、操作时间。只要库存发生变化,就必须同时写入一条流水,这一步不可省略,而且要放在同一个事务里。
2.3 和供应商有关的字段设计细节
供应商表设计时,有几个字段是实际业务中经常用到但新手容易漏掉的。
一个是"默认供应商",同一件商品可能有多个供应商供货,需要标记哪个是默认采购渠道。另一个是"结算方式",常见的有月结、现结、账期结算,这个字段直接影响采购单能不能做,因为信用额度不足时采购流程应该被拦截。还有一个是"分类"或"采购周期",这个服务于采购计划模块,虽然不一定在初期版本就做,但表结构里预留字段可以省掉后续加字段的麻烦。
另外要注意,供应商和商品之间是多对多关系,很多系统会直接在供应商表里加一个"供货商品ID"的字符串字段,这种做法非常不可取。正确的方式是建一张中间表,结构是供应商ID加商品ID加供货价,必要的话再加上起订量和交货周期。
3. 供应商管理与采购入库的实现细节
3.1 供应商资料管理:不只是增删改查
供应商管理模块表面上就是增删改查,但真正落地时有两个点值得注意。
第一个点是供应商状态。"启用/停用"状态必须单独出来,而不是用删除来替代失效。因为历史采购单可能还关联着这个供应商,直接删除会导致单据上查不到供应商信息。成都话说"旧账不能烂",做系统也是这样,单据只能标记失效,不能物理删除。
第二个点是供应商与商品的供货价联动。进货价是进销存成本计算的核心,而同一商品在不同供应商那里的价格可能不同。采购入库时,系统应该自动带出该供应商对该商品的最近一次供货价,而不是让仓管员每次手工输入。手工输入的每次都可能打错,打错了库存成本就全错了。
3.2 采购单入库的完整流程
采购入库的完整流程大致是:创建采购单、供应商确认、到货登记、质检、入库过账。每一步在这个系统里对应一个单据状态:草稿、已确认、已到货、已过账。
这里关键的设计决策是"入库过账"和"采购单单据"的分离。一个采购单可以分批到货,比如采购100件,第一批到60件,第二批到40件,系统必须支持多次入库登记,直到累计入库数量等于采购数量,采购单才能自动变为已完成状态。
实现上,采购单主表存总数量,入库记录表存每次实际入库的明细。每次入库记录提交后,系统循环更新商品库存表、写入库存流水、更新采购单的累计入库数量。这三个动作必须在同一个事务里完成。
3.3 库存变动记录的代码实现
我用Spring Boot加MyBatis-Plus简单演示一下核心逻辑。这个入库操作我习惯命名为stockInService.doInbound:
@Service public class StockInboundService { @Transactional(rollbackFor = Exception.class) public void doInbound(PurchaseInboundDTO dto) { // 1. 查询采购单,校验状态 PurchaseOrder order = purchaseOrderMapper.selectById(dto.getOrderId()); if (order == null || !OrderStatusEnum.CONFIRMED.getCode().equals(order.getStatus())) { throw new BusinessException("采购单不存在或状态不允许入库"); } // 2. 校验入库数量不能超过剩余未入库数量 Integer inboundTotal = inboundRecordMapper .selectSumByOrderId(dto.getOrderId()); int remainQty = order.getTotalQty() - (inboundTotal == null ? 0 : inboundTotal); if (dto.getInboundQty() > remainQty) { throw new BusinessException("入库数量超过剩余可入库数量"); } // 3. 保存入库记录 InboundRecord record = new InboundRecord(); record.setOrderId(order.getId()); record.setSkuId(dto.getSkuId()); record.setSupplierId(order.getSupplierId()); record.setInboundQty(dto.getInboundQty()); record.setInboundTime(new Date()); inboundRecordMapper.insert(record); // 4. 更新库存 InventoryStock stock = inventoryStockMapper.selectBySkuIdForUpdate(dto.getSkuId()); if (stock == null) { stock = new InventoryStock(); stock.setSkuId(dto.getSkuId()); stock.setStockQty(dto.getInboundQty()); inventoryStockMapper.insert(stock); } else { stock.setStockQty(stock.getStockQty() + dto.getInboundQty()); inventoryStockMapper.updateById(stock); } // 5. 写入库存流水 StockFlow flow = new StockFlow(); flow.setSkuId(dto.getSkuId()); flow.setFlowType(StockFlowTypeEnum.INBOUND.getCode()); flow.setChangeQty(dto.getInboundQty()); flow.setBeforeQty(stock.getStockQty() - dto.getInboundQty()); flow.setAfterQty(stock.getStockQty()); flow.setRelatedOrderNo(order.getOrderNo()); stockFlowMapper.insert(flow); // 6. 更新采购单累计入库数量 purchaseOrderMapper.increaseInboundQty(order.getId(), dto.getInboundQty()); } }注意第四步这里我用了selectBySkuIdForUpdate,也就是对库存行加行级锁。这一步是整个入库操作不出并发问题的关键,不加这个锁,同一件商品两个同时入库的请求就可能把库存数量写丢。
4. 销售出库与库存扣减的联动逻辑
4.1 销售单状态机设计
销售出库比采购入库要更谨慎,因为销售直接面对客户,错了是要被投诉的。销售单的状态我建议设计成:待付款、已付款待出库、已出库、已取消、已完成。
每个状态之间的流转必须明确,不能随意跳。比如"待付款"除了可以进入"已付款待出库",还可以进入"已取消";但"已付款待出库"就不能直接取消,得先走退款流程。这个状态约束最好在代码里用状态机的模式做,而不是靠业务人员自觉。
4.2 出库与扣库存的事务边界
销售出库时,扣减库存和生成出库记录、写入库存流水是同一个事务里不可分割的三个操作。我的写法通常是:
@Transactional(rollbackFor = Exception.class) public void doOutbound(SaleOutboundDTO dto) { SaleOrder saleOrder = saleOrderMapper.selectById(dto.getOrderId()); if (saleOrder == null || !SaleStatusEnum.PAID.getCode().equals(saleOrder.getStatus())) { throw new BusinessException("销售单不存在或当前状态不允许出库"); } InventoryStock stock = inventoryStockMapper.selectBySkuIdForUpdate(dto.getSkuId()); if (stock == null || stock.getStockQty() < dto.getOutboundQty()) { throw new BusinessException("库存不足,无法出库"); } OutboundRecord record = new OutboundRecord(); record.setOrderId(saleOrder.getId()); record.setSkuId(dto.getSkuId()); record.setOutboundQty(dto.getOutboundQty()); outboundRecordMapper.insert(record); int rows = inventoryStockMapper.deductQty( dto.getSkuId(), dto.getOutboundQty(), stock.getStockQty()); if (rows == 0) { throw new BusinessException("库存扣减失败"); } saleOrderMapper.updateStatus(saleOrder.getId(), SaleStatusEnum.OUT_DONE.getCode()); StockFlow flow = new StockFlow(); flow.setSkuId(dto.getSkuId()); flow.setFlowType(StockFlowTypeEnum.OUTBOUND.getCode()); flow.setChangeQty(-dto.getOutboundQty()); flow.setRelatedOrderNo(saleOrder.getOrderNo()); stockFlowMapper.insert(flow); }扣减库存的UPDATE语句很有讲究,我用的是带条件的原子操作:
UPDATE inventory_stock SET stock_qty = stock_qty - #{outQty} WHERE sku_id = #{skuId} AND stock_qty >= #{outQty}这个SQL返回影响行数,只有库存足够时才会更新成功。配上前面的行锁,双保险,基本可以避免超卖。
4.3 负库存拦截与超卖处理
库存系统最怕的就是库存变成负数。一旦允许负库存,后面所有对账、成本计算、毛利统计都会出问题。
拦截负库存有两种做法。一种是在代码里先查库存判断够不够,不够就抛异常,但这一步必须放在事务内并且配合锁使用,否则并发请求下判断结果可能已经过期。另一种就是上面SQL里直接写的,把判断条件放进UPDATE语句,让数据库来保证不超扣。两种方法同时用,是我经历过多次库存事故后沉淀下来的做法。
真出现了数据异常导致负库存,不要直接在库存表上手工改数字,应该通过"盘亏单"来处理。盘亏单会记录真实库存与账面库存的差异,同时写入库存流水,整个过程可追溯。这也是为什么前面反复强调流水表就是账本,账本乱了,一切盘查手段都会失效。
5. 并发扣减库存的两个可行技术方案
5.1 乐观锁控制
乐观锁的做法是在库存表上增加一个版本号字段,更新时带上版本号条件:
UPDATE inventory_stock SET stock_qty = stock_qty - #{outQty}, version = version + 1 WHERE sku_id = #{skuId} AND version = #{oldVersion}如果返回值是0,说明版本号已经被别的请求改了,当前请求需要重试或者直接提示用户库存紧张。乐观锁适合并发量不太大、冲突概率不高的场景。它的好处是性能好,不需要一直持有数据库锁;坏处是重试逻辑要自己写,要是冲突率高,用户体验会差一些。
5.2 事务内联锁与分布式锁兜底
进销存系统毕竟不是秒杀系统,并发量一般不会高到要专门引入Redis做扣减。单机部署的情况下,事务内对库存行做行级锁就足够了。
但你要是上了微服务架构,或者库存服务独立部署了多个实例,行级锁就管不住跨实例并发了。这个时候才需要考虑分布式锁。方案上推荐Redisson的分布式锁,把锁粒度控制在"商品ID维度",锁的key就设计成stock:lock:skuId。要注意锁的超时时间设置为合理值,建议加看门狗机制,防止业务执行时间超过锁有效期导致并发问题。
我个人的态度是:进销存系统优先把事务和行锁用到位,分布式锁不要一上来就上,先看业务量有没有到这个程度。
5.3 对账机制不能省
不管前面用了什么锁机制,对账都该有。我在做这套系统时加了一个定时任务,每天晚上把三条数据进行核对:采购入库总流水、销售出库总流水、库存表当前存量。核对规则很简单,就是"前一日库存 + 当日入库总数量 - 当日出库总数量 = 当日库存"。
如果对不上,系统会把异常商品ID和差异数量写到一张对账异常表里,第二天上班直接去排查。这一步看起来简单,但是能在真实业务里发现大量问题。比如某次开发环境误改了数据库、某笔单据被运维手动删除、某个库存流水漏写了,都能通过对账暴露出来。
6. 部署运行与真实踩坑记录
6.1 快速跑通这套系统的步骤
这里给一套我自己常用的快速启动流程。前提是你已经装好了MySQL、JDK、Python环境。
第一步,建库。MySQL里创建数据库inventory_db,字符集用utf8mb4,排序规则建议utf8mb4_general_ci,避免后面中文乱码。
第二步,把Spring Boot工程里的数据库连接改成本地信息。注意MyBatis-Plus的map-underscore-to-camel-case属性要设为true,数据库字段是下划线命名,Java属性是驼峰命名,不配置这个就会出现在调试时查出来字段全是null的情况。
第三步,启动Spring Boot工程,确认/api/supplier/*这类接口能通。然后用Django的migrate命令把辅助服务的数据表建好,创建超级管理员账号,进入Admin后台确认能正常登录。
第四步,准备一份测试数据。我习惯写一个CommandLineRunner,启动时自动插入几个供应商、几十个商品、几张采购单,方便联调。
@Component public class DataInitializer implements CommandLineRunner { @Override public void run(String... args) { if (supplierMapper.selectCount(null) > 0) { return; } Supplier s = new Supplier(); s.setName("测试供应商A"); s.setContactName("张三"); s.setContactPhone("13800000000"); s.setStatus(1); supplierMapper.insert(s); } }6.2 Django侧最常见的坑
Django这边我踩过几个比较典型的坑,写出来供你参考。
第一个坑是跨域问题。Spring Boot在主域名跑着,Django辅助服务在另一个端口或者另一个域名下,Admin或数据同步接口被前端调用时会遇到CORS拦截。解决办法是安装django-cors-headers,在MIDDLEWARE里加上配置,并设置CORS_ALLOW_ALL_ORIGINS = True。内网系统这么设置问题不大,公网环境就需要限定具体的Origin。
第二个坑是Django的时区设置。默认情况下Django使用UTC时间,同步进MySQL的数据会比本地时间少8小时。在settings.py里必须设置TIME_ZONE = 'Asia/Shanghai',同时USE_TZ = False。这个问题隐蔽在,有时候开发环境看不出问题,数据到了报表里才暴露。
第三个坑是Python环境与系统Python环境冲突。Django服务一定要用虚拟环境跑,用python -m venv venv创建独立环境再安装依赖。我之前有过一次直接在全系统Python里装了老版本Django,导致辅助服务启动报错的情况。
6.3 给毕设和业务项目的后续优化建议
如果你的目标是把毕设答辩做得漂亮,或者让系统真正能用起来,有几个扩展方向值得考虑。
第一个是增加采购建议功能。根据商品的安全库存线、近三十天平均销量、供应商交货周期,自动算出建议采购量。这个功能评委和老板都爱看,因为它体现了系统不是简单录数据,而是能辅助决策。
第二个是库存预警。库存低于安全库存线时,在管理后台和首页做醒目的预警提示。技术上不复杂,一个定时统计加上一个查询接口就行,但对实际运营帮助非常大。
第三个是使用记录。给登录用户增加操作日志,记录谁在什么时间做了哪些关键操作。这个功能对追溯问题很有价值,比如几天后库存对不上了,通过操作日志能快速定位到具体是哪个人在哪个时间改了数据。
我自己在做这套系统时的体会是,进销存系统的难点从来不在某个单独的功能,而在于各个模块之间的数据联动和一致性。供应商的采购单会拉动库存增长,销售单会推动库存下降,每一步都牵一发而动全身。设计时抓住"流水是账本、库存是结果"这条主线,代码层面严守事务和锁的边界,这套系统就能站得住脚。最后再分享一个排错技巧:库存数据出问题时,先查流水,别急着改库存表。流水完整,问题大概率是某一个环节逻辑写错了;流水缺失,那才是数据源头上的大隐患。