ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Spring Boot的柑橘类水果管理系统设计与实现

基于Spring Boot的柑橘类水果管理系统设计与实现 简介本资源是一款面向农业信息化管理场景的Java企业级应用源码适用于水果种植企业、生鲜供应链公司及高校课程设计开发者聚焦柑橘类水果从生产、库存到销售的全流程数字化管理。压缩包共554个文件总大小39.79MB涵盖120个Java核心业务类、179个XML配置文件含数据库连接与系统参数、2个SQL建库脚本、2个IntelliJ IDEA项目配置文件、1个JSON数据配置、1个Cookies会话文件及1个HTTP请求调试文件结构完整、模块清晰便于二次开发与环境快速部署。已有285人下载学习适合具备Java基础并希望深入理解SSM/Spring Boot架构下农业管理系统实现逻辑的中高级开发者。资源附带readme.txt入门指南与doc文档目录包含数据库设计说明、模块功能划分及典型业务流程注释可直接导入IDE运行是难得的兼具实用性与教学参考价值的完整工程实践案例。1. 项目从哪来为什么偏偏要给柑橘做一套管理系统先交代一下这套系统的来龙去脉。我本身做Java后端开发前两年接了一个农业信息化相关的私活项目需求方是南方一个做柑橘种植、仓储和批发销售的合作社。他们原来的库存记录全靠Excel仓库里不同批次、不同品种的橘子混在一起经常出现账上还有3000斤实际发货时发现早就坏了或者客户要的是沃柑发货时错发成椪柑这种问题。数据一乱年底盘账更是扯皮。对方最开始想找个外包公司买一套通用的进销存系统但问了一圈通用系统要么太贵要么流程匹配不上——他们要的不是单纯的出入库而是围绕着柑橘类水果这个品类本身的管理品种分类、成熟度分级、糖度记录、入库批次溯源、冷链时效跟踪这些通用进销存根本没有。于是在2023年初我基于Java技术栈从零设计并实现了一套柑橘类水果管理系统。项目采用Spring Boot MyBatis-Plus MySQL的经典组合前端用Vue3 Element-Plus做了管理后台权限上区分了系统管理员、仓库管理员、销售员三个角色。系统跑起来之后合作方的仓库账目清晰了很多至少发错货和账实不符这两类问题基本根治了。这篇文章我打算把整套系统的设计思路、核心代码实现、数据库表结构和落地过程中踩过的坑完整拆开讲一遍。不管你是Java新手想找一个能写进简历的项目源码来学习还是业务方正准备做一套类似的农产品管理系统这篇文章应该都有参考价值。我会尽量把当初为什么这么设计讲透而不只是贴代码。2. 技术选型与工程结构为什么是Spring Boot而不是别的2.1 技术栈选型的真实考量技术上选Spring Boot 2.7.x理由很简单生态最成熟招人好招遇到问题搜得到答案。对于一个农业合作社的项目稳定性和可维护性排在第一位追求新版本没有意义。技术组件选型版本选型理由JDK1.8合作社现有服务器是腾讯云2核4GJava 8足够稳定且兼容老项目Spring Boot2.7.14稳定版社区问题沉淀最全MyBatis-Plus3.5.3单表CRUD几乎不用写SQL提升开发效率明显MySQL5.7数据量不大5.7够用且稳定Redis6.x用于登录Token缓存和热点数据缓存Sa-Token1.34.0轻量级权限框架比Shiro配置简单比Spring Security门槛低Vue3 Element-Plus3.x后台管理页面快速搭建组件我说句实在话很多教程一上来就Spring Cloud Alibaba全套微服务放在这个场景是过度设计。这个系统的并发量一天也没多少核心是业务逻辑正确、数据不出错。单体应用前后端分离足够应对。2.2 工程目录结构与分层思路citrus-manage ├── pom.xml ├── sql │ └── citrus_manage.sql └── src └── main ├── java │ └── com/citrus/manage │ ├── common // 统一返回结果、异常处理、常量 │ ├── config // 配置类Redis、拦截器、CORS │ ├── controller // 控制层 │ ├── entity // 实体类 │ ├── mapper // 数据访问层 │ ├── service // 业务逻辑层 │ └── util // 工具类 └── resources └── application.yml分层方式用的是最标准的Controller-Service-Mapper三层架构但我在Service层内部做了一些细节优化。比如把库存变更这类核心业务单独抽出一个InventoryService其他的业务服务都调用它来修改库存而不是各自直接操作库存表。这样做的目的只有一个库存变更是资金级别的操作必须收敛到同一个事务入口避免A模块改库存、B模块也改库存最后谁也说不清楚哪笔变动是哪个功能产生的。2.3 统一返回结果与全局异常处理接口统一返回R对象这个是我每个项目都会坚持的规范。前端拿到code200就是成功非200就是业务异常前端只需要统一拦截非200的响应弹提示。Data public class RT { private Integer code; private String msg; private T data; public static T RT ok(T data) { RT r new R(); r.setCode(200); r.setMsg(success); r.setData(data); return r; } public static T RT fail(String msg) { RT r new R(); r.setCode(500); r.setMsg(msg); return r; } }全局异常处理这块我用了Spring的RestControllerAdvice。最重要的是把BusinessException和系统未知异常分开处理业务异常比如库存不足、批次已过期返回明确的提示信息系统异常统一记录日志并返回系统繁忙这种模糊提示避免把SQL异常信息直接甩给前端泄露表结构。注意异常处理不是随便写写的。最忌讳在Controller里到处try-catch代码又乱又容易漏。全局异常处理能保证任何异常都有兜底前端不会白屏。3. 数据库设计核心是批次和品种两张王牌3.1 需求分析驱动的表结构设计柑橘类水果管理系统的核心和通用进销存最大的不同在于水果是有生命的商品它会变质、会分级、有品种差异。所以数据库设计不能只设计商品-库存-出入库单这种通用模型必须考虑柑橘类水果的专属属性。我的核心表设计如下表名用途关键字段variety柑橘品种表variety_name, sugar_min, sugar_max, storage_daysorchard果园信息表orchard_name, location, contact, certificationbatch入库批次表batch_no, variety_id, orchard_id, weight_in, sugar_content, grade, price_ininventory库存批次表batch_id, remaining_weight, storage_location, entry_timeoutbound_order出库单表outbound_no, customer_id, total_weight, total_amount, statusoutbound_item出库明细表outbound_id, batch_id, out_weight, price_outcustomer客户表customer_name, phone, address, levelsys_user系统用户表username, password, real_name, role_id最核心的设计思想是入库时按批次建立库存出库时指定从哪个批次扣减。每一批柑橘入库时都有独立的批次号包括它的来源果园、品种、糖度、入库重量、入库单价。出库时销售员必须选择具体批次系统按批次扣库存。这就保证了每一斤卖出去的柑橘都能追溯到哪个果园、什么时候收的、糖度多少、入库单价多少。3.2 批次表和库存表为什么要拆开这是我设计过程中反复权衡过的一个点。最初我打算只设计一张batch表里面既有批次信息又有剩余库存后来发现不行有两个原因。第一个原因是职责太混乱。batch表应该记录这批橘子原始是什么样——是什么品种、从哪个果园来的、入库时多少斤、糖度多少。而inventory表应该记录这批橘子现在还剩多少、放在哪个冷库、状态是否可售。如果混在一张表里每次出库都要去修改batch表的原始入库数据原始信息被覆盖溯源就无从说起。第二个原因是扩展性。合作方后来提出要记录不同仓库的库存分布如果设计成一张表就得加仓库编码字段还要处理一个批次分仓存放的情况。拆成两张表后inventory表天然支持同批次存多个仓库只需要加一条仓库位置字段。CREATE TABLE batch ( id bigint(20) NOT NULL AUTO_INCREMENT, batch_no varchar(32) NOT NULL COMMENT 批次号日期品种ID随机数, variety_id bigint(20) NOT NULL COMMENT 品种ID, orchard_id bigint(20) NOT NULL COMMENT 果园ID, weight_in decimal(10,2) NOT NULL COMMENT 入库重量斤, sugar_content decimal(5,2) DEFAULT NULL COMMENT 糖度, grade varchar(16) DEFAULT NULL COMMENT 等级A/B/C, price_in decimal(10,2) NOT NULL COMMENT 入库单价元/斤, entry_time datetime NOT NULL COMMENT 入库时间, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;库存表的剩余重量和批次表的入库重量必须联动。每次新增批次时同时向inventory表插入一条剩余重量等于入库重量的记录。后续出库只动inventory表batch表始终保持原始快照。这就是一入库双写的规则。3.3 关键唯一约束和索引策略批次号batch_no建立唯一索引后续扫码溯源、单号查询都靠它。出库单号outbound_no建立唯一索引防止并发出库时生成重复单号。inventory表的batch_id建立唯一索引一个批次在同一个仓库只能有一条库存记录。品种表的variety_name建立唯一索引避免沃柑和Wogan这种重复录入。按entry_time建立普通索引因为列表页经常按入库时间倒序查询。唯一索引是防止脏数据最有效的手段比在Service层做逻辑判断可靠得多。数据库层面能兜住的绝不依赖代码自觉。3.4 数据库设计阶段的避坑回顾这个项目里我最大的一个教训是初始版本把糖度字段设计成了varchar类型因为想着可能有些品种会记录类似12-15这样的范围值。后来发现这是个馊主意——一旦需要做条件筛选比如找出糖度大于12的批次varchar类型的范围比较就把自己坑了要么隐式转换不走索引要么结果不对。后来我把糖度拆成了两个字段sugar_content实测糖度用工人测完填的数值和sugar_low品种表中保存参考低值。查询条件一律用十进制字段。凡是数值类型的数据就应该用数值类型存储这个原则越早遵守越好不要抱有侥幸心理。4. 核心功能模块实现从入库到出库再到盘点4.1 入库管理新增批次扣减待入库计划入库是整个系统的起点。合作方实际的业务流程是采购员和果园谈好一批柑橘先在系统里登记入库计划类似预入库单包含品种、果园、预计重量、预计单价。柑橘实际运到仓库后仓库管理员做检验填实际重量、实际糖度、等级确认后系统才真正增加库存。为什么要走计划-实入两步因为实际业务中谈好的订单和实际到货量经常有出入可能是运输损耗、可能是果园采摘量不足。如果入库单直接就是实际库存那做计划和实际对账时就没有参照物。核心入库逻辑在BatchServiceImpl里Transactional(rollbackFor Exception.class) public void confirmBatchInbound(BatchInboundRequest request) { // 1. 校验入库计划是否存在并且状态为待入库 InboundPlan plan inboundPlanMapper.selectById(request.getPlanId()); if (plan null || !PENDING.equals(plan.getStatus())) { throw new BusinessException(入库计划不存在或状态异常); } // 2. 生成批次信息并插入batch表 Batch batch new Batch(); batch.setBatchNo(generateBatchNo(request.getVarietyId())); batch.setVarietyId(request.getVarietyId()); batch.setOrchardId(request.getOrchardId()); batch.setWeightIn(request.getActualWeight()); batch.setSugarContent(request.getSugarContent()); batch.setGrade(request.getGrade()); batch.setPriceIn(request.getActualPrice()); batch.setEntryTime(new Date()); batchMapper.insert(batch); // 3. 同步初始化库存批次记录 Inventory inventory new Inventory(); inventory.setBatchId(batch.getId()); inventory.setRemainingWeight(request.getActualWeight()); inventory.setStorageLocation(request.getStorageLocation()); inventory.setStatus(AVAILABLE); inventoryMapper.insert(inventory); // 4. 更新入库计划状态为已完成 plan.setStatus(FINISHED); inboundPlanMapper.updateById(plan); }这里最关键的是Transactional注解。入库操作涉及batch表、inventory表、inbound_plan表三张表的数据变更任何一步失败都必须全部回滚否则就会出现批次表有数据库存表没数据的脏数据。事务这件事生产环境下真的会出问题本地测试时很少暴露因为本地没有并发、没有突发断电。4.2 出库管理严禁负库存批次先进先出出库逻辑是整个系统中最容易出bug的地方。合作方的出库场景是销售员和客户谈好订单在系统里创建出库单选择品种和数量系统自动按批次先进先出FIFO分配库存。先进先出原则是水果行业的硬性要求因为柑橘是生鲜早入库的批次必须优先出库否则积压时间长了会烂。实现时我写了一个matchBatches方法从inventory表里查询该品种下所有剩余重量大于0的批次按入库时间升序排列然后依次扣减。Transactional(rollbackFor Exception.class) public void createOutboundOrder(OutboundCreateRequest request) { ListInventory inventories inventoryMapper.findAvailableByVariety( request.getVarietyId()); // 按入库时间升序排序 inventories.sort(Comparator.comparing(Inventory::getEntryTime)); BigDecimal needWeight request.getWeight(); ListOutboundItem items new ArrayList(); for (Inventory inv : inventories) { if (needWeight.compareTo(BigDecimal.ZERO) 0) { break; } BigDecimal available inv.getRemainingWeight(); if (available.compareTo(BigDecimal.ZERO) 0) { continue; } BigDecimal deduct available.compareTo(needWeight) 0 ? needWeight : available; // 生成出库明细 OutboundItem item new OutboundItem(); item.setBatchId(inv.getBatchId()); item.setOutWeight(deduct); items.add(item); // 扣减库存 inventoryMapper.deductWeight(inv.getId(), deduct); needWeight needWeight.subtract(deduct); } if (needWeight.compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(当前品种库存不足缺口 needWeight 斤); } // 创建出库单主表记录 // ... 省略主表插入逻辑 }需要注意的细节是扣减库存时不能用先查出来、Java里减完、再写回去的方式因为并发场景下会丢失更新。正确做法是使用SQL原子更新比如UPDATE inventory SET remaining_weight remaining_weight - #{weight} WHERE id #{id} AND remaining_weight #{weight}这行SQL自带校验如果更新结果返回0说明实际库存已经不够扣了说明出现了并发扣减此时应该抛异常回滚。这个方案比先查询再更新要安全得多。4.3 盘点管理账实差异的纠偏机制再好的系统实际仓库里也可能出现差异可能是入库时磅秤误差、可能是存储过程中水分蒸发导致的重量变化、也可能是老鼠偷吃和腐烂损耗。所以盘点功能是必须要做的。盘点流程我设计成三步系统生成盘点单锁定所有需要盘点的库存批次。仓库管理员实际称重后在系统中填写实盘重量。系统自动对比账面库存和实盘库存差异部分生成盘盈/盘亏记录并调整库存。盘点单生成时我会把当前位置的所有库存批次和账面重量打印成一张表仓库管理员扛着磅秤挨个核对。差异记录需要注明原因比如水分蒸发腐坏丢弃称重误差方便财务审核。盘点这块我特别想分享一个经验不要在生产环境随意做盘点完成这个动作。因为盘点会牵扯到库存冻结如果恰好处在销售高峰期盘点会导致出库订单创建失败。我后来加了一个开关盘点单创建后可以选择只读模式还是冻结模式非紧急情况下默认只读不锁库存只看差异记录。4.4 统计报表让数据反向驱动决策系统还做了几个统计接口这部分虽然代码量不大但合作方反馈是整个系统里面他们用得最多的功能。品种维度销售统计某段时间内哪个品种卖得最好销售金额和销售重量排行。客户维度销售统计哪个客户拿货最多、账期最长重点维护大客户。批次损耗分析对比入库重量和累计出库重量算出每个批次的损耗率这个数据会直接影响明年跟果园谈采购价。库存周转预警如果某个批次的剩余重量长期不降超过存储建议天数系统自动在首页提醒XX批次已存储35天建议优先促销。这些报表都是用MyBatis-Plus的selectMaps直接查汇总数据没有额外引入报表引擎。数据量不大时SQL聚合已经够用每次都提醒自己不要为了报表上重引擎。5. 三个高频踩坑点与排查链路这些坑值得你注意5.1 坑一批次出库出现“超卖”还是“负库存”系统上线第二周一个仓库管理员突然反馈出库单明明创建成功了但批次明细里的扣减数量加起来比出库单重量少了6斤。我当时第一反应是不可能因为出库单创建逻辑里是累加明细校验过总重量的。后来排查链路是这样的先找出问题出库单发现这笔出库单只涉及一个批次明细重量8斤出库单总重量14斤缺失6斤。查看这个批次的库存流水发现此前的剩余重量是16斤扣完8斤后应该剩8斤但实际成了14斤。怀疑是并发问题 —— 同一个批次同时被两个出库请求处理两个请求都先查到了剩余16斤第一个扣了8斤剩8斤第二个扣了6斤应该失败但由于代码是查出来再减再写回两个请求都基于16斤计算写回时第二个覆盖了第一个的结果。用压测工具模拟并发调用出库接口果然复现。根因定位后修复方案就是我上面提到的UPDATE ... SET remaining_weight remaining_weight - #{weight} WHERE remaining_weight #{weight}原子扣减同时在库存流水表里记录每次变更前的账面值和变更后的账面值方便排查。这个坑的排查过程其实很痛苦因为报表看不出异常只有实际发货时才发现账实不符。也提醒了我凡是涉及资金和库存的操作不能写先读后写的代码必须依赖数据库的原子操作或行锁。5.2 坑二入库时 decimal 精度问题导致库存少了 0.01 斤第二坑是小数点精度。业务中入库重量多数是整数或者一位小数但折损、多批次分摊时会出现多位小数。Java里用的是BigDecimal数据库用的是decimal(10,2)本来设计没问题但出库时单位换算出现了一次bug。具体情况是有一个客户下了一个50.123斤的订单系统按先进先出匹配了两个批次一个批次出48.50斤另一个批次出1.623斤。库存表只有两位小数1.623存入数据库时被四舍五入成了1.62扣减后批次剩余重量对了但出库明细表里两笔加起来是50.12斤和订单的50.123斤差了0.003斤。虽然这个差异在实际业务中几乎无感但财务对账时就发现问题了。修正方案是所有出库明细的出库重量在分配量的时候就必须按两位小数做四舍五入并且最后一批的分配量用需求总量 - 前面已分配量的和来反推而不是直接减最后批次的可扣量。这样能保证分配明细之和和订单总重量完全一致。这个细节很小但恰恰是项目从demo能跑到生产能对账的关键差距。5.3 坑三前端时间传参导致入库日期偏差8小时第三个坑是前端提交入库日期到后端后查询结果总是偏一天。前端用的JavaScriptnew Date()序列化后是带时区的ISO格式比如2023-07-15T08:00:00.000ZJackson反序列化时如果不加时区配置会默认按UTC解析导致入库时间变成2023-07-15 16:00:00正好差了8小时。排查链路在数据库客户端直接执行SQL发现记录的时间是对的。后端打印Controller接收到的参数发现时间已经错了。检查Jackson配置发现没有配置时间时区。在application.yml中增加spring.jackson.time-zone: GMT8并在实体字段上使用JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)。这个坑也算经典了前后端时间传参稍有疏忽就会踩尤其是跨国公司服务器在海外的情况下更容易出问题。我给所有前端传时间的接口都加了DateTimeFormat和JsonFormat双保险凡是设计到时间的字段一律全链路统一为GMT8。6. 系统部署与测试最终交付不是能跑就行6.1 本地开发环境搭建要点这个系统本地跑起来的步骤我给合作方写过一个傻瓜式部署文档这里精简整理一下安装JDK 1.8并配置JAVA_HOME环境变量这个不用多说。安装MySQL 5.7执行citrus_manage.sql脚本创建数据库和表。安装Redis默认端口6379即可。修改application.yml中的数据库连接信息改成自己本地的密码。启动Spring Boot项目端口8080。前端项目npm install后npm run dev启动访问http://localhost:9527。初始账号建议在sys_user表里手动插入一个admin账号密码用BCrypt加密后存入不要在教程里用明文密码。注意application.yml里密码配置这块千万别硬编码。我后来用jasypt-spring-boot-starter做了配置加密部署时通过环境变量传入密钥。合作方那边运维水平一般就把加密密钥写在了启动脚本里至少数据库密码不会直接以明文形态暴露在代码仓库中。6.2 核心接口的测试用例设计业务逻辑测试我只针对最核心的链路写了测试用例入库、出库、库存一致性。这里分享我设计测试用例的几条思路。第一个测试case同一批次入库100斤按两次出库各50斤最后库存应为0批次状态变为已售罄。第二个测试case两个并发请求同时出库同一批次100斤、各60斤预期一个成功一个报库存不足库存最终为40斤或0斤绝不会出现负数。第三个测试case先进先出分配逻辑两个批次入库时间不同出库时应优先消耗早入库的批次。这些测试用SpringBootTest加JUnit5加上Transactional保证测试数据不污染生产库。虽然我只写了核心链路的测试但足以应付交付验收。6.3 上线部署与交接经验部署环境用的是腾讯云轻量服务器2核4G操作系统CentOS 7.6。项目用mvn package打成jar包通过systemd配置成系统服务开机自启。前端npm run build后把dist目录扔到Nginx的html目录下配置反向代理把/api转发到后端8080端口。和合作方交接时我额外做了三件事写了一份详细的操作手册截图配文字说明每个按钮是干嘛的。录制了两段操作视频——一段日常入库出库操作一段月度盘点操作。讲解数据库备份策略设置每天凌晨自动mysqldump备份保留最近7天的备份文件。前两件是方便使用第三件是保命。数据是业务的核心资产任何生产系统没有备份机制都等于裸奔。7. 可扩展的方向这套系统还能怎么演进交付之后合作方陆续提了一些新需求有些已经实现了有些还在规划这里列出来供大家参考。7.1 柑橘品质溯源二维码最受客户欢迎的扩展是溯源二维码。每个批次入库时生成一个二维码贴在外包装上扫描后可以看到品种、产地果园、入库时间、糖度、农药残留检测报告。这个功能本质上是把batch表、orchard表、检测记录表的数据通过一个二维码链接暴露给消费者。实现思路不复杂就是一个单独的/trace/{batchNo}页面后端查询相关数据返回给页面展示。二维码的生成用ZXing库代码量不大但业务价值很高——消费者扫一下就知道手里的橘子来自哪里信任感提升明显。7.2 价格趋势分析与滞销预警基于销售数据按品种、按时间段统计平均成交价绘制价格趋势曲线。再结合每个批次的存储天数阈值设置自动预警规则库存超过N天未动销就推送给销售组长。这块我建议用定时任务比如Spring的Scheduled每天跑一次把预警结果写入一张预警记录表而不是实时计算。实时计算的代价和收益不成比例。7.3 对接电子秤和PDA手持终端再往后走可以对接仓库的蓝牙电子秤入库时自动读取重量减少人工录入错误也可以对接PDA手持终端让仓库管理员在货架旁边就能完成盘点操作不用捧着一沓纸质表来回跑。这个方向涉及硬件对接和移动端开发实际上已经超出了管理系统的范畴更像是物联网终端和业务系统的集成。如果真要做建议把设备接入层独立成一个微服务通过消息队列把采集数据异步传给核心系统避免硬件抖动影响业务系统的稳定性。最后再说几句这个项目从需求调研、数据库设计、代码编写到部署上线前后大概花了三个月。回头复盘技术上其实没有用到什么高深框架Spring Boot MyBatis-Plus Vue这套组合几乎所有Java开发者都熟悉。但真正拉开项目和demo差距的是那些细节事务边界的划分、并发库存扣减的安全性、时间时区的统一、小数精度的处理、盘点对账流程的设计。这些细节没有哪个框架能帮你自动解决都要靠对业务场景的理解和测试中不断踩坑积累。如果你正准备做类似的农产品管理系统我的建议是先把业务流程跑通、跑顺再考虑技术上怎么实现。数据库表设计时多想一步这个字段以后可能要做哪些查询比回头改表结构省事得多。还有凡是涉及钱和库存的操作写代码前多问自己一句如果并发来了怎么办能省下不少后面排查问题的精力。希望这篇分享对你有点帮助。如果你也在做农业信息化方向的项目欢迎多交流。本文还有配套的精品资源点击获取
返回列表