☰
HIS系统源码落地:字典初始化、门诊收费与发药一致性拆解
2026/10/30 6:02:28 网站建设 项目流程

简介:这套完整HIS系统源码面向医院信息化开发者、Delphi程序员及医疗软件学习者,覆盖药房管理、护士站、药库管理、住院结算等核心业务模块,可用于理解医院信息系统的业务流程、界面组织与数据交互方式。压缩包共4290个文件,约6.51MB,以pas源码、dfm窗体、res资源、dpr工程及dcu编译单元为主,并含sql脚本、文档与维护资料,便于对照VCL界面实现和数据库结构进行学习。已有2603人学习下载,说明其在HIS与Delphi技术社区中具有一定参考价值。读者可从中获得较完整的模块划分、电子医嘱与护理记录、药品库存与住院费用结算等实现思路,以及SmallStruct数据组织、系统文档和维护排错线索,适合做二次开发参考或课程设计拆解。

1. 一套 HIS 系统源码摆在面前:先别急着点启动

上周有个同行发来一份 HIS 系统源码包,说他跑了两天,登录页面出来,登录后是白屏。我让他执行select count(*) from bd_charge_item,返回 0。问题不在代码,在于他跳过字典脚本,直接导入了演示数据;一个没有收费项目字典的门诊系统,和空壳没有区别。

HIS 系统这类资源,完整不完整,看的不是模块数量和代码行数,而是三件事:字典表有没有初始化、主链路(挂号→就诊→收费→发药)的事务边界清不清楚、对外接口有没有留对照层。这份包的价值在于门诊、住院、药房、报表、权限五条线都齐,坑在于它默认你懂医院的业务顺序,文档写得很省。

适合谁:想拿一套能跑的院内系统做二次开发或课程设计的人;想摸清门诊收费链路怎么设计事务的人。不适合指望开箱即用、直接上线的人。

2. 把源码包跑起来:建库顺序、连接串参数与前后端联调

2.1 模块划分与技术栈:这套组合为什么在 HIS 里够用

拆开压缩包,目录结构大致是后端多模块 + 前端单工程:

模块职责备注
his-common统一返回、异常、工具、常量被所有模块依赖,改这里要全量回归
his-auth登录、JWT、角色菜单和 basedata 一起启动,否则菜单空
his-basedata科室、人员、收费项目、药品、诊断、对照表整个系统的地基
his-outpatient挂号、就诊、处方、收费、退费主链路所在
his-pharmacy药库出入库、药房发药、库存依赖 outpatient 的处方
his-inpatient入院、医嘱、记账、出院结算可以后置启动
his-report日报、月报、科室核算建议单独数据源
his-interface对外交互层只在配置齐全时才装配

技术栈是 Spring Boot + MyBatis-Plus + MySQL 8 + Redis + Vue 3 + Element Plus,鉴权走 JWT,定时任务用 Quartz。为什么这套组合在 HIS 场景站得住:HIS 的并发峰值极其集中,早上 7:30 到 9:00 的挂号收费能占全天三成以上,剩下时间几乎闲置,所以不需要上微服务那套东西,单体加连接池调优足够;而业务上又要求强事务,同库同事务比跨服务 Saga 省心得多。

真正需要你判断的是三个边界。第一,住院和门诊是不是共用一张收费明细表——共用会方便报表,但退费逻辑会互相污染。第二,药房库存扣减发生在收费环节还是发药环节。第三,报表是不是直连主库。这三个选择在源码里都有体现,改动成本很高,动手前先看清楚。

2.2 建库与初始化:四个脚本的执行顺序不能换

sql/目录下的脚本一般按编号排,但顺序不是随便标的,字典必须早于业务数据,菜单必须早于角色绑定。

