Delphi销售管理系统毕业设计:通用数据模型、FireDAC与开单事务
2026/9/20 3:45:56 网站建设 项目流程

简介:面向计算机专业毕业设计选题的 Delphi 通用销售管理系统完整论文资料,以 PDF 形式呈现,适合需要完成信息管理系统类毕设、参考传统单机 C/S 架构开发流程的本科与高职学生。文档围绕销售管理系统的需求分析、可行性研究、数据库设计与前端实现展开,依次讲解 Delphi 6.0 语言概述、数据库系统简介、经济与时间可行性论证、数据库建立与连接、系统总体设计与详细设计,并给出登录界面、销售管理、查询统计、图表分析及主界面五个模块的界面设计与代码实现要点,末尾附结论、致谢与参考文献,结构完整可直接对照写作。压缩包共 1 个 PDF 文件,大小约 5.46MB,便于阅读与打印。目前已有 104 人学习,可作为选题参考、论文框架模板以及 Delphi 数据窗口、ADO 访问数据库的入门范例。

1. Delphi 写销售管理系统,卡住毕业设计的往往不是代码量

答辩现场最尴尬的一幕,通常是老师随手点了"新增销售单",程序弹出一个"Cannot open file sales.db",或者连续开两张单把库存扣成了负数。Delphi 做销售管理系统这类毕业设计,代码量从来不是瓶颈,三五百行就能把界面铺满;真正卡人的是"通用"两个字——采购单、销售单、退货单要不要各写一套窗体,字段改一个要不要重编译,库存和订单怎么保证在同一个事务里。不少同学用 Delphi 7 起步,拖控件快,但一碰到并发扣减、单据配置、报表对账就露怯。这篇按一线做企业内部管理系统的路子,把数据模型、FireDAC 连接参数、单据可配置方案、开单事务和几个答辩加分技巧讲透,读完能直接落到自己的作品里,也能回答老师"你这个通用体现在哪"的追问。适合正在做 Delphi 销售管理系统毕业设计、或者要接手一套存量 Delphi 管理软件的人。

2. 通用销售管理系统的数据模型与 FireDAC 连库参数

2.1 把"通用"翻译成表结构:主数据、单据、配置三层

"通用销售管理系统"里的通用,落到数据库上就是三件事:主数据可维护(商品、客户、仓库、供应商),单据结构统一(一张主表 + 一张明细表支撑所有业务类型),行为参数可配置(单据类型、字段显示、审批开关)。很多同学一上来给每个业务建一套表,t_sale_ordert_purchase_ordert_return_order三份结构几乎一样的表,字段一改三处同步,改到第三周自己都记不清哪张表有Discount。我一般会收敛成t_bill+t_bill_item,用BillType区分业务,金额方向、库存方向这类差异用配置表描述,真正需要独立字段的再挂一张扩展表。

表名作用关键约束
t_goods商品主数据Code 唯一,Price 以分存储
t_customer客户档案支持信用额度、账期
t_stock商品库存主键 GoodsID,Qty 不允许为负
t_bill / t_bill_item单据主表与明细OrderNo 唯一索引
t_field_map字段显示与校验配置联合主键 (BillType, FieldName)
t_op_log操作日志记录操作人、IP、时间

这张清单看着朴素,但把"通用"从口号变成了可查询的数据。答辩时老师问"加一个新单据类型要不要改代码",你可以指着 t_field_map 说:加行数据就行,窗体字段是运行时读配置生成的。

2.2 建库脚本:订单唯一约束与库存表设计

先把最小可跑的骨架建出来,SQLite 适合单机演示,MySQL 适合答辩时多机联调,DDL 差别不大,这里以 SQLite 为例。注意金额字段用整数存"分",浮动类型在反复累加后会飘,报表对账时能差出几毛钱,老师一眼就能看出问题。

