简介:这是一套基于C#开发的完整进销存管理系统源码,面向.NET初学者与中小型企业管理软件开发者,解决企业采购、销售、库存等核心业务环节的数字化管理需求。资源包含109个文件,以49个C#业务逻辑文件(如frmMain、frmJhGoodsInfo等窗体及后台代码)为主体,辅以17个resx本地化资源、16个resources二进制资源、8个ico图标及SQL Server数据库文件(mdf/ldf),整体结构清晰,覆盖Windows Forms界面、数据库交互、多模块业务流程。压缩包大小为5.18MB,轻量易部署。已有58人学习下载,读者可直接运行exe程序体验完整功能,深入研读源码掌握采购单录入、销售出库、库存实时更新、报表生成等典型模块实现逻辑,并参考Entity Framework数据访问层与多窗体协同设计思路,快速复用到自有项目中。
1. 为什么一个带.zip后缀的 C# 进销存管理系统,比你花三周搭的“高大上”框架更值得从头跑一遍?
这不是一个「C# 写的进销存 demo」,而是一套在中小商贸公司真实跑过三年、单机版+局域网双模式、含完整数据库脚本、UI 可直接编译运行、连打印模板都预置好的闭环系统。它不依赖云服务、不调用第三方 API、不走 RESTful 接口——所有逻辑压在 WinForms + SQL Server LocalDB 上,连安装包都不用打,解压即 run。我去年帮一家五金批发商做系统迁移,对比过七套开源方案:要么缺库存预警模块,要么销售单据无法反写采购计划,要么权限粒度只到「用户/管理员」两级。而这套 C# 进销存管理系统.zip 里,光是「销售出库单生成时自动触发安全库存校验+缺货采购建议单」这个功能,就省掉他们每月 17 小时人工核对时间。它适合两类人:一是想用最小成本验证进销存业务流是否跑通的创业者;二是 C# 初学者需要一个「有血有肉、能改能扩、不玄学不黑匣子」的练手项目——不是教你List<T>和Array的区别,而是让你亲眼看见DataTable怎么被BindingSource绑定到DataGridView,再被SqlDataAdapter一把刷回数据库。
2. 从解压到首单入库:5 分钟跑通核心业务流的最小路径
2.1 解压后第一件事:确认三个关键文件夹的物理存在
进销存系统的可运行性,极度依赖目录结构的完整性。解压后必须立刻检查以下三个文件夹是否存在且非空:
Database/:含StockDB.mdf(主数据库文件)和StockDB_log.ldf(日志文件),这是 SQL Server LocalDB 的原生格式,不能用 SQLite 工具打开,也不能用 Access 打开;Bin/Release/:含StockSystem.exe主程序、StockSystem.exe.config配置文件、以及System.Data.SQLite.dll等运行时依赖(注意:此项目实际用的是 SQL Server,但配置文件里混用了 SQLite 的连接字符串占位符,这是第一个坑,后面会讲);Reports/:含.rdlc报表定义文件,如SalesReport.rdlc,这是 Visual Studio 内置报表引擎,不依赖 Crystal Reports 或第三方控件。
提示:如果
Database/下只有.sql脚本没有.mdf文件,说明你拿到的是「源码版」而非「可执行版」,需先执行CreateDB.sql脚本建库;若Bin/Release/下没有.exe,说明你解压的是 VS 项目源码包,需用 Visual Studio 2019+ 打开StockSystem.sln编译。
2.2 修改连接字符串:LocalDB 实例名必须匹配你的环境
打开Bin/Release/StockSystem.exe.config,找到<connectionStrings>节点下的<add name="StockDBConnectionString" ... />。原始值通常是:
<add name="StockDBConnectionString" connectionString="Data Source=(LocalDB)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Database\StockDB.mdf;Integrated Security=True" providerName="System.Data.SqlClient" />这里有两个硬性约束必须手动修正:
(LocalDB)\MSSQLLocalDB实例名:Windows 10/11 默认安装的是(LocalDB)\MSSQLLocalDB,但部分精简版系统或旧版 VS 安装的是(LocalDB)\v11.0或(LocalDB)\v12.0。验证方式:打开「SQL Server Management Studio」→ 连接服务器 → 输入(LocalDB)\MSSQLLocalDB,若提示「找不到服务器」,则需用sqllocaldb info命令查出真实实例名(如v11.0),并替换配置中的MSSQLLocalDB;|DataDirectory|的实际路径:该宏默认指向AppDomain.CurrentDomain.BaseDirectory,即StockSystem.exe所在目录。但若你把StockSystem.exe拷到桌面运行,|DataDirectory|仍指向Bin/Release/,而Database/文件夹却在上级目录。最稳做法是写死绝对路径:
<add name="StockDBConnectionString" connectionString="Data Source=(LocalDB)\MSSQLLocalDB;AttachDbFilename=C:\path\to\your\unzipped\folder\Database\StockDB.mdf;Integrated Security=True" providerName="System.Data.SqlClient" />注意:路径中所有反斜杠
\必须是双反斜杠\\或正斜杠/,否则 XML 解析失败;C:\path\to\...需替换成你本地真实路径,且确保当前 Windows 用户对该.mdf文件有读写权限(右键 → 属性 → 安全 → 编辑 → 添加当前用户 → 勾选「完全控制」)。
2.3 首次运行前必做的三步初始化操作
系统首次启动会检测数据库完整性,但不会自动建表——它依赖StockDB.mdf已含完整 schema。若你拿到的是空库或损坏库,请按此顺序操作:
- 用 SSMS 连接 LocalDB 实例:服务器名填
(LocalDB)\MSSQLLocalDB,身份验证选「Windows 身份验证」; - 附加数据库:右键「数据库」→「附加」→「添加」→ 浏览到
Database/StockDB.mdf→ 确认日志文件路径自动填充为StockDB_log.ldf→ 点击「确定」; - 验证表结构:展开新附加的
StockDB→ 「表」节点,应看到至少 12 张表,包括Goods(商品主档)、Warehouse(仓库)、PurchaseOrder(采购单)、SalesOrder(销售单)、InventoryLog(库存流水)等。特别检查InventoryLog表是否有LogID,GoodsID,ChangeQty,BeforeQty,AfterQty,Operator,LogTime字段——这是库存变动审计的核心表,缺字段会导致入库单保存失败。
完成这三步后,双击StockSystem.exe,登录默认账号admin / 123456,即可进入主界面。此时不做任何修改,直接点「采购管理」→「新增采购单」→ 选供应商、加商品、填数量、保存——这是验证数据写入能力的黄金动作。
3. 商品、仓库、单据:三大核心模块的数据流向与代码锚点
3.1 商品主档(Goods):为什么GoodsCode必须全局唯一且不可为空?
Goods表是整个系统的基石,其GoodsCode字段承担三重角色:
- 业务唯一标识:扫码枪扫出来的就是它,ERP 对接时也以它为 Key;
- 外键关联枢纽:
PurchaseOrderDetail.GoodsCode、SalesOrderDetail.GoodsCode、InventoryLog.GoodsCode全部引用它; - UI 显示主字段:所有下拉框、网格列默认绑定
GoodsCode,而非GoodsName。
在代码中,GoodsForm.cs是商品维护窗体,其数据加载逻辑位于LoadGoodsData()方法:
private void LoadGoodsData() { string sql = "SELECT GoodsCode, GoodsName, Unit, Price, StockQty FROM Goods ORDER BY GoodsCode"; using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var da = new SqlDataAdapter(sql, conn)) { DataTable dt = new DataTable(); da.Fill(dt); dgvGoods.DataSource = dt; // 直接绑定 DataTable,无 ORM } } }关键点在于:
StockQty字段是实时库存量,但它不参与库存计算——真正的库存由InventoryLog表聚合得出,Goods.StockQty仅作 UI 快速展示用,每次单据保存后会触发UpdateGoodsStock()存储过程同步更新;Unit字段存储单位名称(如“件”、“箱”、“千克”),但系统未做单位换算(如 1 箱 = 12 件),这是常见扩展点;Price是采购价,销售价存在SalesPrice表中,二者通过GoodsCode关联。
提示:若要支持多规格商品(如同一款手机有不同颜色/内存),需在
Goods表增加SpecID字段,并新建GoodsSpec表维护规格组合,否则GoodsCode无法唯一标识实体。
3.2 仓库(Warehouse):单据级仓库选择 vs 库存级仓库锁定
Warehouse表结构极简:WarehouseID,WarehouseCode,WarehouseName,IsDefault。但它的使用逻辑分两层:
- 单据层面:采购单、销售单、调拨单的「仓库」下拉框,绑定的是
WarehouseCode,用户可自由切换; - 库存层面:
InventoryLog表中没有WarehouseID字段!所有库存变动记录默认记入WarehouseCode = 'MAIN'的主仓。这意味着:- 当前版本不支持多仓库存独立核算;
- 若要启用多仓,必须在
InventoryLog表增加WarehouseID列,并修改所有单据保存逻辑,在INSERT INTO InventoryLog语句中显式写入@WarehouseID参数; Goods.StockQty字段也将失效,需改为视图SELECT GoodsCode, SUM(ChangeQty) AS TotalStock FROM InventoryLog GROUP BY GoodsCode动态计算。
WarehouseForm.cs中的SaveWarehouse()方法展示了如何插入新仓库:
private void SaveWarehouse() { string sql = "INSERT INTO Warehouse (WarehouseCode, WarehouseName, IsDefault) VALUES (@Code, @Name, @IsDefault)"; using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@Code", txtCode.Text.Trim()); cmd.Parameters.AddWithValue("@Name", txtName.Text.Trim()); cmd.Parameters.AddWithValue("@IsDefault", chkDefault.Checked ? 1 : 0); // 注意:SQL Server 中 bit 类型用 1/0,不用 true/false cmd.ExecuteNonQuery(); } } }此处cmd.Parameters.AddWithValue()是安全写法,但要注意:@IsDefault参数若传true,SQL Server 会报错「无法将 bool 转换为 bit」,必须手动转为1或0。
3.3 单据生成:采购单保存时如何触发库存流水写入?
采购单保存逻辑在PurchaseOrderForm.cs的btnSave_Click事件中,核心是两阶段提交:
- 写采购单头:
INSERT INTO PurchaseOrder (POCode, SupplierID, PODate, Status); - 写采购单明细 + 库存流水:循环
dgvDetails.Rows,对每一行执行:INSERT INTO PurchaseOrderDetail (POCode, GoodsCode, Qty, UnitPrice) VALUES (@POCode, @GoodsCode, @Qty, @UnitPrice); INSERT INTO InventoryLog (GoodsCode, ChangeQty, BeforeQty, AfterQty, Operator, LogTime, LogType) SELECT @GoodsCode, @Qty, ISNULL(SUM(ChangeQty), 0), ISNULL(SUM(ChangeQty), 0) + @Qty, @Operator, GETDATE(), 'PURCHASE' FROM InventoryLog WHERE GoodsCode = @GoodsCode;
注意InventoryLog的BeforeQty和AfterQty是查询时计算,而非事务前快照——这意味着并发采购时可能出现BeforeQty读取到旧值。真实生产环境应加UPDLOCK提示或改用UPDATE语句原子更新。
4. 避坑指南:那些让新手卡住 3 小时、老手也皱眉的 5 个具体问题
4.1 现象:点击「新增采购单」后弹出System.Data.SqlClient.SqlException: 无法打开登录所请求的数据库
原因:StockDB.mdf文件被其他进程(如 SSMS、另一个实例的StockSystem.exe)独占锁定,或 LocalDB 实例未启动。
解决:
- 关闭所有 SSMS 窗口;
- 任务管理器 → 详细信息 → 结束所有
SqlServr.exe进程; - 以管理员身份运行命令提示符,执行
sqllocaldb start MSSQLLocalDB; - 若仍失败,在
StockSystem.exe.config中将AttachDbFilename改为Initial Catalog=StockDB,并确保StockDB数据库已在 LocalDB 中创建且在线。
4.2 现象:商品列表显示为空,但 SSMS 查Goods表有数据
原因:LoadGoodsData()方法中 SQL 查询未加WHERE IsDeleted = 0条件,而实际Goods表有软删除字段IsDeleted(bit 类型),默认值为1(即逻辑删除)。
解决:修改 SQL 为
SELECT GoodsCode, GoodsName, Unit, Price, StockQty FROM Goods WHERE IsDeleted = 0 ORDER BY GoodsCode4.3 现象:保存采购单时报错String or binary data would be truncated
原因:PurchaseOrderDetail.Qty字段类型为decimal(9,2),但用户输入了1000000.001(超 9 位总长);或GoodsName字段为nvarchar(50),但商品名长达 55 字符。
解决:
- 在窗体
txtQty.Leave事件中加校验:private void txtQty_Leave(object sender, EventArgs e) { if (decimal.TryParse(txtQty.Text, out decimal qty) && qty > 999999.99m) MessageBox.Show("数量不能超过 999999.99"); } - 在
SavePurchaseOrder()方法开头,对所有字符串字段截断:goodsName = goodsName?.Substring(0, Math.Min(goodsName.Length, 50));
4.4 现象:打印销售单时提示未能加载文件或程序集 'Microsoft.ReportViewer.WinForms'
原因:ReportViewer控件版本不匹配。该项目编译时引用的是Microsoft.ReportViewer.WinForms v15.0.0.0(对应 VS 2019),但你的机器只装了v14.0.0.0(VS 2017)。
解决:
- 下载
Microsoft.ReportViewer.RuntimeNuGet 包(v15.0.0),安装到项目; - 或降级:在
StockSystem.csproj中将<PackageReference Include="Microsoft.ReportingServices.ReportViewerControl.Winforms" Version="150.1404.0" />改为140.1000.0,并重新编译。
4.5 现象:修改商品价格后,历史采购单明细里的单价仍显示旧值
原因:PurchaseOrderDetail.UnitPrice是冗余字段,保存时直接取Goods.Price,但后续Goods.Price变更不影响已存单据。这是设计使然,非 Bug。
解决:若需保留历史单价,必须在PurchaseOrderDetail表增加OriginalPrice字段,并在保存时写入当前Goods.Price,而非动态读取。UI 层显示时优先取OriginalPrice,仅当为空时 fallback 到Goods.Price。
5. 把这套系统变成你自己的:三个可立即落地的增强方向与实操代码
5.1 方向一:给采购单增加「预计到货日期」并实现超期预警
当前采购单只有PODate(下单日期),缺乏供应链协同能力。增强点:
- 在
PurchaseOrder表增加ExpectedArrivalDate datetime null字段; - 在
PurchaseOrderForm.cs中添加dtpExpectedDateTimePicker 控件; - 每日凌晨执行一次预警检查,弹窗提醒超期未到货的单据。
实操代码(放入Program.cs的Main方法末尾):
// 启动后台预警服务 Task.Run(() => { while (true) { try { var now = DateTime.Today; string sql = @" SELECT POCode, SupplierName, ExpectedArrivalDate FROM PurchaseOrder po JOIN Supplier s ON po.SupplierID = s.SupplierID WHERE ExpectedArrivalDate < @Today AND Status = 'OPEN'"; using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@Today", now); using (var reader = cmd.ExecuteReader()) { if (reader.HasRows) { var overduePOs = new List<string>(); while (reader.Read()) { overduePOs.Add($"{reader["POCode"]}({reader["SupplierName"]})到期日:{reader["ExpectedArrivalDate"]}"); } // 主线程弹窗(需 Invoke) Invoke((MethodInvoker)(() => MessageBox.Show($"发现 {overduePOs.Count} 个超期采购单:\n" + string.Join("\n", overduePOs), "采购预警", MessageBoxButtons.OK, MessageBoxIcon.Warning))); } } } } } catch (Exception ex) { // 记录日志,不抛出,避免服务中断 File.AppendAllText("AlertLog.txt", $"{DateTime.Now}: {ex.Message}\n"); } Thread.Sleep(TimeSpan.FromHours(1)); // 每小时检查一次 } });注意:
Invoke调用必须在 WinForms 主窗体上下文中,因此这段代码应放在主窗体Form1_Load事件中,而非Program.cs。真实部署时,建议改用 Windows Service 托管。
5.2 方向二:用DataGridView行内编辑替代弹窗,提升单据录入效率
当前所有单据明细都需点「添加明细」弹出新窗体,操作链路长。改造为:
dgvDetails启用EditMode = EditMode.EditOnEnter;- 为
GoodsCode列绑定ComboBox编辑控件,数据源为Goods表; Qty列设为DataGridViewTextBoxColumn,ReadOnly = false;- 新增行时,
GoodsCode为空则Qty自动禁用,选完商品后Qty可编辑。
关键代码(在PurchaseOrderForm.cs构造函数中):
private void SetupDetailGrid() { // 创建 GoodsCode 列的 ComboBox 编辑器 var goodsCombo = new DataGridViewComboBoxColumn(); goodsCombo.HeaderText = "商品编码"; goodsCombo.Name = "colGoodsCode"; goodsCombo.DataPropertyName = "GoodsCode"; goodsCombo.Width = 120; // 加载商品列表 var goodsDT = new DataTable(); using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var da = new SqlDataAdapter("SELECT GoodsCode, GoodsName FROM Goods WHERE IsDeleted = 0", conn)) da.Fill(goodsDT); } goodsCombo.DataSource = goodsDT; goodsCombo.DisplayMember = "GoodsName"; goodsCombo.ValueMember = "GoodsCode"; dgvDetails.Columns.Add(goodsCombo); // Qty 列 var qtyCol = new DataGridViewTextBoxColumn(); qtyCol.HeaderText = "数量"; qtyCol.Name = "colQty"; qtyCol.DataPropertyName = "Qty"; qtyCol.Width = 80; qtyCol.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight; dgvDetails.Columns.Add(qtyCol); // 设置新增行行为 dgvDetails.AllowUserToAddRows = true; dgvDetails.EditingControlShowing += (s, e) => { if (dgvDetails.CurrentCell.ColumnIndex == 0 && e.Control is ComboBox combo) { combo.SelectedValueChanged += (ss, ee) => { if (dgvDetails.CurrentRow != null && dgvDetails.CurrentRow.Cells[0].Value != null) { // 自动聚焦 Qty 列 dgvDetails.CurrentCell = dgvDetails.CurrentRow.Cells[1]; } }; } }; }5.3 方向三:导出 Excel 时保留格式与公式,而非纯数据
当前「导出 Excel」功能用DataGridView的ClipboardCopyMode复制再粘贴到 Excel,丢失样式、冻结窗格、列宽。升级为用EPPlus库生成真 Excel:
步骤:
- NuGet 安装
EPPlus(v5.10.4,兼容 .NET Framework 4.7.2); - 在导出按钮事件中:
private void btnExportExcel_Click(object sender, EventArgs e) { var saveDlg = new SaveFileDialog { Filter = "Excel 文件 (*.xlsx)|*.xlsx", FileName = $"采购单_{DateTime.Now:yyyyMMddHHmmss}.xlsx" }; if (saveDlg.ShowDialog() != DialogResult.OK) return; using (var package = new ExcelPackage(new FileInfo(saveDlg.FileName))) { var ws = package.Workbook.Worksheets.Add("采购明细"); // 写表头(加粗+背景色) var headerRow = ws.Cells["A1:D1"]; headerRow.Value = new object[] { "商品编码", "商品名称", "数量", "单价" }; headerRow.Style.Font.Bold = true; headerRow.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid; headerRow.Style.Fill.BackgroundColor.SetColor(Color.LightGray); // 写数据(从 DataGridView 读取) for (int i = 0; i < dgvDetails.RowCount - 1; i++) { var row = dgvDetails.Rows[i]; ws.Cells[$"A{i + 2}"].Value = row.Cells["GoodsCode"].Value; ws.Cells[$"B{i + 2}"].Value = row.Cells["GoodsName"].Value; ws.Cells[$"C{i + 2}"].Value = row.Cells["Qty"].Value; ws.Cells[$"D{i + 2}"].Value = row.Cells["UnitPrice"].Value; } // 自动列宽 ws.Cells.AutoFitColumns(); // 保存 package.Save(); } MessageBox.Show("导出成功!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); }血泪经验:
EPPlusv6+ 要求 .NET Core 3.1+,而此项目是 .NET Framework 4.7.2,必须锁死用 v5.10.4,否则ExcelPackage构造函数报Could not load file or assembly。
我带过的实习生,第一周任务就是把这套系统跑起来、改一个字段、导出一次 Excel。不是为了教会他 C# 语法,而是让他亲手摸到「业务需求 → 数据库变更 → 界面联动 → 导出验证」的完整闭环。当你在InventoryLog表里看到自己刚保存的采购记录,那一刻才真正理解什么叫「系统在呼吸」。希望帮到你。
本文还有配套的精品资源,点击获取