# 1) 建库:字符集必须 utf8mb4,患者姓名和地址里出现生僻字是常态 mysql -uroot -p -e "CREATE DATABASE his DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2) 按编号顺序导入,跳步会导致外键和字典关联断裂 mysql -uroot -p his < sql/01_schema.sql # 表结构与索引 mysql -uroot -p his < sql/02_dict.sql # 科室/收费项目/药品/诊断字典 mysql -uroot -p his < sql/03_menu.sql # 菜单与按钮权限 mysql -uroot -p his < sql/04_demo.sql # 演示患者与排班,可选 # 3) 导入后立刻验证字典是否落地,别等登录后才发现 mysql -uroot -p his -e " SELECT '收费项目' AS t, COUNT(*) FROM bd_charge_item UNION ALL SELECT '药品', COUNT(*) FROM bd_drug UNION ALL SELECT '科室', COUNT(*) FROM bd_dept UNION ALL SELECT '菜单', COUNT(*) FROM sys_menu;"

这段的关键在第二步。02_dict.sql里除了字典本身,通常还包含排班模板和号别定义,04_demo.sql依赖它们生成演示排班;顺序反了不会报错,只会让你在挂号页看到「无可用号源」,然后花两小时去查号源表。最后那条校验 SQL 是为了把「导入成功」和「导入有效」区分开,我一般会把它固化成初始化流程的最后一步。

还有一个容易忽略的点:如果脚本里有SET FOREIGN_KEY_CHECKS=0,记得确认末尾有没有恢复成 1。有些包里漏了恢复语句,后续所有外键约束都是失效状态,数据错乱时你连报错都看不到。

2.3 三个配置项决定你能不能登进去

后端启动前,application.yml里有三处必须改,改错的表现各不相同:

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/his?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: his password: 改成你自己的 hikari: maximum-pool-size: 20 # 早高峰集中在半小时内,池子按默认 10 会在收费页排队 connection-timeout: 3000 # 超时设短一点,快速失败好过页面转圈 max-lifetime: 1200000 # 要小于 MySQL 的 wait_timeout,否则报 Communications link failure redis: host: 127.0.0.1 port: 6379 database: 3 # 单独库号,避免和同机其他服务串 key his: reg: slot-lock-seconds: 300 # 号源锁定时长,必须大于一次挂号完整链路耗时 security: token-expire-minutes: 480 # 门诊一个班次八小时,中途掉登录会被投诉

serverTimezone不写或写错,表现是收费记录的时间比实际早或晚八小时,日报统计直接错位。characterEncoding写utf8而不是utf8mb4,表现是部分患者姓名存成问号,且只在特定字符上出现,最难查。database不隔离,表现是别人的 session 把你的验证码覆盖了,登录偶发失败。

前端这边:

cd his-web npm config set registry https://registry.npmmirror.com npm install # .env.development 里只改这两行,别把本地地址带进生产构建 # VITE_API_BASE_URL=http://127.0.0.1:8080/api # VITE_APP_TITLE=某院信息系统 npm run dev

前端最常见的翻车点不是依赖装不上,而是跨域。后端如果没配全局 CORS,浏览器控制台会报has been blocked by CORS policy,但后端日志一条都没有——很多人会去查 JWT,方向就偏了。要么在后端加CorsFilter,要么在 Vite 里配 proxy,二选一,别两边都配,重复的Access-Control-Allow-Origin同样会被浏览器拒绝。

3. 门诊主链路拆解:号源、收费、发药三处的一致性怎么写

3.1 号源模型:为什么不用「剩余号数」一个字段

很多教学版 HIS 的排班表就一个remain_count字段,挂号时select出来判断大于 0 再update减一。这套写法在演示环境永远不出问题,在真实排班里必然超卖。

正确的模型是两层:排班(医生 + 日期 + 时段 + 号别 + 总号数)和号源明细(一条记录对应一个可挂号位)。明细表的好处是能绑定患者、能记录占用时间、能做取消后回收,而不是靠一个数字来回加减。

