ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于C# Winform和SQL Server的药品管理系统设计与实现

基于C# Winform和SQL Server的药品管理系统设计与实现 简介一套基于C# WinForm与SQL Server开发的医院药品管理系统完整源码包面向需要完成课程设计、毕业设计或进行医疗管理二次开发的C#学习者。系统功能覆盖用户管理、药品信息维护、入库出库、申领与审核、库存预警设置以及病人信息录入能较完整地演示药品流转与权限控制流程。压缩包共154个文件大小7.39MB核心内容包括52个C#源码文件、17个界面资源文件、18个动态库依赖以及SQL Server数据库的.mdf/.ldf文件和可执行程序既可直接附加数据库运行体验也可打开工程对照学习。目前已有226人浏览学习。对希望掌握WinForm分层结构、ADO.NET数据操作或库存预警逻辑的读者这份资源提供了结构清晰、易于落地的参考实现。1. 药品管理系统用 Winform SQL Server先想清楚边界再动手医院药品管理这类系统很多课程设计和实际项目选型时都会落到 C# Winform 上不是因为它是技术含量最高的方案而是因为桌面端在局域网医院环境里依然有不可替代的交互优势窗体布局直观、鼠标键盘操作响应快、数据录入反馈即时。这套基于 C# 的医院药品管理系统功能覆盖用户登录、药品信息维护、入库出库、申领审核、库存预警、病人信息录入数据库走 SQL Server并且自带完整源码和建库脚本适合做课程设计也适合作为二次开发的基础骨架。拿到手之后不要急着跑起来先理清两个问题一是 Winform 项目的分层是不是清晰二是 SQL Server 的表结构和业务逻辑的耦合程度。实际拆解这套系统时会发现它的核心价值不在于某个单一功能有多复杂而在于把“药品主数据、库存流水、申领审批流、预警阈值”这几类业务对象串联成了一条完整链路。下文我会从数据库表设计、登录权限、出入库事务、申领审核状态机、库存预警触发机制这几个层面展开分析代码里的关键实现并给出可以复用的写法。2. 数据库表设计和 SQL Server 初始化先稳住数据模型2.1 核心表拆分逻辑主数据与流水数据分离打开项目里的 SQL 脚本会看到系统并不是把所有药品字段塞进一张表而是拆成了药品信息表、库存表、入库记录表、出库记录表、申领单表、申领明细表、用户表、病人信息表。这种拆分方式和大多数进销存系统的思路一致药品的基本信息是静态主数据库存数量和批次变化是动态状态而每一次入库、出库、申领操作都应该是不可修改的流水记录。以药品表为例常见的关键字段包括CREATE TABLE DrugInfo ( DrugId INT IDENTITY(1,1) PRIMARY KEY, DrugCode NVARCHAR(20) NOT NULL UNIQUE, DrugName NVARCHAR(50) NOT NULL, Spec NVARCHAR(50), Unit NVARCHAR(10), Manufacturer NVARCHAR(100), PurchasePrice DECIMAL(10,2), SalePrice DECIMAL(10,2), StockQuantity INT NOT NULL DEFAULT 0, WarningQuantity INT NOT NULL DEFAULT 50, IsDelete BIT NOT NULL DEFAULT 0 );DrugCode设置唯一约束避免同一个厂家的同一规格药品重复录入StockQuantity作为冗余字段存在目的是在药品列表展示时不需要每次 join 流水表去聚合数量但代价是需要在每次出入库事务中同步更新这个字段。WarningQuantity是库存预警阈值用户可以修改因此不能写死成常量。库存表或者直接在药品表上的库存字段和出入库流水表之间是“余额 流水”的关系。每次入库在StockInRecord中插入一条正向记录同时把DrugInfo.StockQuantity增加对应数量出库则相反。如果只记录流水不更新余额列表页每次加载都要SUM所有历史记录数据量大时会出现明显的卡顿。2.2 建库脚本和连接字符串配置这套源码附带的是 SQL Server 数据库脚本拿到后先在本地 SQL Server 中执行建库脚本。注意脚本中可能包含创建数据库、建表、插入初始管理员账号的语句执行时不要只选部分语句就跑。常见做法是直接用 SQL Server Management Studio 打开脚本文件切换到目标实例后整体执行。如果脚本中包含USE [HospitalDB]需要确认当前连接的账号有建库权限。项目中的连接字符串通常在App.config或app.config里类似connectionStrings add nameHospitalDB connectionStringData Source.;Initial CatalogHospitalDB;User IDsa;Password123456; providerNameSystem.Data.SqlClient / /connectionStrings代码里读取连接串的方式一般是用ConfigurationManager.ConnectionStrings[HospitalDB].ConnectionString。如果本机 SQL Server 用的是 Windows 身份验证可把User ID和Password换成Integrated SecurityTrue。注意Data Source.表示本机默认实例如果你安装的是命名实例要改成.\SQLEXPRESS这类格式。提示如果程序启动后提示“无法连接到数据库”先用 SQL Server Management Studio 测试同样的连接参数能否登录再检查 SQL Server 服务是否启动。很多时候是sa账号未启用或者混合认证模式没打开。2.3 外键约束与级联删除的取舍药品信息表和入库/出库记录表之间通常会建立外键关联但实现时要注意一个细节流水表引用了药品表的DrugId那么药品信息就不应该允许物理删除否则会出现历史流水找不到对应药品的空洞。这套系统中常见的做法是给DrugInfo增加IsDelete字段执行删除时变成UPDATE逻辑删除操作。外键关系建议保留在数据库层面不要只靠代码控制。虽然 Winform 客户端在界面上做了删除按钮但如果某条药品已经被申领单引用直接删除会违反外键约束并抛出异常。更好的方案是在删除前检查引用例如SELECT COUNT(*) FROM StockInRecord WHERE DrugId DrugId UNION ALL SELECT COUNT(*) FROM StockOutRecord WHERE DrugId DrugId UNION ALL SELECT COUNT(*) FROM ApplyDetail WHERE DrugId DrugId任何一个计数大于 0就在界面上提示“该药品存在业务记录不能删除可改为停用状态”。对于课程设计来说逻辑删除字段会比物理删除让演示评分更完整。3. 登录权限与用户管理三层角色控制不是摆设3.1 登录窗口的实现与验证流程系统启动时最先出现的是登录窗体通常是FrmLogin。用户输入账号密码后点击登录按钮执行查询string sql SELECT COUNT(*) FROM Users WHERE UserNamename AND UserPasswordpwd AND IsDeleted0; using (SqlConnection conn new SqlConnection(connStr)) { using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(name, txtUserName.Text.Trim()); cmd.Parameters.AddWithValue(pwd, txtPassword.Text); conn.Open(); int count (int)cmd.ExecuteScalar(); if (count 0) // 登录成功 { // 保存当前用户信息到全局类 CurrentUser.UserName txtUserName.Text.Trim(); this.DialogResult DialogResult.OK; } else { MessageBox.Show(用户名或密码错误); } } }代码里使用了参数化查询避免 SQL 注入这一点对于课程设计来说属于加分项。密码字段没有做加密处理直接明文存库这在生产系统中会有风险但作为演示项目可以接受。如果二次开发建议改成 MD5 或 SHA256 加盐存储登录时对输入的密码做同样哈希后再比对。3.2 主窗体的菜单权限控制登录成功后进入主窗体FrmMain这里通常使用MenuStrip或ToolStrip组织功能入口。权限控制一般有两种做法第一种是在主窗体加载时根据当前用户角色隐藏无权的菜单项第二种是在每个子窗体加载时检查权限。前者更直观后者更安全。参考实现private void FrmMain_Load(object sender, EventArgs e) { if (CurrentUser.Role 普通用户) { 药品入库ToolStripMenuItem.Visible false; 药品出库ToolStripMenuItem.Visible false; 用户管理ToolStripMenuItem.Visible false; } }用户表一般包含Role字段取值可以是管理员、药房操作员、护士等。管理员拥有全部权限操作员可以执行出入库和审核操作普通用户只能查看药品信息和提交申领。这个角色划分逻辑不需要写得太复杂做一个Role字符串判断即可满足系统演示需求。提示真正要拦截篡改的话不能只靠隐藏菜单。任何涉及写操作的按钮事件里都应该再校验一次CurrentUser.Role防止通过快捷键或直接调用打开子窗体绕过菜单。二次开发时可以在子窗体的Load事件里加权限判断。3.3 用户管理界面的增删改查用户管理模块相对独立界面一般是DataGridView直接绑定用户表数据。新增用户时需要注意用户名唯一性保存前执行SELECT COUNT(*) FROM Users WHERE UserNamename检查。修改密码时不要做成直接编辑整行而是单独弹窗输入新密码。删除用户时要避免删除当前登录账号否则会出现账号被删但程序还在运行的矛盾状态。这套系统里的用户管理功能还有一层作用为后续的申领审核提供“操作人”数据。一张申领单必须记录申请人、审核人这样流程才有追溯性。所以在用户表设计时建议增加RealName字段而不仅仅是登录用的UserName。界面显示和流程记录中使用RealName避免员工离职后账号删除导致历史单据上的用户名失效。4. 药品入库、出库与库存更新事务边界决定数据一致性4.1 入库单据的设计思路药品入库不是简单地把数量加到库存字段上而是需要记录入库单号、供应商可选、入库日期、操作人、入库明细药品、数量、批号、有效期。对于医院场景批号和有效期很重要但课程设计可以简化只保留药品 ID 和数量。界面布局通常是上半部分是入库主单信息单号自动生成、入库日期、操作员下半部分是DataGridView用于选择药品和填写入库数量。点击“确认入库”时一次性把主单和明细写入两张表。这里的关键点是事务using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); SqlTransaction tran conn.BeginTransaction(); try { // 插入入库主单 string sqlMaster INSERT INTO StockInMain(InCode, InDate, OperatorId) VALUES(code, date, opId); SELECT SCOPE_IDENTITY();; SqlCommand cmdMaster new SqlCommand(sqlMaster, conn, tran); cmdMaster.Parameters.AddWithValue(code, inCode); cmdMaster.Parameters.AddWithValue(date, DateTime.Now); cmdMaster.Parameters.AddWithValue(opId, CurrentUser.UserId); int mainId Convert.ToInt32(cmdMaster.ExecuteScalar()); // 根据选中的明细逐条插入并更新库存 foreach (DataGridViewRow row in dgvDetails.Rows) { int drugId Convert.ToInt32(row.Cells[DrugId].Value); int qty Convert.ToInt32(row.Cells[Quantity].Value); string sqlDetail INSERT INTO StockInDetail(InMainId, DrugId, Quantity) VALUES(mainId, drugId, qty); SqlCommand cmdDetail new SqlCommand(sqlDetail, conn, tran); cmdDetail.Parameters.AddWithValue(mainId, mainId); cmdDetail.Parameters.AddWithValue(drugId, drugId); cmdDetail.Parameters.AddWithValue(qty, qty); cmdDetail.ExecuteNonQuery(); string sqlUpdate UPDATE DrugInfo SET StockQuantity StockQuantity qty WHERE DrugId drugId; SqlCommand cmdUpdate new SqlCommand(sqlUpdate, conn, tran); cmdUpdate.Parameters.AddWithValue(qty, qty); cmdUpdate.Parameters.AddWithValue(drugId, drugId); cmdUpdate.ExecuteNonQuery(); } tran.Commit(); MessageBox.Show(入库成功); } catch (Exception ex) { tran.Rollback(); MessageBox.Show(入库失败 ex.Message); } }这段代码把“插入主单 → 插入明细 → 更新库存”包在同一个事务中任何一步抛出异常都会回滚。注意SCOPE_IDENTITY()用于获取刚插入主单的自增 ID这样明细表的外键才能正确指向主单。如果不用事务可能出现主单插入了但明细插入失败的情况导致库存没有增加但单据存在后续对账会非常麻烦。4.2 出库逻辑和负库存校验出库和入库结构对称区别在于库存更新方向是减。但出库前必须判断当前库存是否足够否则会出现负库存的脏数据。常见写法string sqlCheck SELECT StockQuantity FROM DrugInfo WHERE DrugIddrugId; int stock (int)cmdCheck.ExecuteScalar(); if (stock qty) { throw new Exception($药品[{drugName}]库存不足当前库存{stock}); }注意在事务内部做检查和扣减时如果多个用户并发操作同一药品理论上需要SELECT ... WITH (UPDLOCK)锁住行或者用UPDATE DrugInfo SET StockQuantity StockQuantity - qty WHERE DrugIddrugId AND StockQuantity qty这样带条件更新的原子语句。课程设计通常不涉及高并发但代码里养成这种习惯会避免生产环境中的大坑。出库单据的用途有多种科室领药出库、报损出库、退回供应商。这套系统里把“药品申领”和“出库”分开处理申领是一个独立的审批流审批通过后自动减少库存而不是直接做一条出库单。这种设计逻辑更贴近医院科室的实际情况。4.3 入库出库查询界面的通用检索方式主界面的查询条件常见的有单号、药品名称、日期范围。查询语句建议使用动态 SQL 拼接但必须使用参数化方式防止拼接字符串时引入注入风险。例如string sql SELECT m.InCode, d.DrugName, d.Spec, t.Quantity, m.InDate, u.RealName FROM StockInMain m JOIN StockInDetail t ON m.InMainId t.InMainId JOIN DrugInfo d ON t.DrugId d.DrugId JOIN Users u ON m.OperatorId u.UserId WHERE 11; if (!string.IsNullOrEmpty(txtInCode.Text.Trim())) { sql AND m.InCode LIKE inCode; cmd.Parameters.AddWithValue(inCode, % txtInCode.Text.Trim() %); } if (dtpStart.Value ! dtpStart.MinDate) { sql AND m.InDate startDate; cmd.Parameters.AddWithValue(startDate, dtpStart.Value.Date); } if (dtpEnd.Value ! dtpEnd.MaxDate) { sql AND m.InDate endDate; cmd.Parameters.AddWithValue(endDate, dtpEnd.Value.Date.AddDays(1)); }使用WHERE 11只是为了方便动态拼接AND条件实际执行时查询优化器会忽略这个恒真条件不会影响性能。日期范围查询时结束日期要加一天再比较因为InDate如果包含时分秒单纯比较 endDate会漏掉当天的记录。5. 药品申领与审核状态机从提交到出库的完整流程5.1 申领单的数据结构药品申领模块的通病是只做了“插入一条申领记录”却忽略状态流转。这套系统的设计相对完整申领单包含状态字段如0-待审核、1-已通过、2-已拒绝。申领主表和申领明细表结构如下CREATE TABLE ApplyMain ( ApplyId INT IDENTITY(1,1) PRIMARY KEY, ApplyNo NVARCHAR(20), ApplicantId INT, ApplyDate DATETIME, Status INT DEFAULT 0, -- 0待审核 1已通过 2已拒绝 ReviewUserId INT NULL, ReviewDate DATETIME NULL, ReviewRemark NVARCHAR(100) ); CREATE TABLE ApplyDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, ApplyId INT, DrugId INT, Quantity INT );提交流程时为了防止误操作重复提交按钮提交后应立刻把界面控件置灰。一般做法是申领窗体加载时展示药品列表和库存数量用户选择药品并填写数量点击“提交申领”后将数据写入ApplyMain和ApplyDetail状态默认为 0。5.2 审核界面的列表加载与通过处理审核人登录系统后进入“申领审核”界面。DataGridView只展示状态为待审核的申领单可以通过 SQL 过滤string sql SELECT m.ApplyNo, u.RealName AS Applicant, m.ApplyDate FROM ApplyMain m JOIN Users u ON m.ApplicantId u.UserId WHERE m.Status 0;双击某条申领单弹出明细窗口显示具体药品和数量。审核人点击“通过”时系统需要做两件事更新申领单状态为 1同时扣减对应药品库存。这个操作必须放在同一个事务里否则可能出现审核通过但库存没减的情况。参考代码using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); SqlTransaction tran conn.BeginTransaction(); try { string sqlUpdateStatus UPDATE ApplyMain SET Status1, ReviewUserIdreviewer, ReviewDateGETDATE() WHERE ApplyIdapplyId; SqlCommand cmdStatus new SqlCommand(sqlUpdateStatus, conn, tran); cmdStatus.Parameters.AddWithValue(reviewer, CurrentUser.UserId); cmdStatus.Parameters.AddWithValue(applyId, applyId); cmdStatus.ExecuteNonQuery(); // 加载申领明细 string sqlDetails SELECT DrugId, Quantity FROM ApplyDetail WHERE ApplyIdapplyId; SqlCommand cmdDetails new SqlCommand(sqlDetails, conn, tran); cmdDetails.Parameters.AddWithValue(applyId, applyId); using (SqlDataReader reader cmdDetails.ExecuteReader()) { List(int drugId, int qty) details new List(int, int)(); while (reader.Read()) { details.Add((Convert.ToInt32(reader[DrugId]), Convert.ToInt32(reader[Quantity]))); } reader.Close(); // 注意关闭读取器后再执行更新语句 foreach (var item in details) { // 先检查库存不足则抛异常 string sqlCheck SELECT StockQuantity FROM DrugInfo WHERE DrugIddrugId; SqlCommand cmdCheck new SqlCommand(sqlCheck, conn, tran); cmdCheck.Parameters.AddWithValue(drugId, item.drugId); int stock (int)cmdCheck.ExecuteScalar(); if (stock item.qty) { throw new Exception(药品库存不足无法通过申领); } string sqlStock UPDATE DrugInfo SET StockQuantity StockQuantity - qty WHERE DrugIddrugId; SqlCommand cmdStock new SqlCommand(sqlStock, conn, tran); cmdStock.Parameters.AddWithValue(qty, item.qty); cmdStock.Parameters.AddWithValue(drugId, item.drugId); cmdStock.ExecuteNonQuery(); } } tran.Commit(); MessageBox.Show(审核通过库存已扣减); } catch (Exception ex) { tran.Rollback(); MessageBox.Show(审核失败 ex.Message); } }这里有一个细节在SqlDataReader未关闭时同一个连接上不能执行其他SqlCommand。所以代码里reader.Close()放在更新库存之前否则会报“已有打开的 DataReader 与此 Command 相关联”的异常。这也是 Winform SQL Server 开发中常见的坑很多新手在using (SqlDataReader)块内直接执行更新就会踩到。5.3 拒绝逻辑与审核备注审核人选择“拒绝”时只需要更新状态为 2并写入ReviewRemark拒绝原因。申请人在“申领记录查询”界面可以看到状态显示以及备注。拒绝操作不涉及库存变更单条UPDATE语句即可。注意审核界面中已经审核过的单据不应再显示在待审核列表中否则会出现重复审核。状态机的价值在于让整个流程可追踪。当出现“药品数量对不上”时可以通过申领单的状态和审核人、审核时间倒查是哪一步出了问题。课程设计的答辩环节中评审老师通常比较关注这个审核逻辑的严谨程度。5.4 与直接出库记录的区别如果系统同时存在“药品出库”和“申领审核”就会面临一个概念上的重叠科室申领通过后扣库存算不算出库严格意义上应该生成一条关联的出库记录。但很多课程设计为了简化只更新库存不再额外插入出库流水导致后续统计口径混乱报表中的出库数是手工出库数不包含申领发药数。如果要做得严谨应该在审核通过的同一事务中插入一条出库主单和出库明细并把ApplyMain的ApplyId关联到出库主表上。这样库存流水和单据流水才能一一对应。二次开发时建议按这个方向补全把“申领通过”转换成“申领出库”避免月底对账时数据对不上。6. 库存预警的触发机制与 DataGridView 颜色标记技巧6.1 预警数量的设置逻辑库存预警不是每次打开盘点报表时才触发而是在药品信息维护界面提供了“预警数量”字段用户可以单独设置每一种药品的库存下限也可以批量设置统一阈值。批量操作通常放在“库存管理”菜单下比如一键把所有药品的预警量设为 50string sql UPDATE DrugInfo SET WarningQuantity warningQty WHERE IsDelete 0; SqlCommand cmd new SqlCommand(sql, conn); cmd.Parameters.AddWithValue(warningQty, numericUpDownWarning.Value); cmd.ExecuteNonQuery();预警数量的值建议限制为非负整数界面上使用NumericUpDown控件比TextBox更省事避免用户输入非法字符。6.2 预警列表的动态刷新与高亮库存预警界面通常在窗体激活时重新加载数据而不是使用定时器每秒钟刷新。因为 Winform 里频繁刷新DataGridView会导致 UI 卡顿尤其是数据量大时。一个常见的做法是重写OnActivated事件或调用LoadData()方法。加载预警数据的 SQLSELECT DrugId, DrugCode, DrugName, Spec, StockQuantity, WarningQuantity FROM DrugInfo WHERE StockQuantity WarningQuantity AND IsDelete 0 ORDER BY StockQuantity - WarningQuantity ASC;加载后在DataGridView的RowPrePaint事件中根据数量差值的不同范围设置单元格背景色。比如库存低于预警量但相差 10% 以内用黄色低于 50% 用红色private void dgvWarning_RowPrePaint(object sender, DataGridViewRowPrePaintEventArgs e) { if (e.RowIndex 0) return; DataGridViewRow row dgvWarning.Rows[e.RowIndex]; int stock Convert.ToInt32(row.Cells[StockQuantity].Value); int warning Convert.ToInt32(row.Cells[WarningQuantity].Value); double percent (double)stock / warning; // warning 不为0 if (percent 0.5) { row.DefaultCellStyle.BackColor Color.LightCoral; } else if (percent 0.9) { row.DefaultCellStyle.BackColor Color.LightGoldenrodYellow; } }注意warning不能为 0否则除零会抛异常。在预警数量设置界面上就应限制最小值为 1或者在代码里判断warning 0时直接按严重缺货处理。6.3 预警后的一键补货操作预警列表的实操价值在于快速定位缺货药品。界面可以增加“一键生成出库单”或者“快速入库”的快捷按钮。不需要做得太复杂选中预警行后点击“入库”自动把药品 ID、名称带出入库窗体并默认填入建议补货量。建议补货量可以用公式(WarningQuantity - StockQuantity) * 2或者用户自定义。6.4 Winform 界面性能细节避免 UI 假死药品数量达到几百上千条时DataGridView加载数据如果逐行Add会非常慢。正确做法是设置数据源为DataTable或ListT一次性绑定到DataSource属性让控件自己处理绘制。同时用SuspendLayout()和ResumeLayout()包裹绑定过程dgvWarning.SuspendLayout(); dgvWarning.DataSource dt; dgvWarning.ResumeLayout();如果还需要在绑定后修改单元格样式建议在RowPrePaint事件里做而不是绑定后再循环改每行的DefaultCellStyle。循环修改会触发多次重绘导致肉眼可见的闪烁。另外DataGridView开启双缓冲可以在深层设置里处理但一般通过BeginUpdate和EndUpdate减少重绘次数也足够。6.5 预警告警的业务闭环从业务闭环看库存预警做完之后最好还提供一个“预警记录”或“缺货登记”功能记录每次缺货发生的时间和处理结果。这样在答辩时可以展示系统的不只是单一功能而是完整的“发现异常 → 处理异常 → 追踪结果”链路。如果源码中没有这个表二次开发时增加一个WarningLog表字段包括药名、缺货量、预警时间、处理方式、操作员。这个附加功能不难实现但会让系统完整性有明显的提升。对于 UI 刷新的卡顿问题除了减少数据量还可以考虑把DataGridView替换成虚拟模式或分页加载。医院药品数量一般不会超过上万条分页会比无限加载更简单直接。用DataPager或者自己维护pageIndex每次只查 100 条配合总页数控件体验远好于一次性绑定所有数据。本文还有配套的精品资源点击获取
返回列表