拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C#药店管理系统毕业设计:数据模型、事务与报表实战

简介基于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或MySQLC#药店管理系统的主流选型是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里不再出现任何SQLBLL调DALDAL只做参数化查询这是C#项目里最标准也最好答辩的分层方式药店管理系统照抄这个结构完全够用。2.2 药品表、供应商表、销售表的核心字段设计先想清楚要建哪几张表。药店管理系统最小集是药品表、供应商表、用户表、销售主表、销售明细表最好再加一张进货流水表。药品表是最容易设计错的有人把有效期、批次号、进价全堆在一张表里结果同一药品不同批次得用重复行表达主键都不知道设哪个。实际上药店是严格按批次管理的同一个药不同批次的进价和有效期都可能不同但毕设如果直接把批次逻辑做进去工作量会立刻翻倍。我建议在第一版做简化药品表只存当前库存和当前进价预留ExpiryDate字段在答辩说明里提一句“完整方案支持按批次FIFO出库”这比一股脑全做完更稳。下面这张是简化版药品表结构也是我经常用在毕设里的字段集合字段名类型说明DrugIDINT IDENTITY药品主键自增DrugCodeVARCHAR(20)药品编码配合扫码枪DrugNameNVARCHAR(50)药品通用名SpecNVARCHAR(30)规格如10mg*20片UnitNVARCHAR(10)单位盒/瓶/袋PurchasePriceDECIMAL(10,2)进价SellPriceDECIMAL(10,2)零售价StockINT当前库存ExpiryDateDATE有效期SupplierIDINT外键关联供应商表价格字段用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 namePharmacyDB connectionStringData Sourcelocalhost;Initial CatalogPharmacyDB;Integrated SecurityTrue; providerNameSystem.Data.SqlClient / /connectionStringsC#端封一个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, ListSaleDetailItem 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说明startDateTime起始日期取Picker的ValueendDateTime结束日期取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 SecurityTrue首次运行时LocalDB会自动把mdf附加为实例不需要手工建库。这样部署的缺点是整体文件会大几十兆但换来的是演示时对环境依赖大幅降低值得。答辩前一天我建议把数据库里的测试数据清空只保留几笔具有代表性的记录然后按“添加药品→进货入库→销售→查看报表→库存预警”的顺序整体走一遍。这个时候最容易暴露平时没注意的问题DateTimePicker默认值是否合理、销售后库存是否立即刷新、报表日期范围是否覆盖当天。把这些点挨个验证掉演示时就不会出现让评委皱眉的意外。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门