ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#开发医院HIS系统:从门诊收费到住院医嘱全流程解析

C#开发医院HIS系统:从门诊收费到住院医嘱全流程解析

简介:这是一套基于C#与.NET框架开发的医院HIS系统完整源码包,定位于医疗信息化项目学习与二次开发,适合医院信息系统开发者、在校生及需要快速搭建HIS原型的技术人员。资源共186个文件,压缩包约2.8MB,核心包含cs源码、SQL数据库脚本与mdf/ldf数据文件,以及sln工程文件、resx界面资源、exe可执行程序和dll依赖库等,便于直接编译运行、查看数据库结构并开展定制改造。内容围绕患者管理、挂号诊疗、电子处方、费用结算、药品物资、报表统计、权限控制与系统集成等模块展开,附有界面按钮位图资源和配置文档,能够帮助理解医嘱数据流转、财务计算和医疗数据安全设计。已有641人学习下载,适合希望以完整项目为参考,掌握C#桌面端医疗系统分层开发与数据库建模的读者。

1. C# 医院 HIS 项目:把门诊收费、药房发药、住院医嘱串成一张闭环的网

某医院门诊窗口每天下午五点半交账,收费员对着结算单抓狂:一笔 35 元的检查费退费,把挂号和检查费一起退了,药房那边却已经按处方发了药。这类账实不平,本质是钱、药、医嘱三条独立流在状态上没对上。这个 C# 医院 HIS 系统,正是把挂号、收费、发药、住院医嘱、日常结算串成一条完整链路的可运行样例工程。它适合两类人:一是想跑通一个完整医疗业务系统、从零理解 C# 医疗项目结构的开发者,二是要接手医院内部项目、需要快速看懂核心业务的实施同学。它解决的不是界面做成什么样,而是退了钱怎么拦住已发药、医嘱开了十次为什么只记了八次账这类真问题。

2. HIS 的技术底座:从桌面客户端到数据库的两次握手

2.1 客户端与服务端:C/S 不是老古董,是终端效率优先

现在新起一个 C# HIS 项目,比较少去用 WCF 那套老骨架,常见做法是桌面端(WinForms 或 WPF)加 ASP.NET Core Web API。医院内网环境下带宽稳定、窗口端键盘操作频率高,桌面客户端在挂号、收费这类高频场景里输入效率远高于浏览器页面;服务端换成 Web API,是为了让 LIS、PACS、自助机都能通过 HTTP 对接同一套业务接口。

我一般会这样搭服务端入口,下面是 Program.cs 的截取:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); // 连接字符串走配置中心或 appsettings,测试环境直接写在本地文件 builder.Services.AddScoped<IDbConnection>(provider => new SqlConnection(builder.Configuration.GetConnectionString("HIS"))); builder.Services.AddScoped<IChargeService, ChargeService>(); builder.Services.AddScoped<IOrderService, OrderService>(); var app = builder.Build(); app.MapControllers(); app.Run();

说明:IDbConnection 注册成 Scoped 而不是 Singleton。常见做法是每次业务请求拿一条新连接,少数项目图省事注册成单例,但 SqlConnection 不是完全线程安全的,多窗口并发收费时容易出怪问题,连接池机制会负责复用,不需要手工 close 到怀疑人生。

客户端那边界面层只做一件事:把用户输入组装成请求对象,POST 到 Web API,界面里尽量不写 SQL。很多老 HIS 项目会在窗体里直接拼 SQL,新项目宁可多建几个 Service 也把数据访问收口到仓储层,后续换数据库、加缓存,改动的范围是闭合的,不会出现改一个字段要翻二十个窗体的惨状。

2.2 数据库设计:流水为主,状态为辅

HIS 的表结构各家医院不会完全一样,但核心事实表很固定:挂号单、收费明细、处方、库存批次、医嘱。设计上我比较认同流水为主、状态为辅:每张业务单据都有一张明细表,金额、数量、状态全落在明细行,而不是在表头上挂一个总和。

CREATE TABLE ChargeDetail ( ChargeNo VARCHAR(20) NOT NULL, ItemId INT NOT NULL, ItemType TINYINT NOT NULL, -- 10 挂号费 / 20 检查费 / 30 药费 Amount DECIMAL(10,2) NOT NULL, Quantity INT NOT NULL DEFAULT 1, Status TINYINT NOT NULL DEFAULT 0, -- 0 未付 1 已付 2 已退 RefundRefNo VARCHAR(20) NULL, CreatedAt DATETIME NOT NULL DEFAULT GETDATE(), PRIMARY KEY (ChargeNo, ItemId) );