// 挂号核心:条件更新 + 影响行数判断,替代 select 再 update public RegResult register(RegCmd cmd) { // 1) 取号源,只取没被占用且未过期的 ScheduleSlot slot = slotMapper.selectAvailable(cmd.getScheduleId()); if (slot == null) { throw new BizException("该时段号源已满,请选择其他时段"); } // 2) 条件更新占位,版本号参与 where,被抢走时影响行数为 0 int affected = slotMapper.occupy(slot.getId(), slot.getVersion(), cmd.getPatientId()); if (affected == 0) { // 这里不要重试到底,直接提示,重试会放大早高峰的锁竞争 throw new BizException("号源刚被占用,请重新选择"); } // 3) 同一事务里写挂号记录,两者必须同生共死 Registration reg = new Registration(); reg.setPatientId(cmd.getPatientId()); reg.setSlotId(slot.getId()); reg.setRegNo(regNoGenerator.next()); // 挂号流水号,全局唯一 reg.setStatus("1"); // 1 已挂号 2 已就诊 3 已退号 registrationMapper.insert(reg); return new RegResult(reg.getRegNo(), slot.getVisitNo()); }

对应的 SQL 是这段逻辑的核心:

-- 号源占用:where 里带 version 和状态,靠影响行数做互斥 UPDATE bd_schedule_slot SET status = '2', patient_id = #{patientId}, occupy_time = NOW(), version = version + 1 WHERE id = #{slotId} AND status = '1' AND version = #{version};

参数说明:status的'1'是可用、'2'是已占用、'3'是停诊回收;version是乐观锁版本号,由第一次查询带出来;occupy_time用于超时未就诊的自动回收任务。

为什么不直接select ... for update:挂号是高频短事务,行锁持有时间如果覆盖到写挂号记录、写费用、发短信,几秒钟就够把排班表锁死。条件更新的锁窗口只有一条 update 的时长,代价是失败要重来,这在挂号场景完全可以接受。

另外建议在bd_schedule_slot上加唯一索引(schedule_id, seq_no),这是最后一道防线。业务代码再怎么写,重复号源在数据库层插不进去,比事后对账强得多。

3.2 收费重算:前端传来的金额一个字都不能信

门诊收费最常见的返工不是算错,是退费对不上。根源在收费时把前端传来的金额直接落库了,退费时按比例一算,和原记录差几分钱,日结报表永远差账。

规则只有一条:前端只传收费项目编码和数量,单价、金额、自付比例全部在服务端从字典重新取。

// 收费结算:所有金额在服务端重算,前端传来的 price 直接忽略 @Transactional(rollbackFor = Exception.class) public ChargeResult settle(ChargeCmd cmd) { BigDecimal total = BigDecimal.ZERO; List<ChargeDetail> details = new ArrayList<>(); for (ChargeItemCmd it : cmd.getItems()) { BillingItem item = billingItemMapper.selectByCode(it.getCode()); if (item == null || !"1".equals(item.getStatus())) { throw new BizException("收费项目不存在或已停用:" + it.getCode()); } // 单价取自字典,不用前端传的 price,这是退费对不上账的头号原因 BigDecimal unitPrice = item.getPrice(); BigDecimal qty = BigDecimal.valueOf(it.getQty()); // 自付比例同样取字典,别信前端算好的金额 BigDecimal selfRate = item.getSelfPayRate() == null ? BigDecimal.ONE : item.getSelfPayRate(); BigDecimal amount = unitPrice.multiply(qty).setScale(2, RoundingMode.HALF_UP); BigDecimal selfPay = amount.multiply(selfRate).setScale(2, RoundingMode.HALF_UP); ChargeDetail d = new ChargeDetail(); d.setItemCode(item.getCode()); d.setUnitPrice(unitPrice); d.setQty(it.getQty()); d.setAmount(amount); d.setSelfPay(selfPay); d.setInsurancePay(amount.subtract(selfPay)); details.add(d); total = total.add(amount); } // 明细金额之和必须等于主表金额,不等就是有项目被漏算 if (total.compareTo(cmd.getPayAmount()) != 0) { throw new BizException("金额校验不通过,实际应收 " + total); } // 主表 + 明细 + 处方状态更新,同一个本地事务 return chargeWriter.write(cmd, details, total); }

几个参数是必须落库的:unit_price(当时单价,字典后续调价不影响历史)、qty、amount、self_pay、insurance_pay。退费按明细逐条退,允许部分退,但不允许「整单退完再部分退」——那会让主表金额和明细对不上。退费金额一律从明细表读,不从字典重算,因为字典可能已经调过价了。

