简介:这是一套面向高校计算机相关专业学生与C#初学者的医院药品管理系统完整源码包,基于WinForm框架与SQL Server数据库开发,可作为课程设计、毕业设计或二次开发参考。系统覆盖用户管理、药品信息维护、入库出库、药品申领与审核、库存预警及预警数量设置、病人信息录入等模块,较完整地还原了医院药品流转的业务闭环。压缩包共154个文件,约7.39MB,以52个cs源码文件为核心,辅以resx与resources资源文件、dll依赖库、json配置及mdf/ldf数据库文件,并附带sln解决方案与csproj工程文件,可直接用Visual Studio打开运行。目前已有226人学习下载。对于需要快速搭建桌面端管理系统的读者,可从中参考WinForm界面布局、SQL Server数据表设计以及申领审核、库存预警等业务逻辑的实现思路,节省从零搭建框架的时间。
1. 从一份 WinForm 药品管理源码说起:它到底能解决药房里的哪些麻烦
药房里最怕的不是药多,而是账对不上。入库、出库、盘点、效期预警、库存下限提醒,这几件事只要有一件靠手工台账,月底就得加班到怀疑人生。基于 C# 的医院药品管理系统(WinForm 源码 + SQL Server 数据库)这个标题,指向的就是一套典型的桌面端药房业务系统:用 WinForm 做操作界面,用 SQL Server 存药品、库存、出入库流水和用户权限,最终打包成安装程序交付给药房或信息科。它适合两类人:一类是计算机专业做课程设计或毕设的学生,需要一套能跑通、能讲清业务闭环的完整案例;另一类是中小医院、诊所或药企信息科的一线人员,想用低成本方式把药品台账从 Excel 里搬出来。这套东西的核心价值不在界面多炫,而在于把「药品基础信息 → 入库 → 出库 → 库存结余 → 效期预警」这条链路用数据库事务串起来,让每一次库存变动都有流水可查。下面我按实际落地顺序,把选型、建库、核心代码、打包和踩坑一次讲透。
2. 技术选型与数据库设计:为什么是 WinForm + SQL Server 而不是别的
2.1 WinForm 在这个场景里为什么还没被淘汰
很多人一听 WinForm 就觉得老,但在医院药房这种内网、单机或局域网、操作人员固定、不需要浏览器兼容的场景里,WinForm 反而是最省事的选择。它启动快、控件拖拽即可布局、跟 SQL Server 的 ADO.NET 配合几乎零学习成本,而且部署时只要装个 .NET Framework 运行时就能跑。对比 Web 方案,WinForm 不需要额外配 IIS、不需要处理跨域和会话,药房那台老电脑只要装了 SQL Server 客户端就能连库。常见做法是用 Visual Studio 2015 或更高版本建一个 Windows 窗体应用,目标框架选 .NET Framework 4.5 以上,这样兼容性最好。界面美化方面,不用上第三方皮肤库,把 DataGridView 的交替行颜色、列宽、冻结列调好,再配几个 GroupBox 分区,操作员就能看得清楚。热搜里常出现的 winform 界面美化、winform做简单表格,本质上都是把 DataGridView 和布局容器用熟,而不是堆控件。
2.2 数据库表结构怎么定:五张核心表撑起整个药房
药品管理系统的数据库不需要几十张表,抓住主数据、库存、流水、用户、供应商这五块就够。下面是我一般会用的建库脚本,直接在 SQL Server Management Studio 里执行即可。注意数据库名和文件路径按自己机器改,别照抄路径导致建库失败。
-- 创建数据库(路径按实际磁盘调整) CREATE DATABASE PharmacyDB ON PRIMARY ( NAME = 'PharmacyDB_data', FILENAME = 'D:\SQLData\PharmacyDB_data.mdf', SIZE = 20MB, FILEGROWTH = 5MB ) LOG ON ( NAME = 'PharmacyDB_log', FILENAME = 'D:\SQLData\PharmacyDB_log.ldf', SIZE = 10MB, FILEGROWTH = 5MB ); GO USE PharmacyDB; GO -- 药品基础信息表 CREATE TABLE Drug ( DrugID INT IDENTITY(1,1) PRIMARY KEY, DrugCode VARCHAR(20) NOT NULL UNIQUE, -- 药品编码,唯一 DrugName NVARCHAR(50) NOT NULL, -- 药品名称 Spec NVARCHAR(30) NULL, -- 规格 Unit NVARCHAR(10) NULL, -- 单位 Producer NVARCHAR(50) NULL, -- 生产厂家 Price DECIMAL(10,2) NOT NULL DEFAULT 0, -- 单价 StockQty INT NOT NULL DEFAULT 0, -- 当前库存 MinStock INT NOT NULL DEFAULT 10, -- 库存下限 ExpireDate DATE NULL, -- 有效期 IsDeleted BIT NOT NULL DEFAULT 0 -- 软删除标记 ); GO -- 入库流水表 CREATE TABLE StockIn ( InID INT IDENTITY(1,1) PRIMARY KEY, DrugID INT NOT NULL, InQty INT NOT NULL, InPrice DECIMAL(10,2) NOT NULL, Supplier NVARCHAR(50) NULL, InDate DATETIME NOT NULL DEFAULT GETDATE(), Operator NVARCHAR(20) NULL, CONSTRAINT FK_StockIn_Drug FOREIGN KEY (DrugID) REFERENCES Drug(DrugID) ); GO -- 出库流水表 CREATE TABLE StockOut ( OutID INT IDENTITY(1,1) PRIMARY KEY, DrugID INT NOT NULL, OutQty INT NOT NULL, OutReason NVARCHAR(50) NULL, -- 领用科室或销售 OutDate DATETIME NOT NULL DEFAULT GETDATE(), Operator NVARCHAR(20) NULL, CONSTRAINT FK_StockOut_Drug FOREIGN KEY (DrugID) REFERENCES Drug(DrugID) ); GO -- 用户表 CREATE TABLE SysUser ( UserID INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(20) NOT NULL UNIQUE, PwdHash VARCHAR(64) NOT NULL, -- 存 SHA256 哈希,不存明文 RoleName NVARCHAR(20) NOT NULL DEFAULT 'operator' ); GO这段脚本的逻辑很直白:Drug 表存药品主数据和当前库存,StockIn 和 StockOut 分别记录每一次入库和出库,SysUser 管登录和权限。参数上要留意几个点:DrugCode 加了 UNIQUE 约束,防止重复录入同一药品;StockQty 默认 0,实际库存由出入库操作去加减;ExpireDate 用 DATE 类型,方便后面做效期预警查询;IsDeleted 做软删除,避免直接删药品导致流水表外键断裂。MinStock 是库存下限,低于这个值就要在界面上标红提醒。热搜里 sqlserver 字符串转数字、sqlserver多行合并成一行 这类问题,在这个系统里主要出现在报表统计和编码转换上,后面章节会具体说。
2.3 连接字符串与数据访问层怎么放
WinForm 里不要在每个窗体里写连接字符串,抽一个 SqlHelper 静态类出来,所有窗体调它。连接字符串放在 App.config 里,换数据库只改一处。
<!-- App.config --> <configuration> <connectionStrings> <add name="PharmacyConn" connectionString="Data Source=.;Initial Catalog=PharmacyDB;User ID=sa;Password=你的密码;" providerName="System.Data.SqlClient"/> </connectionStrings> </configuration>// SqlHelper.cs —— 统一数据访问入口 using System; using System.Configuration; using System.Data; using System.Data.SqlClient; public static class SqlHelper { // 从配置文件读取连接字符串,避免硬编码 private static readonly string connStr = ConfigurationManager.ConnectionStrings["PharmacyConn"].ConnectionString; // 执行增删改,返回受影响行数 public static int ExecuteNonQuery(string sql, params SqlParameter[] ps) { using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (ps != null) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } // 执行查询,返回 DataTable,方便直接绑 DataGridView public static DataTable ExecuteQuery(string sql, params SqlParameter[] ps) { using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) using (SqlDataAdapter adapter = new SqlDataAdapter(cmd)) { if (ps != null) cmd.Parameters.AddRange(ps); DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } } }这里的关键是参数化查询,所有用户输入都走 SqlParameter,不要用字符串拼接,否则药品名里带个单引号就能把 SQL 搞崩,这也是最常见的注入隐患。ExecuteQuery 返回 DataTable,直接赋给 DataGridView.DataSource 就能显示,省去手动绑定的麻烦。连接字符串里的 Data Source 用点表示本机默认实例,如果 SQL Server 装的是命名实例,要写成.\SQLEXPRESS这种形式。热搜里 sqlserver配置管理器安装、sqlserver 2016 2017 2019安装失败-无法找到数据库引擎启动句柄 这类问题,多半是实例名没写对或者服务没启动,先在服务里确认 SQL Server 服务是运行状态,再用 SSMS 连一次确认实例名。
3. 核心业务代码:入库、出库和库存联动怎么写才不出错
3.1 入库操作:一次事务里同时写流水和加库存
入库不是简单插一条记录,它要同时做两件事:往 StockIn 插一条流水,把 Drug 表的 StockQty 加上本次数量。这两步必须在一个事务里,否则流水写了库存没加,账就乱了。下面是我常用的写法。
// 入库按钮事件 private void btnStockIn_Click(object sender, EventArgs e) { int drugId = Convert.ToInt32(cboDrug.SelectedValue); int qty = int.Parse(txtInQty.Text.Trim()); decimal price = decimal.Parse(txtInPrice.Text.Trim()); string supplier = txtSupplier.Text.Trim(); if (qty <= 0) { MessageBox.Show("入库数量必须大于 0"); return; } using (SqlConnection conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { // 1. 插入入库流水 string sqlIn = @"INSERT INTO StockIn(DrugID,InQty,InPrice,Supplier,Operator) VALUES(@DrugID,@InQty,@InPrice,@Supplier,@Operator)"; SqlCommand cmdIn = new SqlCommand(sqlIn, conn, tran); cmdIn.Parameters.AddWithValue("@DrugID", drugId); cmdIn.Parameters.AddWithValue("@InQty", qty); cmdIn.Parameters.AddWithValue("@InPrice", price); cmdIn.Parameters.AddWithValue("@Supplier", supplier); cmdIn.Parameters.AddWithValue("@Operator", CurrentUser.UserName); cmdIn.ExecuteNonQuery(); // 2. 更新库存 string sqlUpd = "UPDATE Drug SET StockQty = StockQty + @Qty WHERE DrugID = @DrugID"; SqlCommand cmdUpd = new SqlCommand(sqlUpd, conn, tran); cmdUpd.Parameters.AddWithValue("@Qty", qty); cmdUpd.Parameters.AddWithValue("@DrugID", drugId); cmdUpd.ExecuteNonQuery(); tran.Commit(); MessageBox.Show("入库成功"); LoadDrugList(); // 刷新列表 } catch (Exception ex) { tran.Rollback(); MessageBox.Show("入库失败:" + ex.Message); } } } }逻辑说明:先开事务,插流水和改库存都挂在同一个事务上,任何一步抛异常就整体回滚,保证账实一致。参数上,@Operator 取当前登录用户名,方便追溯是谁操作的;库存更新用StockQty = StockQty + @Qty而不是先查再算再写,避免并发下两个操作员同时入库导致覆盖。这里有个血泪经验:如果不用事务,网络一抖或者程序崩了,流水和库存就对不上,后面盘点能查到你想哭。
3.2 出库操作:先校验库存再扣减
出库比入库多一道校验:库存不够不能出。而且出库同样要事务,先插 StockOut 流水,再扣 Drug 库存,扣减时还要防止扣成负数。
// 出库按钮事件 private void btnStockOut_Click(object sender, EventArgs e) { int drugId = Convert.ToInt32(cboDrug.SelectedValue); int qty = int.Parse(txtOutQty.Text.Trim()); string reason = txtOutReason.Text.Trim(); using (SqlConnection conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { // 1. 校验库存是否充足(带 UPDLOCK 防止并发超卖) string sqlCheck = "SELECT StockQty FROM Drug WITH (UPDLOCK) WHERE DrugID = @DrugID"; SqlCommand cmdCheck = new SqlCommand(sqlCheck, conn, tran); cmdCheck.Parameters.AddWithValue("@DrugID", drugId); int stock = Convert.ToInt32(cmdCheck.ExecuteScalar()); if (stock < qty) { MessageBox.Show("库存不足,当前库存:" + stock); tran.Rollback(); return; } // 2. 插入出库流水 string sqlOut = @"INSERT INTO StockOut(DrugID,OutQty,OutReason,Operator) VALUES(@DrugID,@OutQty,@OutReason,@Operator)"; SqlCommand cmdOut = new SqlCommand(sqlOut, conn, tran); cmdOut.Parameters.AddWithValue("@DrugID", drugId); cmdOut.Parameters.AddWithValue("@OutQty", qty); cmdOut.Parameters.AddWithValue("@OutReason", reason); cmdOut.Parameters.AddWithValue("@Operator", CurrentUser.UserName); cmdOut.ExecuteNonQuery(); // 3. 扣减库存 string sqlUpd = "UPDATE Drug SET StockQty = StockQty - @Qty WHERE DrugID = @DrugID"; SqlCommand cmdUpd = new SqlCommand(sqlUpd, conn, tran); cmdUpd.Parameters.AddWithValue("@Qty", qty); cmdUpd.Parameters.AddWithValue("@DrugID", drugId); cmdUpd.ExecuteNonQuery(); tran.Commit(); MessageBox.Show("出库成功"); LoadDrugList(); } catch (Exception ex) { tran.Rollback(); MessageBox.Show("出库失败:" + ex.Message); } } } }这里的关键是WITH (UPDLOCK),它在读取库存时就加更新锁,防止两个操作员同时出库把库存扣成负数。参数上,OutReason 记录领用科室或销售去向,方便后续按科室统计消耗。如果出库量大于库存,直接提示并回滚,不写任何流水。热搜里 c#线程 相关的并发问题,在这个场景里就靠数据库锁和事务解决,不需要在 C# 层自己写复杂的锁。
3.3 库存预警与效期查询:两个 SQL 就能搞定
库存下限预警和近效期预警是药房最常用的两个查询。库存预警查 StockQty < MinStock 的药品,效期预警查 ExpireDate 在 90 天内且库存大于 0 的药品。
-- 库存下限预警 SELECT DrugCode, DrugName, Spec, StockQty, MinStock FROM Drug WHERE IsDeleted = 0 AND StockQty < MinStock ORDER BY StockQty ASC; -- 近效期预警(90 天内到期) SELECT DrugCode, DrugName, Spec, StockQty, ExpireDate, DATEDIFF(DAY, GETDATE(), ExpireDate) AS DaysLeft FROM Drug WHERE IsDeleted = 0 AND StockQty > 0 AND ExpireDate IS NOT NULL AND DATEDIFF(DAY, GETDATE(), ExpireDate) <= 90 ORDER BY ExpireDate ASC;DATEDIFF 算出剩余天数,界面上可以把 DaysLeft 小于 30 的行标红。热搜里 sqlserver offset 后再 top 20 查到的是什么,这里提醒一句:如果要做分页,SQL Server 2012 以上用 OFFSET FETCH,但 OFFSET 是先跳过再取,别和 TOP 混用搞错顺序。药品列表一般数据量不大,直接全查再在 DataGridView 里排序就够,不用急着上分页。
4. 避坑与排查:这套系统落地时最容易翻车的五个地方
4.1 现象:程序在自己电脑能跑,拷到药房电脑就报连接失败
原因:连接字符串里写的是Data Source=.,但药房电脑上 SQL Server 装的是命名实例,或者根本没装 SQL Server 只装了客户端。解决:先在药房电脑上用 SSMS 连一次,确认实例名和认证方式,把 App.config 里的 Data Source 改成实际实例名,比如.\SQLEXPRESS或192.168.1.10,1433。如果是 SQL 验证,确认 sa 账号已启用且密码正确。
4.2 现象:入库时提示外键冲突,流水插不进去
原因:StockIn 表有外键指向 Drug 表,但传入的 DrugID 在 Drug 表里不存在,常见于下拉框绑定的是显示名而不是值,或者药品被软删除后仍被选中。解决:检查 ComboBox 的 ValueMember 是否绑到 DrugID,DisplayMember 绑到 DrugName;查询药品列表时加上WHERE IsDeleted = 0,避免选到已删除药品。
4.3 现象:两个操作员同时出库,库存扣成了负数
原因:出库前查库存和扣库存是两步,中间没有锁,并发时都查到足够库存,然后都扣减。解决:如 3.2 所示,在查询库存时加WITH (UPDLOCK),把校验和扣减放在同一个事务里,让第二个操作等第一个提交后再读。
4.4 现象:DataGridView 绑定后修改数据不生效,或者报并发冲突
原因:直接用 DataAdapter 的 Update 方法绑定 DataGridView,但没有配 CommandBuilder 或者主键没设对。解决:这个系统里我一般不用自动更新,而是让用户点保存按钮走明确的 UPDATE 语句,避免 DataGridView 直接改库带来的不可控。如果一定要用绑定更新,确保 DataTable 有主键,并且配置好 InsertCommand、UpdateCommand、DeleteCommand。
4.5 现象:打包成安装程序后,目标机器提示缺少 .NET Framework
原因:安装程序没有把 .NET Framework 作为前置条件打包进去。解决:在 Visual Studio 的安装项目里,右键项目 → 属性 → 系统必备,勾选 .NET Framework 4.5 或对应版本,并选择「从与我的应用程序相同的位置下载系统必备组件」。这样生成的安装包会自带运行时,药房电脑断网也能装。热搜里 winform打包成安装程序、vs2015 相关的操作,核心就是这个设置。
5. 从能跑到好用:打包交付前我必做的三件事和一个习惯
第一件事是给数据库加一个初始化脚本。交付时不可能让药房自己建表,我一般会写一个InitDB.sql,里面包含建库、建表、插入默认管理员账号和几条测试药品数据。安装程序第一次运行时,用一个隐藏的初始化窗体执行这个脚本,执行完提示「数据库初始化完成」。默认管理员密码存 SHA256 哈希,不要存明文,登录时把输入密码哈希后比对。
第二件事是给所有查询加软删除过滤。Drug 表的 IsDeleted 字段一旦加上,所有 SELECT 都要带WHERE IsDeleted = 0,否则删掉的药品还会出现在下拉框和列表里。这个坑我踩过,删了药品结果出库时还能选到,查了半天才发现是查询没过滤。
第三件事是打包前把连接字符串改成可配置。不要写死在代码里,用 App.config,并且给安装程序加一个配置界面,让实施人员填服务器地址、数据库名、账号密码。这样同一套安装包能部署到不同药房,不用重新编译。
最后一个习惯:每次改完核心业务代码,我都会手动跑一遍「入库 100 → 出库 30 → 查库存 70 → 查流水两条」这个最小闭环。别小看这个动作,很多事务漏提交、参数写反的问题,跑一遍就现原形。药房系统不怕功能少,就怕账对不上,账对了,这套 WinForm + SQL Server 的方案就立住了。希望帮到你。
本文还有配套的精品资源,点击获取