-- 商品与库存:库存单独成表,避免和商品主数据混在一起被误改 CREATE TABLE t_goods ( GoodsID INTEGER PRIMARY KEY AUTOINCREMENT, Code VARCHAR(32) NOT NULL, Name VARCHAR(64) NOT NULL, Spec VARCHAR(64), Price INTEGER NOT NULL DEFAULT 0, -- 单位:分 IsActive INTEGER NOT NULL DEFAULT 1 ); CREATE UNIQUE INDEX ux_goods_code ON t_goods(Code); CREATE TABLE t_stock ( GoodsID INTEGER PRIMARY KEY, Qty INTEGER NOT NULL DEFAULT 0, -- 当前库存 SafeQty INTEGER NOT NULL DEFAULT 0, -- 安全库存,低于此值预警 UpdatedAt TEXT ); -- 单据主表:OrderNo 唯一,Status 用整数枚举便于索引 CREATE TABLE t_bill ( BillID INTEGER PRIMARY KEY AUTOINCREMENT, BillType VARCHAR(16) NOT NULL, -- SALE / PURCHASE / RETURN OrderNo VARCHAR(32) NOT NULL, CustomerID INTEGER, OrderAmt INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL DEFAULT 0, -- 0草稿 1已确认 2已出库 9已作废 CreatedAt TEXT NOT NULL, CreatedBy VARCHAR(32) ); CREATE UNIQUE INDEX ux_bill_no ON t_bill(OrderNo); CREATE INDEX ix_bill_type_status ON t_bill(BillType, Status); -- 单据明细 CREATE TABLE t_bill_item ( ItemID INTEGER PRIMARY KEY AUTOINCREMENT, BillID INTEGER NOT NULL, GoodsID INTEGER NOT NULL, Qty INTEGER NOT NULL, Price INTEGER NOT NULL, Amount INTEGER NOT NULL ); CREATE INDEX ix_item_bill ON t_bill_item(BillID);

ux_bill_no这个唯一索引值得单独说:单号由程序按前缀 + yyyyMMdd + 4 位流水生成,多端同时开单时有概率撞号,交给数据库拦比在代码里加锁稳。撞号时 FireDAC 会抛唯一约束异常,捕获后重取流水重试一次即可,别让用户看到英文报错。

2.3 FireDAC 连接参数:SQLite 与 MySQL 的差别在哪

Delphi 7 时代大家用 ADO 或 BDE,现在新版本直接上 FireDAC,跨 SQLite、MySQL、SQL Server 只换连接参数。SQLite 单机演示最省事,路径一定要用程序所在目录拼,不要写相对路径,双击 exe 和从 IDE 运行时当前目录不一样,这就是"昨天还好好的今天就打不开库"的最大来源。

// SQLite:单机演示首选,无需装服务 FDConn.Params.Clear; FDConn.Params.Add('DriverID=SQLite'); FDConn.Params.Add('Database=' + ExtractFilePath(ParamStr(0)) + 'data\sales.db'); FDConn.Params.Add('StringFormat=Unicode'); // 中文不乱码的前提 FDConn.Params.Add('OpenMode=CreateUTF8'); // 文件不存在时按 UTF8 新建 FDConn.LoginPrompt := False; // 别弹登录框,答辩时很尴尬 FDConn.Connected := True; // MySQL:多机联调或老师要求"要有服务器" FDConn.Params.Clear; FDConn.Params.Add('DriverID=MySQL'); FDConn.Params.Add('Server=127.0.0.1'); FDConn.Params.Add('Port=3306'); FDConn.Params.Add('Database=sales'); FDConn.Params.Add('User_Name=root'); FDConn.Params.Add('Password=123456'); FDConn.Params.Add('CharacterSet=utf8mb4'); // 老 utf8 存不下 emoji 和生僻字 FDConn.Params.Add('Compress=True'); FDConn.Connected := True;

参数含义逐个对齐:OpenMode=CreateUTF8让 SQLite 在库文件不存在时自动建;StringFormat=Unicode决定字符串以何种格式递交,缺了它在某些驱动版本下中文会变成问号;MySQL 的CharacterSet必须和建库字符集一致,服务端 utf8mb4、客户端 utf8,插入生僻字照样报Incorrect string value。连接成功后建议在数据模块的OnConnect里跑一句PRAGMA foreign_keys=ON(SQLite),否则外键约束形同虚设。

3. 单据可配置:字段映射表与 ListBox 自绘商品选择器

3.1 单据类型 + 字段映射:让同一套代码跑通采购单和销售单

配置驱动的核心是把"界面上有哪些字段、标签叫什么、属于哪种数据类型"存进数据库,窗体在运行时读出来生成控件。这样做的好处很直接:新增退货单不用动代码,只在 t_field_map 里插几行;老师临时要求"金额字段加个必填校验",改配置比改源码快得多。字段类型我一般只定义为四种:STRING、INT、MONEY、DATE,够覆盖绝大多数单据字段,再多就是过度设计。

