简介:这套基于C#的医药销售管理系统,适合需要完成课程设计或毕业设计的计算机专业学生,以及希望了解传统桌面端进销存流程的初级开发者。系统采用C#与SQL Server 2000搭建,包含药品信息查询、药品信息管理、销售管理等核心模块,并附带可直接附加的数据库文件(MSMS_Data与MSMS_Log),管理员账号密码均为8,便于快速登录验证功能。压缩包共290个文件,其中78个C#源码文件(.cs)构成主要业务逻辑,73个图标文件用于界面展示,另有SQL脚本、数据库文件(.mdf/.ldf)及项目工程文件(.sln/.csproj),整体仅2.49MB,结构清晰完整。已有141人学习下载,适合用作医药进销存场景的参考实现,可帮助读者理解C# WinForm界面与数据库交互方式,并在此基础上扩展权限管理、库存预警等模块。
1. 医药销售管理系统,为什么值得用 C# 重写一版
药店里最贵的不是药,是效期;最麻烦的不是开单,是回答“这批药从哪进的、现在还剩多少、卖给谁了”。通用进销存能算钱,算不准批号和效期,这是医药销售管理系统跟普通进销存最本质的差别。这套基于 C# 的医药销售管理系统(源码+数据库)的价值,在于把一个带数据库的完整工程交到你手里,不是让你背语法,而是让你顺着真实业务把增删改查、入库出库、批次效期、GSP 追溯串起来。下面按我拿到源码包后的实际路线讲:先看架构和表设计,再把库建起来跑通登录,接着拆批次效期和库存扣减这两块核心逻辑,最后给出让它在门店里真正扛得住运营的踩坑记录。适合三类人:接医药行业定制开发的、药店信息员、想拿真实项目练 C# 的入门者。
2. 先看清这套系统的骨架:C# 医药销售管理系统的模块与架构
2.1 基础资料、采购、批发生存、GSP 几张主表怎么设计
评估一个医药销售管理系统源码包,我第一个动作不是找登录窗口,而是打开数据库脚本和实体类目录,看它的表结构是否尊重医药这个行业。普通进销存的商品、进货、销售、库存四张表是撑不住药店业务的:同一种药可能同时存在三个批号,每个批号效期不同、进货价不同,卖的时候还要知道卖给谁、哪个批号、哪天到期。表设计不把这些问题兜住,后面所有界面都是空中楼阁。
常见的设计会落到下面这几类核心表上。表名各家有差异,但职责基本一致:
| 表职责 | 常见命名 | 关键字段 | 作用 |
|---|---|---|---|
| 药品档案 | Goods / Med_Product | GoodsCode、GoodsName、Spec、DosageForm、Manufacturer、ApprovalNo、IsRx、IsBatchManaged | 全系统基础资料,处方药与非处方药要靠它区分 |
| 供应商 | Supplier / Med_Supplier | SupplierCode、SupplierName、QualificationNo、GSPStatus | 首营企业档案,进货来源的追溯入口 |
| 库存批次 | StockBatch / Inv_StockBatch | GoodsCode、BatchNo、ProduceDate、ExpireDate、Qty | 批次效期的核心表,所有库存变动都围绕它 |
| 采购入库 | PurchaseMain / PurchaseDetail | OrderNo、SupplierCode、GoodsCode、BatchNo、ExpireDate、Qty、PurchasePrice | 记录每个批次的来源,是追溯链的上游 |
| 销售订单 | SalesMain / SalesDetail | OrderNo、CustomerCode、GoodsCode、BatchNo、Qty、SalePrice | 记录销售去向,明细里必须留批号快照 |
| GSP 记录 | GspRecord | RecordDate、Temperate、Humidity、Operator | 温湿度、养护记录,检查时直接打印 |
这里最值得注意的字段是IsBatchManaged。同一个商品是否强制按批次管理,决定了后面所有库存逻辑走哪条分支:开了批次管理的药品,每一次入库都要录批号和到期日期,销售出库按批号扣减;没开批次管理的,可以走普通库存。很多源码包在药品资料上没有这个开关,或者有开关但导入基础资料时漏填,导致系统里所有药品都变成“无批次”状态,这是项目后期最头疼的问题之一。
另一个容易看走眼的位置是销售明细表。只要你想通过 GSP 检查,销售明细就得冗余保存BatchNo和ExpireDate,不能只存商品编号。原因很直接:销售单打印完了,库存批号可能因为后续退货、报损、盘点调整发生变化,如果不做快照,三个月后再问“这批阿莫西林卖给谁了”,答案已经在数据库里拼不回来。拿到源码包先查这两张表,基本能判断这个项目的底子靠不靠谱。
2.2 为什么 WinForms + SQL Server/SQLite 是这类源码包的常见落地组合
医药销售管理系统在真实门店里的部署形态,和互联网产品完全不同。药店的收银台后面没有专职运维,网络环境就是一台普通交换机连着两台收银电脑,ERP 系统挂在内网。这种场景下,WinForms 客户端的优势非常具体:双击 exe 就能用,不需要浏览器兼容调试,扫码枪、小票打印机、钱箱这些外设通过串口或 USB 直接接入,C# 的串口操作和打印控制都成熟。这也是为什么市面上大量医药销售管理系统源码包仍然以 C# + WinForms 为主力,而不是纯 Web 前端。
数据库的选择则看包是给谁用的。完整交付到连锁药房的版本,几乎都是 SQL Server,因为门店多机并发写库存,只有 SQL Server 这种服务型数据库扛得住;但很多教学型的源码包为了降低部署门槛,会附带 SQLite 或 Access 版本。拿到压缩包后,我习惯先看根目录里带的是.bak、.mdf、.sql还是.db文件,这决定了第一步怎么走。SQL Server 版一般给的是.bak备份或整套.sql脚本,SQLite 版给的是单个.db文件,Access 版是.mdb/.accdb。
选择哪种组合,本质上是交付成本的权衡。对培训机构和接单开发来说,WinForms + SQLite 最省事,学员在自己电脑上不需要安装数据库服务就能跑起来;对真正要上线的药店来说,SQL Server 才是正路。我经手过一个门店项目,最初 demo 用的 SQLite,演示很顺利,到了两台收银机同时开单的阶段,database is locked的错误一天出现几十次,最后还是老老实实切回 SQL Server。所以评估源码包时,别只看界面截图,先确认数据库形态和并发写入的设计。
2.3 源码里最常见的三层结构:从解决方案目录认路
成熟一点的 C# 医药销售管理系统源码,解决方案一般长这样。这不是什么新架构,但胜在清晰,新手拿到包后按目录就能定位问题:
MedSales.sln ├─ MedSales.Model/ 实体类:Goods、Supplier、StockBatch、SalesOrder ├─ MedSales.DAL/ 数据访问层:ADO.NET、参数化 SQL、增删改查 ├─ MedSales.BLL/ 业务层:批次扣减、效期预警、登录校验 ├─ MedSales.UI/ WinForms 界面:LoginForm、MainForm、SalesForm、StockForm └─ Database/ ├─ MedSalesDB.sql 建库脚本 └─ init_data.sql 初始基础资料和演示数据Model 层不写业务逻辑,就是一堆属性,对应数据库字段;DAL 层负责把所有 SQL 集中管理;BLL 层调用 DAL,处理“先扣库存还是先写单据”这类规则;UI 层只负责显示和收集输入。好处是明显的:哪一层出了问题,改哪一层,不会牵连全局。我最开始接手这类包时也走过弯路,直接在窗体的按钮点击事件里写 SQL,后来加一个“销售单审核后不允许改单”的需求,翻了几十个窗体才改完,就是因为没有 BLL 隔离。
DAL 层如果做得规整,会先定义接口再写实现,类似下面这样:
public interface IGoodsDal { int Insert(Goods goods); int Update(Goods goods); int Delete(string goodsCode); Goods GetByCode(string goodsCode); DataTable Search(string keyword, bool onlyBatchManaged); }逻辑说明:IGoodsDal定义了药品档案的五个基础操作,Goods是 Model 层实体,DataTable作为查询返回值方便直接绑定 DataGridView。为什么要先定义接口?因为医药项目后期多半要换数据库,或者加缓存,接口存在,DAL 的替换不影响 BLL 和 UI。参数说明:keyword是模糊查询关键字,onlyBatchManaged控制是否只查批次管理商品,这两个参数在实际销售开单的药品选择框里会反复用到。
3. 把源码+数据库跑起来:从建库到登录的最小操作清单
3.1 第一步:还原数据库或执行 .sql 脚本
拿到源码包,先别急着打开 Visual Studio 编译,第一步是把数据库跑起来。数据库形态不同,做法完全不同。如果是.bak备份文件,用 SQL Server Management Studio 右键“数据库 → 还原数据库”,选择源设备指向备份文件即可;如果是.mdf文件,用“附加数据库”功能挂上去。但最常见的还是.sql脚本,因为脚本不依赖备份文件的版本,可读性也好。我一般直接在命令行里用 sqlcmd 执行,比在 SSMS 里一个个点更可控:
sqlcmd -S .\SQLEXPRESS -U sa -P 123456 -d master -i "D:\MedSales\Database\MedSalesDB.sql" sqlcmd -S .\SQLEXPRESS -U sa -P 123456 -d MedSalesDB -i "D:\MedSales\Database\init_data.sql"参数说明:-S指定 SQL Server 实例,.表示本机默认实例,.\SQLEXPRESS表示本机的 SQLEXPRESS 命名实例;-U和-P是 SQL Server 账号和密码,如果用的是 Windows 身份验证,去掉这两个参数换成-E;-d指定当前数据库,第一个脚本必须连 master,因为脚本里要用CREATE DATABASE建库,第二个脚本连刚建好的 MedSalesDB,执行初始数据。-i指定脚本文件路径,路径含空格必须用双引号包住。
这一步最常见的失败是脚本执行到一半报“对象名无效”。原因大多是建表脚本和插入数据的脚本顺序不对,明细表插数据时主表还没建。遇到这种报错,不要硬着头皮继续往下执行,先把脚本里的CREATE TABLE按依赖顺序挑出来逐个跑,再执行数据插入。还有一种翻车现场是.sql文件本身编码不对,中文字段名或中文数据在 SSMS 里显示成乱码,插入失败或者插进去的数据读出来是???。解决办法很简单:用带 UTF-8 编码格式另存脚本文件,不要用 ANSI 默认编码。
如果包给你的是 SQLite 的.db文件,那就更省事,不需要执行任何脚本,后面改一下连接字符串直接指向该文件即可。但要注意,SQLite 文件型数据库的并发写入能力有限,只适合单机演示或教学,不适合直接部署到多台收银机的门店。判断对了数据库形态,后面才不会白忙活。
3.2 第二步:改对连接字符串再启动
数据库就绪后,第二步是找到项目里的配置文件,把连接字符串指向你自己的服务器。C# WinForms 项目的配置通常在app.config或App.exe.config里,打开后找到<connectionStrings>节点。常见写法是把连接字符串单独抽出成一个键值,方便部署时只改这一个地方:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <connectionStrings> <add name="MedSales" connectionString="Data Source=.;Initial Catalog=MedSalesDB;User ID=sa;Password=123456;Connect Timeout=15;MultipleActiveResultSets=true" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>参数说明:Data Source是数据库服务器地址和实例名,.代表本机默认实例,如果数据库在另一台机器上,要写成192.168.1.10或192.168.1.10,1433;Initial Catalog是数据库名,要和实际建的库名完全一致;User ID和Password是 SQL Server 登录凭据;MultipleActiveResultSets=true建议保留,否则同一连接里同时跑多个 DataReader 时会报“已有打开的与此命令相关联的 DataReader”。如果你用的是 Windows 身份验证,把User ID和Password换成Integrated Security=SSPI即可。
Linux 或开发机上用 SQLite 版本的同学,连接字符串则是另一个形态:
<add name="MedSales" connectionString="Data Source=|DataDirectory|MedSales.db;Version=3;Foreign Keys=True;" providerName="System.Data.SQLite" />|DataDirectory|是特殊占位符,它指向程序运行目录下的数据文件夹,好处是发布后不需要把数据库路径写死。Foreign Keys=True必须显式开启,SQLite 默认不启用外键约束,不开的话子表数据随便写,销售明细里能出现不存在的批号,这是数据层一个小黑匣子。
改完连接字符串,我习惯先写一个最小测试:新建一个控制台项目,安装System.Data.SqlClient包,执行一条SELECT 1,能返回结果再启动 WinForms 界面。这样能把环境问题隔离开,避免一打开程序就报连接错误,分不清是数据库没建好还是代码有问题。
3.3 第三步:登录账号与初始数据核对
连接字符串改好,程序能启动,接下来是登录。源码包默认账号一般写在数据库脚本或操作手册里,最典型的是用户名admin、密码admin123或123456。先别急着拿它开始录单,我建议先查一下用户表,确认账号状态和角色权限:
SELECT UserCode, UserName, RoleId, IsDisabled, LastLoginTime FROM Sys_User;参数说明:UserCode是登录账号,RoleId对应角色权限表,IsDisabled为 1 表示账号被停用,LastLoginTime可以用来判断这个系统是不是真的被用过。执行结果往往会暴露问题:有些包把密码明文存在Password字段,有些用的是 MD5,还有些初始化数据里根本没有 admin 账号,实际登录账号是001。这都不要紧,关键是确认你拿到的账号在数据库里真实存在,而且没有被禁用。
登录之后的第一个动作,很多人会忽略:核对基础资料编码规则。药品档案的商品编码是定长还是不定长,单位是“盒”还是“最小销售单位”,供应商编号有没有统一前缀,这些在演示数据里看不出来,等录入几百条真实药品资料后,再改编码规则就晚了。我一般会在正式使用前把演示数据里的药品编码全部导出,看它的生成规则,然后在系统参数里把编码规则定下来,再让门店导实际的商品目录导入。这一步做扎实,后面销售开单、采购入库的模糊查询才不会乱。
顺便提醒一句,如果打算用SqlBulkCopy批量导入药品资料,要注意目标表结构变动带来的映射问题:SqlBulkCopy默认按列名匹配,只要源 DataTable 的列名和数据库表列名对不上,或者脚本里给表加过列,就会报“给定的列名与目标表的列不匹配”。所以导入前先查一次SELECT TOP 0 * FROM Goods,拿到真实的列集合,再决定 DataTable 里放哪几列。
4. 批次效期、GSP 追溯和库存扣减:这 3 块逻辑决定系统能不能上线
4.1 效期预警:不要用生产日期去算,药品过期是算“到期日期”
通用进销存软件对效期的处理普遍很潦草:药品档案里放一个“保质期”字段,按生产日期加天数推算到期日。这在医药行业行不通,因为药监和门店实际管理都以药盒上印着的“有效期至”或“到期日期”为准,厂家给的是具体日期,不是“生产日期+36个月”。药品入库时扫码枪扫出的批号和效期,也是以盒身印刷为准。所以源码里效期预警的正确写法,是直接对StockBatch.ExpireDate做区间判断,而不是靠生产日期推算。
3 个月内到期的批次必须单独拉出来看,这是药房效期管理的默认红线。SQL 可以这样写:
SELECT b.GoodsCode, g.GoodsName, b.BatchNo, CONVERT(varchar(10), b.ExpireDate, 23) AS ExpireDate, SUM(b.Qty) AS StockQty FROM StockBatch b JOIN Goods g ON g.GoodsCode = b.GoodsCode WHERE b.Qty > 0 AND b.ExpireDate >= GETDATE() AND b.ExpireDate <= DATEADD(MONTH, 3, GETDATE()) GROUP BY b.GoodsCode, g.GoodsName, b.BatchNo, CONVERT(varchar(10), b.ExpireDate, 23) ORDER BY b.ExpireDate;逻辑说明:这个查询查的是“还有库存、未过期、但在未来 3 个月内到期”的批次。SUM(b.Qty)用于处理同一商品同一批号分散在多行库存记录的情况,比如分两次入库、单价不同,库存行有两条,但批号相同,合并后才是该批号的真实库存。CONVERT(varchar(10), ..., 23)是把 datetime 转成yyyy-MM-dd格式,方便报表显示和 Excel 导出。参数说明:DATEADD(MONTH, 3, GETDATE())里的 3 是预警天数,放在真实系统里建议做成参数,有的药店想提前 6 个月看近效期,有的只看 1 个月,硬编码会害死后面接手的同事。
这里有个常见误用特别提一下:有些人把预警条件写成DATEADD(MONTH, -12, ProduceDate) <= GETDATE(),意思是用生产日期推到期日再和当前日期比。这有两个问题,一是把生产日期当成效期推算的起点,忽略了厂家印刷的差异;二是一旦药品入库时没录ProduceDate,这个条件的计算结果直接是 NULL,该预警的批次一个都出不来。在医药销售管理系统里,ExpireDate应该作为必填字段,录入时缺它就拒绝入库,而不是在查询时想办法兜底。
4.2 批次库存扣减:先入先出与销售明细的关系
效期预警只是看的层面,真正要命的是销售开单时的批次扣减。药店卖出一盒药,系统必须知道扣的是哪个批号。行业默认的扣减策略是先入先出,也就是按到期日期从早到晚扣,最早到期的批次优先出库。这样能最大限度降低过期损耗。扣减逻辑写不好,常见的后果是:库存总数是对的,但批号台账对不上,某个批号库存变成负数,另一批货却一直压在库里直到过期。
先入先出扣减的核心代码,典型实现是先把批次按到期日期排序取出来,再逐批扣减。伪代码骨架如下:
public bool DeductStock(string goodsCode, decimal needQty, SqlConnection conn, SqlTransaction tx) { var selectSql = @" SELECT BatchId, BatchNo, ExpireDate, Qty FROM StockBatch WHERE GoodsCode = @GoodsCode AND Qty > 0 ORDER BY ExpireDate ASC;"; using var cmd = new SqlCommand(selectSql, conn, tx); cmd.Parameters.AddWithValue("@GoodsCode", goodsCode); using var reader = cmd.ExecuteReader(); var remain = needQty; var dedEntries = new List<(int batchId, decimal qty)>(); while (remain > 0 && reader.Read()) { var batchQty = Convert.ToDecimal(reader["Qty"]); var take = Math.Min(batchQty, remain); dedEntries.Add((Convert.ToInt32(reader["BatchId"]), take)); remain -= take; } reader.Close(); if (remain > 0) return false; // 库存不足 foreach (var entry in dedEntries) { var updateSql = @" UPDATE StockBatch SET Qty = Qty - @Qty WHERE BatchId = @BatchId AND Qty >= @Qty;"; using var updCmd = new SqlCommand(updateSql, conn, tx); updCmd.Parameters.AddWithValue("@Qty", entry.qty); updCmd.Parameters.AddWithValue("@BatchId", entry.batchId); if (updCmd.ExecuteNonQuery() == 0) throw new Exception($"批次 {entry.batchId} 扣减失败,库存已被其他窗口占用"); } return true; }逻辑说明:第一步先把对应商品的批次按ExpireDate升序读出来,计算每个批次要扣多少,第二步再对每个批次执行条件更新。关键的细节在第二步的WHERE BatchId = @BatchId AND Qty >= @Qty:这个条件保证扣减是原子操作,当两个收银员同时卖同一盒药时,后提交的更新会因为批次库存不足而影响行数为 0,系统能立即感知并发冲突,而不是闷头把库存改成负数。
参数说明:needQty是销售数量,dedEntries列表保存每个批次应扣数量。还有个边界情况别忽略:如果一个批次只差 1 盒就够卖,但它在UPDATE时已经被别的窗口扣光了,这个更新会失败并抛异常,整个事务回滚,销售单不会保存。这个设计是刻意为之,宁可让开单失败,也不能让批号和库存对不上。销售明细写入时,务必将BatchNo和ExpireDate一并写入,只存商品编号的销售明细在追溯检查时就是废纸。
4.3 销售主表和明细表的事务:一张销售单涉及的三条 SQL
销售单过账是医药零售系统里事务使用最集中的地方:主表插一条、明细表插若干条、库存批次表更新若干条。这三类操作必须在一个数据库事务里完成,否则程序跑到一半断电或抛异常,就会出现“库存扣了但单子没生成”或者“单子生成了但库存没扣”的中间状态。C# 里用SqlTransaction包住即可,注意连接和事务的作用域贯穿整个方法。
完整的最小事务写法大致如下:
public void SaveSaleOrder(SaleOrder order) { using var conn = new SqlConnection(_connStr); conn.Open(); using var tx = conn.BeginTransaction(); try { // 1. 写销售主表,拿回自增主键 var mainSql = @" INSERT INTO SalesMain(OrderNo, CustomerCode, SaleDate, TotalQty, TotalAmt, OperatorCode) VALUES(@OrderNo, @CustomerCode, @SaleDate, @TotalQty, @TotalAmt, @OperatorCode); SELECT SCOPE_IDENTITY();"; using var mainCmd = new SqlCommand(mainSql, conn, tx); mainCmd.Parameters.AddWithValue("@OrderNo", order.OrderNo); mainCmd.Parameters.AddWithValue("@CustomerCode", order.CustomerCode); mainCmd.Parameters.AddWithValue("@SaleDate", order.SaleDate.Date); mainCmd.Parameters.AddWithValue("@TotalQty", order.TotalQty); mainCmd.Parameters.AddWithValue("@TotalAmt", order.TotalAmt); mainCmd.Parameters.AddWithValue("@OperatorCode", order.OperatorCode); var saleId = Convert.ToInt32(mainCmd.ExecuteScalar()); // 2. 逐条写销售明细,包含批号和效期快照 foreach (var detail in order.Details) { var detailSql = @" INSERT INTO SalesDetail(SaleId, GoodsCode, BatchNo, ExpireDate, Qty, SalePrice) VALUES(@SaleId, @GoodsCode, @BatchNo, @ExpireDate, @Qty, @SalePrice);"; using var detailCmd = new SqlCommand(detailSql, conn, tx); detailCmd.Parameters.AddWithValue("@SaleId", saleId); detailCmd.Parameters.AddWithValue("@GoodsCode", detail.GoodsCode); detailCmd.Parameters.AddWithValue("@BatchNo", detail.BatchNo); detailCmd.Parameters.AddWithValue("@ExpireDate", detail.ExpireDate); detailCmd.Parameters.AddWithValue("@Qty", detail.Qty); detailCmd.Parameters.AddWithValue("@SalePrice", detail.SalePrice); detailCmd.ExecuteNonQuery(); } // 3. 扣减批次库存(带上文 4.2 的 DeductStock) foreach (var detail in order.Details) { if (!DeductStock(detail.GoodsCode, detail.Qty, conn, tx)) throw new Exception($"商品 {detail.GoodsCode} 库存不足"); } tx.Commit(); } catch { tx.Rollback(); throw; // 界面层提示“单据保存失败,已回滚” } }逻辑说明:第一条 SQL 负责插入主表并用SCOPE_IDENTITY()拿自增主键,这个函数只返回当前会话、当前作用域最后插入的 ID,不会因为别的窗口同时开单而拿错值。第二条 SQL 逐条插入明细,这里的ExpireDate是从界面选择的批次带下来的快照,不是查询库存时临时取的。第三条 SQL 调用前面写的批次扣减方法,扣减失败就抛异常,触发 Rollback。三步必须按这个顺序执行,先写主表拿 ID,再写明细,最后扣库存。
参数说明里有个容易被忽略的细节:@SaleDate传的是order.SaleDate.Date,只取日期部分,去掉时间。销售日期如果带时分秒,做日结报表按天分组时会把同一张单算到错误的日期区间里。另外,库存扣减不能放在明细插入之前,否则遇到明细写一半失败,回滚后库存已经被扣过,虽然最终会回滚,但会让调试时更难定位问题。事务里代码顺序要保证可读性和可追踪性,这也是 C# 项目组里 Code Review 时会重点盯的位置。
5. 避坑:把医药销售管理系统源码真正用起来的 5 个高频问题
5.1 数据库附加失败,或者 .sql 脚本执行报错
现象:用 SSMS 附加.mdf文件时,提示“数据库版本高于当前实例版本”或一串数字编号错误;执行.sql脚本时,跑了一堆建表语句后突然报“对象名无效”。
原因:.mdf/.bak文件来自高版本 SQL Server,低版本实例无法附加;.sql脚本里建表和插数据混在一个文件里,执行顺序没考虑外键依赖,或者脚本编码不是 UTF-8,导致中文数据插入失败。
解决:.bak要先还原到高版本 SQL Server 实例,然后执行ALTER DATABASE MedSalesDB SET COMPATIBILITY_LEVEL = 130;降低兼容级别,再附加或脱离备份给低版本用;.sql先按依赖顺序手动执行建表语句,再执行数据插入。脚本文件用记事本另存为 UTF-8 编码再执行,避开乱码问题。这里提醒一下,数据库脚本是给机器执行的,不要用鼠标在 SSMS 里拖拽修改表结构,想加字段用ALTER TABLE显式写清楚,改动可回溯。
5.2 药品资料没开批次管理,库存越卖越乱
现象:同一种药明明进了三批不同效期的货,库存查询里只有一行总数,看不到每个批次的效期;销售开单时也无法选择批号,系统随机扣减,导致后期近效期药品大批积压。
原因:药品档案表Goods.IsBatchManaged字段默认值为 0,导入基础资料时没有按药品类型回填,系统把批次药当普通商品走了无批次库存逻辑。
解决:建表脚本里把这个字段默认值改成 1,或者执行ALTER TABLE Goods ADD CONSTRAINT DF_Goods_IsBatchManaged DEFAULT (1) FOR IsBatchManaged;,已有数据全部按批次库存重算期初。上线前安排一次盘点,按“商品+批号+效期”维度重新建库存,不要直接沿用旧系统带过来的总数。
5.3 日期查询“玄学”:按销售日期查不到单据
现象:销售单明明在库里,界面按日期范围查询就是查不出来;换成另一个日期格式又能查到,结果时好时坏,像玄学一样。
原因:表里的销售日期列是varchar或nvarchar,存的是2026/4/1 8:30这种字符串;C# 端参数化查询传的是DateTime类型,SQL Server 在把字符串转日期时受SET LANGUAGE、区域设置影响,某些格式解析失败返回 NULL,比较结果为空。
解决:把日期列改成datetime2,这属于“数据库修改结构”的典型场景:ALTER TABLE SalesMain ALTER COLUMN SaleDate datetime2 NOT NULL;改完后检查相关索引。查询条件写成SaleDate >= @Begin AND SaleDate < DATEADD(DAY, 1, @End),这个写法能覆盖一整天,又不会在日期列上套函数导致索引失效。代码里统一用SqlParameter传DbType.DateTime,不拼接字符串。吃过一次亏之后,我接手任何 C# 项目都会先检查日期列类型,看到varchar存日期直接列为整改项。
5.4 GSP 检查时,追溯链条打不出来
现象:药监检查要抽查某批号的“来龙去脉”,系统里能查到库存总量,却回答不了“这批药从哪家供应商进的、卖给哪些顾客、什么时候卖的”。
原因:销售明细表只存了GoodsCode,没存BatchNo和ExpireDate快照;采购入库和销售出库之间没有通过StockBatch建立关联,上下游数据各自独立,追溯时无法 join。
解决:销售明细补冗余字段BatchNo、ExpireDate、SupplierCode,追溯查询以StockBatch为枢纽关联采购和销售。典型查询长这样:
SELECT p.BatchNo, p.ExpireDate, s.OrderNo, s.SaleDate, s.CustomerCode FROM SalesMain s JOIN SalesDetail d ON d.SaleId = s.SaleId JOIN StockBatch b ON b.GoodsCode = d.GoodsCode AND b.BatchNo = d.BatchNo JOIN PurchaseDetail p ON p.GoodsCode = b.GoodsCode AND p.BatchNo = b.BatchNo WHERE d.BatchNo = @BatchNo;这段 SQL 的前提是采购明细和销售明细都存了批号。如果发现旧数据里没有批号,那就只能靠手工补录,所以系统上线时要强制销售开单必须选批次,不要图省事默认批次。
5.5 把 C# 当 Access 用:并发写库和文件型数据库的坑
现象:两台收银机同时开单,SQLite 日志里不断出现database is locked,界面卡到失去响应;MySQL 或者 SQLite 数据目录里多出临时 journal 文件,异常退出后库存对不上。这些问题本质上是把文件型数据库用在多机并发场景。
原因:SQLite 和 Access 是文件型数据库,整库只有一个写锁,收银高峰期两个窗口同时写库存,后发起的写操作会被锁阻塞。更隐蔽的是代码里“先查库存再改库存”的分离操作:两个线程都读到了同一批库存 10 盒,各自扣 5 盒,最后写回的结果还是 5 盒而不是 5 盒。
解决:门店多机并发必须上 SQL Server,连接字符串从Data Source=本地文件改成Data Source=服务器地址;暂时换不了库的话,把扣库存改成一条带条件的 UPDATESET Qty = Qty - @Qty WHERE BatchId = @BatchId AND Qty >= @Qty,影响行数为 0 就重试取批。这一点写进代码注释里,防止后来的人“优化”回去。另外,很多源码包附带总部-门店数据同步模块,这类模块适合同步基础资料,不要拿它实时同步库存表,否则晚上同步任务会把白天的销售批次覆盖掉,这是血泪经验。
6. 再往深走一步:批次台账视图与自动化效期预警
6.1 批次效期台账:把供应商、库存、销售拉成一张可追溯视图
能跑通的系统离“能验收”还有一步:GSP 检查要的是随时能拉出某一批号的完整链路。与其每次现写 join 查询,不如建一张批次台账视图,把采购、库存、销售拧成一条流水线:
CREATE VIEW v_BatchLedger AS SELECT b.GoodsCode, g.GoodsName, b.BatchNo, b.ExpireDate, p.OrderNo AS PurchaseOrderNo, p.SupplierCode, s.OrderNo AS SaleOrderNo, s.SaleDate, s.CustomerCode FROM StockBatch b LEFT JOIN PurchaseDetail p ON p.BatchNo = b.BatchNo AND p.GoodsCode = b.GoodsCode LEFT JOIN SalesDetail d ON d.BatchNo = b.BatchNo AND d.GoodsCode = b.GoodsCode LEFT JOIN SalesMain s ON s.SaleId = d.SaleId LEFT JOIN Goods g ON g.GoodsCode = b.GoodsCode;查询时按BatchNo过滤即可。注意一个批号采购一次但可能销售多次,这个视图会返回多行,导出 Excel 时别按行数理解“库存数量”,批次库存总数仍然要以StockBatch.Qty为准。平时抽检哪个批号,直接查这张视图,把结果截图或打印,比临时拼 SQL 快得多。
6.2 效期预警不用天天打开系统:sqlcmd + 计划任务
效期预警做成报表是及格,做成自动化任务才算省心。把近效期查询封装成存储过程,然后用 Windows 计划任务调用 sqlcmd 导出结果:
sqlcmd -S .\SQLEXPRESS -U sa -P 123456 -d MedSalesDB -Q "EXEC sp_NearExpireAlert;" -o "D:\MedSales\reports\expire_%date:~0,10%.csv" -s ","-Q直接执行存储过程,-o把结果输出到文件,%date:~0,10%取当天日期做文件名,避免覆盖前一天的报表,-s ","指定 CSV 列分隔符。每周一早上班前跑一次,近效期药品清单自动生成在共享目录里,门店不用每天手动开系统查。这个思路也能延伸到日结报表、GSP 温湿度记录归档。
我做完这套之后最大的感触是,医药销售管理系统难的不是 C# 语法,而是把医药行业的规则翻译成数据约束。第一次交付时我手工把一批近效期药品录进系统,才发现销售单没记录效期,返工改表到半夜,后来凡经手这类项目,第一件事就是拿着 GSP 检查清单去核对每一张报表能不能顺着批号走通。先把台账、批次、效期这些地基打牢,再去做数据可视化和移动端选型才稳。希望这些经验能帮你在自己的项目上少走几步弯路。
本文还有配套的精品资源,点击获取