简介:基于C#的药店管理系统完整源码包,面向计算机相关专业学生的毕业设计或期末作业场景,也适合希望掌握WinForms与数据库开发的中级开发者参考学习。压缩包共912个文件,主要包括C#源代码、窗体资源、项目工程、报表及DLL库等,大小仅10.65MB,目录层级清晰,便于快速定位核心代码。目前已有72人浏览/学习,适合作为课程设计或简历项目的起点。系统覆盖药品信息增删改查、销售记录、库存管理、权限控制等典型业务模块,涉及ADO.NET/Entity Framework数据库交互、Windows Forms界面设计、MVC分层思路及多线程应用,并附可执行程序与调试符号,可对照源码深入理解运行机制。整个项目能帮助读者掌握从数据表设计、界面布局到事件处理与安全控制的完整过程,且资源来源于网络公开分享,仅供参考。
1. 用C#做药店管理系统毕业设计,先想清楚这三件事
C#药店管理系统几乎每届毕业设计都有人选:技术栈常见、业务直观、评委不用你解释就能看懂。但同一个题目,有人答辩顺利有人被问倒,差别不在窗口做得多好看,而在数据模型有没有考虑药店的批次、近效期和供应商,库存扣减和销售单据有没有包在事务里,以及报表统计能否当场算出正确的数字。下面按“数据模型 → 核心业务 → 报表 → 答辩准备”这条线来讲,适合用WinForms或WPF完成C#药店管理系统的毕业生和期末作业的同学。我会给可以直接跑的代码,也会标出哪些参数必须按你的环境改,保证你带到哪台机器都能顺利演示。
2. 药店管理系统的三层架构与SQL Server数据模型
2.1 为什么选WinForms + SQL Server,而不是WPF或MySQL
C#药店管理系统的主流选型是WinForms加SQL Server,也有不少资料用WPF或MySQL,但我的建议是别在框架选择上给自己加难度。WinForms的DataGridView配BindingSource,做进销存类界面是效率最高的组合:拖控件、设列、绑数据源,半天就能把主界面撑起来。WPF虽然界面更现代,但数据绑定、模板和线程刷新对新手来说是一道不低的门槛,很容易出现“输入卡顿、列表不动”的局面。界面刷新卡顿这个问题在C#技术社区里一直是高频话题,放到答辩演示时更致命,所以我一般不建议毕设项目在WPF上冒险。
数据库选SQL Server而不选SQLite或MySQL,主要是从“换机器演示”考虑。SQL Server可以用mdf文件整体附加,老师把项目拷走后在SSMS里两步就能挂上;SQLite虽说便携,但演示时想看数据得额外装工具,MySQL则要在答辩机上装服务,多一个环节多一个出错点。如果你自己电脑装的是Express版,也不用担心,连接串里把实例名写成localhost\\SQLEXPRESS,开发环境一样跑。
架构层面我建议拆三层:UI窗体、BLL业务逻辑、DAL数据访问。一个常见的坏习惯是每个窗体里直接写SqlConnection和SQL语句,连接串散落七八处。这样做不是不能用,但老师问“加一个‘会员价’规则要改哪里”,你就要逐个窗体翻。三层拆好以后,UI里不再出现任何SQL,BLL调DAL,DAL只做参数化查询,这是C#项目里最标准也最好答辩的分层方式,药店管理系统照抄这个结构完全够用。
2.2 药品表、供应商表、销售表的核心字段设计
先想清楚要建哪几张表。药店管理系统最小集是:药品表、供应商表、用户表、销售主表、销售明细表,最好再加一张进货流水表。药品表是最容易设计错的:有人把有效期、批次号、进价全堆在一张表里,结果同一药品不同批次得用重复行表达,主键都不知道设哪个。实际上药店是严格按批次管理的,同一个药,不同批次的进价和有效期都可能不同,但毕设如果直接把批次逻辑做进去,工作量会立刻翻倍。我建议在第一版做简化:药品表只存当前库存和当前进价,预留ExpiryDate字段,在答辩说明里提一句“完整方案支持按批次FIFO出库”,这比一股脑全做完更稳。
下面这张是简化版药品表结构,也是我经常用在毕设里的字段集合:
| 字段名 | 类型 | 说明 |
|---|---|---|
| DrugID | INT IDENTITY | 药品主键,自增 |
| DrugCode | VARCHAR(20) | 药品编码,配合扫码枪 |
| DrugName | NVARCHAR(50) | 药品通用名 |
| Spec | NVARCHAR(30) | 规格,如10mg*20片 |
| Unit | NVARCHAR(10) | 单位,盒/瓶/袋 |
| PurchasePrice | DECIMAL(10,2) | 进价 |
| SellPrice | DECIMAL(10,2) | 零售价 |
| Stock | INT | 当前库存 |
| ExpiryDate | DATE | 有效期 |
| SupplierID | INT | 外键,关联供应商表 |
价格字段用decimal(10,2)而不用float,是因为二进制浮点数在表示2.10这类十进制小数时有精度误差,药店计算找零和毛利时,误差会大到对不上账。这个选择在2.3的代码说明里还会再讲。
销售数据拆成主表和明细表是必须的。销售主表SalesMain,一条记录对应一张小票,包含SaleTime、OperatorID、TotalAmount;销售明细表SaleDetails,记录小票里的每一行药品、数量、单价、金额。拆开之后,统计一天、一月营业额只需对主表SUM,要导出明细再看子表,逻辑清晰,也符合数据库第三范式。不少网上代码图省事只建一张Sales表,看似简单,但统计和退款处理都会很别扭。
2.3 建库脚本与C#连接字符串的写法
下面这段建库脚本在SSMS的新建查询里执行,能把整套表一次建好。我刻意省略索引和额外约束,保持最少可运行状态,后面加数据量再补索引。
CREATE DATABASE PharmacyDB; GO USE PharmacyDB; GO CREATE TABLE Users ( UserID INT IDENTITY PRIMARY KEY, UserName NVARCHAR(30) NOT NULL UNIQUE, PassWord VARCHAR(32) NOT NULL, RoleName NVARCHAR(20) DEFAULT 'Cashier' ); CREATE TABLE Suppliers ( SupplierID INT IDENTITY PRIMARY KEY, SupplierName NVARCHAR(100) NOT NULL, Phone VARCHAR(20), Address NVARCHAR(200) ); CREATE TABLE Drugs ( DrugID INT IDENTITY PRIMARY KEY, DrugCode VARCHAR(20) NOT NULL UNIQUE, DrugName NVARCHAR(50) NOT NULL, Spec NVARCHAR(30), Unit NVARCHAR(10), PurchasePrice DECIMAL(10,2), SellPrice DECIMAL(10,2), Stock INT DEFAULT 0, ExpiryDate DATE, SupplierID INT FOREIGN KEY REFERENCES Suppliers(SupplierID) ); CREATE TABLE SalesMain ( SaleID INT IDENTITY PRIMARY KEY, SaleTime DATETIME DEFAULT GETDATE(), OperatorID INT FOREIGN KEY REFERENCES Users(UserID), TotalAmount DECIMAL(10,2) ); CREATE TABLE SaleDetails ( DetailID INT IDENTITY PRIMARY KEY, SaleID INT FOREIGN KEY REFERENCES SalesMain(SaleID), DrugID INT FOREIGN KEY REFERENCES Drugs(DrugID), Quantity INT, UnitPrice DECIMAL(10,2), Amount DECIMAL(10,2) ); CREATE TABLE StockLog ( LogID INT IDENTITY PRIMARY KEY, DrugID INT FOREIGN KEY REFERENCES Drugs(DrugID), ChangeType VARCHAR(5), Quantity INT, Price DECIMAL(10,2), OperateDate DATETIME DEFAULT GETDATE(), SupplierID INT );这段脚本里,PassWord用VARCHAR(32),对应C#端MD5的32位十六进制输出;如果要加盐,就把长度加长。DrugCode加上UNIQUE约束,方便扫码枪按编码精确查询。外键只落在Drugs.SupplierID上,销售明细的外键和主键冲突会在插入时被数据库拒绝,帮你拦住一部分脏数据。StockLog是进货流水表,ChangeType用‘IN’和‘OUT’区分入库出库,后面做库存追溯会用到。
连接串不要写在代码里,统一放App.config。这样换机器改连接串时,只需要改配置文件,不用重新编译:
<connectionStrings> <add name="PharmacyDB" connectionString="Data Source=localhost;Initial Catalog=PharmacyDB;Integrated Security=True;" providerName="System.Data.SqlClient" /> </connectionStrings>C#端封一个DbHelper,每次打开连接都从配置读取,减少连接串不一致的问题:
using System.Configuration; using System.Data.SqlClient; public class DbHelper { private string connStr = ConfigurationManager.ConnectionStrings["PharmacyDB"].ConnectionString; public SqlConnection GetConnection() { SqlConnection conn = new SqlConnection(connStr); conn.Open(); return conn; } }记得在项目里添加System.Configuration程序集引用,不然ConfigurationManager这行会编译报错。DbHelper把连接对象暴露给上层,BLL在使用时用using块包住,用完自动释放连接。
3. C#核心业务:药品入库、库存预警与销售收银
3.1 药品入库的方法设计与事务边界
入库不只是一个INSERT。按药店流程,入库要更新药品的库存数量、记录进货流水、更新供应商表里的最近供货信息,至少涉及两次写操作。如果两次写不在同一事务里,就可能出现“流水记了但库存没加”这种对不上的账。C#端最直接的做法是把多个写操作包在SqlTransaction里,任何一步抛异常就整体回滚。先看一个用参数化Update和Insert完成入库的版本:
public bool PurchaseIn(int drugId, int qty, decimal price, int supplierId) { string sql = @" UPDATE Drugs SET Stock = Stock + @qty, PurchasePrice = @price WHERE DrugID = @id; INSERT INTO StockLog(DrugID, ChangeType, Quantity, Price, OperateDate, SupplierID) VALUES(@id, 'IN', @qty, @price, GETDATE(), @supplierId);"; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.Add("@id", SqlDbType.Int).Value = drugId; cmd.Parameters.Add("@qty", SqlDbType.Int).Value = qty; cmd.Parameters.Add("@price", SqlDbType.Decimal).Value = price; cmd.Parameters.Add("@supplierId", SqlDbType.Int).Value = supplierId; return cmd.ExecuteNonQuery() == 2; } } }看一眼逻辑:第一条Update把药品表库存加上入库数量,同时把最新进价写进PurchasePrice,这样下次盘点时能查到最近进价;第二条Insert是流水记录,ChangeType传‘IN’表示入库。返回影响行数等于2,说明两条都执行成功。这里用的是同一个Command多语句执行,SQL Server默认允许这种写法,但它不是一个显式事务。严谨的版本要像3.3的销售代码那样用SqlTransaction把两次ExecuteNonQuery包起来,入库代码可以照搬那个模式。
参数说明:@qty是进货数量,按库存单位传入;@price是本次进价,decimal类型强制按数值传参;@supplierId对应供应商表的SupplierID,如果单据里没有选择供应商,可以传0并在SQL里加ISNULL判断。特别注意不能用字符串拼接把这三个值拼进SQL里,扫码枪录入的药品编码、供应商编号都可能包含特殊字符,参数化既可以防注入,也能让SQL Server对同类语句复用执行计划,数据量大时循环入库的速度差别明显。
3.2 库存预警与C#界面刷新卡顿的处理
库存预警是药店管理系统里性价比很高的功能。评委打开主界面,看到底部提示“低库存3种,近效期2种”,比看什么都直观。常见实现是用WinForms的Timer控件,每30秒查一次低库存和近效期数据,并刷新底部Label。直接写会有一个问题:Timer事件里做同步数据库查询,如果表里数据有几千条,界面会在查询期间卡住。“C#界面刷新卡顿”是很多C#项目被抱怨的地方,药店管理系统在毕设答辩时也可能被问到,处理方式很简单——把查询放到Task.Run里跑。
private async void timerStock_Tick(object sender, EventArgs e) { DataTable dt = await Task.Run(() => bll.GetStockWarnings(20, 90)); lblStockWarning.Text = $"低库存 {dt.Rows.Count} 种"; dgvWarnings.DataSource = dt; }GetStockWarnings的两个参数,第一个是低库存阈值,单位与药品库存单位一致,20盒以下就算偏低;第二个是近效期天数,90天意味着距离ExpiryDate不足三个月就提醒。阈值不该写死在方法里,建议放到配置文件的AppSettings节,不同药店可以调。Task.Run把查询任务丢到线程池,查询期间UI还能响应鼠标,await回到UI线程后再更新控件,这就是async/await解决卡顿的标准姿势。
不过Timer只负责“无人操作时定时刷新”,真正的实时提醒要在每次销售成功后同步检查一次。Timer间隔再短也有空隙,只有业务动作触发的那次检查才不漏。把GetStockWarnings方法抽象成公共方法,入库、销售、盘点三个业务完成后都调用它,主窗体和报表页都能复用,代码不重复,老师问起来也说得清。
3.3 销售收银的防超卖代码写法
销售收银是整套系统的核心,也是我建议你花最多时间打磨的地方。一个完整的销售动作要写销售主表、写销售明细、扣减库存,三个写操作必须用事务包起来。下面这个方法可以直接抄到BLL层,SaleDetailItem是一个只包含DrugId、Quantity、UnitPrice三个属性的实体类,除去库存扣减部分,其余按注释理解就行:
public bool SaveSale(int operatorId, List<SaleDetailItem> items) { using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) // 事务开始 { try { decimal total = items.Sum(i => i.Quantity * i.UnitPrice); string sqlMain = @" INSERT INTO SalesMain(SaleTime, OperatorID, TotalAmount) VALUES(GETDATE(), @op, @total); SELECT CAST(SCOPE_IDENTITY() AS INT);"; SqlCommand cmdMain = new SqlCommand(sqlMain, conn, tran); cmdMain.Parameters.Add("@op", SqlDbType.Int).Value = operatorId; cmdMain.Parameters.Add("@total", SqlDbType.Decimal).Value = total; int saleId = (int)cmdMain.ExecuteScalar(); foreach (SaleDetailItem item in items) { string sqlDetail = @" INSERT INTO SaleDetails(SaleID, DrugID, Quantity, UnitPrice, Amount) VALUES(@sid, @did, @qty, @price, @amount);"; SqlCommand cmdDetail = new SqlCommand(sqlDetail, conn, tran); cmdDetail.Parameters.Add("@sid", SqlDbType.Int).Value = saleId; cmdDetail.Parameters.Add("@did", SqlDbType.Int).Value = item.DrugId; cmdDetail.Parameters.Add("@qty", SqlDbType.Int).Value = item.Quantity; cmdDetail.Parameters.Add("@price", SqlDbType.Decimal).Value = item.UnitPrice; cmdDetail.Parameters.Add("@amount", SqlDbType.Decimal).Value = item.Quantity * item.UnitPrice; cmdDetail.ExecuteNonQuery(); string sqlStock = @" UPDATE Drugs SET Stock = Stock - @qty WHERE DrugID = @did AND Stock >= @qty;"; SqlCommand cmdStock = new SqlCommand(sqlStock, conn, tran); cmdStock.Parameters.Add("@qty", SqlDbType.Int).Value = item.Quantity; cmdStock.Parameters.Add("@did", SqlDbType.Int).Value = item.DrugId; if (cmdStock.ExecuteNonQuery() == 0) throw new Exception("库存不足,销售单已回滚"); } tran.Commit(); return true; } catch (Exception ex) { tran.Rollback(); throw new Exception("保存销售单失败", ex); } } } }这段代码的逻辑线很清晰:先根据明细行求和算出订单总额,插入主表后用SCOPE_IDENTITY()取回自增主键;然后遍历每一条销售明细,写入明细表并扣减库存。防超卖的关键在WHERE DrugID = @did AND Stock >= @qty这行。库存足够时,UPDATE返回影响行数1;库存不足时,影响行数是0,程序抛异常回滚整个事务,销售单不会写入数据库。
注意事务的finally边界。using (SqlTransaction tran = conn.BeginTransaction())保证连接和事务对象在方法结束时会释放,但事务本身必须显式Commit或Rollback。上面代码在异常路径里调用了Rollback,如果不调用,连接关闭时事务会被隐式回滚,但抛出异常时资源释放的顺序就不确定了。用catch里Rollback再加throw是最稳妥的写法,调用方拿到的异常信息也能直接弹给收银员看。
这里用到的items.Sum(i => i.Quantity * i.UnitPrice)是C# LINQ的语法。如果不熟悉LINQ,也可以循环累加,效果一样,但答辩时提一句“用LINQ对销售明细求和”会显得你见过生产环境的代码。
4. 用DataGridView和Chart控件做药店销售统计报表
4.1 销售统计时间段查询的SQL写法与参数坑
报表在毕设里占的印象分比想象中高。评委经常做的动作是:打开销售统计页,选一个日期范围,点查询,看数字是否合理。药店管理系统通常要提供“按日销售额”和“按时间段汇总”两种视图。下面的SQL是按天分组的营业汇总:
SELECT CONVERT(date, SaleTime) AS SaleDate, COUNT(SaleID) AS OrderCount, SUM(TotalAmount) AS TotalAmount FROM SalesMain WHERE SaleTime >= @start AND SaleTime < DATEADD(day, 1, @end) GROUP BY CONVERT(date, SaleTime) ORDER BY SaleDate DESC;这里有个很容易踩的坑。界面上的DateTimePicker选出来的是一个DateTime对象,比如2025-10-15 00:00:00。如果你在SQL里写BETWEEN @start AND @end,那@end就是10月15日零点,而SalesMain里的SaleTime可能是10月15日14:30,正好被排除在外。所以结束时间要加一天取左闭右开区间,也就是上面SaleTime < DATEADD(day, 1, @end)的写法,这样10月15日全天都被包含进去了。
C#端调用时,用两个DateTimePicker,一个叫dtpStart,一个叫dtpEnd,把它们的Value传给DAL:
| 参数 | SqlDbType | 说明 |
|---|---|---|
| @start | DateTime | 起始日期,取Picker的Value |
| @end | DateTime | 结束日期,取Picker的Value |
这里@start和@end都只带日期部分,把BETWEEN换成半开区间后,不需要再对时间部分做Trim。多传时分秒也没有问题,只要保证结束值在SQL里加了一天。
4.2 用Chart控件画近七天营业额趋势
WinForms自带的Chart控件做趋势图足够用,不用额外拉第三方库。主窗体右键工具箱,勾选Chart后拖进来,然后代码里绑数据:
private void LoadWeeklyTrend() { DataTable dt = bll.GetDailySales(DateTime.Today.AddDays(-6), DateTime.Today); chartSales.DataSource = dt; chartSales.Series[0].XValueMember = "SaleDate"; chartSales.Series[0].YValueMembers = "TotalAmount"; chartSales.Series[0].ChartType = SeriesChartType.Line; chartSales.ChartAreas[0].AxisX.Interval = 1; chartSales.ChartAreas[0].AxisX.LabelStyle.Format = "MM-dd"; chartSales.ChartAreas[0].AxisY.LabelStyle.Format = "C0"; chartSales.Titles.Clear(); chartSales.Titles.Add("近7天营业额趋势"); }BLL里的GetDailySales同样接收两个日期参数,去调4.1那段SQL,返回DataTable。图表绑定的步骤只有三行:指定X轴成员、Y轴成员、图表类型。AxisX.Interval设置为1,是让横轴的每个日期标签都显示,而不是自动省略;LabelStyle.Format是日期显示格式;Y轴Format用C0表示金额格式,不带小数。
有个细节要注意:chartSales.DataSource = dt之后,必须先绑定再设置Series属性,顺序反了会导致Series里的XValueMember被清空,图表画出来是一条空线。这也是WinForms Chart控件常见的新手问题,出现时检查代码顺序就行。
4.3 低库存与近效期列表的条件格式高亮
报表页除了销售统计,我一般再放一个“库存预警”Tab,把低库存和近效期药品用颜色标出来。DataGridView的RowPrePaint事件是标准做法,在每行绘制前按数据改变背景色:
private void dgvWarnings_RowPrePaint(object sender, DataGridViewRowPrePaintEventArgs e) { DataGridViewRow row = dgvWarnings.Rows[e.RowIndex]; int stock = Convert.ToInt32(row.Cells["Stock"].Value); int expiryDays = Convert.ToInt32(row.Cells["ExpiryDays"].Value); if (stock < 20) row.DefaultCellStyle.BackColor = Color.FromArgb(255, 250, 200); if (expiryDays <= 90) row.DefaultCellStyle.BackColor = Color.FromArgb(255, 220, 220); }ExpiryDays列可以在SQL里用DATEDIFF(day, GETDATE(), ExpiryDate)算出来,别在C#里逐个算,SQL端一句搞定。低库存阈值20和近效期阈值90在这里是写死的,建议和3.2一样放到配置中,图表和列表共用同一个配置项。
当低库存和近效期同时命中时,后一个条件会覆盖前一个颜色,实际显示为偏红色,代表着更紧急的状态。如果你想让两个状态叠加成不同颜色,就不要直接给BackColor赋值,而是把两个判断合并生成一个Color变量,这样更灵活。RowPrePaint在滚动时会被频繁触发,但对几十行数据的预警列表来说性能完全够用,不需要考虑复杂优化。
5. C#药店管理系统的部署与答辩边界处理
5.1 登录模块的MD5校验与角色权限
登录功能看着简单,但我见过太多直接把数据库里的明文密码下拉出来对比的写法。药店管理系统至少要把密码哈希一下再入库。C#端用MD5做一次哈希很简单:
DataRow row = userTable.Rows[0]; string inputHash = GetMd5(txtPassword.Text); if (inputHash != row["PassWord"].ToString()) { MessageBox.Show("用户名或密码错误"); return; }GetMd5方法内部用MD5.Create()算出字节数组再转十六进制,返回32位小写字符串,跟建库脚本里的VARCHAR(32)正好对应。注册用户时同样调用GetMd5后再写入数据库,保证库里不落明文。这里不推荐加盐,因为加盐之后你还需要管理盐值,期末项目的规模里,MD5加基本校验已经足够。角色权限我建议按RoleName字段做一次判断:管理员和收银员登录后,在Load事件里决定哪些按钮可见,比如“数据备份”“用户管理”只对管理员开放。这是最简单的RBAC实现,答辩时说“基于角色的界面级权限控制”,措辞上也站得住。
5.2 数据库附加与连接串修改
答辩最稳妥的做法,是把数据库文件做成可以附加的形态。操作顺序是:在SSMS中对PharmacyDB执行EXEC sp_detach_db 'PharmacyDB';,把分离后的mdf和ldf文件放进项目根目录的DB文件夹;答辩机上打开SSMS,右键“数据库”节点选择“附加”,选中mdf后确认;最后把App.config里的Data Source改成答辩机的实例名。这一套流程提前在同学电脑上演练一遍,比自己临时装SQL Server要可靠。
5.3 用ClickOnce或自解压包部署到演示机
如果答辩环境不允许额外装SQL Server,就用Visual Studio的发布功能。项目属性里选“发布”,用ClickOnce发布,输出一个setup.exe,目标机运行后自动装上程序。数据库连接串要改成LocalDB形式,例如Data Source=(LocalDB)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\PharmacyDB.mdf;Integrated Security=True,首次运行时LocalDB会自动把mdf附加为实例,不需要手工建库。这样部署的缺点是整体文件会大几十兆,但换来的是演示时对环境依赖大幅降低,值得。
答辩前一天,我建议把数据库里的测试数据清空,只保留几笔具有代表性的记录,然后按“添加药品→进货入库→销售→查看报表→库存预警”的顺序整体走一遍。这个时候最容易暴露平时没注意的问题:DateTimePicker默认值是否合理、销售后库存是否立即刷新、报表日期范围是否覆盖当天。把这些点挨个验证掉,演示时就不会出现让评委皱眉的意外。
本文还有配套的精品资源,点击获取