1. 为什么我会做这样一套云诊所系统
1.1 传统诊所药品管理的痛点
先聊聊这套系统诞生的背景。做云诊所系统这件事,最初源于我接手的一个实际场景:某家社区诊所,药房里有两千多种SKU,日常全靠一位老药师的经验和Excel台账在管理。药品库存是否充足、哪些药临近有效期、哪些药销量异常下滑,基本都靠"人脑判断"。结果就是:某高血压常用药在月初断货、某批感冒药到了过期日期才发现还剩三箱,这类事故隔三差五就发生一次。
诊所药品管理跟大型医院GSP合规管理不一样。大型机构有专门的ERP和WMS系统,有专职库管员;但中小诊所往往只有一两个人在同时兼顾看病、收费和药房,根本没有时间做精细化的库存盘点。这就需要一套"轻量但聪明"的系统:不需要太复杂的供应链功能,但一定要能自动发现问题、提前预警。
我当时给自己定下的目标很明确:做一套SaaS化的云诊所系统,核心模块围绕"药品进销存+智能预警"展开。也就是标题里说的,用SpringBoot做后端服务,用Vue.js做前端界面,重点把"智能化药品库存预警管理"这件事做扎实。
注意:这里说的"智能",不是堆砌算法。对诊所而言,真正有价值的预警能力是:能感知多维度的缺货风险、滞销风险和近效期风险,并且用合适的方式及时通知到人。
1.2 技术选型:SpringBoot + Vue.js 的组合逻辑
技术栈选型上,SpringBoot加Vue.js是这类中小型业务系统非常成熟的组合,但选它并不是因为"流行",而是因为它贴合实际需求。
后端用SpringBoot,核心考量有三点。第一,生态成熟,无论是MyBatis-Plus操作数据库、Spring Security做登录鉴权,还是Quartz/Spring Task做定时扫描,都有非常稳定的方案,不需要从零造轮子;第二,单体应用足够应对诊所并发量。很多开发者一上来就想搞微服务、消息队列,但对一个日均请求量几千次的中小诊所系统来说,单体加合理缓存就是最优解,维护成本也低;第三,Cloud原生支持好,后续要做成多租户SaaS,SpringBoot配合Nacos、Docker部署,扩展路径很平滑。
前端选Vue.js,原因更直接:开发效率高、上手门槛低、生态组件丰富。Element Plus提供的表格、表单、弹窗、通知组件,几乎覆盖了后台管理系统的全部常见界面需求,配合Vuex或Pinia做状态管理,整个前端开发周期可以压缩一半。而且Vue的双向绑定机制,特别适合这类"库存数据实时变化、界面需要频繁刷新"的管理场景。
架构上我设计的是经典前后端分离:后端提供RESTful API,前端独立部署,两者通过JSON交互。考虑到诊所网络环境可能不稳定,API设计上我做了幂等处理和超时重试机制,确保弱网情况下也不至于出现重复扣库存之类的问题。
2. 库存预警的整体设计思路
2.1 预警的核心要素:不只是数量下限
如果只做"库存低于某个数值就提醒",这套系统根本称不上"智能"。诊所药品库存管理比较特殊,它同时受几个维度约束:
数量维度,指的是实际库存量与安全库存的比较。但安全库存不能是一个固定值——不同药品的消耗速度差异巨大,抗生素可能一周消耗两百盒,某些外用药一个月才出一两支,不能用同一套数字衡量。
效期维度,药品跟普通商品最大的区别是"会过期"。药品法明确规定过期药必须销毁,所以"提前预警近效期"比"缺货预警"更紧迫。通常的做法是:距有效期还有6个月、3个月、1个月时分别触发不同级别的提醒。
消耗维度,也就是周转率。有些药库存量充足、效期也新,但长期卖不动,这同样是个问题——药房货架和资金被滞销品占用,是诊所运营中不容易察觉的隐性问题。
所以,我的预警模型由三类规则叠加组成:数量预警、效期预警、积压预警。每条规则都有独立的触发阈值和通知策略,可以在系统里灵活开启或关闭。
2.2 系统模块划分与数据流
整个系统我在后端拆成了以下核心模块:
- 药品管理:药品基础信息、通用名/商品名/规格、生产厂家、批准文号等静态数据维护
- 库存管理:入库单、出库单、盘点单、库存流水,所有库存操作必须产生流水记录
- 预警引擎:扫描库存数据、匹配预警规则、生成预警记录、触发通知动作
- 采购建议:根据预警结果和订单周期,自动计算建议采购量
- 系统管理:用户角色、操作日志、参数配置、通知渠道配置
模块之间的数据流大概是这样的:药品入库/出库操作改变库存数据和库存流水,预警引擎通过两种方式感知变化——一是实时触发,在每一次库存变更后对该药品立即执行规则匹配;二是定时兜底,每天晚上固定时间全量扫描一遍,防止异常数据漏掉。预警记录生成后,系统根据配置把消息推送到站内信、微信公众号模板消息或者短信渠道。
2.3 为什么"诊所云化"要用多租户思路
标题里专门提到"云诊所",这就涉及架构上的多租户设计。我采用的方案是"独立数据库+共享应用"的混合模式:每个诊所逻辑隔离,数据之间不互通,但共享同一套应用服务。
这里有个取舍。真正的大规模SaaS通常会做共享数据库加共享表结构,用租户ID区分数据,好处是成本低;但诊所数据属于医疗健康数据,敏感度高,独立库甚至独立schema更安全。考虑到诊所数量本身有限,这种"重隔离"的成本完全可以接受。我在代码层面做了一个TenantContext,所有Service方法的查询和写入都会自动注入当前租户ID,开发时不需要每个Mapper手写租户条件,非常方便。
3. 核心模块实操:智能化预警从0到1的实现
3.1 数据库与实体设计
预警系统的数据库设计是整个项目的地基。药品库存相关的表,我建了这几张核心表。
药品表(drug_info):药品的唯一编号、名称、通用名、规格、单位、生产厂家、批准文号、用法用量、预警开关状态。
库存表(drug_stock):药品ID、总库存数量、可用库存数量、锁定数量、所在库房、最近入库时间。
药品批次表(drug_batch):批次号、药品ID、生产日期、有效期至、批次库存数量。注意,库存表里的数量其实是所有批次数量的汇总,批次表才是药品效期管理的核心。
预警记录表(stock_warning_log):药品ID、预警类型(1缺货/2近效期/3积压)、预警级别(1普通/2重要/3紧急)、触发数值、规则快照、状态(未处理/已处理/忽略)、通知时间、处理时间、处理人。
仓库流水表(stock_flow_record):每次出入库操作的类型、数量、涉及单据号、操作人ID、操作时间。
DDL设计上有一个关键细节:所有涉及金额和数量的字段,一律用整数类型存最小单位。比如数量用stock_num INT表示最小包装单位,而不是用DECIMAL(10,2)。这样做的好处是彻底规避浮点运算精度问题,统计报表时再通过单位换算展示。我在项目中踩过这个坑,改字段成本很高,所以如果现在还在设计阶段的朋友,直接按这个习惯来。
3.2 预警规则引擎后端实现
预警引擎的核心代码并不复杂,但逻辑设计要严谨。我先把规则配置抽象成一张表:规则名称、规则编码、适用药品范围(单个药品/药品分类/全部)、判断字段(库存量/有效期剩余天数/百天消耗量)、比较操作符、阈值数值、阈值单位、通知策略。
引擎执行时,跑一个规则匹配流程:加载规则列表,按适用范围过滤出待检查的药品集合,逐个执行规则判断。这里判断逻辑可以抽象成一个策略类,避免每个规则写一套if-else。
下面是核心的规则判断代码示例,我简化了一些项目细节,但业务逻辑是一致的:
@Component public class StockWarningEngine { @Resource private DrugStockMapper stockMapper; @Resource private DrugBatchMapper batchMapper; public List<WarningResult> executeRule(WarningRule rule, TenantContext tenantContext) { List<WarningResult> results = new ArrayList<>(); List<DrugStock> stockList = stockMapper.selectByRuleScope(rule.getScopeType(), tenantContext); for (DrugStock stock : stockList) { // 针对效期规则,需要在批次层面判断 if ("expiry".equals(rule.getRuleCode())) { List<DrugBatch> batches = batchMapper.selectNearExpiry( stock.getDrugId(), rule.getThresholdValue(), tenantContext); if (!batches.isEmpty()) { results.add(buildResult(rule, stock, batches)); } } else if ("lowStock".equals(rule.getRuleCode())) { int safeStock = calcSafeStock(stock.getDrugId(), tenantContext); if (stock.getAvailableStock() <= safeStock) { results.add(buildResult(rule, stock, safeStock)); } } // 其他规则类似... } return results; } private int calcSafeStock(Long drugId, TenantContext tenantContext) { // 安全库存计算,核心逻辑在4.1节详述 } }这里有几个容易被忽略的细节。第一,规则匹配必须做范围控制,不然报表数据量大时性能会急剧恶化;第二,每次扫描生成结果前,先查一下最近一次预警记录,如果同药品同规则连续触发,应该走"持续预警"逻辑而不是重新建立一条新记录,避免预警列表被刷屏。
3.3 实时触发与定时兜底的双通道机制
预警系统的执行时机,我最终定为双通道并行。
实时通道:在入库、出库、盘点等相关Service方法内部,通过事件发布机制触发预警引擎。Spring的ApplicationEventPublisher在这里非常合适。我的做法是:库存变更成功后,发布一个StockChangedEvent,事件监听器里拿到药品ID,异步执行该药品相关的规则匹配。这样,正常的CRUD接口流程不受影响,消耗时间几乎为零。
定时兜底:使用Spring自带的@Scheduled注解,配合可配置的Cron表达式,默认每晚凌晨两点执行全量扫描。加这个通道的原因很实际——有些数据可能是人工调整的、DBA直接改的,或者实时通道某次执行失败被吞掉了异常。全量扫描就是最后一道保险。
@Component public class WarningScheduledTask { @Scheduled(cron = "${warning.scan.cron:0 0 2 * * ?}") public void fullScan() { List<WarningRule> rules = ruleMapper.selectEnabled(); for (WarningRule rule : rules) { ruleEngine.executeRuleAll(rule, tenantContextHolder.getAllTenants()); } } }定时任务还有一个扩展点:支持手工触发。系统管理后台留了一个"立即全量扫描"按钮,方便运维人员在场时主动执行一次验证配置是否生效。这个小功能在实际交付中非常加分,客户通常会用它在上线初期频繁验证预警规则。
3.4 前端看板与交互实现
Vue.js前端部分,我重点实现了两个页面:库存总览看板和预警中心。
库存总览看板放在登录后的首页,展示维度有三个卡片区:今日库存预警数量(按级别用颜色区分)、近效期药品Top10列表、库存周转异常药品列表。数据接口是后端聚合好的DashboardDTO,一次请求返回全部卡片数据,避免前端多次调用。刷新策略上,我用的是30秒轮询加页面切换刷新,诊所场景不追求毫秒级实时性,轮询就够用了,没必要上WebSocket带来额外的连接维护成本。
预警中心页面是典型的列表加筛选:按药品名称搜索、按预警类型筛选、按级别筛选、时间范围选择。列表每行显示药品名、规则类型、触发值、参考值、预警描述、状态标签。操作按钮包括"标记已处理"和"生成采购建议"。
前端代码有个小技巧值得分享:预警级别的颜色映射不要散落在各个组件里,用计算属性统一维护。这样后续改视觉规范只需要改一处:
<template> <el-table :data="warningList"> <el-table-column label="级别"> <template #default="{ row }"> <el-tag :type="levelMap[row.level]">{{ levelText[row.level] }}</el-tag> </template> </el-table-column> </el-table> </template> <script setup> const levelMap = { 1: 'warning', 2: 'danger', 3: 'danger' }; const levelText = { 1: '普通', 2: '重要', 3: '紧急' }; </script>4. 几个关键业务细节的深入解析
4.1 安全库存的数学模型与计算过程
安全库存这个概念,很多开发者理解得过于简单,以为就是一个"最小库存量"。真正合理的做法是结合消耗速度和补货周期动态计算。
我最终采用的是类似零售行业经典的补货点算法,但做了轻量化裁剪:
安全库存 = 日均消耗量 × 补货周期天数 × 安全系数
日均消耗量,取最近90天出库总量的平均值。为什么是90天?因为有些药品存在季节性波动(比如感冒药冬季消耗明显上升),30天数据太敏感,180天又反应太慢,90天是折中值。统计SQL可以这样写:
SELECT drug_id, SUM(change_num) / 90 AS daily_consume FROM stock_flow_record WHERE change_type = 'OUT' AND change_time >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) GROUP BY drug_id;补货周期天数,指的是从发起采购到药品到货上架通常需要的时间。这个值在药品分类里配置:比如当地配送的常用药可能是3天,需要远程调货的冷门药要10天甚至更长。
安全系数,默认取1.5。这个系数的作用是吸收销售波动和采购延期风险。系统内置三档可选:1.2(货源稳定、用量平稳)、1.5(默认)、2.0(波动大、补货时间长)。
举个例子说明整个计算过程:某种胃药,过去90天共出库270盒,日均3盒;补货周期5天;安全系数1.5。那么安全库存 = 3 x 5 x 1.5 = 22.5盒,向上取整为23盒。也就是说,当该药库存低于23盒时,预警触发建议补货,而建议采购量 = 安全库存 + 补货周期消耗量 - 当前可用库存,算出来是23 + 15 - 实际库存。这个数字才是真正有业务指导意义的"建议采购量",而不是拍脑袋写个阈值。
4.2 药品批次与有效期管理的实现细节
效期管理是诊所药品管理里最容易出问题的环节,也是这套系统相对普通进销存系统最体现价值的地方。
药品入库时,必须要求录入批次号和有效期。系统按批次存储,出库时遵循"近效期先出"原则。这个逻辑看起来简单,但实际开发时有个容易翻车的细节:出库操作需要先把需求数量从多个批次里扣减,扣减顺序必须先按有效期排序,还要保证同一个批次的库存够扣时不能拆到下一个批次——如果涉及批次间分摊,还会遇到事务一致性问题。
我的出库扣减伪代码如下:
@Transactional(rollbackFor = Exception.class) public void outboundStock(Long drugId, Integer outNum, TenantContext tenant) { List<DrugBatch> batches = batchMapper.selectValidBatches(drugId, tenant); // 先按有效期升序排序 batches.sort(Comparator.comparing(DrugBatch::getExpiryDate)); int remaining = outNum; for (DrugBatch batch : batches) { if (remaining <= 0) break; int deductNum = Math.min(remaining, batch.getAvailableStock()); batch.setAvailableStock(batch.getAvailableStock() - deductNum); remaining -= deductNum; batchMapper.updateById(batch); flowRecordMapper.insert(buildOutFlow(drugId, batch.getId(), deductNum)); } if (remaining > 0) { throw new InsufficientStockException(); } }批次的效期预警规则单独处理:新建批次时计算"距有效期的剩余天数",落库时直接做好标记。预警引擎扫描时只需要一条SQL就能拿到所有近效期批次:
SELECT * FROM drug_batch WHERE expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL #{thresholdDays} DAY)4.3 预警分级与通知策略配置
预警不能"一刀切",不同级别需要不同的处理链路。我定义了三级别模型:
普通级别(1级):库存低于安全库存但未跌破最低库存线。这类预警只记录在预警中心,不做外部通知,药师当天上班看一眼即可。
重要级别(2级):库存低于最低库存线,或者药品距离有效期只剩3个月。此类触发站内信加微信公众号模板消息,提醒"需要安排采购"或"需要优先促销/调配"。
紧急级别(3级):库存归零且后台还有未处理处方需求,或者药品距离有效期只剩1个月。此类短信加电话通知(短信即可,电话留给极端情况),同时系统自动生成紧急采购单。
通知配置采用模板加渠道开关的方式。模板是在系统管理里维护的,每条预警规则可以绑定一个通知模板,模板内容支持占位符,比如"药品[DrugName]库存仅剩[StockNum]盒,已低于最低库存线,请及时处理"。解析时用正则替换占位符即可。
注意:短信接口的对接要提前确认服务商的审核机制。药品相关的内容在某些渠道可能被划分到特定行业,审核时间较长。一定要在联调阶段就做好这个准备,不然上线后预警短信发不出去会非常被动。
5. 实际部署与运维中的踩坑记录
5.1 并发扣减库存导致的超卖问题
第一次压测时,我就发现了一个严重问题:多个收费窗口同时结算,导致同一药品的库存扣减出现超卖——库存总量变成了负数。根源在于我的扣减逻辑是"先查库存,判断是否够用,再扣减",三步操作不是原子的,两个请求同时通过判断后,双双执行了扣减。
解决办法是使用数据库的行级锁做原子扣减。MyBatis-Plus的乐观锁插件实现起来最省事,但我最终选择的是SQL层面的原子更新加上条件判断:
UPDATE drug_stock SET available_stock = available_stock - #{outNum} WHERE drug_id = #{drugId} AND available_stock >= #{outNum}这条SQL只有受影响行数为1时才代表扣减成功,否则返回0就需要重新查询并提示库存不足。这个方案比乐观锁更简单直接,也不用引入额外插件,从根上避免了并发超卖。
5.2 预警风暴:重复通知引发的投诉
系统上线两周后,某诊所药师反馈:一早上收到六十多条同样的缺货预警短信。排查后发现原因是:该药连续多天处于缺货状态,定时任务每天凌晨扫描一次,每次都会生成一条新预警记录,并且按配置触发短信通知。
这个问题本质上就是预警记录没有做好"状态去重"。我在预警表增加了一个逻辑:同一天内,同一药品+同一规则,如果已有级别相同的预警记录存在,并且状态是"待处理",就不再生成新记录,而是更新原记录的"连续触发次数"和最近触发时间字段。只有状态变为"已处理"后,再次触发才会生成新记录。
这个改动在代码上只需要在写入前加一个条件查询,但业务体验提升非常明显。
5.3 前端轮询导致的连接数堆积
前端看板我一开始设置的轮询间隔是5秒。单个诊所用户量少没什么感觉,但当我在本地模拟了30个并发在线诊所用户联调时,后端发现连接数非常高。Nginx的默认连接超时配置加上频繁的轮询请求,导致大量处于TIME_WAIT状态的连接堆积。
排查后做了三个调整:轮询间隔从5秒改为30秒;前端增加一个"页面不可见时暂停轮询"的逻辑,用document.visibilitychange事件控制;后端接口增加了本地缓存,同一查询在10秒内的请求直接返回缓存结果。三个措施叠加后,后端压力下降了超过60%。
这个经验在诊所场景特别适用:药房人员通常只在上下午各看一次预警看板,并没有高频刷新的真实需求,过度的实时性只会浪费服务器资源。
5.4 容器部署中数据库连接池的配置陷阱
系统交付时用Docker Compose部署,后端容器配置了20个数据库连接池上限。结果运行几天后频繁报"HikariPool-1: Connection is not available, request timed out"。
排查发现两个问题:第一,Docker容器里JVM参数没限制内存,堆开到很大,导致容器频繁GC,数据库连接长时间被占用;第二,部分慢查询SQL没有索引,全表扫描拖长了连接占用时间。解决措施是:在启动参数里显式设置-Xms512m -Xmx512m,避免容器内存溢出风险;给库存流水的drug_id + change_time建立联合索引,慢查询日志清零。
提示:容器部署时,数据库连接池上限的配置一定要综合考虑上层的API网关连接数、数据库本身的最大连接数。诊所类系统规模不大,15到20个连接池就够用了,但前提是SQL必须走索引。
6. 扩展方向与个人心得
做完这套系统后,我个人最大的体会是:库存预警真正的技术复杂度不在算法有多花哨,而在于规则的灵活性和数据的准确性。同一个"缺货预警"需求,要考虑安全库存怎么算、补货周期怎么配、连续缺货怎么收敛、通知怎么不扰民——每个环节都需要跟实际业务场景打磨。
系统后续的扩展空间其实很大。效率最高的方向是先做采购单闭环,预警生成后直接一键转成采购申请单,推送给供应商或总部采购中心,这样预警从"告知"变成"处置",价值提升一个量级。其次是移动端适配,给药师做一个工作台小程序,每天早上的待办清单自动汇总所有级别的预警,省得打开网页看。再往后可以做销售预测优化,基于历史数据做季节性需求预测,动态调整安全库存系数,这就是真正往"智能"方向走了。
如果你现在正打算做一个类似的诊所或药店管理系统,我建议先把预警模型设计吃透,再动手写代码。具体说就是想清楚四个问题:预警规则面向哪些维度、阈值如何动态计算、预警记录如何避免重复、通知链路如何按级别分级。这四个问题解决了,系统的实用性和用户口碑基本就有了。至于界面好看不好看、代码结构花哨不花哨,在诊所场景里都是次要的——药房老师傅打开系统最关心的就是一件事:今天有没有哪个药需要处理。把这件小事做到极致,这个系统的价值就立住了。