☰
C#医院HIS系统源码:WinForms三层架构与挂号收费药房实践
2026/10/28 5:02:31 网站建设 项目流程

简介:“C#医院HIS系统”是一套面向医疗信息化学习者、.NET开发者与院校课程设计的完整源码工程,以C#与.NET框架实现患者管理、挂号诊疗、财务收费、药品物资、报表统计及权限控制等核心业务模块,可用于课程设计、毕业设计或二次开发参考。压缩包共186个文件、约2.8MB,包含30个cs源文件、22个resx与resources界面资源、23个exe可执行程序、9个dll类库,以及bmp与ico图标、sql脚本、mdf与ldf数据库文件、sln解决方案等,兼顾代码阅读、程序试运行与数据库部署。已有643人学习下载,具备一定教学参考价值。借助完整解决方案与数据库备份,读者可梳理窗体结构与数据访问写法,理解基于角色的权限控制和报表输出思路,并在此基础上替换业务字段、扩展模块或排查运行环境问题。

1. 门诊早高峰的那条数据链路

早上七点半,挂号窗口开始排队,收费窗口同时要处理退费和补打票据,药房那边还在等昨天的库存对账结果——这套 C# 医院 HIS 系统要解决的,就是把这几件事串成一条不出错的数据链路:挂出去的号能查到、开出来的单能收到钱、收完钱的处方能发得出药、发完药的库存能对得上账。它是一套带完整源码的 .NET 桌面端业务系统,前端 WinForms 客户端,后端经典三层架构加 SQL Server,覆盖挂号、就诊、收费、药房、报表五条主线。

适合三类人:刚学完 C# 语法、想找一套真实业务系统读通的新手;接基层医疗机构定制开发、需要一个能直接改的外包底座;以及想拆解「业务代码怎么写才不出错」的熟手。它不属于教学玩具:号源并发、金额精度、事务边界这些容易出问题的点,代码里都硬碰硬处理过。

2. 技术选型与整体架构:一次挂号请求到底走了几层

2.1 为什么是 WinForms + 三层 + 原生 ADO.NET

先给结论:这套系统没有用 WPF,也没有上 Entity Framework,更没有前后端分离。原因不复杂——HIS 系统的客户端跑在收费窗口、护士站、药房这些地方的旧电脑上,Windows 7 和 4G 内存的机器还在服役,WinForms 启动快、依赖少、部署就是拷贝一个目录,换成别的方案反而会引来一堆环境问题。数据访问层直连 ADO.NET,也是同样的考虑:HIS 的查询里大量是带WITH (NOLOCK)的报表统计和手工调优的存储过程,ORM 生成出来的 SQL 往往不能用索引,调优时没法下手。

层次选型关键作用
表现层 UIWinForms + .NET Framework 4.7.2挂号、收费、发药等业务窗体,DataGridView 承载列表
业务逻辑层 BLL纯 C# 类库校验规则、金额计算、事务编排
数据访问层 DALADO.NET + SqlClient手写 SQL、参数化查询、事务传递
实体模型 ModelPOCO 类跨层传参,不带任何持久化逻辑
数据库SQL Server 2016 及以上主数据、业务流水、报表视图
报表与打印自绘打印 + 模板挂号条、收费票据、发药单

这套组合在今天的眼光看确实很传统,但业务系统的第一诉求是稳定和可维护。我见过太多项目为了追新技术,把一个 CRUD 系统包装成微服务,最后连一个退费事务都保证不了原子性——HIS 这种系统,用成熟方案比用新方案重要得多。

2.2 三层之间怎么传:一个挂号请求的完整链路

理解这套代码的关键,是搞清楚一次点击从窗体到数据库再回到窗体,中间经过了哪些地方。以「挂号」为例:用户点确认按钮,窗体收集界面上的患者、科室、医生、号别信息,打包成一个请求对象交给 BLL;BLL 做业务校验,再调用 DAL;DAL 在一个事务里做占号、写挂号记录、生成待收费单;成功后逐层返回挂号流水号和号序,窗体刷新列表。