CREATE TABLE t_field_map ( BillType VARCHAR(16) NOT NULL, -- SALE / PURCHASE FieldName VARCHAR(32) NOT NULL, -- 物理列名,动态控件按它命名 Caption VARCHAR(32) NOT NULL, -- 界面标签 DataType VARCHAR(16) NOT NULL, -- STRING / INT / MONEY / DATE IsShow INTEGER NOT NULL DEFAULT 1, IsRequired INTEGER NOT NULL DEFAULT 0, SortNo INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (BillType, FieldName) ); INSERT INTO t_field_map VALUES ('SALE','OrderNo','销售单号','STRING',1,1,10); INSERT INTO t_field_map VALUES ('SALE','CustomerID','客户','INT',1,1,20); INSERT INTO t_field_map VALUES ('SALE','OrderAmt','单据金额','MONEY',1,0,30); INSERT INTO t_field_map VALUES ('SALE','Remark','备注','STRING',1,0,40);

排序字段 SortNo 用 10、20 递增,日后中间插字段不用重排所有行,这个小习惯能省掉不少 UPDATE 语句。

3.2 动态生成录入界面的核心代码

下面这段是运行时根据配置生成编辑控件的最小实现,控件命名统一加edt_前缀,提交时按字段名反查取值,不用为每个单据写一遍赋值代码。

procedure TfrmBill.BuildEditor(const ABillType: string); var Q: TFDQuery; Ed: TControl; Lb: TLabel; Top, LineH: Integer; begin while pnlEditor.ControlCount > 0 do // 先清空旧控件,避免切换单据类型时堆叠 pnlEditor.Controls[0].Free; Top := 8; LineH := 28; Q := TFDQuery.Create(nil); try Q.Connection := DM.Conn; Q.SQL.Text := 'SELECT FieldName, Caption, DataType, IsRequired FROM t_field_map ' + 'WHERE BillType = :bt AND IsShow = 1 ORDER BY SortNo'; Q.ParamByName('bt').AsString := ABillType; Q.Open; while not Q.Eof do begin Lb := TLabel.Create(Self); Lb.Parent := pnlEditor; Lb.Left := 8; Lb.Top := Top + 4; Lb.Caption := Q.FieldByName('Caption').AsString; Ed := CreateEditorByType(Q.FieldByName('DataType').AsString); Ed.Parent := pnlEditor; Ed.Left := 96; Ed.Top := Top; Ed.Width := 180; Ed.Name := 'edt_' + Q.FieldByName('FieldName').AsString; if Q.FieldByName('IsRequired').AsInteger = 1 then Ed.Hint := '必填'; // 提交时统一收集 Hint 做校验 Top := Top + LineH; Q.Next; end; finally Q.Free; end; end;

逻辑说明:先清空面板防止重复生成;查询按 SortNo 排序保证界面顺序稳定;控件名由字段名拼出,提交时用pnlEditor.FindComponent('edt_OrderNo')直接定位,不用维护映射数组。CreateEditorByType按类型返回 TEdit、TDBEdit、TDateTimePicker 或 TComboBox,MONEY 类型返回带掩码的 TEdit,把输入限制在两位小数内。

3.3 用 ListBox 自绘做商品选择列表

开单时选商品,如果只显示商品名,操作员得靠记忆猜规格和库存,出错率高。TListBox 的lbOwnerDrawFixed风格配上OnDrawItem能把三行信息塞进一行高度:名称加粗、规格和库存在下方、价格右对齐,这就是常说的 delphi listbox自绘。要注意 TColor 是 BGR 顺序,写颜色时别按 RGB 想。

procedure TfrmGoodsPick.lbGoodsDrawItem(Control: TWinControl; Index: Integer; Rect: TRect; State: TOwnerDrawState); var Lb: TListBox; G: TGoodsInfo; begin Lb := TListBox(Control); G := FGoodsList[Index]; // 外部维护的商品数组,与列表索引一一对应 if odSelected in State then Lb.Canvas.Brush.Color := $00F5E6D3 // BGR,呈浅蓝灰,比系统高亮色柔和 else Lb.Canvas.Brush.Color := clWhite; Lb.Canvas.FillRect(Rect); // 必须自己填背景,否则残留旧文字 Lb.Canvas.Font.Style := [fsBold]; Lb.Canvas.TextOut(Rect.Left + 6, Rect.Top + 3, G.Name); Lb.Canvas.Font.Style := []; Lb.Canvas.Font.Color := clGray; Lb.Canvas.TextOut(Rect.Left + 6, Rect.Top + 20, G.Spec + ' 库存 ' + IntToStr(G.Stock) + ' 件'); Lb.Canvas.Font.Color := clMaroon; Lb.Canvas.TextOut(Rect.Right - 80, Rect.Top + 3, FormatFloat('0.00', G.Price)); end;

