ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#医药销售管理系统实战:从数据库设计到批次效期与GSP追溯

C#医药销售管理系统实战:从数据库设计到批次效期与GSP追溯 简介这套基于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_ProductGoodsCode、GoodsName、Spec、DosageForm、Manufacturer、ApprovalNo、IsRx、IsBatchManaged全系统基础资料处方药与非处方药要靠它区分供应商Supplier / Med_SupplierSupplierCode、SupplierName、QualificationNo、GSPStatus首营企业档案进货来源的追溯入口库存批次StockBatch / Inv_StockBatchGoodsCode、BatchNo、ProduceDate、ExpireDate、Qty批次效期的核心表所有库存变动都围绕它采购入库PurchaseMain / PurchaseDetailOrderNo、SupplierCode、GoodsCode、BatchNo、ExpireDate、Qty、PurchasePrice记录每个批次的来源是追溯链的上游销售订单SalesMain / SalesDetailOrderNo、CustomerCode、GoodsCode、BatchNo、Qty、SalePrice记录销售去向明细里必须留批号快照GSP 记录GspRecordRecordDate、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 version1.0 encodingutf-8 ? configuration connectionStrings add nameMedSales connectionStringData Source.;Initial CatalogMedSalesDB;User IDsa;Password123456;Connect Timeout15;MultipleActiveResultSetstrue providerNameSystem.Data.SqlClient / /connectionStrings /configuration参数说明Data Source是数据库服务器地址和实例名.代表本机默认实例如果数据库在另一台机器上要写成192.168.1.10或192.168.1.10,1433Initial Catalog是数据库名要和实际建的库名完全一致User ID和Password是 SQL Server 登录凭据MultipleActiveResultSetstrue建议保留否则同一连接里同时跑多个 DataReader 时会报“已有打开的与此命令相关联的 DataReader”。如果你用的是 Windows 身份验证把User ID和Password换成Integrated SecuritySSPI即可。Linux 或开发机上用 SQLite 版本的同学连接字符串则是另一个形态add nameMedSales connectionStringData Source|DataDirectory|MedSales.db;Version3;Foreign KeysTrue; providerNameSystem.Data.SQLite /|DataDirectory|是特殊占位符它指向程序运行目录下的数据文件夹好处是发布后不需要把数据库路径写死。Foreign KeysTrue必须显式开启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 检查清单去核对每一张报表能不能顺着批号走通。先把台账、批次、效期这些地基打牢再去做数据可视化和移动端选型才稳。希望这些经验能帮你在自己的项目上少走几步弯路。本文还有配套的精品资源点击获取
返回列表