// BLL/RegistrationService.cs public class RegistrationService { private readonly RegistrationDal _dal = new RegistrationDal(); // 挂号主入口:校验 -> 占号 -> 写挂号记录 -> 生成待收费单 public RegisterResult Register(RegisterRequest req) { if (req.PatientId <= 0) throw new BizException("未选择患者信息"); if (req.ScheduleId <= 0) throw new BizException("未选择出诊排班"); using (var tran = DbHelper.BeginTransaction()) // 事务边界从这里开始 { // 1. 加锁取号,避免并发下两个窗口拿到同一个号序 var seq = _dal.LockAndNextSeq(req.ScheduleId, tran); if (seq > req.TotalQuota) throw new BizException("该号别今日号源已用完"); // 2. 写挂号主表,返回院内挂号流水号 var regNo = _dal.InsertRegistration(req, seq, tran); // 3. 同步生成待收费单,金额口径统一由收费模块算 _dal.InsertChargeBill(regNo, req.ItemCode, tran); tran.Commit(); return new RegisterResult { RegNo = regNo, SeqNo = seq }; } } }

几个参数值得单独说。ScheduleId是排班主键,一个医生一天一个班次对应一条排班记录,号源总数TotalQuota挂在排班表上而不是号别表上,这样临时加号只改一条记录。ItemCode是收费项目编码,挂号费、诊疗费都走同一套收费项目字典,避免挂号模块自己维护价格。BizException是自定义业务异常,UI 层统一捕获后弹提示,不弹堆栈——患者站在窗口前,看到NullReferenceException就尴尬了。

2.3 事务边界:为什么收费和库存必须绑在一起

这套代码里,事务不是随便加的,只在「一次操作要改多张表」的地方加。挂号要改排班表的当前号序、挂号主表、收费单表;收费要改收费主表、收费明细表、患者账户表;发药要改发药单表、库存表、库存流水表。这些操作要么全成功,要么全回滚,中间不能停。

常见做法是把事务放在 BLL 编排、DAL 执行:BLL 开事务,把SqlTransaction对象一路作为参数传进 DAL 的每个方法,DAL 内部所有SqlCommand都挂上这个事务。这种写法比较笨,但控制得很精确,出问题时能一眼看出事务从哪开始、在哪提交。

提示:不要把事务开在 UI 层,也不要在 DAL 里自己开事务。事务边界一旦分散在多个层,排查问题就会变得很困难。

3. 核心模块拆解:挂号、收费、药房三条主线

3.1 目录结构:每个文件夹放什么

拿到源码先别急着 F5,把解决方案目录过一遍,心里就有数了。

HisSolution/ ├── His.UI/ # WinForms 启动项目 │ ├── Forms/ # 各业务窗体:挂号、收费、发药、报表 │ ├── Controls/ # 自定义控件:患者选择器、金额输入框 │ ├── Print/ # 票据打印模板与绘制逻辑 │ └── App.config # 连接串、打印参数 ├── His.BLL/ # 业务逻辑,一个模块一个 Service ├── His.DAL/ # 数据访问,一个模块一个 Dal ├── His.Model/ # 实体类与请求/响应对象 ├── His.Common/ # 工具类:加密、日期、枚举、日志 └── DbScripts/ # 建库脚本、种子数据、升级脚本

Forms下面的窗体命名基本遵循「模块 + 动作」的规则,比如FrmRegister、FrmCharge、FrmDispense,找功能不用翻代码,看文件名就够了。DbScripts这个目录容易被忽略,但它其实是整套系统的命根子——建库、初始化字典、历史升级脚本全在里面,后面部署到客户现场时,靠的就是这一套脚本把环境拉起来。

3.2 号源与挂号:号序重复的根因和行锁写法

挂号模块最容易出问题的地方就是号序。两个窗口同时给同一个医生的同一个班次挂号,如果先查当前号序、再更新,中间没有任何锁,两条请求可能读到同一个值,最后两个患者拿到同一个号——患者吵起来,窗口也没法解释。

正确写法是把「读」和「写」放进同一条语句,并且加更新锁。

// DAL/RegistrationDal.cs public int LockAndNextSeq(int scheduleId, SqlTransaction tran) { // UPDLOCK 读时加更新锁,HOLDLOCK 保持到事务结束,两者组合等价于行级排他 const string sql = @" UPDATE T_Schedule SET CurrentSeq = CurrentSeq + 1 WHERE ScheduleId = @ScheduleId; SELECT CurrentSeq FROM T_Schedule WHERE ScheduleId = @ScheduleId;"; using (var cmd = new SqlCommand(sql, tran.Connection, tran)) { cmd.Parameters.Add("@ScheduleId", SqlDbType.Int).Value = scheduleId; // 返回自增后的号序,调用方直接拿去用 return Convert.ToInt32(cmd.ExecuteScalar()); } }