金额字段在数据库里统一用decimal(12,2),Java 侧统一BigDecimal。源码包里如果有double参与金额运算的地方,建议第一轮就全改掉,0.1 + 0.2这类问题在收费场景里会直接变成投诉。

3.3 发药扣库存:扣减时机比扣减逻辑更容易错

「收费就扣库存」还是「发药才扣库存」,这是设计选择,不是对错。收费扣的好处是能防止超卖,坏处是患者缴费后不来取药,库存被虚占。发药扣的好处是库存准确,坏处是需要在发药前做可用量校验。

这套源码走的是发药扣减,同时库存表拆成available_qty(可用)和locked_qty(处方已开未发)。整个链路是:开处方落locked_qty,发药时把 locked 转成实际扣减,退药时反向回补。

-- 发药扣减:同事务内锁批次、扣数量、写明细 START TRANSACTION; -- 1) 按效期先进先出挑批次,锁住这一行 SELECT id, batch_no, available_qty FROM ph_stock WHERE drug_code = #{drugCode} AND dept_code = #{pharmacyCode} AND available_qty >= #{qty} ORDER BY expire_date ASC LIMIT 1 FOR UPDATE; -- 2) 扣可用量,同时释放处方占用量 UPDATE ph_stock SET available_qty = available_qty - #{qty}, locked_qty = locked_qty - #{qty} WHERE id = #{stockId}; -- 3) 发药明细必须记批次号,退药时按批次回补,否则库存对不上 INSERT INTO ph_dispense_detail(dispense_id, drug_code, batch_no, qty) VALUES (#{dispenseId}, #{drugCode}, #{batchNo}, #{qty}); COMMIT;

参数说明:dept_code区分药房,同一个药品在不同药房是两条库存记录;expire_date排序保证近效期先出,这在药事检查里是硬要求;batch_no必须落到明细,退药时不带批次回补会导致某些批次负库存。

失败时看什么:如果发药报「库存不足」但库存表里明明有数,先查locked_qty是不是被别的处方占着,再查是不是药房选错了。这两个原因占了发药失败的大半。如果报的是死锁,看ph_stock上的更新顺序——多个处方同时发多个药品时,一定要按drug_code排序后再逐条更新,否则 A 锁了药 1 等药 2、B 锁了药 2 等药 1,必然死锁。

4. 权限与医保对照:HIS 上线后返工最多的两块

4.1 RBAC 之外的数据权限:科室与院区怎么落到 SQL

标准 RBAC 解决的是「能不能进这个页面、能不能点这个按钮」,HIS 还需要第二层:能看到哪些数据。医生工作站如果只是限制菜单,A 科室的医生照样能查到 B 科室的患者,这在院内是明确的越权。

常见做法是三层:用户绑角色,角色绑菜单(含按钮级perm_code),角色再带一个数据范围(全院 / 本科室 / 本人)。数据范围不落在菜单表,单独用角色扩展字段存。

// 数据权限拦截:只对带标记的查询追加科室条件 @Intercepts(@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})) public class DeptScopeInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; // 只拦截约定后缀的查询,字典类查询不能被拼条件,否则科室下拉框会空 if (!ms.getId().endsWith("selectWithDeptScope")) { return invocation.proceed(); } LoginUser user = SecurityContext.get(); if (user == null || "ALL".equals(user.getDataScope())) { return invocation.proceed(); // 全院范围不拼条件 } // 本科室范围:把 dept_code 塞进查询参数,SQL 里用 <if> 判断 Map<String, Object> param = (Map<String, Object>) invocation.getArgs()[1]; param.put("scopeDeptCode", user.getDeptCode()); return invocation.proceed(); } }

对应的 Mapper XML 里加一段条件:

<select id="selectWithDeptScope" resultType="RegVO"> SELECT r.reg_no, r.patient_name, r.dept_code, r.reg_time FROM op_registration r WHERE r.reg_time BETWEEN #{begin} AND #{end} <if test="scopeDeptCode != null and scopeDeptCode != ''"> AND r.dept_code = #{scopeDeptCode} </if> ORDER BY r.reg_time DESC </select>

