我是怎么用Spring Boot搭医疗ERP的

前言

做医疗行业的程序员大概都有这个感受——市面上的ERP要么太重,要么太贵,要么根本不懂医疗行业的玩法。我之前在一家医疗器械公司做后台开发,天天看着业务人员用Excel管库存、用微信对账、用纸单子追批号,实在看不下去了,于是决定搞一个。

声明一下:这个项目并不是完全从零搭建的,而是基于市面上的开源项目改造而来。开源项目提供了基础的进销存框架,我在此基础上做了大量行业定制和功能扩展。站在巨人肩膀上比自己从零搭省事太多,这点得承认。

项目叫壶卢医疗ERP,名字取自"悬壶济世"的典故——葫芦是古代盛药的器具,也是中医的标志物,做医疗ERP,得有点仪式感。

项目地址:www.xnysj.cn:8088,支持自行注册使用。

技术选型

先说技术栈,不整花哨的,就是Spring Boot全家桶:

组件 版本 说明
Spring Boot 2.0.0 主框架
JDK 8 稳定为主
MyBatis-Plus 3.0.7 ORM,多租户插件很好用
MySQL 5.7 数据存储
Redis - 缓存+Token管理
Netty 4.1.x WebSocket聊天服务
Springfox 2.7 API文档
EasyPOI 4.4 Excel导入导出

别急着吐槽版本老,后面会说到升级的事。

为什么选Spring Boot

说实话,选Spring Boot没什么特别的理由,就是。做Java后台的,Spring生态是绕不过去的,Spring Boot又是最省事的启动方式。对于一个人开发的中小型项目来说,自动配置、内嵌Tomcat、starter依赖管理,这些就是刚需。

另外一个重要原因是多租户——医疗行业客户多是小微企业,SaaS模式是刚需。Spring Boot + MyBatis-Plus的多租户插件,SQL层面自动拼tenant_id,业务代码完全不用管,这个组合非常省心。

核心架构

多租户SaaS

这是整个系统最核心的设计。每个客户是一个租户,数据完全隔离:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 多租户插件,自动在SQL中拼接tenant_id条件
    TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(new TenantLineHandler() {
        @Override
        public Expression getTenantId() {
            // 从请求上下文获取当前租户ID
            Long tenantId = TenantContext.getTenantId();
            return new LongValue(tenantId);
        }
        @Override
        public String getTenantIdColumn() {
            return "tenant_id";
        }
        @Override
        public boolean ignoreTable(String tableName) {
            // 部分表不需要租户过滤
            return skipTenantIdTables.contains(tableName);
        }
    });
    interceptor.addInnerInterceptor(tenantInterceptor);
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
}

这种方案的好处是:业务代码完全不需要关注租户隔离,MyBatis-Plus在SQL执行前自动拼条件,查询时加 WHERE tenant_id = ?,插入时自动填充tenant_id。缺点是数据库层是共享的,属于逻辑隔离,不是物理隔离。对医疗行业小微企业来说够用了。

模块划分

系统分三大块:

1. 公司ERP — 面向企业用户的核心业务

  • 采购管理:请购单 → 采购订单 → 采购入库 → 采购退货
  • 销售管理:销售订单 → 销售出库 → 销售退货
  • 商品管理:商品信息、批号管理、库存修正
  • 财务管理:收入单、支出单、收款单、付款单、转账单
  • 报表查询:库存统计、采购销售统计、进销存、客户/供应商对账

核心业务流程:以销定购

这是整个系统最核心的设计,也是朋友公司日常用得最多的流程。

他们的业务场景是这样的:公司人不多,不是所有岗位都需要用系统,但采购是刚需——客户要什么,就采购什么,同一批采购可能要找不同供应商。所以系统的"以销定购"开关默认是开启的,整个流程走下来非常顺:

第一步:录销售订单

采购员先在"销售管理 → 销售订单"录一个单据,指定一个客户,商品明细里录入多条数据。这些商品可能来自不同供应商,但每个特定商品的供应商是相对固定的,所以供应商ID直接维护在商品信息里。选商品的时候可以根据供应商来筛选,非常方便。