这段代码的逻辑是:先无条件自增,再读回自增后的值。UPDATE语句本身会对目标行加排他锁并持有到事务提交,所以后进来的请求只能排队等待,拿到的一定是递增后的值。这比「先 SELECT 再 UPDATE」稳妥得多,也不需要额外的应用层锁。

号源校验放在自增之后做判断:如果seq > TotalQuota,说明号已挂满,BLL 直接抛BizException并回滚,自增操作因为事务回滚会一起撤销。这一点必须确认事务真的回滚了,否则号序会一直增长——我见过因为忘记回滚导致号序没满就提前用完的情况,排查了半天。

3.3 收费结算:decimal、舍入和金额口径

收费模块的核心就两件事:算对钱、记对账。算对钱的前提是用对类型——金额一律用decimal,绝对不能用double或float。浮点数在做0.1 + 0.2这类运算时会产出0.30000000000000004,这个值传到收款界面就是一块钱的误差,日结时账目对不上是必然的。

// BLL/ChargeService.cs public ChargeResult Settle(ChargeRequest req) { decimal total = 0m; foreach (var item in req.Items) { // 单价与数量都用 decimal,乘法不丢精度 var amount = item.UnitPrice * item.Qty; // 显式指定「四舍五入」,不用默认的银行家舍入 item.Amount = Math.Round(amount, 2, MidpointRounding.AwayFromZero); total += item.Amount; } // 折扣按整单打折,折扣率 0 到 1 var discount = Math.Round(total * req.DiscountRate, 2, MidpointRounding.AwayFromZero); var payable = total - discount; // 医保记账部分单独算,自费部分才是患者掏的钱 var insurance = Math.Round(payable * req.InsuranceRatio, 2, MidpointRounding.AwayFromZero); var selfPay = payable - insurance; return new ChargeResult { TotalAmount = total, DiscountAmount = discount, InsuranceAmount = insurance, SelfPayAmount = selfPay }; }

这里有个容易被忽略的细节:Math.Round的默认行为是银行家舍入(MidpointRounding.ToEven),2.5会被舍入成2而不是3。财务对账时这个差异会被放大,所以必须显式写MidpointRounding.AwayFromZero。折扣和医保分摊也都要单独舍入再相减,不能让「自费 = 总额 - 医保」这样的减法产生分位误差——分位误差一次两次看不出来,一个月下来能差出几十块,对账时够喝一壶。

金额计算通篇建议采用「先算明细、再算整单、最后算分摊」的顺序,每一步都落一个decimal变量,方便在调试时逐行查看中间值。这套代码里收费模块的每一行都能对着票据逐项核对,这就是保持可追溯性的价值。

3.4 药房发药:库存扣减与流水留痕

发药模块和收费模块的逻辑结构很像,但多了一个「库存不能为负」的硬性约束。常见做法是扣库存时带上条件判断,把校验交给数据库。

-- 扣减库存:条件里带 StockQty >= @Qty,扣成负数直接失败 UPDATE T_DrugStock SET StockQty = StockQty - @Qty WHERE DrugId = @DrugId AND StockQty >= @Qty; -- 影响行数为 0 说明库存不足,业务层抛异常回滚

@@ROWCOUNT是这里的裁判:影响行数等于 0,就说明库存不够,BLL 抛异常回滚整个事务。这个写法比「先查库存再扣」少了一次往返,也不会产生并发窗口。扣减成功后,还要往库存流水表插一条记录,记录发药单号、药品、数量、操作人、时间。流水表只增不改,是后续盘点和审计的唯一依据。发药本身还要更新发药单状态,从「待发药」变成「已发药」,这个状态字段要和其他更新放在同一个事务里,否则会出现库存扣了但单据还是待发药的状态,患者拿不到药,系统显示还没发。

4. 从零跑起来:环境、数据库和冒烟验证

4.1 环境准备:版本对了,一半问题就没了

