
做乡村诊所药品配送这套系统前前后后用了将近三个月。项目一句话概括就是基于Spring Boot Vue给乡镇卫生院和下面各村卫生室搭一套药品申请、审核、配送、签收的闭环管理系统。刚开始看到这个需求时我脑子里第一反应是这不就是个进销存加个配送单吗真做进去才发现药品流通和普通商品配送完全是两码事批号、效期、冷链、近效期预警、库存预占、多级权限每个细节都能折腾人。这篇就把整个项目的拆解思路、方案选型、核心模块实现和踩坑记录完整写出来给正在做医疗信息化、县域药品供应保障或者类似To B仓储配送系统的朋友做个参考。1. 需求定位乡村诊所药品配送系统到底要解决什么问题1.1 从3天没送到的一盒退烧药说起我在前期调研时蹲过一家村级卫生室看过他们的真实工作状态。村医报药靠什么靠电话、靠微信群甚至靠药厂业务员跑村时顺口说一句。整个流程是村医翻库存本子手写缺药清单拍照发到卫生院药库的微信群药库管理员再对着照片手动录入。碰上信号不好、照片拍糊了、字写得潦草的情况一盒退烧药配送三天到不了村太正常了。这还不是最头疼的。药品配送后村医收货时只看包装箱里有没有药很少对批号、效期更不会去核对这次送来的药和上次送来的药是不是同一批。时间一长柜子里堆了好几批不同的药先到的没过期后到的反而先过期。药库那边也说不清楚哪个村还剩多少库存就只能哪个村叫得凶就先送哪个村。整个链路里需求是零散的、配送是凭经验的、库存是糊涂账。这个项目要解决的核心问题不是做一个漂亮的药品管理系统而是把药品从卫生院到村卫生室这一段混乱的配送链路理顺让每一盒药有去向、有批号、有效期可查。1.2 系统边界与角色闭环谁在用信息怎么流转做系统前先得把角色和流程盘清楚。这个系统里的用户不是普通电商用户而是有明确分工的医疗相关角色村卫生室的医生/护士负责发起药品申领查看自己的库存和待收货状态。卫生院药库管理员负责审核各村的申领计划安排拣货、出库、打印配送单。司机/配送员负责按配送单去各村送货在系统里确认送达。卫生院管理员/药师负责人维护药品目录、设置库存预警线、查看近效期报表系统参数也归他管。核心业务流程是这样闭环的村医在系统里勾选缺药品种、填数量提交一张申领单系统把多个村的申领单按统一模板汇总成一份配送计划药库管理员审核计划确认库存够不够不够就驳回或者部分满足审核通过后生成正式配送单系统自动锁定对应批次的库存仓库按单拣货、出库扣减司机送货村医扫码或签字确认收货系统更新村级库存。这个闭环一旦跑通从村医下单到药品签收全链路都有记录谁在哪个环节卡住了打开系统看状态就知道。1.3 为什么不能直接套用通用进销存很多朋友会问市面上现成的进销存软件一大把为什么还要单独开发因为通用进销存的核心是管好仓库自己的进销存账它不考虑配送环节的角色协同。村医要的不是一个复杂的库存软件他要的是我缺什么系统能告诉我该申请什么申请了之后药什么时候到药库管理员要的不是一堆出入库单据而是各村报上来的需求怎么汇总、怎么分配库存、怎么安排配送最合理。通用进销存里没有申领计划这个概念更不会有按批次锁定库存给某个诊所的流程。它通常只记录出库给谁不关注出库后谁签收、签收时批号对不对。而医疗场景对批号、效期、追溯的要求又特别严格这不是加两个字段就能解决的得从数据模型上围绕配送计划来重新设计。所以这个项目不是做一个进销存而是做一个以配送计划为主线、以批号效期为准绳的药品供应协同平台。2. 技术选型与项目架构拆解2.1 选定Spring Boot Vue这套组合的核心理由技术上为什么选Spring Boot Vue而不是其他更新潮的组合我在做决定前把几个方向都过了一遍。后端用Spring Boot核心原因是Java在医疗信息化领域的人才储备和生态积累太扎实了。Spring Boot把配置、打包、部署都简化了内置Tomcat一个Jar包就能跑起来特别适合这种功能不算特别复杂、但要稳定可靠的中小型管理系统。项目规模远没到需要微服务的程度强行拆十几个服务只会增加运维负担。我当时定的基调就是单体应用 模块化代码业务模块之间通过接口交互后续如果某个模块真的复杂到需要独立部署再拆出去也来得及。前端用Vue主要看中它的渐进式特性和组件化开发效率。这个系统的页面不少申领单、配送计划、库存台账、报表看板几十个页面Vue的单文件组件写起来清晰Element Plus这类组件库可以直接把表格、表单、弹窗这些高频组件都覆盖掉。对比过其他方案React生态虽然也成熟但对团队里习惯Vue的同事来说学习成本更高也考虑过后端模板引擎直接渲染页面但前后端分离之后接口可以同时支撑H5端和后续的小程序端村医以后手机上操作也方便这是模板渲染给不了的。2.2 整体功能模块划分从基础数据到统计报表整个系统在功能上划分成六大模块前期做需求文档时我把每个模块的输入、输出、涉及角色都列了表格基础数据模块药品目录、诊所档案、用户角色、数据字典。这个模块是所有业务的地基。申领管理模块村医提交申领单、查看审核状态、修改未审核的单据。配送计划模块系统按日期汇总各村申领单生成配送计划支持人工调整分配数量。库存管理模块入库、出库、盘点、批次库存查询、库存预警。配送签收模块司机配送、村医签收、签收差异登记。统计报表模块各村药品耗用趋势、配送及时率、近效期药品列表、库存周转情况。模块划分的原则是高内聚、低耦合。比如申领和配送计划虽然是连贯的两个环节但分别独立成模块后申领单可以积累多天配送计划可以灵活调整不会互相锁死。实际开发时我把基础数据和数据字典单独抽了一层相当于做了一套公共底座后面所有模块都依赖它这个底座做扎实了业务模块的开发速度就上来了。2.3 数据库设计的几个关键决策批号、双单位、状态字段数据库设计是整个项目里最需要提前想清楚的环节药品管理的数据模型和普通商品有几个明显的区别我得单独拿出来讲。第一库存必须按批次管理。同一盒药批号不同效期不同不能混在一起算库存。所以我建了一张批次库存表核心字段有药品ID、批号、生产日期、效期、当前数量、锁定数量、所在仓库。库存台账页展示的是药品维度的汇总但实际扣减和预占必须精确到药品ID 批号这一层。第二双单位问题。药品经常有盒和最小单位两种计量方式比如一盒12片村医申领时习惯说要2盒药库出库时可能按最小单位清点。为了避免单位换算混乱我统一用最小单位作为系统内部存储单位界面上展示时再按包装规格换算换算关系放在药品基础信息里。第三核心单据的状态字段。我建议用可读性强的varchar状态码比如WAIT_SUBMIT、WAIT_AUDIT、WAIT_PICK、DELIVERING、FINISHED、REJECTED不要用莫名其妙的0、1、2。状态码可读性高排bug时不用对着代码查注释而且后续要加状态直接加字符串枚举就行不会打乱原有顺序。下面这个是申领单主表的核心SQL示例其他的配送计划表、批次库存表结构类似CREATE TABLE drug_apply_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL COMMENT 申领单号, clinic_id BIGINT NOT NULL COMMENT 诊所ID, status VARCHAR(20) NOT NULL DEFAULT WAIT_SUBMIT COMMENT 状态, apply_user_id BIGINT NOT NULL COMMENT 申领人, audit_user_id BIGINT COMMENT 审核人, audit_remark VARCHAR(255) COMMENT 审核意见, total_amount DECIMAL(12,2) DEFAULT 0 COMMENT 预估金额, expect_delivery_date DATE COMMENT 期望送达日期, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_clinic_status (clinic_id, status), KEY idx_create_time (create_time) ) COMMENT药品申领单主表;我特别强调一个点业务表尽量都加上update_time的自动更新否则排查谁在什么时候改了数据时只能看操作日志效率很低。另外凡是经常按诊所状态查的表联合索引一定要建否则数据量一上来查询性能肉眼可见地变差。3. 核心功能模块的实现思路与关键细节3.1 药品目录、批号管理与库存预警从囤菜到效期雷达药品目录是全院统一的村卫生室不能自己往系统里乱加药只能从目录里挑。目录字段包括通用名、商品名、规格、剂型、生产厂家、批准文号、存储条件、默认预警天数等。这个统一目录的好处是一次维护、处处使用报表统计时不会出现同一个药两个村叫法不一样的问题。库存预警我设计了两套逻辑一套是数量预警一套是效期预警。数量预警给每个药设置一个最低库存阈值库存低于阈值时系统在药库管理员的首页弹提醒同时给对应科室生成补货建议单。效期预警更关键药品效期在6个月内的系统自动列入近效期清单效期3个月内还在出库的出库时强制弹窗提醒药师确认。这个逻辑可能大家觉得简单但真正要写对有个坑不能只看药品主表的效期要看批次库存表里每个批次的效期而且要把数量大于0的批次过滤出来否则已经出库完的历史批次也会出现在预警列表里干扰判断。预警功能其实很像家里冰箱的囤菜逻辑买菜时想着肯定能吃完放久了就忘了等想起来已经在角落里坏掉了。药库如果不做效期雷达一批药在角落躺过期损失不只是钱还可能牵扯用药安全责任。3.2 配送计划与订单状态机设计库存预占是灵魂配送计划是整个系统的业务主干。村医提交申领单后系统把同一天的申领单汇总成一张配送计划药库管理员打开计划能看到每个村申请了什么药、申请了多少、当前总库存多少。管理员可以手动微调数量比如A村库存不够就把A村的申请量调低把多的库存分给B村然后审核通过。审核通过瞬间系统要做的就是库存预占——把对应批次的库存从可用数量划到锁定数量。这个锁定动作是整个系统的灵魂。为什么要锁定因为药品是稀缺资源两个村同时申请同一种药如果谁先出库谁就能拿后出库的人发现库存没了配送计划就被打乱。预占机制相当于给每个村的申请先占个位审核通过后这批药在物理上虽然还没出库但已经在系统里被标记为这个村的其他村的申请不能再用这部分库存。状态机我设计成WAIT_AUDIT待审核→ WAIT_PICK待拣货/已预占→ DELIVERING配送中→ FINISHED已签收中间任意环节可以走REJECTED驳回回到村医修改。每次状态变更都要写操作日志记录操作人、操作时间、操作前后状态、备注这是医疗物流追溯的基本要求。3.3 多角色权限控制怎么落地页面级、按钮级、数据级这个系统的用户角色包含村医、药库管理员、配送员、系统管理员权限控制必须做到三层页面级权限不同角色看到不同菜单按钮级权限比如药库管理员才能看到审核通过按钮村医看不到数据级权限比如村医只能看自己诊所的申领单和库存不能看到其他村的数据。后端我用Spring Security JWT做认证授权。登录成功后签发JWT里面带上用户ID和角色编码每次请求由拦截器解析JWTSpring Security的PreAuthorize注解做细粒度校验。数据级权限不是靠SQL里写死where条件而是通过自定义数据权限注解在服务层解析当前登录用户的clinicId自动拼接到查询条件上。前端则用Vue Router的导航守卫做菜单路由控制登录后根据后端返回的菜单列表动态生成路由按钮级用自定义指令v-permission控制显示。权限这块最怕的是前端口子松了后端没兜底。我一直坚持前后端双重校验前端控制只是用户体验后端必须有硬校验否则跳过页面直接调接口数据就裸奔了。4. 实操记录环境搭建、代码落地与部署要点4.1 开发环境选择与项目初始化少踩版本坑开发环境方面我统一用了这些版本组合JDK 1.8Spring Boot 2.7.xVue 3 Vite Element PlusMySQL 8.0Redis 6.xMaven 3.8。选JDK 8不是因为它新而是因为它稳市面上主流的生产环境跑JDK 8的比例仍然很高项目维护成本低。Spring Boot 2.7和Vue 3这个组合经过大量项目验证资料多遇到问题搜起来方便。项目初始化时我用Maven多模块方式拆了common、system、drug、delivery、report五个子模块。common放公共工具类、统一返回结果、异常处理system管用户角色权限drug管药品目录、批次库存delivery管申领、配送计划、签收report管统计报表。这样做的好处是边界清晰ci编译时各模块依赖关系明确不会出现一个类不知道该放哪个包的问题。前端用Vite创建Vue3项目配好路径别名、环境变量、axios封装然后引入Element Plus和Pinia。部署方案我做了个减法单机部署就够了。一台4核8G的云服务器装好MySQL、Redis、Nginx后端打一个Jar包跑systemd服务前端build之后丢到Nginx的静态目录反向代理/api到后端端口。这样运维只需要会基本的Linux命令就行。不要一上来就上K8s乡村卫生院没有人天天盯着集群单体Jar包加个定时备份反而是最可靠的方案。4.2 库存预占与事务回滚的代码实现注意锁和并发库存预占是整个系统技术含量最高的地方这里直接放一段核心的服务层代码。Transactional(rollbackFor Exception.class) public void lockStock(DeliveryPlanDetail detail) { // 1. 查询该药品所有可用批次按效期升序先进先出 ListBatchStock batches batchStockMapper.findAvailableBatches( detail.getDrugId(), detail.getRequiredQty()); int remainQty detail.getRequiredQty(); for (BatchStock batch : batches) { if (remainQty 0) break; // 2. 乐观锁方式预占只更新可用数量 需要数量的批次 int rows batchStockMapper.lockBatch( batch.getId(), remainQty, batch.getAvailableQty()); if (rows 0) { remainQty - remainQty; } } if (remainQty 0) { throw new BusinessException(库存不足无法锁定); } // 3. 生成预占明细后续出库时直接引用 deliveryLockMapper.insert(detail); }每行代码背后的逻辑我得说明白。第一步按效期升序取批次是为了实现先进先出优先锁定先到期的药避免后到期的先出库效期管理就废了。第二步是核心通过update语句带条件available_qty 需要数量来原子性地扣减如果影响行数为0说明这个批次被别人锁过了跳过继续下一个批次。这里没有用悲观锁是因为select for update在高并发下容易把行锁住影响其他诊所的正常操作乐观锁加条件更新这个写法简单高效已经能覆盖诊所并发申请的场景。第三步如果所有可用批次凑不齐需求数量直接抛异常回滚。Transactional保证了预占明细和批次扣减的一致性要么全部成功要么全部回滚不会出现批次库存扣了但预占明细没生成的不一致状态。这段代码我在压测时试过并发200个请求同时锁同一批库存最终库存数始终不为负没有出现超卖。4.3 前端动态路由与权限按钮的实现前端权限控制我从登录后就开始。用户登录成功后后端一次性返回用户信息和权限码列表前端把它存到Pinia里。然后通过router.beforeEach做三件事判断有没有token没有就跳登录页有token但还没生成动态路由就调用后端接口拉取菜单和按钮权限生成动态路由已经放行的就直接进入。核心的路由守卫代码如下router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token) { if (to.path /login) return next() return next(/login) } const permissionStore usePermissionStore() if (permissionStore.routesLoaded) { return next() } permissionStore.generateRoutes().then(routes { routes.forEach(route router.addRoute(route)) permissionStore.routesLoaded true next({ ...to, replace: true }) }) })这里有个容易踩的坑动态路由用addRoute添加后当前导航是进行中的状态如果直接next()会白屏或者404。正确做法是next({...to, replace: true})让它重新触发一次导航这次动态路由已经挂载好了页面才能正常渲染。按钮权限用自定义指令v-permissiondelivery:audit指令内部检查当前用户权限码列表没有就直接把元素移除。这个方案好处是权限逻辑集中在指令里代码里不用到处写v-if判断。4.4 弱网环境下的接口设计与部署注意乡村的4G/5G信号不稳定村医的手机可能在屋里没信号、走到院子才有两格。这个现实决定了接口设计不能按城市内网的思路来。我做了三个针对性处理第一个是接口超时时间调高。axios默认超时设置成15秒后端对应调整接口响应时间避免村医在信号差的地方点一下按钮转圈半天然后报超时。第二个是幂等性设计。村医提交申领单时前端生成一个请求流水号UUID后端用这个流水号做唯一索引重复提交同一流水号直接返回第一次的结果不会重复生成两张申领单。这个处理太重要了村里信号差用户等不及就会再点一次没有幂等性重复单能把整个配送计划打乱。第三个是操作反馈做轻提示而不是强弹窗。村医年纪普遍偏大用电脑本来就不熟练弹窗太多他反而不知道点哪里。所有操作成功就在页面顶部浮一个绿色提示条失败才用弹窗减少干扰。部署上Nginx配置了gzip压缩接口数据量大的列表页开启压缩后传输体积能降一半以上弱网下打开速度提升明显。5. 上线前后遇到的真实问题与排查技巧5.1 效期先进先出执行不到位的坑从源头规范出库逻辑上线第一周我就接到卫生院药库的电话某个村收货时发现送去的药效期比仓库里留着的还短等于是先到期的药还在库里后到期的先送走了。排查发现问题出在拣货环节。系统虽然出库时按效期排序推荐了批次但仓库管理员实际拣货是看着打印出来的配送单去货架拿的货架上同一个药品不同批次没有做物理分区管理员随手就拿了一批无意中绕过了系统的先进先出逻辑。这个问题光靠系统强制不够得线上线下结合解决。系统端我在出库单上把推荐批次用加粗大号字体突出显示并且加了二次确认弹窗提示本单包含近效期批次请确认先拣效期更短的批次。线下端我建议药库调整货架摆放同一药品只放一个主批次新到批次暂时放待上架区系统上架时才录入批号。这个组合动作执行后再也没有出现过先到期后出库的情况。5.2 并发预占导致的库存负数问题一条update语句解决的教训开发阶段我在本地模拟两个诊所同时申请同一种库存紧张的药品时出现过一次库存变负数的情况。现象描述一下药品库存剩10盒A诊所锁了8盒B诊所几乎同时锁了8盒两个请求都成功了扣减后库存显示-6。原因很典型我的扣减逻辑是先select查库存判断够不够再update扣减select和update之间不是原子的第二个请求在第一个update之前读到了旧的库存数10判断8 10就执行扣减于是两个8都扣成功了。修复方案就是我前面代码里展示的把查询扣减合并成一条带条件的update语句让数据库来保证原子性。核心SQL长这样UPDATE batch_stock SET locked_qty locked_qty #{qty} WHERE id #{batchId} AND available_qty #{qty}如果更新行数为0说明库存被其他人先锁走了代码里就继续尝试下一个批次。这个教训给项目组所有人的印象都很深高并发场景下不要相信先查再改要相信条件更新。5.3 数据导入不规范引发的耗用统计错误系统上线时需要导入历史药品库存和厂家信息导入后发现月度耗用统计报表里同一个药出现了好几行比如阿莫西林胶囊和阿莫西林 胶囊中间多了个空格系统就当成两个药品。这种脏数据直接导致统计口径混乱药库管理员反映报表没法看。排查后定了一个规矩药品名称和厂家名称的导入清洗。导入Excel时代码里统一做trim去空格、全角转半角、繁体转简体厂家名称建立简称-标准名对照表比如系统里维护华北制药的标准名导入时如果出现华北制药厂华药这种变体自动映射到标准名。数据字典表我提前定义好了编码分类所有下拉选项都走字典不硬编码在前端后续要加配送方式、存储条件这些枚举改数据库一行记录就行不用发版。这个问题也反映出一个通用教训系统上线时的初始数据质量直接决定后面所有报表的准确度。宁可前期多花两天做数据清洗也不要后期花两周去核对每一行统计数字的来源。这套系统做下来我最大的体会是乡村场景做软件技术不是瓶颈理解业务和落地细节才是。Spring Boot Vue这套组合很成熟真正拉开差距的是你知不知道药品要按批次管、配送计划要锁库存、村医在弱网环境会重复提交。我个人建议如果后续想扩展可以往两个方向走一是给药品申领加一个智能推荐根据每个村近三个月的耗用均值自动生成申领建议减少村医手动计算的工作量二是做一套移动端H5或小程序让村医用手机就能处理申领和签收毕竟很多村卫生室没有像样的电脑。项目到这一步就已经能稳定跑了而这一路踩过的坑恰恰是这类系统最值钱的部分。