这里的关键是「只拦截约定后缀」。我见过有人图省事拦截整个query方法,结果字典表、菜单表全被拼上科室条件,系统当场不可用。数据权限要做成显式声明,不要做成全局隐式规则。

还有一个容易漏的点:数据权限要覆盖导出。列表页做了限制,导出功能如果走另一条 SQL,导出的 Excel 里就是全量数据,这类问题往往在检查时才被发现。

4.2 医保对照表:编码不能改,只能映射

院内收费项目编码和外部目录编码从来不是一套。新手最容易做的事是「把院内编码改成和外部一致」,短期看着省事,长期是灾难:历史收费记录、报表口径、退费重算全部对不上。

正确做法是加一张对照表,做单向映射,不改原编码:

CREATE TABLE bd_insurance_mapping ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_type VARCHAR(16) NOT NULL COMMENT 'CHARGE 收费项目 / DRUG 药品 / MATERIAL 耗材', local_code VARCHAR(32) NOT NULL COMMENT '院内编码', out_code VARCHAR(32) NOT NULL COMMENT '外部目录编码', out_name VARCHAR(128) COMMENT '外部目录名称,用于对账核对', self_pay_rate DECIMAL(5,4) DEFAULT 1.0000 COMMENT '自付比例', eff_date DATE NOT NULL COMMENT '生效日期', exp_date DATE DEFAULT NULL COMMENT '失效日期,NULL 表示长期有效', UNIQUE KEY uk_type_local_eff (item_type, local_code, eff_date), KEY idx_out_code (out_code) ) COMMENT '院内与外部目录对照表';

三个设计点值得说。第一,eff_date/exp_date是必须的,外部目录每年调整,没有生效期就无法回答「去年这笔按什么比例结的」。第二,唯一键带上eff_date,允许同一项目在不同时期有多条映射。第三,out_name冗余存一份,是为了对账时能肉眼比对,光看编码很难发现问题。

业务代码里取映射要按业务发生日期查,不是按当前日期:

// 按业务发生日期取对照,不能用 now(),否则跨年退费会算错 InsuranceMapping m = mappingMapper.selectEffective( "CHARGE", itemCode, visitDate); if (m == null) { throw new BizException("该项目未维护对照关系:" + itemCode); }

visitDate用就诊日期而不是结算日期,这个细节决定了跨月退费能不能对上账。另外建议做一个「未对照清单」页面,把bd_charge_item里没有有效映射的项目列出来,上线前逐条清空,否则收费到一半才报错,患者就在窗口等着。

5. 排查清单:五条从上线现场带回来的踩坑记录

5.1 启动与环境类

现象一:登录成功,进去是白屏,控制台没有任何报错。

原因:菜单表有数据,但sys_role_menu里没有给当前角色绑菜单,前端拿到的路由数组是空,路由匹配不到任何页面,自然白屏。

解决:先跑一句SELECT COUNT(*) FROM sys_role_menu WHERE role_id = ?,是 0 就补绑定。有些包把菜单绑定放在03_menu.sql的后半段,导入时中途报错被忽略,前半段进去了后半段没进,所以脚本导入一定要看有没有错误输出,别只看表建出来了。

现象二:明明改了配置,重启后不生效。

原因:多模块工程里,application.yml出现在两个地方,主启动模块一份,his-common里还有一份application-common.yml,后者被spring.profiles.include引入,优先级更高。

解决:启动时加--spring.config.location显式指定,或者直接看启动日志里打印的The following profiles are active。定位配置问题最快的办法永远是把生效配置打出来,不要靠猜。

现象三:本地能跑,换台机器就报Communications link failure。

原因:连接池的max-lifetime大于 MySQL 的wait_timeout,空闲连接被服务端先断开,客户端还拿着死连接去用。

解决:max-lifetime设成比wait_timeout小 30 秒左右,例如 MySQL 默认 28800 秒,池子设 1200000 毫秒以内比较稳。同时把connection-test-query打开,或者用 JDBC4 的isValid探测。