说明:主键用 ChargeNo 加 ItemId,不依赖自增 ID,是为了退费时能精确找到同一张单的所有明细,而且收费单号要打印、要口头报号,自增的无规律数字在窗口场景不实用。ItemType 用数字枚举而不是字符串,老项目处理业务分支时 switch 方便,报表统计也稳定。Status 的状态流转靠应用层控制,不靠数据库触发器,因为每一步状态变化都要带操作人、操作时间和原因备注,触发器很难把这些上下文带进历史表。

单号生成单独建一张号源表:

CREATE TABLE SeqNo ( SeqKey VARCHAR(10) NOT NULL, -- GH 挂号 / CHG 收费 / ODR 医嘱 CurrentNo INT NOT NULL DEFAULT 0, BusinessDate DATE NOT NULL, PRIMARY KEY (SeqKey, BusinessDate) );

这张表存在的理由是当天单号从 1 开始、跨天清零。如果直接读业务表的 Max(Id) 再加 1,两个窗口并发取号一定会撞,解决方式见第 5 章排查部分。

2.3 目录结构:按业务领域拆工程,而不是把三层写进一个项目

这个项目的目录大致按领域拆:

HIS.sln ├─ HIS.Domain // 实体、枚举、业务规则 ├─ HIS.Application // 收费、挂号、医嘱、库存应用服务 ├─ HIS.Infrastructure // SQL Server 访问、仓储实现 ├─ HIS.WebApi // ASP.NET Core Web API 入口 └─ HIS.WinClient // WinForms/WPF 桌面端

各层依赖方向是 Domain 谁都不依赖,Application 依赖 Domain,Infrastructure 依赖 Application 定义的接口,WebApi 和 WinClient 只依赖 Application。WinForms 客户端不要直接引用 Infrastructure,否则界面改字段要跟着改 SQL,下一个需求会让你翻车。按领域分工程之后,接手的人能明确知道收费在 Application 里、数据库访问在 Infrastructure 里,改需求时不会因为找不到文件而四处翻。

3. 核心模块拆解:一张收费单从门诊窗口到日结报表的完整生命周期

3.1 挂号预收费:先占号源,再进入收费

挂号这个动作在业务上由两步组成:占用号源、生成待支付费用。常见做法是先插入挂号流水,状态置为待支付,同时把号源的剩余数减 1;等支付成功再把收费明细状态置为已付。