第二步:销售订单转采购订单

这是关键操作。在销售订单页面有"转采购订单-以销定购"按钮,选择需要转的销售订单,点击后只需指定一个供应商,系统自动过滤出该供应商下的所有商品,一键生成一条采购订单。也就是说,一个销售订单可以自动拆分为多个采购订单,取决于销售订单里有多少个不同供应商的商品。

第三步:采购订单转采购入库

采购订单页面有"转采购入库"按钮,一键创建采购入库单据。入库时默认本次付款填0——因为货到了不代表钱付了。

第四步:付款闭环

财务登录系统后,在"财务管理 → 付款单"页面能看到所有有欠款的采购入库单,针对欠款单据创建付款单,形成采购和付款的闭环。

反方向同理:销售订单也可以通过"转销售出库"按钮快捷创建出库单据,出库后在"收款单"页面针对欠款创建收款单,形成销售和收款的闭环。

整个流程:销售订单 → 采购订单 → 采购入库 → 付款单,采购端闭环;销售订单 → 销售出库 → 收款单,销售端闭环。每一步都有快捷转单按钮,不用重复录入数据,效率很高。

2. 个人记账 — 这个是我自己加的,也是花心思最多的模块

很多人觉得ERP里放个人记账不伦不类,但实际使用中发现,很多小老板公私账目不分,ERP和个人财务混在一起管是真实需求。

随手记模块参考了市面上成熟的随手记App,在此基础上做了不少调整:

  • 今日收入/今日支出:两个页签分别展示,分类清晰
  • 分类管理:可以禁用用不到的分类,也可以修改分类别名,满足不同人的记账习惯
  • 今日记事和备注:不只是记账,还能随手记事
  • 图片附件:支持上传图片,拍照记账、存票据都很方便
  • 大额收支:个人空间独有模块,可以在一个页面同时记录和预览收入与支出数据。虽然单条记录只能是收入或支出,但多条数据录入后列表页可以统一查看,一目了然
  • 个人债务:借入借出管理

这里有个设计我觉得比较巧妙:系统分"公司ERP"和"个人空间"两种模式,它们是两个独立的租户,数据天然隔离。"账务管理"菜单下的"收入单"和"支出单"是两种模式都有的,但个人空间多了一个"大额收支"模块。在个人空间内,"大额收支"和"收入单/支出单"的数据是打通的——在"大额收支"里记的收入和支出,会同步出现在"收入单"和"支出单"列表中;反过来也一样。两个视角,一份数据,既能在"大额收支"里统览全貌,也能在"收入单"和"支出单"里分类管理。

说实话这块花了不少心思,尤其是分类管理——市面上的记账App分类都是写死的,我加了禁用和别名功能,灵活度一下子就上来了。用起来比专业的记账App还方便,因为数据在一个系统里,不用来回切。

3. 系统管理 — 权限和配置

  • 租户管理员:角色管理、用户管理、机构管理、日志管理
  • 超级管理员:功能管理、租户管理、商品属性、FAQ管理、插件管理

聊天工具

这个也是我自己加的,基于Netty实现的WebSocket聊天服务。说实话目前用户用得不多,没什么反馈,算是实验性功能。但功能还是做全了:

  • 默认群组:租户内所有成员自动加入,消息全员可见
  • 私聊:点对点即时沟通
  • 系统助手:对接FAQ管理,用户提问自动匹配回答,相当于内置的智能客服
  • AI助手:对接AI大模型,用户可以直接与AI对话,目前大模型部署在本地服务器,线上环境通过内网穿透访问
  • 文件传输助手:跨设备传文件
  • 好友系统:跨租户沟通,添加好友即可

系统助手是个比较有意思的设计。管理员在FAQ管理页面维护问答对(支持分类树、排序、启用/禁用),用户在聊天中向系统助手提问,系统自动匹配最相关的FAQ回答。这样常见问题不用人工回复,新员工也能快速上手。