5.2 业务数据类

现象四:日结报表和收费明细差几分钱,反复核不出来。

原因:金额字段有一处用了double,或者某个环节做了setScale但没指定舍入模式,默认HALF_EVEN和业务约定的四舍五入不一致。

解决:全链路排查double/float,改成BigDecimal;所有setScale显式写RoundingMode.HALF_UP;数据库金额列统一decimal(12,2)。改完拿一批历史数据跑一次对账脚本,主表金额与明细之和必须完全相等,差一分都算没改完。

现象五:收费成功,药房查不到处方。

原因:收费和处方状态更新跨了两个事务,或者中间隔了一次异步消息,消息没落库就丢了。表现是收费记录有、处方状态还是「待收费」。

解决:同库场景直接合并成一个本地事务,别为了「解耦」拆开。确实要跨服务,用本地消息表:业务数据和消息记录在同一个事务里写,再由定时任务投递,失败重试,幂等靠业务唯一键保证。这套系统里门诊收费和处方状态就是同库,直接@Transactional覆盖即可。

5.3 并发与性能类

现象六:早高峰收费页转圈,数据库 CPU 打满。

原因:报表查询直连主库,op_charge_detail上做全表GROUP BY,和收费写入抢资源。

解决:报表单独配只读数据源,走从库;查询强制带时间范围并命中(charge_time)索引;大报表改成定时预计算落汇总表,页面只查汇总。这条不是代码问题,是部署结构问题,越早拆越好。

6. 验收式自测:用六个校验点确认这套 HIS 真的完整

拿到一套系统,我一般不急着看代码写得好不好,先跑一遍主链路自测。跑通了,说明这套东西值得投入时间;跑不通,先修通再谈二次开发。

六个校验点,每个都对应一句 SQL 或一个操作:

序号校验点操作预期结果
1字典完整查收费项目、药品、科室、诊断四张字典均大于 0,且无重复编码
2号源可用按医生和日期查排班有可用号源,剩余数正确
3挂号闭环挂一个号号源状态变占用,挂号记录生成
4收费重算手工改前端金额再提交服务端拒绝或按字典重算
5发药扣减发药后查库存可用量减少,明细有批次号
6退费对账部分退费后查日结主表金额等于明细之和

第 4 条和第 6 条是重点,前者验证服务端有没有做金额重算,后者验证退费是不是按明细走。这两条过了,这套 HIS 的账务底子就是可靠的。

自测脚本可以固化成一段 SQL,每次改完代码跑一遍:

-- 主链路体检:任何一项不为 0 都要查清楚 SELECT '号源被占用但无挂号记录' AS chk, COUNT(*) AS cnt FROM bd_schedule_slot s LEFT JOIN op_registration r ON r.slot_id = s.id WHERE s.status = '2' AND r.id IS NULL UNION ALL SELECT '收费主表金额与明细不符', COUNT(*) FROM op_charge c JOIN (SELECT charge_id, SUM(amount) amt FROM op_charge_detail GROUP BY charge_id) d ON d.charge_id = c.id WHERE c.total_amount <> d.amt UNION ALL SELECT '已发药但库存未扣减', COUNT(*) FROM ph_dispense d JOIN ph_dispense_detail dd ON dd.dispense_id = d.id LEFT JOIN ph_stock s ON s.drug_code = dd.drug_code AND s.batch_no = dd.batch_no WHERE d.status = '1' AND s.id IS NULL;

这三条查询覆盖了挂号、收费、药房三个环节的数据一致性,全部返回 0 才算干净。我把它们挂在测试环境的一个定时任务上,每天凌晨跑一次,出问题第二天一早就知道,比等月底对账再翻旧账强太多。

最后说个习惯。这套源码我第一次跑的时候,也是先改代码后看字典,折腾了大半天。从那以后我每次拿到这类系统,都强制先走一遍「导入脚本 → 校验字典 → 跑主链路 → 查一致性」这四步,一步不跳,确认底子是干净的再动代码。省下来的时间远比多花的多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询