两个容易踩的点:控件Style属性必须设为lbOwnerDrawFixedItemHeight至少 38,否则两行文字会被裁掉;商品数量多时不要在 OnDrawItem 里查数据库,先把数据取到FGoodsList,自绘只做绘制,否则拖动滚动条会明显掉帧。搜索框配合自绘列表时,用Items.BeginUpdate/EndUpdate包住批量更新,能消掉闪烁。

4. 开单、扣库存与定时任务:销售主流程落地

4.1 一张销售单的事务边界怎么划

销售单保存涉及四步写入:主表、明细、库存扣减、库存流水。这四步必须同生共死,否则会出现"单子建了库存没扣"的对账黑洞。FireDAC 的事务对象要显式指定,别依赖自动提交,自动提交在多表写入时一旦中途报错,前面几条已经落库了。

function TOrderService.SaveSaleOrder(const AOrder: TOrderDTO): Boolean; begin Result := False; DM.Conn.StartTransaction; try InsertBill(AOrder); // 1. 写主表,Status = 1 已确认 InsertItems(AOrder); // 2. 写明细 for var I := 0 to AOrder.Items.Count - 1 do begin if not DeductStock(AOrder.Items[I]) then raise Exception.CreateFmt('商品 %s 库存不足', [AOrder.Items[I].GoodsName]); WriteStockLog(AOrder.Items[I], AOrder.OrderNo); // 3. 写流水,便于追溯 end; DM.Conn.Commit; Result := True; except on E: Exception do begin DM.Conn.Rollback; LogError(AOrder.OrderNo, E.Message); // 日志单独走另一连接或写文件 raise; // 继续向上抛,界面层统一提示 end; end; end;

StartTransactionCommit之间不要弹对话框、不要更新界面进度条,事务持有时间越长,SQLite 越容易报database is locked,MySQL 上则表现为行锁等待。日志写入如果用同一个连接,回滚会把日志一起回滚掉,这是新手最常见的自坑点,改成独立连接或直接写文本文件。

4.2 扣减库存与超卖判断的代码

库存扣减不要先 SELECT 再 UPDATE,中间那条缝就是超卖的入口。正确做法是一条带条件的 UPDATE,让数据库来判断够不够,再用受影响行数决定成败。这个思路在 SQLite 和 MySQL 上都通用,且不需要显式加锁。

function TOrderService.DeductStock(const AItem: TOrderItemDTO): Boolean; var Q: TFDQuery; Rows: Integer; begin Q := TFDQuery.Create(nil); try Q.Connection := DM.Conn; Q.SQL.Text := 'UPDATE t_stock SET Qty = Qty - :qty, UpdatedAt = :ts ' + 'WHERE GoodsID = :gid AND Qty >= :qty'; // 条件写在 WHERE 里,天然防超卖 Q.ParamByName('qty').AsInteger := AItem.Qty; Q.ParamByName('gid').AsInteger := AItem.GoodsID; Q.ParamByName('ts').AsString := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now); Rows := Q.ExecSQL; // 返回受影响行数 Result := Rows = 1; finally Q.Free; end; end;

Qty >= :qty这个条件是整个函数的灵魂:库存只剩 3 件、要出 5 件时,UPDATE 影响 0 行,函数返回 False,上层抛异常触发回滚,全程不会出现负库存。参数化写法同时挡住了 SQL 注入,商品名里带单引号也不会把语句拆坏。安全库存预警可以另开一个查询,Qty < SafeQty的记录在开单界面上用红底提示,不要塞进这条 UPDATE 里。

4.3 TTimer 自动释放超时未付款订单

销售单确认后常见做法是锁库存,客户半小时不付款就把单子作废、库存回吐。这个任务用 TTimer 做轮询最省事,但必须处理重入问题——上一轮还没跑完,定时器又触发了,库存会被扣两次,这就是调试时排查 delphi timer.working 状态的意义所在。