医疗行业的特殊需求

做医疗ERP和做通用ERP最大的区别,就在批号管理上。

医疗器械有批号、生产日期、有效期,这三个字段对医疗行业来说非常重要。商品可以设置为无批号,也可以开启批号管理,是灵活可选的。目前用户还在熟悉系统阶段,暂时没有启用批号功能,等系统用顺了再把批号考虑进去,减少人工追批号的工作。

商品开启批号管理后:

  1. 采购入库时必须填写批号、生产日期、有效期
  2. 销售出库时必须选择具体批号
  3. 商品名称后面自动带"批"字标识
  4. 库存按批号维度统计,不是简单的数量汇总

这个看似简单的功能,对数据模型的影响是巨大的。同一商品不同批号是不同的库存记录,出入库要指定批号,报表要按批号维度展示。通用ERP不管这些,但医疗行业迟早要用到批号管理。

另外还有几个医疗行业的特色字段:

  • 注册证号:医疗器械注册证编号
  • 存储注意事项:冷链、避光等要求
  • 生产厂家:与供应商可能不同,需要独立字段

这些字段在业务单据导出时会用到,不填就缺内容。

数据库设计要点

表结构

48张表,核心表几个:

  • jsh_depot_head / jsh_depot_item:单据主表/子表,所有出入库单据共用一套表,通过type字段区分
  • jsh_account_head / jsh_account_item:财务主表/子表,同理
  • jsh_material:商品表,40个字段,医疗行业字段多
  • jsh_material_extend:商品价格扩展,支持多仓库多价格
  • 所有表都有 tenant_iddelete_flag,逻辑删除 + 租户隔离

单据编号

jsh_sequence 表实现自增编号,格式如 CGRK20260520001(采购入库+日期+序号),比数据库自增ID更适合业务展示。

遇过的坑

坑1:Spring Boot 2.0太老了

这是最大的坑。2.0.0版本对JDK 11+支持不完整,CGLIB反射访问在JDK 11会警告,JDK 17直接报错。升级计划8→11→17→21分四步走。

坑2:循环依赖

Spring Boot 2.6开始默认禁止循环依赖,我的项目里Service层有循环引用。解决办法:

  • 简单查询的循环依赖:直接改调Mapper,去掉Service层的间接调用
  • 有业务逻辑的循环依赖:加 @Lazy 注解
  • 第三方框架的循环依赖(springboot-plugin-framework、PageHelper):临时配置 spring.main.allow-circular-references=true 兜底,后续升级框架版本根治
@Service
public class OrderService {
    @Lazy
    @Autowired
    private UserService userService;
}

坑3:MyBatis-Plus大版本升级

从3.0.x升到3.5.x,整个插件体系重构了。PaginationInterceptorMybatisPlusInterceptorTenantSqlParserTenantLineInnerInterceptorSqlParserFilter@InterceptorIgnore 注解。迁移工作量不小,但新API更清晰。

另外MyBatis-Plus 3.5.x带的JSqlParser 4.x对SQL解析更严格,ifnull(delete_flag,'0') !='1' 会报错,改成标准SQL写法 <> 即可:ifnull(delete_flag,'0') <> '1'

坑4:sun.* 内部API

JDK 8可以随便用 sun.security.action.GetPropertyAction,JDK 11+默认不让访问。替换方案很简单:

// 旧写法
String tmpDir = AccessController.doPrivileged(new GetPropertyAction("java.io.tmpdir"));

// 新写法
String tmpDir = System.getProperty("java.io.tmpdir");

坑5:maven-assembly-plugin版本太低

2.5.1版本跟JDK 11不兼容,打包报 ZipArchiver 错误,升级到3.6.0解决。

坑6:Tomcat对URL特殊字符校验更严格

Spring Boot 2.7内嵌的Tomcat升级后,URL中的 {}[] 等特殊字符会被拒绝,直接返回400。前端传JSON作为查询参数时必踩。解决办法:

server.tomcat.relaxed-query-chars={,},[,]

