简介:基于C#与MySQL开发的药品销售库存信息管理系统源码包,主要面向计算机相关专业正在准备毕业设计、课程设计或需要进行项目实战练习的C#学习者。系统覆盖药品信息维护、库存管理和销售处理等核心模块,并支持库存状态提醒,整体功能完整,适合课程设计演示或毕业设计二次开发。压缩包内共70个文件,以C#源码、VS解决方案、界面资源、配置文件和数据库脚本为主,另有可执行程序与DLL库便于直接运行参考,整体大小约2.67MB,目录结构清晰。目前已有408人学习下载,项目附带完整源码、数据库脚本与项目文档,经过严格调试确保可运行,数据库脚本可直接导入MySQL,便于快速搭建环境并辅助理解系统设计思路与业务流程;文档还能帮助理清数据库表结构与代码模块的对应关系,为二次开发提供参考。
1. 药品销售库存信息管理系统:从课程设计到项目落地
拿到一套“基于C#+MySql实现的药品销售库存信息管理系统”源码,第一反应不是看界面,而是先看数据库脚本和项目结构。这套系统的核心其实不是常规的增删改查,而是“销售时怎么扣库存、库存不足时怎么及时提醒”。很多课程设计做到最后,界面很完整,但一并发就出问题:库存变负数、重复出库、订单和库存对不上。这套源码里包含了drug_mgmt_sys.sql数据库脚本、VS2015的解决方案DrugMgmtSys.sln,以及Main.cs、InfoMgmtForm.cs、ShowOrder.cs等窗体代码,正好适合改造成一个能支撑日常练习的C#项目。适合正在做数据库课程设计和C#课程设计的学生,也适合想快速熟悉WinForms + MySql.Data库开发方式的人。
2. 数据库设计:药品表、库存表与销售流水的关系模型
2.1 表结构设计:为什么要把药品信息和库存拆开
打开drug_mgmt_sys.sql,常见的设计是三张核心表:药品信息表drug、库存表stock、销售记录表sale_order。注意不要把库存数量直接放在drug表里,否则每次销售都要同时更新药品表,还要处理历史数据。拆开后,drug表负责药品静态信息,stock表负责动态数量,sale_order表负责每一笔销售明细。这样在统计销售、盘点库存时,SQL写起来才清晰。
下面是一个兼容MySQL 5.7/8.0的建表示例,字段按资源中常用设计整理:
CREATE TABLE drug ( drug_id INT AUTO_INCREMENT PRIMARY KEY COMMENT '药品ID', drug_code VARCHAR(20) NOT NULL UNIQUE COMMENT '药品编码', drug_name VARCHAR(100) NOT NULL COMMENT '药品名称', spec VARCHAR(50) DEFAULT '' COMMENT '规格', unit VARCHAR(10) DEFAULT '盒' COMMENT '单位', purchase_price DECIMAL(10,2) DEFAULT 0 COMMENT '采购价', sale_price DECIMAL(10,2) DEFAULT 0 COMMENT '销售价', manufacturer VARCHAR(100) DEFAULT '' COMMENT '生产厂家', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock ( stock_id INT AUTO_INCREMENT PRIMARY KEY, drug_id INT NOT NULL, quantity INT DEFAULT 0 COMMENT '当前库存', min_quantity INT DEFAULT 10 COMMENT '库存下限', last_update_time DATETIME ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_stock_drug FOREIGN KEY (drug_id) REFERENCES drug(drug_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sale_order ( order_id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, drug_id INT NOT NULL, sale_quantity INT NOT NULL, sale_price DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL, sale_time DATETIME DEFAULT CURRENT_TIMESTAMP, operator VARCHAR(50) DEFAULT '' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;| 表名 | 关键字段 | 在模块中的作用 |
|---|---|---|
| drug | drug_id, drug_code, drug_name, sale_price | 维护药品基础档案,供查询和销售计价使用 |
| stock | drug_id, quantity, min_quantity | 记录实时库存和提醒阈值,销售时被扣减 |
| sale_order | order_no, drug_id, sale_quantity, amount | 记录每笔销售流水,支撑对账和统计 |
这个结构里,外键约束保证stock和sale_order引用的药品必须存在于drug表。MySQL 8.0安装配置时注意默认用utf8mb4字符集,否则药品名称里的生僻字写入可能报错。字段类型上,价格用DECIMAL而不用float,避免浮点误差;库存数量用INT,如果以后要扩展批次管理,可以再引入batch_no字段。
2.2 库存提醒:用查询和存储过程而不是触发器
系统要求“必要时对库存状态进行提醒”,很多课程设计会把提醒写成触发器,每次插入销售记录后自动更新库存。实际项目里触发器不好排错,也不方便在C#中获取提醒结果,建议在存储过程或C#业务层处理。更稳妥的方式是维护stock表的min_quantity字段,销售后判断库存是否低于下限。
下面是存储过程sp_sale_and_check,它在同一事务里扣库存、写流水、返回提醒标志:
DELIMITER $$ CREATE PROCEDURE sp_sale_and_check( IN p_order_no VARCHAR(32), IN p_drug_id INT, IN p_quantity INT, IN p_operator VARCHAR(50) ) BEGIN DECLARE curr_qty INT; DECLARE min_qty INT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; SELECT quantity INTO curr_qty FROM stock WHERE drug_id = p_drug_id FOR UPDATE; IF curr_qty < p_quantity THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足'; END IF; UPDATE stock SET quantity = quantity - p_quantity WHERE drug_id = p_drug_id; INSERT INTO sale_order(order_no, drug_id, sale_quantity, sale_price, amount, operator) SELECT p_order_no, p_drug_id, p_quantity, sale_price, sale_price * p_quantity, p_operator FROM drug WHERE drug_id = p_drug_id; SELECT min_quantity INTO min_qty FROM stock WHERE drug_id = p_drug_id; IF (curr_qty - p_quantity) < min_qty THEN SELECT '库存不足提醒' AS remind_msg, (curr_qty - p_quantity) AS remain_qty, min_qty AS min_quantity; END IF; COMMIT; END$$ DELIMITER ;这里使用FOR UPDATE锁住库存行,防止两个销售员同时下单导致超卖。SIGNAL SQLSTATE '45000'是MySQL 5.6以后才有的语法,如果使用MySQL 5.5,需要改成ROLLBACK; SELECT '库存不足' AS msg;。存储过程的好处是C#端只需要调用一个MySqlCommand执行存储过程,不需要拼接多条SQL。DELIMITER是mysql命令行客户端的指令,在MySQL Workbench里可以直接执行整个脚本。
2.3 数据库课程设计中常见的设计误区
不少数据库课程设计会把“药品信息”和“库存数量”合在一张表,这样做简单,但会带来三个问题:一是删除药品时误删库存历史;二是无法追踪哪笔销售导致库存减少;三是盘点时很难对账。正确做法是保留sale_order流水,库存变化都能从流水里推出来。
另一个误区是主键用字符串编码,比如drug_code直接做主键。药品编码一旦修改,所有关联表都要改,所以建议用无业务含义的整型自增id,drug_code只作为业务编号加唯一索引。还有表引擎要显式写成InnoDB,MyISAM不支持事务,销售扣库存时没有回滚能力,这在课程设计答辩中很容易被问到。
如果还没有配置好环境,可以参考MySQL安装配置教程把MySQL 8.0装好,再用MySQL Workbench导入drug_mgmt_sys.sql。导入时如果报“Unknown collation: utf8mb4_0900_ai_ci”,说明MySQL版本低于8.0,需要把SQL文件里的排序规则改成utf8mb4_general_ci。
3. C#连接MySQL与数据访问层:MySqlTools参数化查询
3.1 连接字符串与App.config配置
项目里连接数据库的信息一般写在App.config中,这样更换数据库服务器时不需要重新编译WinForms程序。注意MySQL Connector/NET的默认端口是3306,如果本机装了多个MySQL实例,端口可能被改成3307、3308,连接字符串里的Port要和实际一致。
<?xml version="1.0" encoding="utf-8"?> <configuration> <connectionStrings> <add name="MySqlConn" connectionString="Server=127.0.0.1;Port=3306;Database=drug_mgmt_sys;Uid=root;Pwd=123456;CharSet=utf8mb4;SslMode=None;" providerName="MySql.Data.MySqlClient" /> </connectionStrings> <startup useLegacyV2RuntimeActivationPolicy="true"> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.2" /> </startup> </configuration>这里的CharSet=utf8mb4告诉驱动用UTF-8字符集,如果漏掉,C#界面录入的中文在MySQL命令行里可能是乱码。SslMode=None表示不启用SSL,本地调试时减少握手时间。providerName会在代码中被ConfigurationManager.ConnectionStrings["MySqlConn"].ProviderName读取。
| 参数 | 示例值 | 说明 |
|---|---|---|
| Server | 127.0.0.1 | 数据库主机,远程时填公网IP或内网IP |
| Port | 3306 | MySQL端口,8.0默认3306,多实例时要改 |
| Database | drug_mgmt_sys | 对应导入的数据库名 |
| Uid/Pwd | root/123456 | 登录账号和密码,按实际环境修改 |
| CharSet | utf8mb4 | 避免中文乱码,建议固定使用 |
| SslMode | None | 本地调试跳过SSL握手,生产环境建议Required |
3.2 MySqlTools封装:从MySqlConnection到DataTable
源码中的MySqlTools.cs是一个静态工具类,作用是封装连接创建、SQL执行、参数化查询等重复操作。不少C#上位机和WinForms项目都会这么做,先在工具类里统一管好连接和异常,再让窗体调用。参照常见写法,这个类至少需要提供三个方法:查询DataTable、执行非查询、执行标量值。
using System; using System.Configuration; using System.Data; using MySql.Data.MySqlClient; public static class MySqlTools { private static readonly string connStr = ConfigurationManager.ConnectionStrings["MySqlConn"].ConnectionString; public static DataTable GetDataTable(string sql, params MySqlParameter[] parameters) { using (MySqlConnection conn = new MySqlConnection(connStr)) using (MySqlCommand cmd = new MySqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); MySqlDataAdapter adapter = new MySqlDataAdapter(cmd); DataTable table = new DataTable(); adapter.Fill(table); return table; } } public static int ExecuteNonQuery(string sql, params MySqlParameter[] parameters) { using (MySqlConnection conn = new MySqlConnection(connStr)) using (MySqlCommand cmd = new MySqlCommand(sql, conn)) { conn.Open(); if (parameters != null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteNonQuery(); } } public static object ExecuteScalar(string sql, params MySqlParameter[] parameters) { using (MySqlConnection conn = new MySqlConnection(connStr)) using (MySqlCommand cmd = new MySqlCommand(sql, conn)) { conn.Open(); if (parameters != null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteScalar(); } } }这段代码的关键是使用using块,确保连接即使发生异常也会被释放。GetDataTable没写conn.Open(),因为MySqlDataAdapter.Fill内部会自行打开连接;ExecuteNonQuery和ExecuteScalar则必须手动Open()。调用时用params MySqlParameter[],让窗体代码可以这样传参:
DataTable dt = MySqlTools.GetDataTable( "SELECT * FROM drug WHERE drug_name LIKE @name", new MySqlParameter("@name", "%阿莫西林%"));使用参数化查询而不是拼接字符串,能防止SQL注入,也避免药品名称里的单引号把SQL截断。这里@name中的%是模糊匹配通配符,不是参数的一部分,如果写成"%@name%"会变成字面量,这是初学者常犯的错误。
3.3 VS2015与MySql.Data程序集引用
在Visual Studio 2015里新建.Net Framework 4.5.2项目后,默认没有MySql.Data引用。需要在NuGet包管理器中搜索“MySql.Data”安装,或者从MySQL官网下载Connector/NET。安装后项目里会出现MySql.Data.dll,并且自动引用较新版本。如果本机同时安装了MySQL 8.0和旧版驱动,可能遇到MySql.Data.MySqlClient.MySqlConnection 版本不匹配的异常,原因是App.config里没有加bindingRedirect运行时重定向。
手动在App.config的<configuration>节点下补充:
<runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="MySql.Data" publicKeyToken="c5687fc88969c44d" culture="neutral" /> <bindingRedirect oldVersion="0.0.0.0-8.0.33.0" newVersion="8.0.33.0" /> </dependentAssembly> </assemblyBinding> </runtime>publicKeyToken和newVersion要根据实际安装的DLL版本调整,查看方法是在解决方案资源管理器里展开引用,选中MySql.Data,在属性中看版本号。版本对不上时,运行时最常见的报错是“未能加载文件或程序集 MySql.Data, Version=6.9.9.0”。使用VS2015打开这套源码时,如果本机没有安装对应版本,就会触发这个提示。
此外,如果源码里的DrugMgmtSys_TemporaryKey.pfx是用于ClickOnce签名,编译时可能提示密码错误。可以直接在项目属性 -> 签名中取消选择“为ClickOnce清单签名”,不影响Debug运行。
4. 销售处理与库存扣减:事务、重入与阈值判定
4.1 销售下单的业务流程
在Main.cs或InfoMgmtForm.cs中,销售流程通常是:在主界面选择药品 -> 输入数量 -> 点击“销售”按钮 -> 弹出InputNumDiaLog确认数量 -> 扣减库存并写入sale_order -> 刷新列表。这里容易忽略的是,数量变化和订单写入必须在一个事务里,否则会出现库存扣了但订单没保存的脏数据。
实际代码逻辑可以概括为这几步:
- 从DataGridView选中药品,获取drug_id和当前库存。
- 用InputNumDiaLog窗体获取销售数量,校验是否为数字且大于0。
- 调用事务性方法完成扣库存、写订单。
- 重新加载药品和库存列表,更新DataGridView。
4.2 事务性库存扣减:避免并发超卖
上一章的存储过程把事务放在数据库端,但也可以直接用C#的MySqlTransaction控制。两者各有优缺点:数据库存储过程在网络传输上更少,但C#端事务更容易配合业务校验。下面是在C#代码里实现事务的典型方式,核心是用MySqlTransaction。TransactionScope需要启用MSDTC,在课程设计里过于复杂,所以推荐MySqlTransaction。
public bool SaleDrug(int drugId, int quantity, string operatorName) { string checkSql = "SELECT quantity, min_quantity FROM stock WHERE drug_id=@drugId FOR UPDATE"; string updateSql = "UPDATE stock SET quantity = quantity - @num WHERE drug_id = @drugId"; string insertSql = @"INSERT INTO sale_order(order_no, drug_id, sale_quantity, sale_price, amount, operator) SELECT @orderNo, @drugId, @num, sale_price, sale_price * @num, @operator FROM drug WHERE drug_id = @drugId"; using (MySqlConnection conn = new MySqlConnection(MySqlTools.ConnStr)) { conn.Open(); using (MySqlTransaction tx = conn.BeginTransaction(IsolationLevel.ReadCommitted)) using (MySqlCommand cmd = new MySqlCommand()) { cmd.Connection = conn; cmd.Transaction = tx; try { cmd.CommandText = checkSql; cmd.Parameters.AddWithValue("@drugId", drugId); using (MySqlDataReader reader = cmd.ExecuteReader()) { if (!reader.Read()) throw new Exception("药品不存在"); int currQty = reader.GetInt32("quantity"); int minQty = reader.GetInt32("min_quantity"); if (currQty < quantity) throw new Exception($"库存不足,当前仅剩{currQty}"); } cmd.Parameters.Clear(); cmd.CommandText = updateSql; cmd.Parameters.AddWithValue("@drugId", drugId); cmd.Parameters.AddWithValue("@num", quantity); cmd.ExecuteNonQuery(); cmd.Parameters.Clear(); cmd.CommandText = insertSql; cmd.Parameters.AddWithValue("@orderNo", DateTime.Now.ToString("yyyyMMddHHmmssfff")); cmd.Parameters.AddWithValue("@drugId", drugId); cmd.Parameters.AddWithValue("@num", quantity); cmd.Parameters.AddWithValue("@operator", operatorName); cmd.ExecuteNonQuery(); tx.Commit(); return true; } catch { tx.Rollback(); throw; } } } }IsolationLevel.ReadCommitted配合FOR UPDATE,保证在检查库存到更新库存这段时间内,其他事务无法读到同一行的未提交变化。这里注意ExecuteReader返回后,必须先用reader.Read()判断是否存在数据,再取字段值。粗心的写法是先读后判断,遇到空记录会抛InvalidOperationException。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 适用场景 |
|---|---|---|---|---|
| ReadUncommitted | 可能 | 可能 | 可能 | 很少用,课程设计不推荐 |
| ReadCommitted | 否 | 可能 | 可能 | 配合FOR UPDATE,适合本次库存扣减 |
| RepeatableRead | 否 | 否 | 可能 | MySQL InnoDB默认级别 |
| Serializable | 否 | 否 | 否 | 并发低、强一致场景 |
4.3 库存过低提醒:避免DataGridView刷新卡顿
库存提醒在界面上一般展示为:库存小于等于min_quantity时,该行字体变红或背景变黄。完成绑定后,遍历DataGridView行进行判断即可。但如果在for循环里逐个更改单元格样式,数据量较大时会有明显卡顿,这正好对应C#循环数据采集和UI刷新卡顿问题。
解决办法是在加载数据之前调用BeginUpdate(),数据操作结束后调用EndUpdate(),并配合SuspendLayout/ResumeLayout。下面这段代码放在销售完成后的刷新逻辑里:
dgvDrugList.BeginUpdate(); dgvDrugList.DataSource = dtStock; dgvDrugList.SuspendLayout(); for (int i = 0; i < dgvDrugList.Rows.Count; i++) { DataGridViewRow row = dgvDrugList.Rows[i]; int currentQty = Convert.ToInt32(row.Cells["quantity"].Value); int minQty = Convert.ToInt32(row.Cells["min_quantity"].Value); if (currentQty <= minQty) { row.DefaultCellStyle.ForeColor = Color.Red; row.DefaultCellStyle.Font = new Font("Microsoft YaHei", 9F, FontStyle.Bold); } } dgvDrugList.ResumeLayout(); dgvDrugList.EndUpdate();BeginUpdate/EndUpdate会让DataGridView在数据源变化时暂停重绘,等循环结束后一次性刷新。如果还嫌卡,可以把循环里的currentQty <= minQty判断改成在SQL层完成,新增一个字段is_low,例如:
SELECT d.drug_name, s.quantity, s.min_quantity, CASE WHEN s.quantity <= s.min_quantity THEN 1 ELSE 0 END AS is_low FROM drug d JOIN stock s ON d.drug_id = s.drug_id这样C#端只需要判断is_low == 1,减少一次单元格取值。要注意的是,库存过低的判断标准不是0,而是min_quantity,否则要到缺货才提醒就已经晚了。
5. 进阶实践:扫码枪录入与跨窗体数据同步
5.1 把扫码枪当成高效数据入口
药品零售场景里,手动敲药品编码很慢,而且容易出错。市面上的USB扫码枪大多是“键盘楔入模式”,扫码后相当于用键盘高速输入一串字符并自动追加回车。利用这个特征,可以在主窗体的TextBox上监听回车,把扫到的编码立即变成查询条件。
private StringBuilder barcodeBuffer = new StringBuilder(); private void txtBarcode_KeyPress(object sender, KeyPressEventArgs e) { if (e.KeyChar == (char)13) { string code = barcodeBuffer.ToString(); barcodeBuffer.Clear(); LoadDrugByCode(code); e.Handled = true; } else if (char.IsLetterOrDigit(e.KeyChar) || e.KeyChar == '-') { barcodeBuffer.Append(e.KeyChar); } }这里没有直接把字符赋值给TextBox,而是先存到StringBuilder。原因是扫码枪输出速度远快于人工输入,文本框会频繁触发TextChanged事件,容易导致界面闪烁。拼接完成后按回车统一查询,既减少事件次数,也方便兼容扫码枪和人工回车。实际使用中要注意,个别扫码枪默认不带回车后缀,需要在说明书里设置,或者在KeyPress里通过时间间隔判断“如果两个字符间隔小于50毫秒就当作扫码输入”。
5.2 销售完成后如何让主界面自动刷新
如果销售操作放在ShowOrder子窗体中,子窗体提交订单后,主窗体的库存列表不会自动变化。简单粗暴的方案是关闭子窗体后重新绑定一次数据,但这样主界面焦点和滚动位置会丢失。推荐的做法是在子窗体里声明一个事件,销售成功时把订单号传回主窗体。
public event Action<string> SaleCompleted; private void btnSave_Click(object sender, EventArgs e) { bool result = SaleDrug(drugId, quantity, CurrentUser); if (result) { SaleCompleted?.Invoke(txtOrderNo.Text); this.DialogResult = DialogResult.OK; } }主窗体订阅这个事件后,只需要刷新当前页,不需要重新加载全部数据。跨窗体同步的思路同样适用于库存盘点、采购入库等场景,把“业务动作”和“界面刷新”解耦,比在每个按钮里都写一遍加载逻辑要干净得多。如果后续要把这套系统迁移到C#上位机或Web API,这种事件驱动方式也更接近实际开发习惯。最后检查一下,扫码录入后LoadDrugByCode里做参数化查询,避免把扫码内容直接拼进SQL。
本文还有配套的精品资源,点击获取