Spring Boot连锁药店进销存系统设计:从数据库到上线的完整实践
经营连锁药店最头疼的往往是库存和进销存。门店铺货、总部调拨、近效期药品、供应商结算每一环都牵扯着实实在在的利润。我之前帮朋友搭过一套单体架构的管理系统从需求梳理到数据库设计再到部署上线踩了不少坑也总结了不少经验。这篇文章就以“Spring Boot连锁药店进销存业务系统”为例把从零到一搭建一个可用系统需要哪些核心模块、数据库怎么设计、代码结构怎么组织、部署调试有哪些坑全部理一遍。不管是拿来练手的Java学习者还是真打算在中小药店落地的技术负责人相信都能从中获得不少有价值的信息。1. 内容整体设计与思路拆解1.1 连锁药店进销存系统的核心诉求连锁药店和普通单店零售最大的区别在于“多门店、统一管控、强合规”。以进销存为例单店只需要关心“我进了什么、我卖了什么、还剩多少”但连锁体系里一定要回答下面这几个问题各家门店库存分别有多少总部的仓库数据如何汇总药品从总部库房调到门店或者门店之间临时调拨账务怎么走同一药品在不同批次的进价、售价、效期各不相同怎么精确追溯处方药、含麻药品、冷链药品有严格的监管要求系统怎么留痕从这个角度出发进销存系统本质上不是为了“记流水账”而是为了做“批次跟踪”和“多组织结算”。用市面上的通用进销存软件当然也能记但真要定制化满足连锁药店的品类管控和门店核算需求自己基于Spring Boot搭建一套是更合适的路线。可扩展性强还能方便地对接后续的会员系统、医保接口、财务系统。1.2 技术栈选型为什么是Spring Boot MyBatis Plus MySQL很多开发者在选型时容易陷入“越新越复杂越好”的误区。老实说对于一个以业务流转为核心、没有超高并发诉求的进销存系统技术选型的核心原则就是“人效优先、生态成熟、排查简单”。我这边采用的是一套非常经典且稳的组合Spring Boot 2.x版本不追求最新但求稳定社区资料丰富遇到问题一搜就有解决方案。MyBatis Plus进销存系统里涉及大量复杂动态查询比如药品多条件组合筛选、库存流水多维度聚合MyBatis Plus的LambdaQueryWrapper真的能节省不少时间同时保留了手写SQL的余地。MySQL 8.0事务支持、行级锁、可靠的ACID特性是进销存这类强一致性业务的基础保障。Redis主要用来做缓存和分布式锁比如生成唯一流水号、避免并发扣减库存时的超卖。JWT Spring Security用于后台用户认证和接口鉴权。这套方案对不同的业务模块都有很强的支撑能力而且不挑服务器配置2核4G的云主机就能跑得很流畅。1.3 整体功能架构从采购到销售再到财务的一体化闭环一个系统的成功与否不取决于功能多不多而取决于核心链条是否闭环。连锁药店进销存系统的闭环我总结为“采购入库—验收入库—库存管理—销售出库—退补管理—财务结算”六大环节。围绕这个闭环我把系统拆成了几个模块基础资料模块药品信息、供应商档案、门店档案、仓库档案、客户档案采购管理模块采购订单、采购入库、采购退货、供应商对账库存管理模块库存查询、批次管理、库存调拨、盘点管理、效期预警销售管理模块销售出库、销售退货、门店零售/开单财务管理模块应收应付、费用管理、利润统计系统管理模块用户管理、角色权限、操作日志、数据字典这个结构看起来不复杂但每一个模块背后都有业务细节尤其是库存模块。后面我会重点讲数据库设计因为很多看似不起眼的字段设计其实决定了业务能不能跑顺。2. 核心细节解析与实操要点2.1 药品资料与批次管理的设计精髓药品不同于普通商品最核心的属性是“批准文号”和“批次”。同一种药品不同批次的有效期、供应商、进价都可能不同。如果像普通商品一样在数据库里用“商品表库存数量”来维护后面做效期管理、批次追溯、近效期催销就会很吃力。所以我建议把核心模型拆成三层药品主档表drug_info记录药品通用信息比如通用名、商品名、规格、剂型、生产厂家、批准文号、医保类别、销售单价、库存上下限。药品批次表drug_batch记录某一批次的专属信息比如批号、生产日期、有效期、进价、供应商ID。库存表stock拆到“门店/仓库 药品 批次”的颗粒度每个库存记录都关联批次ID。这种设计在查询当前库存时走stock表和drug_batch表关联就能拿到所有在库批次在处理近效期预警时只要把drug_batch.expiry_date和当前日期做比较就很清晰了。连锁药店做药品召回、追踪某个批次流向时这套模型也能承受住压力。2.2 库存流水和动态库存的账实一致进销存系统里最忌讳的是“只记当前库存、不记流水”。一旦数据异常你不知道它是从哪个环节开始错的也根本无法对账。我的习惯是每次库存变化都必须同步写一张库存流水表stock_flow记录变动前数量、变动后数量业务类型采购入库、销售出库、调拨出库、调拨入库、盘点调整、报损等关联业务单号比如采购单号、销售单号、盘点单号操作人、操作时间、备注这样一套流水的价值非常大。第一可以还原任意时间点的库存状态第二可以排查库存数量不一致的原因第三是后续财务核算毛利的依据。在设计流水表时字段宁愿多也不要缺后期补字段很麻烦。2.3 药品效期管理近效期预警怎么算才科学药品效期管理是药店进销存的命门。药店GSP规范有明确要求近效期药品一般指有效期剩下6个月的药品部分特殊药品可能是3个月甚至更短。具体实现方式是在drug_batch表里存expiry_date然后系统每天跑定时任务把有效期在提醒范围内的批次标记出来推送到门店端或管理后台的待办列表。我一般会设置两个预警阈值效期提醒有效期剩余6个月提示门店“该批次临近效期需重点关注促销或退货”。效期锁定有效期剩余3个月系统限制销售或者必须上报总部审批后才能出售。这个逻辑看起来简单实际实现时要注意一个细节必须结合门店的日均销量来做“预计售罄日期”的判断。比如一批药效期还有4个月但某个门店库存量很大按当前销速90天才卖得完这类药品就属于高危品。只按效期不看库存预警会产生大量无效信息时间长了反而没人关心预警列表。2.4 调拨与盘点连锁场景的库存变动核心操作门店之间的调拨本质上是库存从A仓库出、到B仓库入的过程。这里最容易出现的问题是“双边不平账”A仓库已经出库了B仓库因为货物没到还没入库这段时间在途库存算谁的我的解决办法是增加一个“调拨在途”的状态。调拨单创建后库存被冻结发出方确认出库库存虽然减掉了但仍旧算在途接收方确认收货在途转为在库库存。这样财务报表的库存数据和各门店的实物情况是吻合的。盘点更是个细节活。连锁药店盘点的痛点在于金额差异和数量差异。在系统里涉及“盘盈”或“盘亏”时一定要生成对应的库存调整单据并且把差异金额归集到“盘点差异”科目等待财务审批。千万不要直接改库存数量否则后面每个月对账都对不齐。3. 实操过程与核心环节实现3.1 项目搭建与基础依赖配置项目我直接采用Maven多模块结构虽然早期单模块开发更省事但考虑到连锁药店系统后续可能要拆出独立的定时任务模块、接口模块从一开始就分层清晰更好。最基础的pom.xml中核心依赖包括dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency进销存系统通常还需要整合定时任务来做效期预警和库存同步框架本身自带的Scheduled就够用了核心配置需要在启动类加上EnableScheduling注解。3.2 核心数据库表设计与关系梳理这一部分是整个系统的地基。我以几张最重要的表为例展开说明。部门门店表store字段示例CREATE TABLE store ( id bigint(20) NOT NULL AUTO_INCREMENT, store_code varchar(32) NOT NULL COMMENT 门店编码, store_name varchar(64) NOT NULL COMMENT 门店名称, store_type tinyint(1) DEFAULT 0 COMMENT 类型0总部1门店, address varchar(255) DEFAULT NULL, status tinyint(1) DEFAULT 1, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT门店表;药品批次表drug_batchCREATE TABLE drug_batch ( id bigint(20) NOT NULL AUTO_INCREMENT, drug_id bigint(20) NOT NULL COMMENT 关联药品主档, batch_no varchar(64) NOT NULL COMMENT 批号, produce_date date DEFAULT NULL COMMENT 生产日期, expiry_date date DEFAULT NULL COMMENT 有效期至, purchase_price decimal(10,2) NOT NULL COMMENT 批次进价, supplier_id bigint(20) DEFAULT NULL COMMENT 供应商ID, PRIMARY KEY (id), KEY idx_drug_id (drug_id), KEY idx_expiry_date (expiry_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品批次表;库存表stockCREATE TABLE stock ( id bigint(20) NOT NULL AUTO_INCREMENT, store_id bigint(20) NOT NULL, drug_id bigint(20) NOT NULL, batch_id bigint(20) NOT NULL, quantity int(11) NOT NULL DEFAULT 0 COMMENT 当前库存数量, lock_quantity int(11) NOT NULL DEFAULT 0 COMMENT 锁定数量, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_store_drug_batch (store_id,drug_id,batch_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表;注意这里设置了唯一索引uk_store_drug_batch这是防止同一个门店、同一种药品、同一个批次出现多条库存记录的硬保证。库存流水表stock_flowCREATE TABLE stock_flow ( id bigint(20) NOT NULL AUTO_INCREMENT, store_id bigint(20) NOT NULL, drug_id bigint(20) NOT NULL, batch_id bigint(20) DEFAULT NULL, change_type varchar(32) NOT NULL COMMENT 类型PURCHASE_IN,SALE_OUT,ALLOT_OUT,ALLOT_IN,STOCKTAKING,WASTE, before_qty int(11) DEFAULT NULL, change_qty int(11) NOT NULL, after_qty int(11) DEFAULT NULL, biz_order_no varchar(64) DEFAULT NULL COMMENT 业务单号, create_by varchar(32) DEFAULT NULL, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_store_drug (store_id,drug_id), KEY idx_biz_order_no (biz_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水表;整套数据库设计的核心难点在于如何保证事务一致。比如采购入库时要先更新库存表数量写入流水表再更新采购单状态这三步必须在一个事务内完成否则数据迟早出错。3.3 核心接口实现采购入库的事务处理采购入库是进销存里最高频、也最容易出错的接口。核心逻辑上需要做这几件事校验采购单是否处于待入库状态。逐条处理采购单明细对每一条药品执行“批次入库”。如果该药品批次已存在则累加库存否则新增批次记录和库存记录。写库存流水记录操作前后数量。更新采购单状态为“已完成”。以一个入参示例来说明前端提交的采购入库单大概长这样{ purchaseOrderNo: PO20240617001, storeId: 1, items: [ { drugId: 101, batchNo: 20240501, produceDate: 2024-05-01, expiryDate: 2026-05-01, purchasePrice: 12.50, quantity: 100 } ] }Service层代码关键片段如下Transactional(rollbackFor Exception.class) public void purchaseInbound(PurchaseInboundRequest request) { // 1. 校验采购单状态 PurchaseOrder order purchaseOrderMapper.selectByNo(request.getPurchaseOrderNo()); if (order null || !待入库.equals(order.getStatus())) { throw new BusinessException(采购单不存在或状态不允许入库); } // 2. 遍历处理明细 for (PurchaseInboundItem item : request.getItems()) { // 查询或创建批次 DrugBatch batch drugBatchMapper.selectByDrugAndBatch(item.getDrugId(), item.getBatchNo()); if (batch null) { batch new DrugBatch(); // 设置药品ID、批号、生产日期、有效期、进价 drugBatchMapper.insert(batch); } // 锁定或更新库存 Stock stock stockMapper.selectByStoreDrugBatch(request.getStoreId(), item.getDrugId(), batch.getId()); int beforeQty 0; if (stock null) { stock new Stock(); // 设置门店ID、药品ID、批次ID、初始数量为0 stockMapper.insert(stock); } else { beforeQty stock.getQuantity(); } stock.setQuantity(stock.getQuantity() item.getQuantity()); stockMapper.updateById(stock); // 写流水 StockFlow flow new StockFlow(); flow.setStoreId(request.getStoreId()); flow.setDrugId(item.getDrugId()); flow.setBatchId(batch.getId()); flow.setChangeType(PURCHASE_IN); flow.setBeforeQty(beforeQty); flow.setChangeQty(item.getQuantity()); flow.setAfterQty(stock.getQuantity()); flow.setBizOrderNo(request.getPurchaseOrderNo()); stockFlowMapper.insert(flow); } // 3. 更新采购单状态 order.setStatus(已完成); purchaseOrderMapper.updateById(order); }这里有几个细节值得强调Transactional必须带上rollbackFor Exception.class否则当抛出的是自定义运行时异常时Spring默认才能正确回滚。但如果只抛受检异常而没配置这个参数事务是不会回滚的。高并发场景下库存累加不能采用“读出来加1再写回去”的方式可以使用UPDATE stock SET quantity quantity #{num} WHERE id #{id}这样的原子SQL防止并发覆盖。批次插入前要做唯一性校验最好是给drug_id batch_no建唯一索引。3.4 销售出库与库存扣减如何避免超卖销售出库的逻辑和采购入库正好相反核心是扣减库存。这里最容易出现的问题是“超卖”同一个药品批次只有10盒库存结果同时来了两个请求各卖8盒都扣减成功。解决超卖最稳妥的办法是使用MySQL的原子更新UPDATE stock SET quantity quantity - #{saleQty} WHERE id #{stockId} AND quantity #{saleQty}执行这个更新后如果影响行数是1代表扣减成功如果影响行数是0说明库存不足直接抛出业务异常。这个方案在单数据库实例下性能足够实现也最简单。如果非要引入Redis分布式锁还得考虑锁超时、锁释放异常这些额外的复杂度实际收益并不大。3.5 统一返回结果与全局异常处理给前端提供的接口一定要有统一的返回结构。我在项目里定义了一个ResultT类通用结构如下Data public class ResultT { private int code; private String message; private T data; public static T ResultT success(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; } }再配一个全局异常处理器把业务异常、参数校验异常、系统异常分别处理这样前端就只需要针对code做统一提示。这里有个细节千万不要把系统原始异常堆栈直接抛给前端既暴露内部信息又给用户带来极差的体验。3.6 系统界面要点与前端页面规划系统界面我按角色做了三套视图总部管理员、门店店长、收银员。各自看到的首页数据肯定不一样。总部管理员关心的是连锁整体库存金额、近效期药品分布、各门店销售排行门店店长更关心本店低库存预警、今日销售额、待处理调拨单收银员只需要简洁的收银开单界面和库存速查。界面技术上我用的是Vue 3 Element Plus后端接口全部走RESTful风格。为了快速出效果我把首页拆成了卡片区、图表区、表格区三块。卡片区展示概览数字图表区使用ECharts展示销售趋势和品类占比表格区列出最近10条异常库存记录。整体布局不需要花里胡哨核心是让操作员在3步之内完成高频操作。4. 常见问题与排查技巧实录4.1 数据库连接报错时区问题和驱动问题用MySQL 8.0以上版本时最容易遇到的启动报错就是The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这是时区配置问题。在application.yml里加上spring: datasource: url: jdbc:mysql://localhost:3306/pharmacy_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai另外MySQL 8.0的驱动类名已经变成了com.mysql.cj.jdbc.Driver不是原来的com.mysql.jdbc.Driver老项目升级时经常在这里踩坑。4.2 本地能跑部署到服务器后接口很慢这种情况我排查过多次最终多数都是因为服务器上没有配MySQL索引或数据库连接池太小。在部署前需要检查药品表、库存表、流水表是否都有合适的索引尤其是store_id drug_id组合索引。数据库连接池参数是否太低我一般配置为initial-size5, min-idle5, max-active50。是否开了慢查询日志抓出来真正耗时的SQL再去优化。进销存系统查询量一大往往就卡在几张核心表的全表扫描上。项目里我养成了一个习惯任何列表查询都强制分页不允许一次查近百万条流水到内存里统计。4.3 盘点数据不准怎么快速定位如果发现库存数和实际数对不上我最快的定位方法是查流水表。按药品维度把stock_flow表的时间序列拉出来对每个业务的变动前、变动数量、变动后做一个累计求和再和当前stock表数据对比。官方对账SQL可以这么写SELECT drug_id, SUM(CASE WHEN change_type IN (PURCHASE_IN,ALLOT_IN) THEN change_qty ELSE -change_qty END) AS cal_qty FROM stock_flow WHERE store_id 1 GROUP BY drug_id;如果这个结果和stock表不一致说明流水有缺失或者存在直接改库存的脏操作。知道问题出在哪张单子上修复就有方向了。4.4 Spring Boot项目启动失败端口被占用或Bean冲突这种情况其实很常见。端口被占用时要么改启动端口server: port: 8081要么在命令行里直接定位并关掉占用端口的进程。Bean冲突更多体现在Service层的Service注解重复标注或者Mapper接口扫描了多个包导致同名字段映射错乱。排查时只要仔细看启动日志中的Error creating bean with name提示基本能一步到位。4.5 接口权限控制不同门店不能越权查看数据连锁药店系统的权限设计需要关注“数据权限”就是不同角色不仅菜单不一样数据范围也不一样。总部账号能看全部门店的数据门店店长只能看自己门店。我的做法是在公共查询条件里加一个数据权限过滤模块从登录用户的Token里解析出门店ID然后自动加到所有查询SQL上。具体在MyBatis Plus里用拦截器实现比较方便对所有查询自动拼接store_id {当前用户门店ID}这样业务代码里就不需要每处都手工传门店条件了也避免了开发者漏写导致数据越权。5. 项目打包部署与调试部署心得5.1 本地开发环境准备清单很多初学者卡在最开始的“开发环境搭建”上。这里我列一下最省心的配置方案JDK最好是JDK 1.8和Spring Boot 2.x兼容性最好。Maven3.6用于依赖管理。MySQL5.7或8.0均可。RedisWindows下可以直接用解压版Linux就用yum install redis。IDE直接用IntelliJ IDEA社区版就够用无需破解版。Node.js16用于前端Vue项目构建。在项目启动前还需要在MySQL中先建库并执行初始化SQL脚本。很多同学在这里忽略了一个点只创建了数据库没执行初始化脚本结果启动一查表提示Table doesnt exist。这个错误很容易排查但很影响新手心态。5.2 Maven打包与Spring Boot多环境配置项目开发完成后部署我的习惯是用Maven打到Docker镜像里或者直接打成Jar包放到服务器上用nohup跑。打包命令mvn clean package -DskipTests生成的Jar包在target目录下。部署启动可以使用nohup java -jar pharmacy-system.jar --spring.profiles.activeprod app.log 21 这里涉及一个细节尽量区分开发环境和生产环境配置。在application-dev.yml里用本地MySQL在application-prod.yml里用云上数据库。连接密码不要写在代码里使用环境变量注入更安全。5.3 运维过程中的日志排查技巧系统上线后日志是排查问题的重要依据。建议在项目里统一配置Logback按天滚动生成日志文件。排查问题时第一步不是看业务代码而是先看app.log里的异常堆栈。比如事务没生效、SQL写错、NPE、参数类型转换异常日志里基本都会打印出具体行号。还有一个建议对采购、销售、调拨这类核心操作一定要记录完整的操作日志包括操作人、操作时间、操作内容、修改前后数值。后续如果有对账纠纷或审计需要这些数据比什么都有说服力。6. 连锁药店的扩展需求与二次开发方向6.1 对接医保与处方流转平台目前很多连锁药店已经接入了医保电子凭证、处方流转平台这要求在进销存系统上增加一套对外开放的接口层。以Java后端的习惯可以独立出一个openapi模块专门处理外部平台请求的验签、解密、回调。核心原则是外部接口和内部业务解耦不要让第三方平台直接操作内部核心表。6.2 自动补货和智能采购建议库存管理做扎实后就能进一步做智能补货了。核心算法不复杂基于每个药品在门店的历史日均销量、当前可用库存、采购在途量、安全库存天数就能计算出一个建议采购量建议采购量 日均销量 × 采购周期天数 安全库存 - 当前库存 - 在途库存这个逻辑可以用一个定时任务每天跑一次生成补货建议单。我做过一个粗略的统计上线智能补货后门店的低库存断货率能下降30%左右同时减少近效期积压。6.3 多租户模式与连锁加盟扩展如果系统将来要做成SaaS化给多个连锁药店品牌共同使用那就要在一开始预留tenant_id的设计。每张业务表都加一个租户ID查询时通过拦截器自动过滤。这个过程越早设计越好后期再改造所有SQL表结构代价会非常大。7. 写在最后的一点经验这类系统的成功60%靠数据库设计30%靠事务和并发处理剩下10%才是界面好不好看。很多人都想把界面画得多漂亮结果业务模型没设计清楚上线跑个把月就各种对不上账再回头改库存表结构那可是牵一发动全身的事情。我个人在开发过程中的一个深刻体会是进销存系统的确没有什么花哨的高并发技术但它对数据一致性和业务完整性的要求非常严格。开发和测试阶段一定要多用异常数据、临界数据去验证比如同一批次多次入库、负库存操作、跨门店并发调拨、盘点时存在在途单等。把这些场景都测透了系统上线后才不会频繁救火。如果你准备自己动手做一套类似的系统建议不要直接拿着一套现成的源码就开始改一定要先花足够的时间去理解供应商、门店、药品、批次、库存流水、采购订单这些核心概念之间的关系。哪怕一开始做得简单一些只要核心链路的数据是通的后面扩展功能就会非常顺手。最后再分享一个压箱底的建议数据库在初期设计时但凡涉及金额、库存、数量的字段全部使用decimal类型别用float或double否则财务对账的时候你会被各种精度问题折磨到怀疑人生。