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

资讯详情

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

MFC仓库管理系统解析:ADO连接与事务处理实战

MFC仓库管理系统解析:ADO连接与事务处理实战 简介一套基于C与MFC框架的仓库管理系统完整源码包面向需要学习桌面端管理软件开发、完成课程设计或进行毕业设计的读者。系统围绕入库管理、入库退货、库存盘点、库存调拨、出库管理、库存上下限预警和查询管理等核心环节展开功能覆盖供应商登记、退货原因记录、库存差异核对、库存调拨跟踪以及多条件组合查询等细节配合MFC消息映射、ODBC/ADO数据库交互与MVC分层设计能够完整体现Windows桌面应用从界面构建到业务逻辑落地的开发路径。压缩包共188个文件、9.78MB包含34个头文件与32个cpp源代码、31个图标及6个位图资源另有MDF/LDF数据库文件、DSP工程文件、OBJ中间文件等项目目录清晰下载后可直接打开、编译与调试。已有547人学习下载适合有一定C基础、希望借助MFC理解Windows应用与数据库联动开发的读者。1. MFC 仓库管理系统一套还在跑的桌面台账代码长什么样压缩包解开没有.sln只有aaa.aps、aaa.clw、DlgBaseInfoM.cpp和一堆.bmp位图很多人第一反应是资源发错了。其实这正是 Visual C 6.0 到 VC2008 时代 MFC 对话框工程最典型的形态.aps是资源编译器把.rc编译后的中间产物.clw是 ClassWizard 维护类与消息映射关系的索引文件真正的业务代码全散落在DlgXXX.cpp里。仓库管理这类系统到今天仍大量以 MFC 形态跑在中小企业内网原因是它本质上是「单据 库存台账」的增删改查Windows 环境 MFC 对话框 ADO 是试错成本最低的组合。这篇文章把工程结构、数据库连接初始化、入库出库盘点调拨的事务实现、库存上下限预警和查询参数化讲透适合接手老 MFC 项目、做数据库课程设计或者想把控件级操作和 ADO 串起来的开发者。2. 对话框工程骨架识别与 ADO 数据库连接初始化2.1 从 .aps、.clw、bmp 还原工程类型这套源码里没有文档视图结构的MainFrm.cpp也没有CWinApp之外的大量框架派生类入口是对话框类典型特征就是那一批Dlg前缀文件。DlgBaseInfoM.cpp从命名上看对应「基础信息管理」也就是商品档案、供应商档案这类主数据维护界面后面接的入库、出库、盘点对话框通常叫DlgInStock、DlgOutStock、DlgStockCheck。看一个 MFC 老工程先翻文件名比先翻代码快Dlg说明是对话框程序没有View说明不采用文档视图架构Toolbar.bmp、Exit32.bmp是工具栏和退出按钮的位图gongsiPic1.bmp一般是公司 Logobitmap28.bmp可能是商品图片或流程示意。.aps删掉会由.rc自动重建.clw丢了 ClassWizard 会重新扫描生成这两个文件不用管它真正的工程设置和编译依赖在.dsp/.dsw里。整体结构就是「一个主对话框 若干业务对话框 ADO 访问数据库」主对话框放功能按钮点击后DoModal()弹出对应业务界面。类继承关系一般是这样业务对话框继承CDialog数据库连接对象用_ConnectionPtr封装放在CWinApp派生类的成员变量里或直接在主对话框里定义成成员变量各对话框通过指针共享。2.2 stdafx.h 里引入 ADO 并初始化 COMMFC 老工程访问数据库常见三条路ODBC、DAO、ADO。ODBC要配数据源DAO偏向 AccessADO对 SQL Server 和 Access 都能用而且_RecordsetPtr可以直接和CListCtrl、CComboBox联动所以这套系统走 ADO 是最合理的路线。因为源码用#import引入 ADO 类型库会在编译时生成msado15.tlh/.tlh包装头必须放在stdafx.h里保证所有对话框都能看到声明。// stdafx.h —— 预编译头ADO 引入必须放在这里 #import msado15.dll no_namespace rename(EOF, adoEOF)注意rename(EOF, adoEOF)不重命名会和在for循环里常用的EOF宏冲突导致编译报错。no_namespace是为了不用写ADODB::_ConnectionPtr这类限定代码短一些。在App::InitInstance()里初始化 COM 库程序结束时释放// App.cpp BOOL CWarehouseApp::InitInstance() { ::CoInitialize(NULL); // 初始化 COM 库ADO 依赖它 // ... MFC 初始化 ... CWarehouseDlg dlg; m_pMainWnd dlg; dlg.DoModal(); ::CoUninitialize(); // 退出时释放 COM return FALSE; }CoInitialize和CoUninitialize必须成对出现InitInstance里初始化了就一定要在退出路径上释放否则进程退出可能报 OLE 初始化错误。注意 MFC 程序里AfxOleInit()也可以初始化 COM但它在内部用的是COleMessageFilter和 ADO 混用没有问题只是老工程里手动CoInitialize更直观、排错更容易定位。2.3 连接字符串与 Provider 选型连接打开的核心代码长这样// 主对话框成员_ConnectionPtr m_pConn; BOOL CWarehouseDlg::ConnectDatabase() { HRESULT hr m_pConn.CreateInstance(__uuidof(Connection)); if (FAILED(hr)) { AfxMessageBox(_T(创建 ADO 连接对象失败)); return FALSE; } CString strConn; strConn _T(ProviderSQLOLEDB;Data Source127.0.0.1\\SQLEXPRESS;) _T(Initial CatalogWarehouseDB;User IDsa;Password******;); try { // 连接串里已带账号密码后两个用户参数传空即可 hr m_pConn-Open(_bstr_t(strConn), _T(), _T(), adConnectUnspecified); } catch (_com_error e) { CString strErr; strErr.Format(_T(数据库连接失败%s), (LPCTSTR)e.Description()); AfxMessageBox(strErr); return FALSE; } return TRUE; }adConnectUnspecified表示未指定连接选项让 OLE DB 按默认方式处理。_com_error::Description()拿到的中文错误信息通常比 HRESULT 码有用所以catch里一定要把错误描述弹出来再看。连接串里账号密码用明文写在代码里是 MFC 仓库系统里最常见也最让人头疼的做法功能上没问题但要注意传参时别把带密码的版本直接发给别人实用做法是把连接串拆到单独的dbconfig.ini用GetPrivateProfileString读取。ADO 连接 SQL Server 的 Provider 有几个容易混淆的选择对比如下Provider 字符串适用数据库特点与坑SQLOLEDBSQL Server 2000–2014系统自带老工程默认新实例要开 TCP/IPSQLNCLI11SQL Server 2005SQL Server 2012 Native Client速度更快Microsoft.ACE.OLEDB.12.0Access 2007需要单独装 Access Database EngineMicrosoft.Jet.OLEDB.4.0Access 2003 及以前32 位进程专用64 位编译会报未注册我一般建议连接 SQL Server 优先试SQLOLEDB跑不通再换SQLNCLI11因为SQLOLEDB是 MDAC 自带组件重装系统的机器上大概率还在。连接失败时先看两点SQL Server 服务是否启动TCP/IP 协议是否启用。很多人代码没错卡在 SQL Server Configuration Manager 里 Named Pipes 启用了而 TCP/IP 没启用ADO 默认走 TCP自然连不上。3. 入库出库盘点调拨消息映射与事务边界3.1 ON_BN_CLICKED 与消息映射表MFC 是事件驱动按钮点击靠BEGIN_MESSAGE_MAP把控件 ID 和成员函数绑定起来。每次新增一个功能按钮都要在BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间加一行映射这是很多人刚接手时最容易漏的一步——对话框资源里加了按钮、类向导也生成了函数但没把两者挂上编译能过、点击没反应。// WareHouseDlg.h class CWarehouseDlg : public CDialog { public: CListCtrl m_listStock; afx_msg void OnBtnInStock(); // 入库 afx_msg void OnBtnOutStock(); // 出库 afx_msg void OnBtnCheck(); // 盘点 afx_msg void OnBtnTransfer(); // 调拨 DECLARE_MESSAGE_MAP() }; // WareHouseDlg.cpp BEGIN_MESSAGE_MAP(CWarehouseDlg, CDialog) ON_BN_CLICKED(IDC_BTN_INSTOCK, CWarehouseDlg::OnBtnInStock) ON_BN_CLICKED(IDC_BTN_OUTSTOCK, CWarehouseDlg::OnBtnOutStock) ON_BN_CLICKED(IDC_BTN_CHECK, CWarehouseDlg::OnBtnCheck) ON_BN_CLICKED(IDC_BTN_TRANSFER, CWarehouseDlg::OnBtnTransfer) END_MESSAGE_MAP()ON_BN_CLICKED的第一个参数是资源头文件Resource.h里的控件 ID第二个参数是消息处理函数地址。函数声明必须带afx_msg前缀否则编译器不认为是消息响应函数。如果点按钮没进函数先检查 ID 和资源里Properties - ID是否一致再检查函数签名是否为void Fn()。3.2 入库操作必须走 BeginTrans/CommitTrans入库的业务动作是「写一张入库单 增加库存」这两个操作必须在一个事务里。否则会出现单据写成功了、库存没加上月底对账怎么都对不上的经典事故。下面是入库的核心代码注意事务边界的写法void CWarehouseDlg::OnBtnInStock() { if (!m_pConn || m_pConn-GetState() adStateClosed) { AfxMessageBox(_T(数据库未连接)); return; } // 从界面控件取值nQty 是入库数量strCode 是商品编码 CString strSql; strSql.Format(_T(INSERT INTO in_stock(goods_code, qty, in_date, supplier_id) ) _T(VALUES(%s, %d, GETDATE(), %d)), strCode, nQty, nSupplierId); CString strUpd; strUpd.Format(_T(UPDATE inventory SET quantity quantity %d ) _T(WHERE goods_code %s), nQty, strCode); try { m_pConn-BeginTrans(); // 开启事务 m_pConn-Execute(_bstr_t(strSql), NULL, adCmdText); if (m_pConn-GetState() adStateClosed) { // 事务中途连接断掉直接回滚 return; } m_pConn-Execute(_bstr_t(strUpd), NULL, adCmdText); m_pConn-CommitTrans(); // 全部成功才提交 AfxMessageBox(_T(入库成功)); } catch (_com_error e) { if (m_pConn-GetState() ! adStateClosed) { m_pConn-RollbackTrans(); // 任一步异常回滚到事务起点 } CString strErr; strErr.Format(_T(入库失败%s), (LPCTSTR)e.Description()); AfxMessageBox(strErr); } }这段代码有三个关键点。第一BeginTrans之后的每次Execute实际执行失败不会立刻抛异常而是返回失败状态或等到提交时报错所以在每个Execute后最好检查连接状态或改用_RecordsetPtr的GetStatus()。第二RollbackTrans执行前要判断连接是否还活着连接断了以后调用它本身会再抛异常。第三库存更新用的是quantity quantity nQty而不是先把库存查出来再加这是为了在多用户同时入库时不丢更新如果先SELECT quantity再UPDATE quantity quantity n两个窗口同时读到 100各自加 10最后库存是 110 而不是 120。3.3 盘点与调拨在 SQL 层面对比盘点不是简单更新库存数字系统里必须留下盘点记录用于审计。常见做法是check_record表存盘点的账面数、实盘数和差异再加一个is_adjusted标志位确认无误后在同一事务里更新inventory。调拨则涉及两个仓库之间的库存转移一个加一个减这两个UPDATE必须同事务且并发时要按固定顺序加锁否则两个窗口同时做方向相反的调拨可能死锁。下表是四个核心模块的事务特征业务模块涉及表事务范围关键点入库in_stockinventory同事务先插单据再quantity n出库out_stockinventory同事务出库前校验库存充足再扣减盘点check_recordinventory同事务差异数据落check_record确认后回调库存调拨transfer_loginventory同事务扣出库仓、加入库仓锁顺序固定调拨的UPDATE inventory语句要固定「先扣出库仓再加入库仓」两个窗口并发调拨时都按这个顺序执行就不会形成A 等 B、B 等 A的循环等待。出库还要加上库存充足校验在事务里先SELECT quantity判断再扣减虽然前面说过并发时先查后改有丢更新风险但出库业务必须先查了才知道够不够这个场景下应该给库存行加更新锁即SELECT ... WITH (UPDLOCK)或直接UPDATE inventory SET quantity quantity - n WHERE goods_code ? AND quantity n用ROWCOUNT判断是否真的扣减成功。4. 库存上下限预警与多条件查询的实现4.1 阈值扫描与超限行的视觉标记商品档案goods表里加两个字段min_qty和max_qty入库、出库、调拨每成功一次后刷新库存列表同时执行一次预警扫描。扫描逻辑是查出所有低于下限或高于上限的商品然后在前端把超限行标红。SQL 条件写成quantity min_qty OR quantity max_qty注意下限是「小于」触发、上限是「大于」触发等于临界值不算超限。void CWarehouseDlg::RefreshStockList() { CString strSql; strSql _T(SELECT g.goods_code, g.goods_name, i.quantity, g.min_qty, g.max_qty ) _T(FROM goods g JOIN inventory i ON g.goods_code i.goods_code ); try { _RecordsetPtr pRs(__uuidof(Recordset)); pRs-CursorLocation adUseClient; // 客户端游标可反复遍历 pRs-Open(_bstr_t(strSql), _variant_t((IDispatch*)m_pConn, true), adOpenStatic, adLockReadOnly, adCmdText); m_listStock.DeleteAllItems(); // 先清空再填充 m_mapOverLimit.RemoveAll(); // 清空上一轮标记 int nRow 0; while (!pRs-adoEOF) { CString strCode (LPCTSTR)(_bstr_t)pRs-GetFields()-GetItem(goods_code)-Value; CString strName (LPCTSTR)(_bstr_t)pRs-GetFields()-GetItem(goods_name)-Value; long nQty pRs-GetFields()-GetItem(quantity)-Value.lVal; long nMin pRs-GetFields()-GetItem(min_qty)-Value.lVal; long nMax pRs-GetFields()-GetItem(max_qty)-Value.lVal; m_listStock.InsertItem(nRow, strCode); m_listStock.SetItemText(nRow, 1, strName); m_listStock.SetItemText(nRow, 2, strQty); if (nQty nMin || nQty nMax) { m_mapOverLimit.SetAt(nRow, TRUE); // 标记这一行超限 } pRs-MoveNext(); nRow; } } catch (_com_error e) { AfxMessageBox(e.Description()); } }adUseClient游标配合adOpenStatic可以来回移动 Recordset如果要遍历两次比如先统计总数再填列表就不会报行集不可用。前端把超限行标红有两个层级简单做法是SetItemText后调用SetItemData存行号再在NM_CUSTOMDRAW通知里判断行的背景色更省事的办法是CListCtrl::SetBkColor只能整体设背景不能按行设所以行级标记必须走NM_CUSTOMDRAW。低频场景还有一个偷懒方案每行SetItemText时在商品名前面拼【超限】两个汉字效果直观但不够专业客户一般不接受。多数 MFC 仓库老工程用的是NM_CUSTOMDRAW这也是客户端 5 年以上的人会关注的点。4.2 动态 SQL 与 ADO Command 参数化查询查询管理模块按日期、商品编码、供应商三个条件组合过滤条件可能全空、也可能只填一个不能写死 SQL必须动态拼接。最稳妥的方式是先拼一个WHERE 11骨架后面每命中一个条件就AND 一条 SQL避免最后一个条件后面多出AND导致语法错误。SELECT r.record_id, r.goods_code, r.type, r.qty, r.record_date, s.supplier_name FROM in_record r LEFT JOIN supplier s ON r.supplier_id s.supplier_id WHERE 11 AND r.goods_code ABC001 AND r.record_date 2025-01-01 AND r.record_date 2025-02-01 AND r.supplier_id 12动态拼字符串有 SQL 注入风险MFC 下最实用的防御手段是用_CommandPtr走参数化查询不要直接拿用户输入拼串。日期尤其要参数化因为中文 Windows 的默认区域设置会把2025-01-02解析成2025 年 1 月 2 日但英文系统的转换结果可能不同同一条 SQL 在中文系统能查、在英文系统查不出数据。参数化的写法如下void CWarehouseDlg::QueryRecords(CString strCode, COleDateTime dtBegin, COleDateTime dtEnd) { CString strSql; strSql _T(SELECT record_id, goods_code, type, qty, record_date ) _T(FROM in_record WHERE 11); _CommandPtr pCmd(__uuidof(Command)); pCmd-ActiveConnection m_pConn; if (!strCode.IsEmpty()) { strSql _T( AND goods_code ?); _ParameterPtr pParam pCmd-CreateParameter( _T(pCode), adVarWChar, adParamInput, 20, _variant_t(strCode)); pCmd-Parameters-Append(pParam); } if (dtBegin.GetStatus() COleDateTime::valid) { strSql _T( AND record_date ?); _ParameterPtr pParam pCmd-CreateParameter( _T(pBegin), adDBTimeStamp, adParamInput, 0, _variant_t(dtBegin)); pCmd-Parameters-Append(pParam); } pCmd-CommandText _bstr_t(strSql); _RecordsetPtr pRs(__uuidof(Recordset)); pRs pCmd-Execute(NULL, NULL, adCmdText); // 遍历 pRs 填充 CListCtrl }注意这里WHERE 11不是性能问题SQL Server 的优化器会把它直接折叠掉不会影响索引选择。真正要注意的是CreateParameter的adVarWChar长度参数字符串类型的参数必须给足长度长度给 0 或省略会造成部分 Provider 无法绑定。日期类型用adDBTimeStamp取值要用COleDateTime而不是CString传_variant_t时才能保证日期格式不依赖区域设置。adParamInput表示这是输入参数输出参数要换成adParamOutput。查询结果的展示还有一个常见误用每次点击查询都DeleteAllItems再InsertItem如果列表绑定了LVS_SORTASCENDING排序标志插入顺序会乱掉用户看到的数据行号和点击时不一致检查超限标记会错位。实现里要在查询前m_listStock.DeleteAllItems()后立即清空标记数组填行时重新打标不要拿旧的m_mapOverLimit去匹配新行号。5. MFC 仓库系统的排错与调试技巧接手老工程最先遇到的坑就是_com_error异常没被捕获直接弹一个Debug Assertion Failed窗口。ADO 的几乎所有Execute、Open、MoveNext都可能抛异常应该把所有数据库操作统一封装到一个CAdoHelper类里内部捕获_com_error并把HRESULT、错误描述、执行语句一起写进日志而不是在OnBtnXxx里到处try-catch。这样排查问题只需要看一条日志// CAdoHelper::Execute void CAdoHelper::ExecuteSql(const CString strSql) { try { m_pConn-Execute(_bstr_t(strSql), NULL, adCmdText); } catch (_com_error e) { CString strLog; strLog.Format(_T([SQL FAILED] 0x%08X %s | %s), e.Error(), (LPCTSTR)e.Description(), (LPCTSTR)strSql); OutputDebugString(strLog); // 调试器输出窗口可见 ::MessageBox(NULL, strLog, _T(数据库错误), MB_ICONERROR); } }e.Error()返回的是 HRESULT不是错误码0x80040E14对应语法错误0x80004005基本是权限或连接问题。看到这两个值就先往 SQL 语句和账号权限方向排查不要盯着代码断点。实际网络搜索里visual c redistributable排错同理先区分是运行库缺失还是依赖组件没装再看具体错误码避免在无关方向浪费一个下午。字符串编码是 MFC 仓库系统的另一个重灾区。工程如果是多字节字符集CString底层是 ANSI_bstr_t是 Unicode BSTR中间用(LPCTSTR)(_bstr_t)value强转会丢字符中文商品名在列表里变成问号。可用的转换方式是显式调用CW2A或W2A宏// 从 Recordset 里取中文字段多字节工程下避免乱码 _bstr_t bstrValue ( _bstr_t)pRs-GetFields()-GetItem(_T(goods_name))-Value; USES_CONVERSION; CString strName OLE2T(bstrValue);OLE2T会根据工程的字符集设置自动转成窄字符或宽字符比手写(LPCTSTR)强转可靠得多。反过来拼 SQL 时用T2OLE把CString转成BSTR保证 SQL 语句里的字符串常量匹配数据库的 Unicode 排序规则。最后抓性能时优先看是不是没有复用连接m_pConn在InitInstance里创建后全程复用不要每个按钮点击都CreateInstance再Open和数据库握手开销很大。OutputDebugString搭配Sysinternals DebugView可以在不打断业务流程的情况下看 SQL 执行顺序比到处AfxMessageBox弹窗高效得多特别是处理到一半出错时弹窗会卡住事务DebugView 只会打日志不会改变执行流程。本文还有配套的精品资源点击获取
返回列表