这套代码是 .NET Framework 项目,不是 .NET Core,环境准备的第一步就是别装错运行时。

  • Visual Studio 2019 或 2022,安装时勾选「.NET 桌面开发」工作负载
  • .NET Framework 4.7.2 或 4.8 开发包,缺了会打不开项目
  • SQL Server 2016 以上版本,Express 版也能跑,但要注意 Express 版单库 10G 上限
  • SQL Server Management Studio,用来执行建库脚本

连接串放在启动项目His.UI的App.config里,改这一处就够了。

<configuration> <connectionStrings> <!-- Server 写 . 表示本机默认实例,命名实例写成 机器名\实例名 --> <add name="HisConn" connectionString="Server=.;Database=HisDb;User Id=sa;Password=你的密码;TrustServerCertificate=True;" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>

TrustServerCertificate=True是给新版驱动准备的,不加的话在部分环境下会报证书链错误,表现是「连接被服务器拒绝」,但其实是 SSL 握手失败,很容易误判为账号密码错。

4.2 数据库初始化:脚本先跑,种子数据后补

DbScripts目录里的脚本一般按序号排,先建库、再建表、最后灌字典数据。执行顺序不能乱,字典数据依赖表结构,业务数据依赖字典。

-- 1 建库 CREATE DATABASE HisDb; GO USE HisDb; GO -- 2 基础字典:科室 CREATE TABLE T_Dept ( DeptId INT IDENTITY(1,1) PRIMARY KEY, DeptCode VARCHAR(20) NOT NULL UNIQUE, DeptName NVARCHAR(50) NOT NULL, IsEnabled BIT NOT NULL DEFAULT 1 ); -- 3 挂号排班:号源总数和当前号序都挂在这张表上 CREATE TABLE T_Schedule ( ScheduleId INT IDENTITY(1,1) PRIMARY KEY, DoctorId INT NOT NULL, DeptId INT NOT NULL, WorkDate DATE NOT NULL, TimeSlot TINYINT NOT NULL, -- 1上午 2下午 3夜间 TotalQuota INT NOT NULL, CurrentSeq INT NOT NULL DEFAULT 0 );

建完表先别急着开客户端,把字典数据灌进去:科室、医生、号别、收费项目、药品目录,这五类数据缺一个,挂号界面就是空的。种子脚本里通常会给几条测试数据,可以直接用来验证。

4.3 首次登录与基础字典的配置顺序

管理员账号一般在种子脚本里预置,用户名和初始密码写在脚本注释里。登录进去第一件事不是挂号,是按顺序把字典配完,顺序错了后面会卡住:

  1. 科室:先建临床科室,再建药房、收费处这类执行科室
  2. 医生:挂到科室下,注意区分医生职称
  3. 号别:普通号、专家号,挂到收费项目上
  4. 收费项目:挂号费、诊疗费、检查费、药品费,编码规则要统一
  5. 药品目录:药品编码、规格、单位、零售价、库存上下限

这五步里,收费项目和药品目录最关键。挂号时选的号别如果没关联收费项目,挂号能成功但生成不了收费单,患者交了钱系统里查不到,这就是典型的配置问题而不是代码问题。

4.4 冒烟走查:一条从挂号到发药的完整路径

环境搭好后,用一条完整业务链验证,比逐个窗体点一遍靠谱得多:

  1. 挂号:选患者、科室、医生、号别,确认后看号序是否从 1 开始递增
  2. 收费:在收费界面查待收费单,确认金额和挂号费一致,完成收费
  3. 就诊:医生站能看到这个患者,开一条药品处方
  4. 收费:处方生成新的待收费单,收完费状态变为已收费
  5. 发药:药房界面能查到这条处方,发药后库存减少、状态变更

这五步全部走通,说明数据库连接、事务、字典、界面绑定都没问题。任何一步卡住,问题范围立刻缩小到对应模块,比漫无目的地点界面高效得多。

5. 避坑与排查:几个让我加班到半夜的问题

5.1 登录报「用户登录失败」,但账号密码明明是对的

现象:SSMS 能连上,客户端一启动就报登录失败。

原因:连接串里的Server写错了。SSMS 连的是命名实例,连接串写的是.(默认实例);或者反过来,本机装的是 Express 版,实例名是.\SQLEXPRESS,写成.就找不到。