procedure TfrmMain.TimerReleaseTimer(Sender: TObject); begin if FReleasing then Exit; // 重入保护:上一轮未结束直接返回 FReleasing := True; TimerRelease.Enabled := False; // 先停表,处理完再开,避免堆积 try ReleaseExpiredBills(30); // 30 分钟未付款的已确认单据 finally TimerRelease.Enabled := True; FReleasing := False; end; end; procedure TfrmMain.ReleaseExpiredBills(AMinutes: Integer); var Q: TFDQuery; begin Q := TFDQuery.Create(nil); try Q.Connection := DM.Conn; // 只处理草稿/已确认状态,已出库的不动 Q.SQL.Text := 'SELECT BillID, OrderNo FROM t_bill ' + 'WHERE Status IN (0,1) AND CreatedAt < :limit'; Q.ParamByName('limit').AsString := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now - AMinutes / 1440); Q.Open; while not Q.Eof do begin CancelBillAndRestoreStock(Q.FieldByName('BillID').AsInteger); Q.Next; end; finally Q.Free; end; end;

TimerRelease.Interval设 60000 到 300000 之间比较合适,太密会频繁开事务,太疏释放不及时。生产环境里这种定时任务通常挪到后台服务或数据库调度里做,桌面程序一关就没了;毕业设计范围内用 TTimer 说明思路足够,但要在论文里写清这个边界,老师反而会觉得你考虑过部署形态。

5. 答辩加分与排错:XMLDoc 注释、IP 归属地与常见报错对照

5.1 给方法和参数加注释提示

代码可读性在答辩里占分不低。Delphi 支持 XMLDoc 注释,在方法或参数上方输入///后按回车,IDE 会自动补出<summary><param><returns>骨架,写完后把鼠标悬停在调用处就能看到提示,这就是很多资料里说的 delphi 给方法和参数增加提示描述。要点是注释块必须紧贴声明行、中间不能空行,否则提示不生效。

/// <summary>按单号查询销售单主表,只返回一条记录</summary> /// <param name="AOrderNo">销售单号,格式 SO+yyyyMMdd+4位流水</param> /// <returns>已打开的查询对象,调用方负责 Free</returns> function TOrderService.GetByOrderNo(const AOrderNo: string): TFDQuery;

<param>的名字必须和形参完全一致,大小写不敏感但拼错就没有提示。把服务层和 DTO 都补上注释,再用 IDE 的类视图浏览一遍,会发现不少命名含糊的字段,顺手改掉比写文档划算。

5.2 操作日志里解析 IP 归属地

管理系统的操作日志只记录 IP 说服力有限,加上归属地能明显提升"完整度"。纯真 IP 库(qqwry.dat)是常见选择,文件结构不复杂:前 8 字节是索引区首尾偏移,之后每条索引 7 字节,前 4 字节起止 IP、后 3 字节记录偏移;记录区里再存结束 IP 和国家、地区字符串偏移,遇到 0x01、0x02 标志要做重定向。用 TFileStream 读二进制、按小端序拼回整数,再和查询 IP 做区间比较即可。实际项目里我一般把它封成一个GetIPLocation(IP: string): string函数,日志写入时调用一次,结果一起入库,避免每次看日志都重读文件。

5.3 商品图片任意角度旋转与报错对照

商品图片需要摆正或做报表水印时,Delphi 自带 Canvas 只能做 90 度整数倍旋转,任意角度得走 GDI+ 或 Graphics32。用 GDI+ 的思路是先把坐标系原点平移到图片中心,旋转指定角度,再平移回去绘制,三行变换加上一次 DrawImage 就能出结果;关键 API 是 TGPGraphics 的 TranslateTransform 和 RotateTransform。热词里常提到的 delphi graphicex 属于另一类封装思路,适合需要频繁做滤镜和缩放的场景,毕业设计用原生 GDI+ 已经够。

现象常见原因处理方式
Cannot open file sales.db用了相对路径,当前目录变了用 ExtractFilePath(ParamStr(0)) 拼绝对路径
中文显示为问号字符集与 StringFormat 不匹配驱动端 utf8mb4,连接加 StringFormat=Unicode
database is locked事务未提交或长事务中弹窗缩短事务范围,日志写独立连接
库存扣成负数先 SELECT 再 UPDATE改成带 Qty >= :qty 条件的 UPDATE
ListBox 自绘闪屏未开双缓冲DoubleBuffered := True,批量更新用 BeginUpdate
定时任务重复执行未做重入保护布尔标志加定时器先停后开
服务端连接被拒MySQL 未授权远程访问建专用账号并按需授权,别用 root 直连

排错时优先怀疑三样:路径、字符集、事务边界,这三类占了毕业设计里报错的大半。把日志按天写文件、关键操作落库,出问题时能顺着单号把主表、明细、库存流水三条线串起来看,比盯着代码猜快得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询