首页 / 资讯中心 / 文章详情

VC+MFC超市管理系统实战:从数据库建模到收银事务与DLL插件

VC+MFC超市管理系统实战:从数据库建模到收银事务与DLL插件 ★ FEATURED ARTICLE
简介一份基于Visual CMFC开发的超市管理系统完整工程面向正在学习C/MFC、需要完成课程设计或入门Windows桌面应用开发的读者。系统覆盖商品档案管理、库存上下限预警、销售流水记录、会员积分与优惠、财务核算等核心模块并包含员工、供应商等辅助维护功能能够帮助理解从数据库设计到界面交互的完整流程。压缩包共177个文件约6.44MB主要含35组cpp/h源文件及配套obj/sbr中间文件、MFC界面资源rc、bmp、cur、ico、可独立运行的exe程序、sql数据库脚本和工程配置文件便于直接打开编译和学习。已有126人学习下载。通过阅读源码、资源脚本与工程结构可以掌握VC环境下菜单、对话框、工具栏、控件布局的实现方式梳理商品录入、库存监控、销售出库、会员管理等业务逻辑的编码思路是一份适合管理信息系统课程设计与桌面开发入门的参考资料。1. VC写的超市管理系统为什么到今天还有人拿它做课程设计和毕业设计超市管理系统是C/S桌面软件里最经典的练手题目而用VCVisual C写一套核心是用MFC或Win32 API把进货、销售、库存、会员、收银这几条业务线串起来。别看现在前端框架满天飞超市收银台、小卖部、仓库管理这种内网环境反而最吃这种免部署、双击就能跑的桌面程序。很多从业者第一份有含金量的项目经历就是从这里开始的。这篇文章要解决的问题很直接VC写的超市管理系统到底该怎么搭选什么界面架构数据库怎么建模登录和收银怎么落地以及MFC里那些只有踩过坑才知道的细节。新手能照着把工程跑起来熟手能对照边界条件和参数设置做复盘。不写花架子只讲能过评审、能真用的做法。2. 从MFC选型到工程结构一个超市管理系统该拆成几块2.1 MFC的Dialog架构为什么比文档视图更适合超市收银MFC的界面框架有两条主流路线基于Dialog的模态对话框架构和基于Document/View的文档视图架构。超市管理系统有自己鲜明的交互特征操作员开机直接进收银界面点几下鼠标就要完成一次销售不存在多文档编辑、撤销重做这类需求。文档视图架构把数据模型和显示视图拆开理论上更优雅但对收银这种固定布局、固定流程的场景反而是多余的间接层。我一般会选Dialog架构做主框架理由有三条。其一CDialog的OnInitDialog里把控件初始化做完整个界面就是一组确定性很高的控件集合排错范围小。其二对话框的模态特性天然适合登录、改价、退货这类必须暂时打断主流程的操作。其三MFC的DDXDialog Data Exchange机制能把控件和成员变量绑定值传递的代码量比文档视图少一大截。常见工程结构是四个模块界面层对话框和控件、业务层收银计算、库存扣减、会员积分、数据层数据库连接与SQL封装、公共工具全局配置、日期时间格式化。把这四块拆开后面往DLL里抽功能时不用动界面代码。2.2 工程骨架的三个核心类App入口、主对话框、数据库封装一个能过评审的VC超市管理系统工程里至少要有三个自定义类应用类CWinApp子类、主对话框类CDialog子类、数据库访问类封装ADO。应用类负责程序启动时初始化数据库连接和加载配置主对话框负责承载商品列表、购物车、结算区域数据库访问类负责把SQL的Connection、Recordset操作收敛到一个地方避免散落在各个按钮事件里。// App入口CWinApp子类程序启动时只做两件事 BOOL CSupermarketApp::InitInstance() { // 读取同目录下的config.ini拿到数据库IP、库名、账号 CString strConn LoadConfigFromIni(_T(config.ini)); // 初始化ADO连接失败则弹窗提示并退出 if (!m_dbHelper.Connect(strConn, _T(ProviderSQLOLEDB;Persist Security InfoTrue;))) { AfxMessageBox(_T(数据库连接失败请检查config.ini配置)); return FALSE; } // 启动登录对话框登录成功才进入主界面 CLoginDlg dlgLogin(m_dbHelper); if (dlgLogin.DoModal() ! IDOK) return FALSE; // 主对话框走模态关闭即退出程序 CMainDlg dlgMain(m_dbHelper); m_pMainWnd dlgMain; dlgMain.DoModal(); return TRUE; }这段代码里最关键的是应用类持有数据库访问对象并通过指针传给登录框和主框保证整个进程里只有一个连接句柄在传递。LoadConfigFromIni是自己写的配置解析函数把服务器IP、数据库名、超时时间放在INI文件里换服务器时不用重新编译。注意连接字符串的前缀Provider是OLEDB的提供程序名ADO就是通过这一层去访问SQL Server的。2.3 数据库访问类的封装边界连接、查询、事务各管各的数据库访问类不要做成万能类把连接、查询、事务三个职责混在同一个函数里后面改一个业务就得动底层。日常做法是拆成三个层次连接层负责Connect和Disconnect查询层负责执行SQL并返回记录集事务层负责BeginTransaction、Commit和Rollback。收银这种涉及多表更新的操作必须走事务层。// 按时段统计销售额查询层只负责返回记录集 _RecordsetPtr CSupermarketDb::GetDailySales(const CString strDate) { CString strSQL; strSQL.Format( _T(SELECT SUM(TotalAmount) AS DayTotal FROM SaleOrder ) _T(WHERE DATEDIFF(day, CreateTime, %s) 0), strDate); return ExecuteQuery(strSQL); }ExecuteQuery内部统一走_ConnectionPtr的Execute方法出错时把SQL语句和错误码一起写进日志文件。超市管理系统的SQL绝大多数是这种按时间、按条码、按分类过滤的查询参数化不强制要求但如果是通过文本框拼SQL必须做单引号转义否则输入一条含单引号的商品名就会把整句SQL打断。这是新手最容易翻车的地方。3. 数据库建模与连接把商品、库存、销售单变成可查询的表3.1 五张核心表和它们的主外键关系超市管理系统的数据模型围绕商品和钱流转。商品信息表是基础表记录条码、品名、进价、售价、分类库存表独立出来记录当前数量和预警下限因为商品可能因为盘点调整产生库存变动和商品基础信息解耦后更容易追历史销售单表记录每一次收银的汇总数据销售明细表记录一张单子里的每个商品行操作员表管登录账号和权限级别。五张表的关系是商品表和库存表一对一通过商品ID关联销售单表和销售明细表一对多通过单号关联销售明细表和商品表通过商品ID关联操作员表和销售单表通过操作员ID关联。有一个细节是条码字段要设唯一索引超市里一瓶水和一个面包可能重名但条码必须唯一。3.2 建表SQL字段类型和索引怎么设才不坑建表是后面所有查询效率的地基。SQL Server的字段类型选择有讲究商品条码用varchar(32)不要用bigint因为条码可能是带字母的店内码金额用decimal(18,2)绝不用floatfloat的二进制存储会让一分钱对不上账日期时间用datetime默认值设GETDATE()数量用int因为超市里按件卖不做称重菜的场景用不到小数。CREATE TABLE Product ( ProductID INT IDENTITY(1,1) PRIMARY KEY, Barcode VARCHAR(32) NOT NULL UNIQUE, ProductName NVARCHAR(64) NOT NULL, CategoryID INT NOT NULL, CostPrice DECIMAL(18,2) NOT NULL, SalePrice DECIMAL(18,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE Invent ( ProductID INT NOT NULL PRIMARY KEY, Quantity INT NOT NULL DEFAULT 0, WarnLine INT NOT NULL DEFAULT 10, LastUpdate DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_Invent_Product FOREIGN KEY (ProductID) REFERENCES Product(ProductID) );注意Invent表的ProductID既是主键又是外键这个设计表达的是「一个商品只有一条库存记录」的约束比单独建一个自增ID更合理。WarnLine字段是库存预警线到货的时候补货员要根据这个数决定要不要下单。查询报表时CategoryID如果不建索引分类汇总一多就会全表扫描千万级数据量下差距立竿见影。3.3 ADO连接串的参数Provider、Server、Database、TimeoutVC连接数据库ADO OLEDB是最常见做法。连接串有四段必须写清楚Provider指定OLEDB提供程序访问SQL Server用SQLOLEDBData Source可以是IP也可以是服务器名Initial Catalog是数据库名User ID和Password是登录凭据。还有一个容易忽略的参数是Connect Timeout默认15秒内网环境下3秒就够了设太长会让操作员以为程序卡死了。// 连接SQL Server优先级最低但最稳的通用写法 bool CSupermarketDb::Connect(const CString strServer, const CString strDb, const CString strUser, const CString strPwd) { CString strConn; strConn.Format( _T(ProviderSQLOLEDB;Data Source%s;Initial Catalog%s;) _T(User ID%s;Password%s;Connect Timeout3;), strServer, strDb, strUser, strPwd); HRESULT hr m_pConnection.CreateInstance(__uuidof(Connection)); if (FAILED(hr)) return false; hr m_pConnection-Open((_bstr_t)strConn, _T(), _T(), adConnectUnspecified); if (FAILED(hr)) { // 记录错误码和当前时间便于排查网络还是账号问题 WriteLog(_T(ADO Connect failed, hr0x%08X), hr); return false; } return true; }CreateInstance是COM的方式创建Connection对象__uuidof编译期就能取出组件的GUID比传统的CoCreateInstance写起来少一行。初始化时用adConnectUnspecified表示不指定连接模式让ADO自己决定同步还是异步。如果SQL Server不开TCP/IP协议Data Source填IP都会超时这个问题在避坑章里细说。4. 把登录、收银、入库做成真能跑的界面核心代码与控件联动4.1 登录对话框权限分级和防重复点击登录对话框是系统的第一道门超市的操作用分为收银员、理货员、店长三档。收银员只能访问收银和退货理货员能进入库和盘点店长全通。做法是操作员表里加一个Level字段登录成功后把这个值存到全局变量或主对话框的成员变量里菜单和按钮的Enable/Disable在OnInitDialog里根据权限批量设置。// 登录按钮响应校验通过才返回IDOK void CLoginDlg::OnBnClickedOk() { // 防止连点按钮导致多次查询 if (m_bChecking) return; m_bChecking TRUE; UpdateData(TRUE); CString strSQL; strSQL.Format(_T(SELECT OperatorID, Level FROM Operator ) _T(WHERE LoginName%s AND PassWord%s AND Status1), m_strName, m_strPwd); _RecordsetPtr pRs m_pDb-ExecuteQuery(strSQL); if (pRs nullptr || pRs-adoEOF) { AfxMessageBox(_T(账号或密码错误)); m_bChecking FALSE; return; } m_nLevel pRs-Fields-GetItem(_T(Level))-Value; m_nOperatorID pRs-Fields-GetItem(_T(OperatorID))-Value; m_bChecking FALSE; CDialogEx::OnOK(); }m_bChecking这个布尔成员变量是防重复点击的关键超市收银员操作很快双击按钮就会触发两次派发。密码明文拼SQL只是演示级做法系统交付时密码字段应该存哈希值SQL改成只比对登录名查出哈希后在程序里校验这样数据库泄露也不会直接暴露密码。登录成功后的调用方通过m_nLevel判断权限在App层就完成筛选。4.2 收银界面的三块布局商品列表、购物车、结算区主界面的收银台一般分三块左侧是商品搜索列表按条码或品名模糊搜索中间是当前购物车列出已扫商品、数量、小计右侧是结算信息显示总价、实收、找零。MFC里实现这种布局用CListCtrl嵌在多个GroupBox里左边用报表视图显示搜索结果中间用报表视图显示购物车右侧用StaticText显示金额。// 收银主界面初始化购物车列表设置列宽和对齐 int CMainDlg::InitCartList() { m_cartList.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); m_cartList.InsertColumn(0, _T(条码), LVCFMT_LEFT, 100); m_cartList.InsertColumn(1, _T(品名), LVCFMT_LEFT, 120); m_cartList.InsertColumn(2, _T(单价), LVCFMT_RIGHT, 80); m_cartList.InsertColumn(3, _T(数量), LVCFMT_RIGHT, 60); m_cartList.InsertColumn(4, _T(小计), LVCFMT_RIGHT, 90); return 0; }LVS_EX_FULLROWSELECT让整行都能被选中这是MFC列表控件最容易漏的样式不做的话用户鼠标点到空白区域就选不中当前商品。LSV_EX_GRIDLINES画网格线超市收银员看表格靠网格对齐视线。列宽设置要按屏幕分辨率自适应可以在OnSize里按比例重设否则高分屏下最右侧的列会跑出可视区这属于常见但很烦的兼容性问题。4.3 收银结算一次事务把库存、明细、单头全更新收银是整个系统里事务性最强的操作。点击收款按钮后要做的事包括写入销售单头总金额、操作员、时间、写入销售明细每件商品的快照信息、扣减库存表数量、累加会员积分。这四步必须在一件事务里完成任何一个失败都要回滚。最典型的失败是库存不足明细写了一半库存扣成负数单头金额对不上。// 收银结算事务提交失败整体回滚 void CMainDlg::OnBnClickedPay() { // 生成单号日期操作员ID自增序号 CString strOrderNo GenerateOrderNo(m_nOperatorID); // 开启ADO事务返回成败标志 if (!m_pDb-BeginTransaction()) { AfxMessageBox(_T(事务启动失败请检查数据库连接)); return; } // 第一步写销售单头 CString strSQL; strSQL.Format( _T(INSERT INTO SaleOrder(OrderNo, OperatorID, TotalAmount, CreateTime) ) _T(VALUES(%s, %d, %.2f, GETDATE())), strOrderNo, m_nOperatorID, m_dbTotal); if (!m_pDb-ExecuteNonQuery(strSQL)) { m_pDb-Rollback(); AfxMessageBox(_T(写销售单失败)); return; } // 第二步遍历购物车逐条写明细并扣库存 for (int i 0; i m_cartList.GetItemCount(); i) { CString strBarcode m_cartList.GetItemText(i, 0); int nQty GetCartItemQty(i); UpdateStockAndDetail(strOrderNo, strBarcode, nQty); } // 全部成功提交事务并清空购物车 m_pDb-Commit(); ClearCart(); AfxMessageBox(_T(收款成功)); }生成单号不能靠数据库自增列因为单号需要带日期和操作员信息一般做成「yyyyMMddHHmmss 操作员ID 三位流水」。事务提交后要立即清空购物车这是收银员的硬性体验要求不能让她手动逐行删除。UpdateStockAndDetail内部如果有一行失败就返回失败并触发回滚购物车不清理错误提示后操作员可以重新结账。5. 超市管理系统避坑指南MFC里最常见的5个翻车现场5.1 现象CListCtrl插入大量商品后界面卡死点击无响应原因逐条调用InsertItem向列表控件添加数据每插入一条控件就重绘一次。当商品超过几百条时重绘开销直接卡死界面。解决先调用SetItemCount告诉控件即将插入多少条再通过SetItemText批量填值数据填完才让控件一次性刷新。另外把列表样式设置为LVS_OWNERDATA虚拟列表海量数据时可以按需回调填充内存占用能降一个量级。5.2 现象UpdateData(TRUE)读不到控件最新输入界面显示和实际值不一致原因某个控件没有绑定DDX变量或者绑定的控件ID在DoDataExchange里被写错了。UpdateData只遍历DDX绑定的控件没绑定的控件即便在界面上输入了内容也不会写入变量。解决写DDX后回规则是在构造函数里把变量清零、在DoDataExchange里严格按控件ID关联。调试时在UpdateData之后加断点查变量值为0就说明绑定没生效。5.3 现象遍历购物车删除选中行时程序崩溃报vector iterator错原因用for循环正序遍历购物车删除某一行后后面所有行的索引向前移动循环变量没同步调整越界访问到了失效位置。解决倒序遍历删除或者删除后把循环变量减一。我建议倒序代码最少且不会漏行。购物车控件的DeleteItem也是一样删除前先取总数从最后一行开始往前删。5.4 现象程序关闭后SQL Server里连接还挂着占用数持续累积原因m_pConnection是智能指针但对话框析构时没有显式调用CloseCOM引用计数没有归零连接池把死连接保留了。解决在主对话框的OnDestroy里显式调用m_pConnection-Close()再调用CoUninitialize。不要依赖析构函数去关对话框被强制结束时会跳过析构流程连接就会一直挂在服务端直到超时。5.5 现象收银事务里偶尔报错说事务已被隐式提交或回滚原因事务中间执行了建表、改表结构的DDL语句或者执行了会隐式提交事务的存储过程。SQL Server里DDL语句会中断当前事务后面的DML就落在事务外了。解决办法是事务内不混入DDL表结构变更操作走独立的连接单独执行。另一个排查点是看SQL里是不是有人不小心调用了SET XACT_ABORT ON这个设置会让任意错误直接终止整个事务。6. 进阶玩法把盘点模块抽成DLL插件给自己留一条升级路6.1 为什么要抽DLL超市系统最难的不是写功能而是应对需求变化超市管理系统交付后需求不会停。今天要加盘点单审核明天要加临期商品预警后天要对接电子秤。每改一次都动主工程编译、测试、回归全要重新来。把盘点这类独立业务抽成DLL插件主程序只加载接口业务实现单独编译、单独替换不用动主程序。交付时DLL放在exe同目录的plugins文件夹里少一个就用系统自带的基础盘点功能兜底。一个可用的插件接口至少要有三个函数插件名、版本号、能否执行。主程序启动时枚举plugins目录下所有DLL用LoadLibrary加载后按协议调用接口把对话框指针和数据库指针传进去插件注册自己的窗口逻辑。退出时统一FreeLibrary避免内存泄漏。// 插件接口主程序和DLL共同遵守的协议 // 插件DLL必须导出这4个函数主程序通过GetProcAddress获取 extern C __declspec(dllexport) int PluginGetName(char* pBuf, int nLen); extern C __declspec(dllexport) int PluginGetVersion(); extern C __declspec(dllexport) void PluginSetDatabase(CDatabase* pDb); extern C __declspec(dllexport) int PluginExec(HWND hWndParent);导出函数用extern C是为了避免C名字修饰导致GetProcAddress取不到符号这是DLL开发里最常见的问题。PluginSetDatabase是主程序在加载DLL后立刻传入的数据库连接指针这样插件里不用自己建连接跟主程序共享同一套事务。PluginExec接收主窗体的HWND插件里用CDialog::CreateFromIndirect或直接在父窗口上创建子对话框。6.2 用插件机制管住你的后续版本日志模块也能这样挂插件机制一旦跑通不只是盘点能用日志、报表导出、供应商对账都能往这个框架里塞。我最常用的做法是给每个插件挂一个配置节叫Enabled和Priority。Enabled为0时主程序跳过加载Priority决定菜单里的排列顺序。这样就算某个插件崩了主程序也不会跟着挂业务连续性有保障。我自己在这类项目上吃过一次亏早期把所有业务函数写进对话框实现文件里一个收银对话框三千行代码后来加一个上传会员的小功能牵一发动全身改三天才敢交付。后来花了两个晚上把盘点、对账、日志三个模块抽成DLL主程序的代码量降了一半而且改其中一个模块不影响另外两个。从那以后我接这类桌面管理系统第一件事就是先定插件目录结构再写功能代码。这个方向值不值得投入时间我的判断是看你的目标。如果是课程设计或毕业设计把架构做好比功能堆得多重要评审老师看的不是按钮多而是你懂不懂事务、懂不懂模块解耦。如果是给现实里的门店用务必考虑收银员的操作习惯界面别做花哨按键响应要快敏感操作要防重复触发。等你把插件机制跑通再回头看最初的版本会明显感觉到自己的进步。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站