尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C#+MySQL药品销售库存管理系统:从数据库设计到库存预警实现

C#+MySQL药品销售库存管理系统:从数据库设计到库存预警实现 简介基于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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;表名关键字段在模块中的作用drugdrug_id, drug_code, drug_name, sale_price维护药品基础档案供查询和销售计价使用stockdrug_id, quantity, min_quantity记录实时库存和提醒阈值销售时被扣减sale_orderorder_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直接做主键。药品编码一旦修改所有关联表都要改所以建议用无业务含义的整型自增iddrug_code只作为业务编号加唯一索引。还有表引擎要显式写成InnoDBMyISAM不支持事务销售扣库存时没有回滚能力这在课程设计答辩中很容易被问到。如果还没有配置好环境可以参考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 version1.0 encodingutf-8? configuration connectionStrings add nameMySqlConn connectionStringServer127.0.0.1;Port3306;Databasedrug_mgmt_sys;Uidroot;Pwd123456;CharSetutf8mb4;SslModeNone; providerNameMySql.Data.MySqlClient / /connectionStrings startup useLegacyV2RuntimeActivationPolicytrue supportedRuntime versionv4.0 sku.NETFramework,Versionv4.5.2 / /startup /configuration这里的CharSetutf8mb4告诉驱动用UTF-8字符集如果漏掉C#界面录入的中文在MySQL命令行里可能是乱码。SslModeNone表示不启用SSL本地调试时减少握手时间。providerName会在代码中被ConfigurationManager.ConnectionStrings[MySqlConn].ProviderName读取。参数示例值说明Server127.0.0.1数据库主机远程时填公网IP或内网IPPort3306MySQL端口8.0默认3306多实例时要改Databasedrug_mgmt_sys对应导入的数据库名Uid/Pwdroot/123456登录账号和密码按实际环境修改CharSetutf8mb4避免中文乱码建议固定使用SslModeNone本地调试跳过SSL握手生产环境建议Required3.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 xmlnsurn:schemas-microsoft-com:asm.v1 dependentAssembly assemblyIdentity nameMySql.Data publicKeyTokenc5687fc88969c44d cultureneutral / bindingRedirect oldVersion0.0.0.0-8.0.33.0 newVersion8.0.33.0 / /dependentAssembly /assemblyBinding /runtimepublicKeyToken和newVersion要根据实际安装的DLL版本调整查看方法是在解决方案资源管理器里展开引用选中MySql.Data在属性中看版本号。版本对不上时运行时最常见的报错是“未能加载文件或程序集 MySql.Data, Version6.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_iddrugId 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 Actionstring 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。本文还有配套的精品资源点击获取
返回列表