public async Task<string> RegisterAsync(RegisterCommand cmd) { // 1. 释放号源前先锁住这一行的排班,避免超挂 var schedule = await _scheduleRepo.LockRowAsync(cmd.DoctorScheduleId); if (schedule.LeftCount <= 0) throw new BizException("该时段号源已挂完"); // 2. 取挂号单号,后面收费、退费都靠它关联 string regNo = await _seqNoService.GetAsync("GH"); // 3. 挂号费 + 首次检查费一起挂到未支付明细 var items = new List<ChargeDetail>(); items.Add(ChargeDetail.Create(regNo, ItemType.RegFee, cmd.RegFee, 1)); if (cmd.CheckFee > 0) items.Add(ChargeDetail.Create(regNo, ItemType.CheckFee, cmd.CheckFee, 1)); // 4. 同事务:减号源 + 写明细,失败一起回滚 await _scheduleRepo.DecreaseLeftAsync(schedule, 1); await _chargeRepo.AddItemsAsync(items); return regNo; }

参数说明:DoctorScheduleId 对应当天某医生某时段的排班行,LeftCount 是剩余可挂数;减号源和写明细必须同一个事务,否则会出现明细没写成功但号源已经扣了的丢单现象。LockRowAsync 的常见实现是SELECT ... FROM DoctorSchedule WITH (UPDLOCK, ROWLOCK) WHERE Id=@Id,加锁才扛得住门诊早高峰的并发。ChargeDetail.Create 是一个静态工厂,把 ItemType、Amount、初始状态封装进去,不要在应用服务里直接 new 实体再逐个赋值,漏赋状态字段是退费出问题的最常见来源。

3.2 收费与退费:状态机不是写来好看的

收费接口把待支付明细批量改为已付;退费则反过来,只允许对状态为已付且没有后续业务引用的明细做退回。下面这段是收费与退费共用控制逻辑的简化版:

public async Task<ChargeResult> PayOrRefundAsync(string chargeNo, bool isRefund, string operatorId) { using var tx = _db.BeginTransaction(); var items = await _chargeRepo.GetItemsAsync(chargeNo, tx); var target = items.Where(x => isRefund ? x.Status == PayStatus.Paid // 退费方向:只看已付 : x.Status == PayStatus.Unpaid // 收费方向:只看未付 ).ToList(); if (target.Count == 0) throw new BizException(isRefund ? "没有可退费用" : "没有待收费用"); // 药费项退费前,必须先确认处方没有进入发药环节 if (isRefund) { foreach (var item in target.Where(x => x.ItemType == ItemType.Medicine)) { bool dispensed = await _pharmacyRepo.IsDispensedAsync(chargeNo, item.ItemId, tx); if (dispensed) throw new BizException("该药品已发药,请先到药房退药再退费"); } } // 更新状态后写一条操作日志 await _chargeRepo.UpdateStatusAsync(chargeNo, target, isRefund ? PayStatus.Refunded : PayStatus.Paid, operatorId, tx); await _chargeRepo.WriteChargeLogAsync(chargeNo, isRefund ? "退费" : "收费", operatorId, tx); tx.Commit(); return new ChargeResult { ChargeNo = chargeNo, PaidAmount = target.Sum(x => x.Amount) }; }

逻辑说明:整个方法只有一个事务,先从明细表读出该单所有行,再按方向过滤——退费只看已付,收费只看未付。这一步就是开头那个场景的防线:挂号费和检查费同时被退,是因为已付明细里同时存在两个 ItemType,业务上允许;但如果被退明细里混入已发药的药品项,界面就必须拦住。IsDispensed 的常见实现是查药房领药表是否已有发药时间和操作人。

参数说明:operatorId 必须由登录态传递,不能从客户端请求体里无条件信任;显式事务使用 SqlTransaction,不需要事务的方法不要多包一层,高峰期容易把连接池耗尽。

3.3 日结与对账:收费员下班的最后一步其实是一道 SQL

很多 HIS 新手跑到收费就停了,忘了日结。日结不是打印机吐一张纸,而是把当天收费员名下的所有已付、已退明细汇总成一张可对账的快照。常见实现是收费员主动点日结按钮,下班前必须处理完。下面是门诊收费明细的汇总 SQL:

SELECT OperatorId, SUM(CASE WHEN Status = 1 THEN Amount ELSE 0 END) AS PaidAmount, SUM(CASE WHEN Status = 2 THEN Amount ELSE 0 END) AS RefundAmount FROM ChargeDetail WHERE BusinessDate = '2025-06-13' GROUP BY OperatorId;

说明:这里 BusinessDate 是收费发生日期,和 CreatedAt 时分秒解耦。窗口收费存在跨天场景,比如 23:59 收费、00:01 退费,用 CreatedAt 会把这笔退费归到第二天,报表口径就乱了。很多医院要求退费必须冲回原收费日,业务实现里要单独记 BusinessDate。日结报表拿这张表关联收费员表,就能出实收、退费、差异四栏,财务核对时一眼能看到有没有长短款。

4. 住院与医嘱:从医生开方到执行记费、药房发药的闭环

4.1 医嘱拆分:把每天两次展开成具体的执行计划

住院医嘱和门诊处方的最大区别是时间维度:长期医嘱每天重复执行直到停止,临时医嘱只执行一次。数据库里不能只存 q12h 这个频次字符串,还要有一张执行计划表,每条计划对应一个具体的执行时间点,否则护士从哪里领执行任务、药房按什么批次备药都会无从下手。

public List<MedicationPlan> BuildPlans(Order order) { var plans = new List<MedicationPlan>(); if (order.OrderType == OrderType.LongTerm) { // 从开立时刻开始,按频次推进到医嘱停止时间 DateTime cursor = order.StartTime; while (cursor <= order.EndTime) { plans.Add(MedicationPlan.Create( order.OrderId, cursor, PlanStatus.Waiting, order.Dosage, order.Route)); cursor = cursor.AddHours(order.FrequencyHours); // 12h / 8h / 6h 等 } } else { // 临时医嘱:开立即执行的单次计划 plans.Add(MedicationPlan.Create(order.OrderId, order.StartTime, PlanStatus.Waiting, order.Dosage, order.Route)); } return plans; }

说明:FrequencyHours 是枚举映射出的间隔小时数,比如每天 3 次映射成 8。这里用 AddHours 推进,遇到跨天会自动带着日期走,因为 DateTime 本身包含日期;实践里我还会加一步校验:上午 11 点半开立的 q12h 长期医嘱,第一次执行应落在开立后的下一个合理时间点,而不是生成一个已经过去的历史时间。护士执行时按 Waiting 状态取计划,执行完更新为 Done。

医嘱停止有个被忽略的坑:停止长期医嘱时,要把未执行的剩余计划标记为 Cancelled,而不是直接删除。病历评审要求留痕,删了记录后面查执行率、科室药品用量都算不准。

4.2 每日记费:把执行后的医嘱换算成费用

住院费用按日结算,每日凌晨把当天有执行记录的计划按收费项目汇总,生成当天费用流水。这里的关键是只有执行过的才算钱,医生开立但护士没执行的医嘱不计入费用;执行一半停止的,只记已执行部分。

public async Task<List<DailyFeeRow>> CalculateDailyFeeAsync( int wardId, DateTime bizDate) { const string sql = @" SELECT p.OrderId, p.Route, SUM(o.Dosage) AS TotalDosage FROM MedicationPlan p JOIN Orders o ON o.OrderId = p.OrderId WHERE p.ExecuteTime >= @BizDateStart AND p.ExecuteTime < @BizDateEnd AND p.Status = 2 -- 2 = Done AND o.WardId = @WardId GROUP BY p.OrderId, p.Route"; return (await _db.QueryAsync<DailyFeeRow>(sql, new { BizDateStart = bizDate.Date, BizDateEnd = bizDate.Date.AddDays(1), WardId = wardId })).ToList(); }

说明:参数 BizDateStart、BizDateEnd 构建一个左闭右开区间,把一天内所有执行计划收进来;Status=2 只统计已执行的。汇总剂量再乘以药品单价,就是这一天的药品费。我一般会在每日记费跑批外加一张 DailyFeeSnapshot 表,把当天汇总结果固化下来,这样即使第二天医生改了医嘱,前一天的账也不会跟着变。这张快照是住院结算和医保对账的底账,不能靠临时算。

4.3 病区药房发药:批次先进先出和事务扣库存

住院药房按执行计划领药,扣库存必须和写已发药记录放同一事务,否则发药单打印了,库存却还没减,或者减了两次。常见做法是按批次先进先出,UPDATE 时带上批次条件,保证不会把某一批库存扣穿。

UPDATE StockBatch WITH (ROWLOCK) SET StockQty = StockQty - @Qty WHERE BatchId = @BatchId AND StockQty >= @Qty; -- 这里挡住批次负库存 IF @@ROWCOUNT = 0 BEGIN -- 这条批次不够扣,事务内直接报错回滚整张发药单 THROW 51000, N'当前批次库存不足,请重新分配批次', 1; END

说明:WHERE 加 StockQty >= @Qty 是库存扣减的经典防线,查询影响行数为 0 就说明并发下这一批已经被别人扣完了,此时不要在应用层继续重试扣下一批,而是让整个发药单回滚,药房重新选批次。发药单和 StockBatch 更新放在同一个显式事务里,避免先扣库存再写发药单失败的中间状态。SQL Server 的 UPDLOCK、ROWLOCK 提示能减少批次行上的锁竞争,但不是万能的,索引建在 BatchId 上才管用。

5. 本地跑通与常见问题排查:三步启动,四处易错

5.1 环境准备:不是装完 IDE 就完事

拿到源码包,先别急着开开发环境。先把数据库准备好,通常是还原一份 .bak 或执行初始化脚本。常见做法是命令行一把过:

sqlcmd -S localhost -U sa -P YourPassword 1> RESTORE DATABASE HISDB FROM DISK = N'D:\HIS\HISDB.bak' WITH REPLACE; 2> GO

然后改连接串。WebApi 的 appsettings.Development.json 里通常是:

{ "ConnectionStrings": { "HIS": "Server=localhost;Database=HISDB;User Id=sa;Password=YourPassword;TrustServerCertificate=True;" } }

参数说明:TrustServerCertificate=True 在本地开发很有必要,否则 SQL Server 自签名证书会直接拦掉连接;正式内网环境建议用 Windows 认证或证书,不要长期拿 sa 高权限跑业务。

启动顺序是先启动 HIS.WebApi,看到控制台输出监听地址,比如 http://localhost:5000,再启动 HIS.WinClient。客户端登录界面如果提示无法连接服务,第一反应是改 App.config 里的 ApiBaseUrl 而不是怀疑代码。验证跑没跑通,最直接的办法是登录后点一下连接测试,或者浏览器直接访问 API 的健康检查地址,返回 JSON 说明服务端活着的。

5.2 常见问题排查:五个真实踩过的坑

现象一:客户端登录一直提示无法连接到远程服务器。 原因:WinForms 客户端的 API 地址写的是 localhost,前后端在同一台机器时能通,一旦分开部署就必然失败。 解决:把客户端配置文件里的 ApiBaseUrl 改成 API 所在机器的 IP,并确认 WebApi 启动时绑定 0.0.0.0,而不是默认只监听 localhost,后端可以用 dotnet run --urls http://0.0.0.0:5000 显式指定。

现象二:还原数据库后登录报 18456,sa 登录失败。 原因:SQL Server 默认只开了 Windows 身份验证,或者 sa 账户被禁用,连接字符串里的认证模式和实例不匹配。 解决:用 Windows 身份进管理工具,执行下面脚本改认证模式并启用 sa:

ALTER LOGIN sa WITH PASSWORD = N'YourPassword'; ALTER LOGIN sa ENABLE; GO

注意改完要重启 SQL Server 服务才生效,生产库不要这么干,本地开发怎么方便怎么来。

现象三:收费单号重复,插入 ChargeDetail 时报主键冲突。 原因:取号逻辑是查 Max(Id) 加 1,两个收费窗口同时点收费,各自读到同一个最大值。 解决:把取号和更新号源放进同一事务,或者用 SQL Server 的应用锁,像 sp_getapplock 包住取号和 update 两步,释放锁后另一个窗口才能取到下一个号。单号生成这种高频操作不能省锁,省了就是开头那种账目混乱的开始。

现象四:页面中文显示成问号或乱码。 原因:常见两类,一类是数据库排序规则用的 Latin1,另一类是建表时把中文字段定义成了 VARCHAR 而不是 NVARCHAR,参数化查询传中文进去被转码弄丢。 解决:建库时选 Chinese_PRC_CI_AS;新增中文字段用 NVARCHAR;查询条件里的中文参数统一用 SqlParameter 传,不要拼字符串。三处都对了中文很少会乱。

现象五:发药成功后库存变负数。 原因:扣库存的 UPDATE 没有带剩余量条件,或者发药接口被前端重复调用,同一张发药单执行了两次。 解决:在扣减语句加 StockQty >= @Qty,并检查影响行数,为 0 就回滚;同时给发药表加唯一约束(BatchId 加 OrderDetailId),把重试的入口堵死。

5.3 验证路径:从登录到日结走一遍才算真正跑通

跑通的标准不是界面能打开,而是能走完一笔真实业务闭环。我的验证路径是:用初始账号登录(代码里通常 Seed 了初始账号,首次登录后建议改密码),进入挂号界面选一个医生和时段,提交挂号产生待收费单;然后进收费界面输入单号,支付成功;再去药房工作台把处方发药;最后在日结页面按当前操作员汇总,能看到刚才那笔 30 元挂号费。走完这一步,数据库里挂号表、收费明细表、库存表、日结表都新增了对应数据,说明整个链路是通的,再去做二次开发心里才有底。

6. 二次开发:从加一个报表到加一个模块的落地路径

实际接到最多的需求不是新写功能,而是把某数据按某维度拉出来。比如医生想查某医生本月开出的某种针剂总量。按照这个项目的分层,加一个报表查询要动三处:Application 增加查询服务、Infrastructure 写 SQL、WinClient 加一个查询窗口,WebApi 加一个只读接口。

[HttpGet("doctor/drug-summary")] public async Task<IActionResult> GetDoctorDrugSummary( string doctorId, DateTime monthStart) { // monthEnd 用日期加一个月,而不是手动拼下月初 var monthEnd = monthStart.AddMonths(1); var rows = await _orderRepo.GetDoctorDrugSummaryAsync( doctorId, monthStart, monthEnd); return Ok(rows); }

仓储 SQL 走 Orders 和 MedicationPlan 两张表按药品编码聚合,这个查询不碰 ChargeDetail,因为统计的是医嘱执行量而不是收费金额,两者口径不同——如果你在收费明细表上查药品量,会漏掉还没有记费的执行记录。加模块时就按这个项目现有的 Application、Service、Repo 三件套扩展,新功能只往 Application 加一个方法,不要顺手在 WinClient 里连数据库。

我一般会先在测试库把 SQL 跑通,拿结果和业务方确认字段口径,再固化到代码里。从那以后我每次接手 HIS 的改动,都强制先走一遍业务口径确认、测试库 SQL 先行、分层改动、全链路回归的流程,改完报表再把日结跑一遍,省得第二天对账时被窗口电话叫醒。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进