坑7:Springfox与Spring Boot 2.6+不兼容

Spring Boot 2.6默认路径匹配策略从 AntPathMatcher 改成了 PathPatternParser,Springfox不支持,启动直接NPE。临时解决:

spring.mvc.pathmatch.matching-strategy=ant_path_matcher

后续Step 3会把Springfox换成SpringDoc,彻底解决。

坑8:空字符串转Long报错

Spring Boot 2.7对参数转换更严格了。前端传 ?id=(空值),旧版本空字符串转Long会得null,新版直接抛 MissingServletRequestParameterException。解决办法:要么逐个接口加 required = false,要么全局注册 CustomNumberEditor 允许空值。

坑9:MySQL驱动坐标变了

mysql:mysql-connector-java:8.0.33 已经迁移到 com.mysql:mysql-connector-j:8.0.33,Maven会警告。驱动类名也要从 com.mysql.jdbc.Driver 改成 com.mysql.cj.jdbc.Driver,连接URL需要加 serverTimezone=Asia/Shanghai 参数。

坑10:Redis客户端从Jedis换成了Lettuce

Spring Boot 2.x默认Redis客户端从Jedis切到了Lettuce,Lettuce的连接池依赖 commons-pool2,不加的话连接池功能不生效。

升级进度

JDK 8→21的升级计划,分四步:

Step 目标 状态
Step 1 JDK 8→11,编译运行通过 ✅ 已完成
Step 2 Spring Boot 2.0→2.7 + 依赖升级 ✅ 已完成
Step 3 JDK 11→17 + Spring Boot 2.7→3.x 🔄 进行中
Step 4 JDK 17→21,最终验证 ⬜ 待开始

升级分支名:upgrade/jdk21,每个Step打tag。

Step 2踩了最多的坑,一口气解决了循环依赖、MyBatis-Plus插件重构、Tomcat参数校验、Springfox兼容性、MySQL驱动迁移、Redis客户端切换等问题。Step 3是真正的坎——javax→jakarta命名空间迁移 + Spring Boot 3.x配置重写。

一些开发体会

1. 别过度设计

我的项目就是一个人开发的,没搞微服务、没搞消息队列、没搞分库分表。Spring Boot单体 + MySQL + Redis,够用了。过早优化是万恶之源,业务跑起来再说。

2. 多租户要早定

多租户方案(共享数据库逻辑隔离 vs 独立数据库物理隔离)是架构层面最早要做的决定。我选了共享数据库,用MyBatis-Plus的租户插件,开发成本最低。如果一开始没考虑多租户,后期加会非常痛苦。

3. 批号管理是医疗行业的刚需

做医疗行业ERP,批号管理迟早要用上。库存、出入库、报表、对账,所有环节都要考虑批号维度。开源项目自带了批号功能,可以先不启用,但数据模型设计阶段就要想清楚,后期加会非常痛苦。

4. 聊天工具目前是实验性功能

加聊天工具的时候觉得ERP系统内沟通成本高,采购问仓库、仓库问财务都跑到微信上去了。想法是好的,但实际上用户用得不多,目前算是实验性功能,后续看看有没有更好的切入点。

5. 开源项目是好的起点

壶卢医疗ERP是基于开源项目改造的,省了大量基础功能开发时间。开源项目本身已经包含了批号管理等功能,但通用方案和医疗行业之间还是有差距——医疗字段定制(注册证号、存储注意事项等)和聊天工具得自己搞。站在巨人肩膀上比自己从零搭省事太多。

写在最后

这个项目从立项到现在,断断续续做了两年多。一个人搞全栈,从后端到前端到部署,累是累了点,但看着系统从开源项目一步步改造到能在生产环境跑起来,还是挺有成就感的。

系统目前还在迭代,JDK升级进行中,后续还会加上更多功能。如果你也是做医疗行业的开发者,或者对Spring Boot ERP感兴趣,欢迎来体验:www.xnysj.cn:8088,自行注册即可使用。

有问题也可以在CSDN评论区交流,大家一起进步。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