☰
C# WinForm医院挂号系统源码改造:SQL Server号源并发防超卖实践
2026/10/28 19:27:45 网站建设 项目流程

简介:面向计算机专业学生与WinForm入门者的C#医院挂号管理系统源码,采用C/S架构和MVC分层,可用于课程设计、毕业设计参考及桌面管理类项目练习。压缩包共185个文件、约10.03MB,包含64个cs源码、42张jpg图片,以及resx/resources资源、sql脚本、chm帮助文档和rpt报表等,覆盖表示层、业务层与数据访问层的主要组成。已有1267人学习下载,说明其作为学习范例具有一定参考热度。功能涵盖用户、科室、医生管理,门急诊挂号、挂号查询、修改口令、挂号单打印和帮助文档,并用存储过程操作数据库、MD5加密用户密码,统计信息以图文报表呈现,还支持医生照片上传,便于读者理解完整业务流程与分层实现。

1. 一套 C# WinForm 医院挂号管理系统,值不值得拿来改

门诊窗口最容易出事的场景不是系统崩了,是两名挂号员同时点“确认”,最后一个号卖出去两次,患者拿着两张一样的挂号单站在导诊台前。这套用 C# WinForm 写的医院挂号管理系统,解决的正是这类窗口场景:建队列、选科室医生、扣号源、打小票、退号冲账。它是典型的桌面端 C/S 结构,SQL Server 做库,WinForm 做界面,适合社区卫生服务中心、二级医院门诊、体检中心这类“窗口数不超过十个、网络环境简单”的场合。你要是指望它直接扛三甲医院高峰期几千并发,那要改造;但作为一套能跑通业务闭环、逻辑看得见摸得着的源码底子,它比从零画界面靠谱得多。

2. 拆工程:WinForm 分层、库表与号源模型怎么对应

拿到源码第一件事不是双击 .sln 按 F5,而是先看它怎么分层、号源存在哪张表。这两件事决定了后面所有并发问题的上限。我见过太多类似系统把“剩余号”放在医生表里,或者干脆用 DataGridView 的行数当余号,这种结构改起来比重写还费劲。

2.1 解决方案分层与窗体的职责边界

一套能长期维护的 WinForm 挂号系统,工程结构大致是四块:UI 层(窗体与用户控件)、BLL 层(挂号、退号、排班这些业务规则)、DAL 层(SQL 与连接管理)、Model 层(实体类)。实际源码里经常看到 BLL 被省掉,窗体里直接new SqlConnection,小项目能跑,但一旦要加“退号时限”或者“医保类型不同收费不同”,改动会散落到十几个窗体里。

我一般按下表来划分职责边界,改源码时先把越界的调用捋回来:

目录/文件职责是否允许出现 SQL
Forms/控件布局、事件响应、输入校验提示不允许
Bll/RegisterService.cs挂号事务、退号规则、号源判断不允许
Dal/ScheduleDal.cs排班与号源的增删改查允许,且只在这里
Dal/DbHelper.cs连接、参数化、事务封装允许
Models/实体类,只放字段不允许
app.config连接串、打印纸张、超时配置允许

关键点是窗体里不写 SQL。窗体只负责把用户输入收成参数,交给 BLL,BLL 拿结果再回填控件。这条线守住了,后面换成 WCF 服务端或者加个 Web 端都不会推倒重来。

另一个容易忽略的是窗体的事件线程。挂号按钮如果直接同步查库,网络一抖界面就白屏,操作员会以为卡死连点三下,直接产生三条重复记录。常见做法是用async/await包一层,按钮在请求期间置灰。