解决:先在 SSMS 里执行SELECT @@SERVERNAME,把返回的实例名原样抄进连接串。改完还是不行,就在客户端启动时把SqlConnection.ServerVersion打出来,能连上就说明连接串没问题,问题出在后面的登录校验逻辑。

5.2 两个窗口同时挂号,号序撞了

现象:早高峰时偶发两个患者拿到同一个号序。

原因:号序是先查后改,中间没有锁;或者加了事务但隔离级别是READ COMMITTED,读的时候没加锁。

解决:改成第 3.2 节那种「UPDATE 自增 + SELECT 回读」的写法,靠数据库行锁保证顺序。改完用两个客户端同时挂号压测一遍,连点二十次不出现重复才算过。

5.3 日结时金额差了几分钱

现象:收费明细加起来和汇总金额对不上,差几分。

原因:金额计算中途用了double,或者多处Math.Round的舍入方式不一致,还可能存在「先算总额再倒推明细」的写法。

解决:全链路统一decimal+MidpointRounding.AwayFromZero,并且规定计算顺序必须是「明细 → 小计 → 折扣 → 分摊」,每一层都单独舍入并落库。改完跑一遍历史数据核对脚本,把差额找出来。

5.4 票据打印错位,换个电脑就歪

现象:开发机上打印正常,客户现场票据整体偏移几毫米。

原因:WinForms 打印默认按 96 DPI 计算,客户端机器缩放比例是 125% 或 150%,坐标全被放大;纸张边距也依赖驱动默认值。

解决:打印前用Graphics.DpiX换算实际 DPI,或者直接设置PageUnit = GraphicsUnit.Millimeter,用毫米做单位而不是像素。边距不要依赖驱动,自己在代码里显式设置Margins。

5.5 跨天结账,昨天的单子混进了今天的账

现象:夜间门诊的单据在第二天日结时被算进当天。

原因:业务日期用的是DateTime.Now而不是「系统工作日」。HIS 里通常有一个「日结时间点」概念,比如凌晨 2 点之前算前一天。

解决:抽一个BusinessDateHelper,统一从配置读日结时间点,所有涉及账期的查询都走这个帮助类,禁止在 DAL 里直接写CONVERT(date, CreateTime)。

6. 二次开发:给它加一个「体检套餐」模块

拿到源码后最常见的改造需求是加业务模块。我一般拿「体检套餐」练手,因为它同时涉及字典、主表、明细和收费联动,改完一遍,整套架构的脉络就通了。

第一步加表,三张:套餐主表、套餐项目明细、体检登记表。主表存套餐编码、名称、总价、是否启用;明细表存套餐下挂的检查项目,外键关联到已有的T_ChargeItem;登记表存患者、套餐、登记时间、状态。

表名关键字段说明
T_ExamPackagePackageCode, PackageName, TotalPrice, IsEnabled套餐定义
T_ExamPackageItemPackageId, ItemCode, Qty套餐包含的收费项目
T_ExamRegisterRegNo, PatientId, PackageId, RegTime, Status体检登记流水

第二步照抄现有模块的三层结构:ExamPackageDal负责三张表的增删改查,ExamPackageService负责校验和事务编排,FrmExamPackage负责界面。这一步不用发明新写法,直接把ChargeService复制过来改名字,能省掉大量调试时间。

第三步接收费。体检登记成功后要生成一张待收费单,复用InsertChargeBill,但金额来源不是单项价格而是套餐总价。这里有个坑:套餐总价必须和明细项目金额之和一致,否则收费单和明细对不上。我一般会在保存套餐时做一次校验,不一致就不让保存。

第四步做冒烟验证:新建套餐、登记体检、生成收费单、完成收费、查询登记状态。四条链路走通,模块就算接进去了。

这套代码的扩展性其实取决于你有没有守住它的约定:事务只在 BLL 开、金额只用 decimal、状态变更和业务数据同事务、字典编码全局唯一。守住这四条,加十个模块也不会乱;破坏其中一条,后面每个模块都得单独打补丁。从那以后我每次接一个新模块,都先把这四条写进项目 README 里,改代码前先对照一遍——这习惯看着笨,但省下来的排查时间是真的。希望帮到你。

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

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

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

立即咨询