简介这是一套基于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.AddScopedIDbConnection(provider new SqlConnection(builder.Configuration.GetConnectionString(HIS))); builder.Services.AddScopedIChargeService, ChargeService(); builder.Services.AddScopedIOrderService, 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 依赖 DomainInfrastructure 依赖 Application 定义的接口WebApi 和 WinClient 只依赖 Application。WinForms 客户端不要直接引用 Infrastructure否则界面改字段要跟着改 SQL下一个需求会让你翻车。按领域分工程之后接手的人能明确知道收费在 Application 里、数据库访问在 Infrastructure 里改需求时不会因为找不到文件而四处翻。3. 核心模块拆解一张收费单从门诊窗口到日结报表的完整生命周期3.1 挂号预收费先占号源再进入收费挂号这个动作在业务上由两步组成占用号源、生成待支付费用。常见做法是先插入挂号流水状态置为待支付同时把号源的剩余数减 1等支付成功再把收费明细状态置为已付。public async Taskstring 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 ListChargeDetail(); 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 IdId加锁才扛得住门诊早高峰的并发。ChargeDetail.Create 是一个静态工厂把 ItemType、Amount、初始状态封装进去不要在应用服务里直接 new 实体再逐个赋值漏赋状态字段是退费出问题的最常见来源。3.2 收费与退费状态机不是写来好看的收费接口把待支付明细批量改为已付退费则反过来只允许对状态为已付且没有后续业务引用的明细做退回。下面这段是收费与退费共用控制逻辑的简化版public async TaskChargeResult 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 新手跑到收费就停了忘了日结。日结不是打印机吐一张纸而是把当天收费员名下的所有已付、已退明细汇总成一张可对账的快照。常见实现是收费员主动点日结按钮下班前必须处理完。下面是门诊收费明细的汇总 SQLSELECT 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 ListMedicationPlan BuildPlans(Order order) { var plans new ListMedicationPlan(); 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 TaskListDailyFeeRow 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.QueryAsyncDailyFeeRow(sql, new { BizDateStart bizDate.Date, BizDateEnd bizDate.Date.AddDays(1), WardId wardId })).ToList(); }说明参数 BizDateStart、BizDateEnd 构建一个左闭右开区间把一天内所有执行计划收进来Status2 只统计已执行的。汇总剂量再乘以药品单价就是这一天的药品费。我一般会在每日记费跑批外加一张 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 ND:\HIS\HISDB.bak WITH REPLACE; 2 GO然后改连接串。WebApi 的 appsettings.Development.json 里通常是{ ConnectionStrings: { HIS: Serverlocalhost;DatabaseHISDB;User Idsa;PasswordYourPassword;TrustServerCertificateTrue; } }参数说明TrustServerCertificateTrue 在本地开发很有必要否则 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 显式指定。现象二还原数据库后登录报 18456sa 登录失败。 原因SQL Server 默认只开了 Windows 身份验证或者 sa 账户被禁用连接字符串里的认证模式和实例不匹配。 解决用 Windows 身份进管理工具执行下面脚本改认证模式并启用 saALTER LOGIN sa WITH PASSWORD NYourPassword; 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 TaskIActionResult 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 先行、分层改动、全链路回归的流程改完报表再把日结跑一遍省得第二天对账时被窗口电话叫醒。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?