private async void btnRegister_Click(object sender, EventArgs e) { // 关键:请求期间禁用按钮,避免操作员连点产生重复挂号 btnRegister.Enabled = false; try { var svc = new RegisterService(); var regNo = await Task.Run(() => svc.Register( _currentPatientId, _currentScheduleId, CurrentUser.UserId, _currentFee)); lblLastRegNo.Text = "挂号单号:" + regNo; RefreshQuotaGrid(); // 号源变化后立刻刷新右侧列表 } catch (InvalidOperationException ex) { MessageBox.Show(ex.Message, "挂号失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); } finally { btnRegister.Enabled = true; // 无论成功失败都要恢复,否则界面永久锁死 } }

Task.Run把阻塞的数据库调用挪到线程池,UI 线程继续响应重绘;finally里的恢复动作是后悔药,少了它一旦抛异常按钮就再也点不动。注意RefreshQuotaGrid内部如果要跨线程访问控件,得用Invoke或者干脆回到 UI 上下文再刷,这点在 3.3 节展开。

2.2 号源模型:排班、号别、剩余号拆成三张表

为什么不能把剩余号塞进医生表?因为同一个医生上午挂普通号、下午挂专家号,配额完全不同;同一天还可能临时停诊再复诊。号源的最小粒度是「医生 + 日期 + 时段 + 号别」这个四元组,对应一张排班表最自然。

-- 排班与号源:一行代表一个可挂号的号源池 CREATE TABLE tb_Schedule ( ScheduleID INT IDENTITY(1,1) PRIMARY KEY, DoctorID INT NOT NULL, DeptID INT NOT NULL, WorkDate DATE NOT NULL, -- 用 DATE 而不是 DATETIME,避开时分秒 TimeSlot TINYINT NOT NULL, -- 0 上午 1 下午 RegTypeID INT NOT NULL, -- 号别:普通/专家/急诊 TotalQuota INT NOT NULL, -- 总号数 RemainQuota INT NOT NULL, -- 剩余号数,挂号时原子递减 Status TINYINT NOT NULL DEFAULT 0,-- 0 正常 1 停诊 RowVer ROWVERSION, CONSTRAINT CK_Schedule_Quota CHECK (RemainQuota >= 0 AND RemainQuota <= TotalQuota) ); -- 同一医生同一天同一时段同一号别,只能有一条正常排班 CREATE UNIQUE INDEX UX_Schedule_Active ON tb_Schedule(DoctorID, WorkDate, TimeSlot, RegTypeID) WHERE Status = 0;

WorkDate用DATE类型是个血泪经验,早期版本用DATETIME存日期,查询时带上00:00:00的时间部分,某台机器区域设置不同就查不到当天排班。CHECK约束是最后一道闸门,即便代码写错,也不可能出现负数余号。过滤唯一索引WHERE Status = 0让停诊记录可以保留历史,同时不妨碍重新排一条正常班。

号别单独建表,是因为收费金额、是否可退、是否限号都挂在这上面:

CREATE TABLE tb_RegisterType ( RegTypeID INT IDENTITY(1,1) PRIMARY KEY, TypeName NVARCHAR(20) NOT NULL, -- 普通号 / 专家号 / 急诊号 Fee DECIMAL(10,2) NOT NULL, -- 挂号费,挂号时快照进挂号记录 Refundable BIT NOT NULL DEFAULT 1 -- 急诊号常见做法是不允许退 );

Fee在挂号记录里要冗余一份快照,不能只存RegTypeID到时候 join。原因是号别价格会调整,历史单据必须显示当时收的钱,否则对账时账实不符,财务会来找你。

挂号记录表的设计决定了后面退号、统计、防重能不能做干净:

CREATE TABLE tb_Register ( RegID BIGINT IDENTITY(1,1) PRIMARY KEY, RegNo VARCHAR(24) NOT NULL UNIQUE, -- 挂号单号,打印在小票上 PatientID INT NOT NULL, ScheduleID INT NOT NULL, DeptID INT NOT NULL, -- 冗余,方便按科室统计 DoctorID INT NOT NULL, -- 冗余,方便按医生统计 WorkDate DATE NOT NULL, TimeSlot TINYINT NOT NULL, Fee DECIMAL(10,2) NOT NULL, -- 价格快照 Status TINYINT NOT NULL DEFAULT 0, -- 0 已挂号 1 已就诊 2 已退号 RegTime DATETIME NOT NULL DEFAULT GETDATE(), OperatorID INT NOT NULL ); -- 同一患者对同一号源,只能存在一条有效记录 CREATE UNIQUE INDEX UX_Register_Patient ON tb_Register(PatientID, ScheduleID) WHERE Status IN (0, 1);

这个过滤唯一索引是整个防重复挂号机制的底盘。前端按钮置灰、后端查询判断都只是体验层,真正兜底的是它——两个窗口同时提交,数据库只放一条进去,另一条抛唯一键冲突,捕获后提示“该患者已挂此号”。

2.3 连接串与 DbHelper 的落地写法

连接串放 app.config,不要硬编码在窗体里。部署到不同门诊,改配置文件比重编译现实得多。

<connectionStrings> <add name="HisDb" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=HisRegister;User ID=hisapp;Password=******;Connect Timeout=5;Application Name=RegClient" providerName="System.Data.SqlClient" /> </connectionStrings>

几个参数值得说:Connect Timeout=5把默认 15 秒压到 5 秒,网络不通时操作员五秒内就能看到报错,而不是干等;Application Name让 DBA 在sys.dm_exec_sessions里一眼看出是哪台客户端的连接;账号hisapp只给db_datareader、db_datawriter加存储过程执行权限,绝不能用 sa,窗口机器中毒的概率比你想的高。

DbHelper 的核心是参数化,任何拼接字符串的 SQL 都是给注入留门。

public static class DbHelper { // 连接串只读一次,避免每次调用都解析配置文件 private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["HisDb"].ConnectionString; public static DataTable Query(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) using (var da = new SqlDataAdapter(cmd)) { if (ps != null) cmd.Parameters.AddRange(ps); var dt = new DataTable(); da.Fill(dt); // DataAdapter 会自动开关连接 return dt; } } public static int Execute(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } }

using保证连接必定释放,连接池才有意义;params SqlParameter[]让调用方必须显式传参,杜绝"... where name='" + txtName.Text + "'"这种写法。事务场景 DbHelper 不够用,因为需要连接和事务对象贯穿多条命令,这块在第 3 章单独写。

3. 挂号主流程:从建档查重到号源扣减的一个事务

挂号这个动作,在业务上是一串:确认患者身份 → 建/取患者档案 → 选号源 → 扣号 → 生成单号 → 落记录 → 打小票。在数据库上,它必须是原子的——号扣了记录没落,号就凭空少了;记录落了号没扣,就会超卖。这一章把这条链路拆成三段写。

3.1 患者建档与身份证去重

患者表的第一原则是身份证号唯一。窗口手工录入,错一位数字就多一个“影子患者”,日后病历、检验结果全对不上。

CREATE TABLE tb_Patient ( PatientID INT IDENTITY(1,1) PRIMARY KEY, PatientName NVARCHAR(30) NOT NULL, IDCard CHAR(18) NOT NULL, Phone VARCHAR(20) NULL, Gender TINYINT NOT NULL, -- 0 未知 1 男 2 女 BirthDate DATE NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE UNIQUE INDEX UX_Patient_IDCard ON tb_Patient(IDCard);

18 位身份证最后一位是校验码,前端算一遍能挡掉大部分手误。算法是前 17 位加权求和后取模 11,映射到校验字符表。

private static readonly int[] Weight = { 7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2 }; private static readonly char[] CheckCode = { '1','0','X','9','8','7','6','5','4','3','2' }; public static bool IsValidIdCard(string id) { if (id == null || id.Length != 18) return false; int sum = 0; for (int i = 0; i < 17; i++) { if (!char.IsDigit(id[i])) return false; // 前 17 位必须全是数字 sum += (id[i] - '0') * Weight[i]; } char expect = CheckCode[sum % 11]; // 末位 X 要按大写比较,小写 x 也算合法 return char.ToUpper(id[17]) == expect; }

建档流程是“先查后插”,但中间有窗口期,两个窗口同时给同一个新患者建档会撞唯一索引。所以插入语句要能接住这个异常,冲突时回查一次拿到已有的PatientID,业务上表现为“建档成功”,而不是弹一个数据库错误给操作员看。

public int GetOrCreatePatient(string name, string idCard, string phone, byte gender, DateTime? birth) { var exist = DbHelper.Query("SELECT PatientID FROM tb_Patient WHERE IDCard = @id", new SqlParameter("@id", idCard)); if (exist.Rows.Count > 0) return Convert.ToInt32(exist.Rows[0][0]); try { DbHelper.Execute(@"INSERT INTO tb_Patient(PatientName, IDCard, Phone, Gender, BirthDate) VALUES(@n, @id, @p, @g, @b)", new SqlParameter("@n", name), new SqlParameter("@id", idCard), new SqlParameter("@p", (object)phone ?? DBNull.Value), new SqlParameter("@g", gender), new SqlParameter("@b", (object)birth ?? DBNull.Value)); } catch (SqlException ex) when (ex.Number == 2601 || ex.Number == 2627) { // 2601/2627 是唯一索引冲突,说明另一窗口刚插进去,忽略即可 } return Convert.ToInt32(DbHelper.Query("SELECT PatientID FROM tb_Patient WHERE IDCard = @id", new SqlParameter("@id", idCard)).Rows[0][0]); }

when (ex.Number == 2601 || ex.Number == 2627)是 C# 6 的异常过滤,比在 catch 里再判断更干净。注意千万别捕获所有SqlException就吞掉,链接超时、死锁也走这个类型,全吞了排查时就是黑匣子。

3.2 号源扣减:一条带条件的 UPDATE

超卖的根因几乎都是“先 SELECT 查余号,判断大于 0,再 UPDATE 减一”。两条语句之间有时间差,两个会话都读到 1,都判断通过,最后余号变成 -1。

正确做法是把判断塞进 UPDATE 的 WHERE 里,让数据库在一条语句内完成“读—判—写”,加锁也是原子的。

public string Register(int patientId, int scheduleId, int operatorId, decimal fee) { using (var conn = new SqlConnection(ConnStr)) { conn.Open(); // 读已提交即可:条件 UPDATE 本身持有行锁,不需要上序列化 using (var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { try { // 1) 扣号:RemainQuota > 0 写进 WHERE,杜绝判断与写入之间的空窗 var cmdQuota = new SqlCommand( @"UPDATE tb_Schedule SET RemainQuota = RemainQuota - 1 WHERE ScheduleID = @sid AND Status = 0 AND RemainQuota > 0", conn, tran); cmdQuota.Parameters.AddWithValue("@sid", scheduleId); if (cmdQuota.ExecuteNonQuery() == 0) throw new InvalidOperationException("该号源已挂完或已停诊"); // 2) 取排班信息,把医生、科室、日期冗余进挂号记录 int deptId, doctorId, slot; DateTime workDate; using (var cmdInfo = new SqlCommand( @"SELECT DeptID, DoctorID, WorkDate, TimeSlot FROM tb_Schedule WHERE ScheduleID = @sid", conn, tran)) { cmdInfo.Parameters.AddWithValue("@sid", scheduleId); using (var rd = cmdInfo.ExecuteReader()) { if (!rd.Read()) throw new InvalidOperationException("号源不存在"); deptId = rd.GetInt32(0); doctorId = rd.GetInt32(1); workDate = rd.GetDateTime(2); slot = rd.GetByte(3); } } // 3) 生成单号:日期 + 序列号,唯一性由数据库保证 string regNo; using (var cmdNo = new SqlCommand( @"SELECT CONVERT(VARCHAR(8), GETDATE(), 112) + RIGHT('000000' + CAST(NEXT VALUE FOR sq_RegNo AS VARCHAR(10)), 6)", conn, tran)) { regNo = (string)cmdNo.ExecuteScalar(); } // 4) 落挂号记录 using (var cmdIns = new SqlCommand( @"INSERT INTO tb_Register (RegNo, PatientID, ScheduleID, DeptID, DoctorID, WorkDate, TimeSlot, Fee, OperatorID) VALUES (@no, @pid, @sid, @did, @doc, @wd, @slot, @fee, @op)", conn, tran)) { cmdIns.Parameters.AddWithValue("@no", regNo); cmdIns.Parameters.AddWithValue("@pid", patientId); cmdIns.Parameters.AddWithValue("@sid", scheduleId); cmdIns.Parameters.AddWithValue("@did", deptId); cmdIns.Parameters.AddWithValue("@doc", doctorId); cmdIns.Parameters.AddWithValue("@wd", workDate); cmdIns.Parameters.AddWithValue("@slot", slot); cmdIns.Parameters.AddWithValue("@fee", fee); cmdIns.Parameters.AddWithValue("@op", operatorId); cmdIns.ExecuteNonQuery(); } tran.Commit(); return regNo; } catch (SqlException ex) when (ex.Number == 2601 || ex.Number == 2627) { tran.Rollback(); throw new InvalidOperationException("该患者已挂此号,请勿重复挂号"); } catch { tran.Rollback(); // 任何异常都要回滚,否则号被扣了记录没落 throw; } } } }

逐段解释。第一步的ExecuteNonQuery() == 0是超卖防线的判定点:如果号已挂完,UPDATE 影响 0 行,直接抛业务异常,事务回滚,不会留下任何脏数据。第二步把排班信息读出来冗余,是为了后续按科室、医生做日结统计时不用多表 join。第三步用SEQUENCE生成流水号,比MAX(RegNo)+1安全,后者在并发下会撞号。第四步插入时如果触发UX_Register_Patient唯一索引,说明该患者在别的窗口刚挂过同一个号源,捕获后回滚并提示。

IsolationLevel.ReadCommitted足够,因为防超卖靠的是条件更新而不是隔离级别,把隔离级别提到Serializable只会增加阻塞和死锁概率,这是很多人第一反应会踩的弯路。

3.3 WinForm 端接线与表格刷新

号源列表用DataGridView展示,绑定方式直接影响体验。直接grid.DataSource = dt每次都重建列、丢选中行,操作员刚点的行会跳走。标准做法是用BindingSource兜一层。

private readonly BindingSource _schedulerSource = new BindingSource(); private void LoadSchedules(int deptId, DateTime workDate, int timeSlot) { var dt = DbHelper.Query( @"SELECT s.ScheduleID, d.DoctorName, t.TypeName, t.Fee, s.TotalQuota, s.RemainQuota FROM tb_Schedule s JOIN tb_Doctor d ON d.DoctorID = s.DoctorID JOIN tb_RegisterType t ON t.RegTypeID = s.RegTypeID WHERE s.DeptID = @dept AND s.WorkDate = @d AND s.TimeSlot = @slot AND s.Status = 0 ORDER BY d.DoctorName", new SqlParameter("@dept", deptId), new SqlParameter("@d", workDate.Date), // 只传日期部分,避开时分秒 new SqlParameter("@slot", timeSlot)); _schedulerSource.DataSource = dt; // 复用同一个 BindingSource gridSchedules.DataSource = _schedulerSource; // 余号为 0 的行标灰,操作员一眼能看出来 foreach (DataGridViewRow row in gridSchedules.Rows) { if (Convert.ToInt32(row.Cells["RemainQuota"].Value) == 0) row.DefaultCellStyle.ForeColor = Color.Gray; } }

函数里@d传workDate.Date,把DateTimePicker带的时间部分砍掉。这是通用坑:DateTimePicker.Value返回的是带当前时分秒的DateTime,直接参数化到DATE列虽然 SQL Server 会隐式转换,但某些区域设置下会丢精度,显式.Date最稳。

刷新时机要卡在挂号成功之后立即执行,而且要重新查库而不是本地把RemainQuota减一。本地减一看似快,但别的窗口也在挂,你手上的数据早就过期了,显示的和实际的对不上,操作员会以为系统出错。

4. 排班、退号、收费:三个状态机别写混

到这一层,系统真正的复杂度才出来。挂号只是“插入”,排班、退号、收费都是“改状态”,而状态迁移一旦没有约束,数据就会进入无法解释的中间态:退了号号源没回来、重复退号把余号加爆、收了费又退号钱没冲。

4.1 排班批量生成与停诊

排班一般按周批量排,医生固定周几出诊。批量插入必须幂等,重复点“生成排班”不能插出一堆重复行。

-- 为指定医生生成未来 @days 天的排班,已存在正常排班的日期自动跳过 ;WITH d AS ( SELECT 0 AS n UNION ALL SELECT n + 1 FROM d WHERE n + 1 < @days ) INSERT INTO tb_Schedule(DoctorID, DeptID, WorkDate, TimeSlot, RegTypeID, TotalQuota, RemainQuota, Status) SELECT @docId, @deptId, DATEADD(DAY, d.n, @startDate), t.Slot, @typeId, @quota, @quota, 0 FROM d CROSS JOIN (VALUES(0),(1)) AS t(Slot) WHERE NOT EXISTS ( SELECT 1 FROM tb_Schedule s WHERE s.DoctorID = @docId AND s.WorkDate = DATEADD(DAY, d.n, @startDate) AND s.TimeSlot = t.Slot AND s.RegTypeID = @typeId AND s.Status = 0);

NOT EXISTS配合 2.2 节的过滤唯一索引形成双保险:并发点两次,第二条会被唯一索引挡下,异常吞掉即可。CROSS JOIN (VALUES(0),(1))生成上午下午两个时段,比写两遍 INSERT 清爽。

停诊的处理要分情况:当天还没挂号的可以直接置Status = 1;已经挂了号的必须走退号退款流程,不能一停了之。我一般加一张停诊操作记录表,把操作人、时间、原因记下来,出问题时能追溯。

UPDATE tb_Schedule SET Status = 1 WHERE ScheduleID = @sid AND Status = 0 AND NOT EXISTS (SELECT 1 FROM tb_Register r WHERE r.ScheduleID = @sid AND r.Status IN (0,1));

NOT EXISTS那段保证“有有效挂号就不许直接停诊”,影响行数为 0 时前端提示“该班次已有挂号,请先处理退号”。这条规则写进 SQL 而不是写进 C# 的 if,是防止有人绕过界面直接改库。

4.2 退号的状态回滚与号源归还

退号是典型的两个写操作:改记录状态 + 归还号源。必须同事务,且两边都要带条件,否则重复点击退号按钮能把余号加爆。

public void Refund(long regId, int operatorId) { using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { try { // 1) 状态迁移:只有"已挂号"能退,"已退号/已就诊"影响 0 行 var cmd1 = new SqlCommand( @"UPDATE tb_Register SET Status = 2 WHERE RegID = @rid AND Status = 0", conn, tran); cmd1.Parameters.AddWithValue("@rid", regId); if (cmd1.ExecuteNonQuery() == 0) throw new InvalidOperationException("该记录已退号或已就诊,无法重复退号"); // 2) 归还号源:RemainQuota < TotalQuota 防止超过总数 var cmd2 = new SqlCommand( @"UPDATE s SET s.RemainQuota = s.RemainQuota + 1 FROM tb_Schedule s JOIN tb_Register r ON r.ScheduleID = s.ScheduleID WHERE r.RegID = @rid AND s.RemainQuota < s.TotalQuota", conn, tran); cmd2.Parameters.AddWithValue("@rid", regId); cmd2.ExecuteNonQuery(); tran.Commit(); } catch { tran.Rollback(); throw; } } } }

第一个 UPDATE 的AND Status = 0是幂等闸门,两个窗口同时点退号,只有一个能改成功,另一个拿到 0 行直接报错,不会出现余号加两次。第二个 UPDATE 里的s.RemainQuota < s.TotalQuota是第二道保险,即使前一道被人改坏了,余号也不会超过总号数——CHECK约束还会兜第三层。

退号时限是个业务规则,比如“开诊前 2 小时可退,开诊后不退”。这条规则要写在 BLL 层并在 SQL 里也体现,不能只靠前端隐藏按钮:

// 开诊时间按 上午 08:00 / 下午 13:30 估算,实际项目里应从配置表读 var deadline = workDate.AddHours(timeSlot == 0 ? 8 : 13.5).AddHours(-2); if (DateTime.Now > deadline) throw new InvalidOperationException("已过退号时限,请联系服务台处理");

时间判断放服务端,是因为客户端机器的系统时间可以被随意修改,靠它做业务约束等于没有约束。

4.3 收费状态与小票打印

很多简易挂号系统把收费和挂号合并成一个动作,窗口一次收钱出单。这在自费门诊行得通,但只要涉及医保结算、发票管理,就必须拆开:挂号记录有Status,收费另有FeeStatus,两者不能混用同一个字段用不同含义。

我一般给tb_Register补一个FeeStatus字段:0 未收费、1 已收费、2 已退费。退号时先退费再改挂号状态,顺序反了会出现“已退号但钱还挂着”的对账差异。

-- 退号同时冲账,两步在同一事务里,顺序不能反 UPDATE tb_Register SET FeeStatus = 2 WHERE RegID = @rid AND FeeStatus = 1; UPDATE tb_Register SET Status = 2 WHERE RegID = @rid AND Status = 0;

小票打印用PrintDocument,坑在纸张尺寸和边距。热敏小票纸常见 80mm 宽,默认A4打出来全是空白。

private void PrintTicket(string regNo, string patientName, string dept, string doctor, decimal fee) { var doc = new PrintDocument(); // 80mm 热敏纸,1mm ≈ 3.78 百分之一英寸 doc.DefaultPageSettings.PaperSize = new PaperSize("Receipt", 315, 600); doc.DefaultPageSettings.Margins = new Margins(8, 8, 10, 10); doc.PrintPage += (s, e) => { var g = e.Graphics; var fontTitle = new Font("微软雅黑", 12, FontStyle.Bold); var fontBody = new Font("微软雅黑", 9); float y = 10; g.DrawString("挂号单", fontTitle, Brushes.Black, 100, y); y += 30; g.DrawString("单号:" + regNo, fontBody, Brushes.Black, 10, y); y += 20; g.DrawString("姓名:" + patientName, fontBody, Brushes.Black, 10, y); y += 20; g.DrawString("科室:" + dept, fontBody, Brushes.Black, 10, y); y += 20; g.DrawString("医生:" + doctor, fontBody, Brushes.Black, 10, y); y += 20; g.DrawString("金额:" + fee.ToString("F2") + " 元", fontBody, Brushes.Black, 10, y); }; doc.Print(); // 生产环境应提供 PrintPreviewDialog 供操作员确认 }

PaperSize的宽高单位是百分之一英寸,315 约等于 80mm。不要用doc.DefaultPageSettings.PaperSize.Width = 315单独赋值,那样不会生效,必须整体替换PaperSize对象。打印失败最常见的原因是驱动里没装自定义纸张,这时doc.Print()会弹系统对话框,要在部署清单里写清楚。

5. 避坑与排查:超卖、重号、时间穿越和部署翻车

这一章是我改这类系统时踩过的坑,按“现象 → 原因 → 解决”写,照着对照基本能定位大半线上问题。

5.1 并发与数据一致性上的三类翻车

现象一:余号出现负数,或者同一个号被两个患者挂到。原因基本是把“查余号”和“减余号”写成了两条语句,中间还夹着弹窗确认或者打印预览,时间窗口能有好几秒。也有一种变体是前端在 DataGridView 里缓存了余号,点确认时用的是缓存值。解决办法是把条件写进 UPDATE 的 WHERE(见 3.2 节),并给tb_Schedule加CHECK (RemainQuota >= 0)作为最后防线。改完之后用两个客户端同时挂最后一个号验证一遍,我每次都这么测。

现象二:同一患者同一时段出现两条挂号记录,退号时只退了一条,另一条成了孤儿。原因是防重逻辑只在前端做,比如“查询该患者今天是否已挂此号”,查完到插入之间别人插了一条。解决是加UX_Register_Patient过滤唯一索引,代码里捕获 2601/2627 转成友好提示。上线前先跑一遍历史数据清洗,把已有的重复记录标成作废,否则建索引会失败。

现象三:退号后余号加不回来,或者加多了。加不回来通常是事务没提交,或者Refund里归还号源的 UPDATE 被RemainQuota < TotalQuota挡住(说明之前已经加过一次);加多了是没有AND Status = 0的幂等判断,操作员连点两下退号。排查时直接跑一句对账 SQL,看余号和实际挂号数对不对得上:TotalQuota - RemainQuota应该等于Status IN (0,1)的记录数,不等就是有问题。

5.2 时间与号别上的坑

现象四:早上八点查不到当天的排班,界面上医生列表是空的。原因是WorkDate列用了DATETIME类型,插入时带了2024-05-20 00:00:00,查询时参数是DateTime.Now带时分秒,等值比较永远不成立。另一种情况是查询语句写了WHERE WorkDate = @d,@d传的是DateTime.Now。解决是把列类型改成DATE,查询参数统一.Date,历史数据用UPDATE ... SET WorkDate = CAST(WorkDate AS DATE)清洗一遍。跨天还有一类:夜班号源归属日期,要在需求阶段就定清楚算“就诊日”还是“挂号日”,代码里两种都见过,混着用必然对不上。

现象五:停诊后重新排班,提示唯一索引冲突。原因是 2.2 节的UX_Schedule_Active建在(DoctorID, WorkDate, TimeSlot, RegTypeID) WHERE Status = 0上,如果停诊用的是软删除但Status没改成 1,或者改成了别的值,索引就认为还有一条正常记录。检查Status的取值是否和索引的过滤条件一致,别一边用1表示停诊、另一边用9表示作废。

5.3 部署与运行环境的坑

现象六:开发机跑得好好的,装到门诊窗口机就报“连接超时”或“未找到实例”。常见原因有三个:客户端装的 SQL Server 客户端组件版本和服务端不一致;实例名用了.\SQLEXPRESS但目标机器是默认实例;防火墙没放行 1433 端口(或者用了动态端口没开 SQL Browser)。排查顺序是先在本机用 SSMS 连一次,连不上就是网络或实例名问题,连得上但程序报错就去看连接串里的Initial Catalog和账号权限。另外注意目标机器装的 .NET Framework 版本,WinForm 项目如果把目标框架定在 4.8,Win7 上默认没有,安装包里得带上。

现象七:小票打出来位置偏、内容被截断。驱动里没有 80mm 自定义纸张是主因,其次是PrintDocument的OriginAtMargins属性没设,默认以可打印区域左上角为原点,不同打印机可打印区域不一样。我一般把OriginAtMargins = true打开,边距统一用Margins控制,换打印机时只调边距不调坐标。还有一点,DrawString的中文字体在某些精简版系统上缺失“微软雅黑”,会回退成宋体导致宽度算错,部署前确认字体存在。

6. 上线前的验证:并发脚本加一张对账 SQL 兜底

改完挂号事务,别急着交付。我习惯做两件事验证:一个并发压测,一张对账查询。

并发压测不用真开十个客户端,写个控制台程序用Parallel.For模拟二十个窗口同时抢最后一个号,看最终成功几条、余号是不是 0。

// 建一个只剩 1 个号的排班,然后 20 个线程同时挂 int scheduleId = 1; int success = 0, fail = 0; var svc = new RegisterService(); Parallel.For(0, 20, i => { try { svc.Register(patientId: 1000 + i, scheduleId: scheduleId, operatorId: 1, fee: 10m); Interlocked.Increment(ref success); // Interlocked 保证计数线程安全 } catch (InvalidOperationException) { Interlocked.Increment(ref fail); // 号挂完 / 重复挂号都走这里 } }); Console.WriteLine($"成功 {success} 条,失败 {fail} 条");

期望输出是“成功 1 条,失败 19 条”,余号归 0。如果成功多条,说明条件 UPDATE 写漏了;如果全部失败,检查Status和余号初始值。跑完记得把测试数据清掉,别把测试患者留在生产库里。

对账 SQL 上线后每天跑一次,挂在计划任务里,异常发邮件:

-- 号源与挂号记录对不上,说明有事务中途失败或有人手工改库 SELECT s.ScheduleID, s.WorkDate, s.TimeSlot, s.TotalQuota, s.RemainQuota, s.TotalQuota - s.RemainQuota AS UsedByQuota, (SELECT COUNT(*) FROM tb_Register r WHERE r.ScheduleID = s.ScheduleID AND r.Status IN (0,1)) AS UsedByRecord FROM tb_Schedule s WHERE s.TotalQuota - s.RemainQuota <> (SELECT COUNT(*) FROM tb_Register r WHERE r.ScheduleID = s.ScheduleID AND r.Status IN (0,1));

两条路径算出来的已用号数必须相等,不等就说明有号被扣了但记录没落,或者反过来。这条 SQL 我一般当体检项,新版本上线第一周每天看,稳定后改周跑。

还有一个进阶技巧是把挂号服务抽成接口,WinForm 只依赖接口,将来换成本地服务或者远程调用都不用改界面。定义IRegisterService,实现放在独立类库里,客户端通过配置文件决定装配哪个实现。这么做的好处是压测时可以不开界面直接压服务层,问题定位范围小很多。代价是多一层抽象,小项目会显得重,取舍看窗口数量。

从那以后我每次改完号源相关的逻辑,都强制跑一遍并发脚本加油对账 SQL,两个都干净才敢打包给现场。这套流程吃过一次亏——某次只测了单窗口,上线当天正好赶上周一上午高峰,余号超卖,导诊台电话被打爆。希望帮到你。

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

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

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

立即咨询