简介:一套完整的 C# 火锅点菜系统项目,主要面向学习 Windows 桌面开发的初学者,以及有餐饮系统定制需求的开发者。项目以 Windows Forms 作为用户界面,结合 ADO.NET 访问数据库,完整实现了菜品展示、购物车点选、订单生成、支付结算、优惠折扣处理和厨房小票打印等餐饮门店常见流程。代码结构上采用了 MVC 分层思路,将界面、业务逻辑与数据访问分离,并穿插事件驱动编程、try-catch 异常处理、数据绑定等 C# 核心知识点,便于学习者对照实际业务理解抽象概念。压缩包共七十一个文件,主要包含二十个代码源文件、多个窗体资源文件、可执行程序与动态库,还带有数据库文件和报表定义文件,整体大小约1.55MB,解压后可用 Visual Studio 直接打开编译运行。截至目前,资源已有约一百四十一人次学习收藏,比较适合作为课程设计或项目实训的参考;通过阅读源码,还能学到菜品分类管理、订单状态流转、金额计算与打印布局调整等实用技巧,稍加扩展也可迁移到其他餐饮或零售点单场景。
1. 一份 C# 火锅点菜系统,卡住你的从来不是界面
做小门店系统这几年,我发现一个反直觉的事:一个 C# 点菜系统,真正决定成败的不是按钮好不好看、界面炫不炫,而是订单数据怎么存、加菜后怎么对账、结账时怎么保证金额不出错。很多课程设计和门店项目,界面画得有模有样,结果一桌加三次菜就开始乱,最后只能重启软件。这份「huoguo.rar 里的 C# 点菜程序」类项目,要解决的其实是三件事:把桌台和菜品管清楚、把每一次加菜变成一条能回溯的明细、把结账金额算到分。正在做课程设计的学生、想给自家火锅店配套系统的店主、想练 C# 数据层功底的开发者,都适合往下看。
2. 数据模型先行:四张表把开台、加菜、结账、清台一次想清楚
2.1 先顺着门店流程推表,别先画界面
做点菜系统最忌讳一上来就拖控件。火锅店的动线其实非常固定:客人进店入座,服务员开台,点锅底,点荤菜素菜和饮料,吃到一半加菜,吃完结账,清台翻台。把这几个动作按顺序写在纸上,每个动作对应哪张表、哪条记录发生了什么变化,就全部清楚了。我一般先陪着店主在店里站半小时,看服务员实际怎么跑,再回来设计表。这一步省掉,后面返工的概率非常高。
入座只是选一张桌台;开台动作是把桌台状态从「空置」改成「占用」,同时生成一条订单头记录;点锅底和点菜都是往订单明细里插一行;加菜是继续插新行,而不是去改旧行;结账时更新订单头的金额和状态,再把桌台恢复成空置。核心就一句话:桌台有状态,订单有头有明细,明细只追加不修改。这样设计,哪怕一桌加八次菜,每一笔都留得住。
2.2 四张核心表的建表脚本与字段取舍
表结构是整个系统的地基。我不喜欢把字段设计得天花乱坠,能覆盖流程、能对账、能回溯,就够了。下面这份 SQLite 建表脚本,是从实际项目中精简出来的版本,字段注释直接写在 SQL 里:
-- 菜品表 CREATE TABLE dish ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- 菜品名:锅底/毛肚/肥牛 category TEXT NOT NULL, -- 分类:锅底/荤菜/素菜/饮料/套餐 price DECIMAL(10,2) NOT NULL, -- 当前售价 cost DECIMAL(10,2) DEFAULT 0, -- 成本价,月底算毛利用 is_available INTEGER DEFAULT 1, -- 1=可售,0=沽清 sort_no INTEGER DEFAULT 0 -- 点菜界面的排序号 ); -- 桌台表 CREATE TABLE table_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_no TEXT NOT NULL UNIQUE, -- 桌号,如 A01、包房1 seat_count INTEGER DEFAULT 4, status INTEGER DEFAULT 0 -- 0=空台 1=已开台 2=待清台 ); -- 订单头:一桌一单 CREATE TABLE order_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_id INTEGER NOT NULL, open_time TEXT NOT NULL, close_time TEXT, status INTEGER DEFAULT 0, -- 0=点餐中 1=已结账 2=已作废 total_amount DECIMAL(10,2) DEFAULT 0, -- 明细原价合计 discount_rate DECIMAL(4,2) DEFAULT 1.00, -- 折扣率,如 0.88 free_amount DECIMAL(10,2) DEFAULT 0, -- 抹零金额 final_amount DECIMAL(10,2) DEFAULT 0, -- 实收金额 remark TEXT -- 备注:免辣/生日聚餐 ); -- 订单明细:每次加菜就是往里插一行 CREATE TABLE order_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, dish_id INTEGER NOT NULL, dish_name TEXT NOT NULL, -- 下单时的菜名快照 unit_price DECIMAL(10,2) NOT NULL, -- 下单时的单价快照 quantity INTEGER NOT NULL DEFAULT 1, amount DECIMAL(10,2) NOT NULL -- 小计 = unit_price * quantity );这份脚本里有几个设计容易被新手忽略。第一,order_detail里存了dish_name和unit_price两份快照,而不是下单时去关联dish表查菜名和价格。原因是菜品价格会调、菜名会改,如果明细用关联查询,三个月后回看历史订单,显示的是改版后的价格,对账直接对不上。第二,订单头order_info和桌台table_info是分开的,桌台没有直接存订单号,而是通过订单头里的table_id反查。这样换台、并台只需改order_info,桌台状态单独维护,职责不混淆。第三,所有金额字段都用DECIMAL,不用DOUBLE。火锅店经常出现 38.5 元的锅底、0.1 元的抹零,浮点数算到后面会出现 0.30000000000000004 这种幽灵值,用DECIMAL从源头掐死。
2.3 订单状态机与金额计算该落在哪一层
字段定好后,要把状态流转定死。订单状态只有三个:0 点餐中、1 已结账、2 已作废。桌台状态也只有三个:0 空台、1 已开台、2 待清台。注意,为什么要给桌台单独留一个「待清台」状态?因为客人结账后服务员还得收拾桌面、重新摆餐具,这需要时间。如果结账瞬间就把桌台置为「空台」,前台看到空台又开给下一桌,两单就串了。我这里踩过坑,后来加了个规矩:结账后桌台变「待清台」,服务员在界面上点「清台完成」,桌台才回「空台」。
金额计算的逻辑,我建议全部收敛到一个独立的BillCalculator静态类里,不要散落在十几个窗体的事件里。折扣、抹零、四舍五入规则,火锅店是经常变的——今天店庆打 88 折,明天老顾客抹零。集中到一处,改规则只改一个文件。计算规则按这个顺序:先用明细合计出total_amount,然后乘折扣率得到折后金额,最后做抹零得到final_amount。抹零是向下取整到分,不是四舍五入,这两个差在月底对账时会显形。
3. 最小可运行版:WinForms + SQLite 一小时跑通点菜主链路
3.1 技术选型:为什么是 WinForms,为什么是 SQLite
先回答两个常见的选型纠结。界面用 WinForms 还是 WPF?我的建议是单机门店和课程设计一律 WinForms。WPF 的样式、绑定、动画确实强,但火锅店点菜界面就是表格加按钮,WinForms 的DataGridView足够,而且部署简单,拷过去就能跑,不装额外运行时。WPF 的MVVM绑定一旦写不好,比 WinForms 的事件代码还难调试。
数据库用 SQLite 还是 SQL Server?huoguo.rar这类小型点菜系统,我一般先用 SQLite。它是单文件数据库,不需要安装服务,数据就是一个.db文件,备份时直接拷走。SQL Server 要装实例、配账号、处理防火墙,门店一台旧电脑光配环境就得折腾半天。等真做到多台收银机同时用了,再把数据层切到 SQL Server,改动量后面第 6 章会说。
3.2 四层项目结构与核心类划分
项目结构我不喜欢搞得太重,但至少分四层:Models放实体类,DAL放数据访问,BLL放业务规则,UI放窗体。一个常见的坏习惯是把 SQL 写在按钮的Click事件里,界面一多,同样的查询逻辑重复三四遍,改一个字段名要全局搜索。四层分开后,UI 只调 BLL,BLL 调 DAL,DAL 只做最朴素的增删改查。实体类就按表来:
public class Dish { public int Id { get; set; } public string Name { get; set; } public string Category { get; set; } public decimal Price { get; set; } public bool IsAvailable { get; set; } } public class OrderItem { public int DishId { get; set; } public string DishName { get; set; } public decimal UnitPrice { get; set; } public int Quantity { get; set; } public decimal Amount => UnitPrice * Quantity; } public class OrderCart { public int TableId { get; set; } public List<OrderItem> Items { get; set; } = new List<OrderItem>(); public decimal TotalAmount { get { return Items.Sum(i => i.Amount); } } }OrderCart是一个临时购物车对象,服务员在界面上把菜一样一样加进去,最后点「下单」一次性写入数据库。这样设计有一个好处:加菜过程中如果客人说「这个不要了」,直接在购物车移除一行就行,不会产生脏数据。
DAL 层用最朴素的 ADO.NET 就好,项目规模小,引入 EF Core 反而增加学习成本。连接字符串统一放在App.config里,方便后面切换数据库。SQLite 连接建议在连接串上加Pooling=true,避免频繁开关连接带来的性能抖动。
3.3 点菜、下单、结账三段核心代码
菜品列表加载是第一个要写的功能。界面上左侧放分类按钮,右侧放DataGridView,点击分类就查一次库:
public List<Dish> GetDishesByCategory(string category) { var result = new List<Dish>(); using var conn = new SQLiteConnection(_connString); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = @" SELECT id, name, category, price, is_available FROM dish WHERE category = $cat AND is_available = 1 ORDER BY sort_no"; cmd.Parameters.AddWithValue("$cat", category); using var reader = cmd.ExecuteReader(); while (reader.Read()) { result.Add(new Dish { Id = reader.GetInt32(0), Name = reader.GetString(1), Category = reader.GetString(2), Price = reader.GetDecimal(3), IsAvailable = reader.GetInt32(4) == 1 }); } return result; }这里的$cat是 SQLite 的参数占位符,一定要用参数化查询而不是拼字符串。点菜系统最容易出的安全问题是服务员在菜名里输入了单引号,或者有人故意在备注里写 SQL 片段,参数化之后这类问题彻底消失。is_available = 1这个条件把沽清的菜挡在列表外,后面第 5 章会专门讲沽清状态的坑。
下单是整个系统里最需要事务保护的环节。一次下单要写三处:订单头插入一条、明细每条插一行、桌台状态改成已开台。这三步必须在一个事务里,否则会出现「订单头有了,明细缺两条,桌台还是空台」的中间状态:
public long CreateOrder(OrderCart cart) { using var conn = new SQLiteConnection(_connString); conn.Open(); using var tx = conn.BeginTransaction(); // 开启事务 try { // 1. 插入订单头 var headCmd = conn.CreateCommand(); headCmd.Transaction = tx; headCmd.CommandText = @" INSERT INTO order_info (table_id, open_time, status, total_amount) VALUES ($tid, $time, 0, $amount); SELECT last_insert_rowid();"; headCmd.Parameters.AddWithValue("$tid", cart.TableId); headCmd.Parameters.AddWithValue("$time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); headCmd.Parameters.AddWithValue("$amount", cart.TotalAmount); var orderId = (long)headCmd.ExecuteScalar(); // 2. 逐条插入明细 foreach (var item in cart.Items) { var detCmd = conn.CreateCommand(); detCmd.Transaction = tx; detCmd.CommandText = @" INSERT INTO order_detail (order_id, dish_id, dish_name, unit_price, quantity, amount) VALUES ($oid, $tid, $name, $price, $qty, $amt)"; detCmd.Parameters.AddWithValue("$oid", orderId); detCmd.Parameters.AddWithValue("$tid", item.DishId); detCmd.Parameters.AddWithValue("$name", item.DishName); detCmd.Parameters.AddWithValue("$price", item.UnitPrice); detCmd.Parameters.AddWithValue("$qty", item.Quantity); detCmd.Parameters.AddWithValue("$amt", item.Amount); detCmd.ExecuteNonQuery(); } // 3. 桌台置为已开台 var tableCmd = conn.CreateCommand(); tableCmd.Transaction = tx; tableCmd.CommandText = "UPDATE table_info SET status = 1 WHERE id = $tid"; tableCmd.Parameters.AddWithValue("$tid", cart.TableId); tableCmd.ExecuteNonQuery(); tx.Commit(); return orderId; } catch { tx.Rollback(); // 任何一步失败,全部回滚 throw; } }last_insert_rowid()是 SQLite 取刚插入记录自增 ID 的函数,注意它必须在同一个连接、同一个事务里调用才有效,换个连接拿到的是别的会话的 ID。下单成功后返回orderId,UI 层拿着它去更新桌台状态显示,并触发厨房小票打印。
结账逻辑同样要包事务。结账不只是把订单状态改成已结账,还要算折扣、抹零、关账、释放桌台,一步都不能漏:
public decimal SettleOrder(long orderId, decimal discountRate, decimal freeAmount) { using var conn = new SQLiteConnection(_connString); conn.Open(); using var tx = conn.BeginTransaction(); try { // 读出原价合计 var totalCmd = conn.CreateCommand(); totalCmd.Transaction = tx; totalCmd.CommandText = "SELECT total_amount FROM order_info WHERE id = $oid"; totalCmd.Parameters.AddWithValue("$oid", orderId); var total = Convert.ToDecimal(totalCmd.ExecuteScalar()); // 先打折,再抹零,抹零是向下取整到分 var afterDiscount = Math.Round(total * discountRate, 2, MidpointRounding.AwayFromZero); var freeCents = (int)Math.Round(freeAmount * 100m, MidpointRounding.AwayFromZero); var finalCents = (int)Math.Floor(afterDiscount * 100m) - freeCents; if (finalCents < 0) finalCents = 0; var finalAmount = finalCents / 100m; // 更新订单状态 var upCmd = conn.CreateCommand(); upCmd.Transaction = tx; upCmd.CommandText = @" UPDATE order_info SET status = 1, close_time = $time, discount_rate = $rate, free_amount = $free, final_amount = $final WHERE id = $oid"; upCmd.Parameters.AddWithValue("$time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); upCmd.Parameters.AddWithValue("$rate", discountRate); upCmd.Parameters.AddWithValue("$free", freeAmount); upCmd.Parameters.AddWithValue("$final", finalAmount); upCmd.ExecuteNonQuery(); // 桌台变待清台,不是直接变空台 var tableCmd = conn.CreateCommand(); tableCmd.Transaction = tx; tableCmd.CommandText = @" UPDATE table_info SET status = 2 WHERE id = (SELECT table_id FROM order_info WHERE id = $oid)"; tableCmd.Parameters.AddWithValue("$oid", orderId); tableCmd.ExecuteNonQuery(); tx.Commit(); return finalAmount; } catch { tx.Rollback(); throw; } }金额计算统一用「分」做整数运算再转回元,这是门店系统的一个实用技巧。浮点数0.1 + 0.2不等于0.3,但整数10 + 20永远等于30。我在finalCents上做Math.Floor,保证 38.51 元最多收 38.5 元,而不是四舍五入多收客人 1 分钱。抹零金额单独存字段,这样月底对账能看出每天一共抹掉了多少钱,老板心里有数。
3.4 时间字段统一用 TEXT,排序才不乱
SQLite 没有独立的日期时间类型,我习惯统一存"yyyy-MM-dd HH:mm:ss"格式的 TEXT。这个格式有一个好处:字典序就是时间顺序,直接ORDER BY open_time DESC就能拿到最新订单,不需要再走datetime()函数转换。当初有人建议存 Unix 时间戳,但门店老板娘偶尔要翻 Excel 看流水,TEXT 格式她双击打开就能看懂,少一道转换工序。
4. 从「能跑」到「能开店」:桌台、套餐、对账与厨房小票
4.1 桌台状态流转:开台、换台、并台的正确姿势
第 2 章说了桌台有三个状态,这里把完整流转写清楚。开台:选中一个空台,创建订单头,桌台变已开台。换台:客人从大厅换到包房,只需把订单头的table_id改掉,同时把两张桌台的状态互换。并台:两桌人商量好坐一起,把 A 桌订单头改到 B 桌,A 桌变空台,B 桌明细自然累加。
换台和并台有一个共同的风险点:目标桌台必须是空台。如果目标桌台已经是已开台,并台会导致两个订单头的明细合在一起,金额翻倍。所以我在这两个操作的入口处都加了一个前置校验:先查目标台状态,不是 0 就弹窗阻止,并给出「请先给目标桌结账」的提示。别小看这个校验,火锅店高峰期最容易出这个乱子。
清台是桌台从「待清台」回「空台」的唯一路径。我建议在界面上单独放一个「清台完成」按钮,而不是让收银员点结账后自动清台。原因前面说过:服务员收拾桌子需要时间,这期间桌台应该处于锁定状态,别人不能再开。有的老板嫌多一步操作麻烦,结果一个月串了两单,客人还没吃完就被领位员带过去,体验极差。
4.2 菜品分类与套餐:锅底也是菜品,套餐也是菜品
菜品分类最简单,就是dish表里的category字段,界面左侧一排按钮绑定这个字段的值。真正容易想复杂的是套餐。火锅店常见的套餐是「双人套餐 128 元:锅底 1 份 + 肥牛 1 份 + 虾滑 1 份 + 蔬菜拼盘 1 份」。我采用的做法是:套餐本身在dish表里也是一条记录,价格 128 元,分类叫「套餐」;另建一张套餐子项表把它拆开:
CREATE TABLE combo_item ( combo_dish_id INTEGER NOT NULL, -- 指向 dish 表中的套餐记录 child_dish_id INTEGER NOT NULL, -- 指向 dish 表中的子项菜品 child_quantity INTEGER DEFAULT 1, -- 子项份数 PRIMARY KEY (combo_dish_id, child_dish_id) );为什么套餐要单独建表拆子项?因为厨房出菜要分档口。点一个双人套餐,前台只看到一条 128 元的记录,但后厨需要知道锅底给火锅档、肥牛给荤菜档、蔬菜给素菜档。如果不拆,后厨小票上只显示「双人套餐 1 份」,师傅们得去猜里面有什么。拆开后,下单时把套餐和子项同时插入订单明细,子项的量用quantity * child_quantity,同时把子项价格记 0 或者记分摊价,这样后厨小票能精确到每道菜,前台也只收套餐价。
4.3 结账对账:折扣、抹零、每日营业额三分钟核对完
对账是所有火锅店老板最关心的功能。我的做法是给收银界面加一个「今日对账」按钮,按天聚合已结账订单:总营业额、现金收了多少、抹零一共抹了多少、每个分类贡献了多少。SQL 写起来很简单:
SELECT COUNT(*) AS order_count, SUM(final_amount) AS total_income, SUM(free_amount) AS total_free, SUM(CASE WHEN category = '锅底' THEN det.amount ELSE 0 END) AS hotpot_sales FROM order_info oi JOIN order_detail det ON det.order_id = oi.id JOIN dish d ON d.id = det.dish_id WHERE oi.status = 1 AND oi.close_time >= $today_start AND oi.close_time < $tomorrow_start;这里有个细节:free_amount是抹零的绝对值,比如应收 200.5 元,抹零 0.5 元,实收 200 元,free_amount存 0.5。月底老板想统计「这个月因为抹零少收了多少钱」,直接SUM(free_amount)就是答案。当初有个同行把抹零做成了负数,对账时怎么都对不平,折腾了两天,其实就是正负号的问题。
厨房小票的打印,我建议直接用 ESC/POS 指令,而不是走 Windows 打印驱动。驱动打印在局域网打印机上经常遇到排队慢、字体乱的问题。下面这个方法是把打印内容拼成字节直接发给打印机的RawPrinterHelper:
private void PrintKitchenTicket(OrderInfo order, List<OrderDetail> details) { var bytes = new List<byte>(); // ESC/POS 初始化打印机 bytes.AddRange(new byte[] { 0x1B, 0x40 }); // 打印标题并居中 bytes.AddRange(new byte[] { 0x1B, 0x61, 0x01 }); bytes.AddRange(Encoding.UTF8.GetBytes("XX火锅 厨房单\r\n")); // 恢复左对齐,打印桌号和下单时间 bytes.AddRange(new byte[] { 0x1B, 0x61, 0x00 }); bytes.AddRange(Encoding.UTF8.GetBytes($"桌号:{order.TableNo} 时间:{order.OpenTime}\r\n")); bytes.AddRange(Encoding.UTF8.GetBytes("------------------------------\r\n")); foreach (var item in details) { bytes.AddRange(Encoding.UTF8.GetBytes($"{item.DishName} x{item.Quantity}\r\n")); } // 走纸并切纸 bytes.AddRange(new byte[] { 0x1B, 0x64, 0x03 }); bytes.AddRange(new byte[] { 0x1D, 0x56, 0x42, 0x00 }); RawPrinterHelper.SendBytes(_printerName, bytes.ToArray()); }0x1B 0x40是初始化指令,0x1B 0x61 0x01是居中对齐,0x1D 0x56 0x42 0x00是切纸指令,这些都是 ESC/POS 的标准指令。需要留意的是编码,打印机要支持中文,Encoding.UTF8通常没问题,但如果打印机固件老,可能需要换成Encoding.GetEncoding("GBK"),这要根据打印机型号试。厨房单和前台结账小票建议分开两台打印机,厨房单打印在出菜口,结账小票在收银台,互不干扰。
5. 避坑篇:C# 点菜系统翻车率最高的 5 个实战问题
5.1 改了菜品价格,昨天的账对不上
现象:月初调了一次肥牛价格,结果 28 号晚上对账时发现月初几天的营业额跟现金对不上,每笔差几块钱。原因:订单明细里没有存价格快照,所有金额都是运行时通过关联dish表实时算出来的。调价之后,历史订单的明细金额也跟着变了。解决:正是第 2 章建表脚本里那两个字段order_detail.dish_name和order_detail.unit_price。下单时把当时的菜名和单价复制进明细,之后菜品表怎么改都不影响历史订单。记住一个原则:订单明细只存快照,不存引用。
5.2 点菜界面卡死,点多了还重复计费
现象:高峰期服务员狂点「加菜」,界面卡住几秒后又弹出一堆重复菜品,客人没点却被收了钱。原因:所有数据库操作都放在 UI 线程里同步执行。DataGridView刷新、SQL 查询、图片加载挤在同一条线程,一旦磁盘慢或数据库锁定,界面就假死;服务员以为没点中又点一下,结果两个请求都进去了。解决:数据库操作丢到后台线程或Task.Run,UI 只负责提交接收结果;同时给「下单」按钮加防重复提交锁,if (_isSubmitting) return;,请求返回后再解锁。这个「下单锁」我后来所有项目都默认加上。
5.3 厨房小票乱码、切纸位置不对
现象:小票上中文变成乱码,或者一张单子打到一半卡纸,切刀把下一单切掉一截。原因:打印时用了系统默认编码,或者发打印指令的时机不对,前一张没走完就发下一张。解决:统一走 ESC/POS 指令,明确指定编码为Encoding.UTF8或GBK;打印内容末尾加走纸指令0x1B 0x64 0x03,让每张单子结尾多走三行纸再切刀。最省事的办法是打印队列串行化,加一个全局锁,同一台打印机一次只允许一个打印任务,不要并发发指令。
5.4 沽清状态只在界面上生效,重启就「复活」
现象:晚上 8 点服务员在界面上把「毛肚」点了沽清,客人点不到;第二天一开机,毛肚又出现在列表里能正常下单,但后厨实际没货。原因:沽清操作只改了内存里DataGridView那一行,没有写回数据库。重启程序后数据从数据库重新加载,内存里的修改自然消失。解决:沽清操作立刻执行UPDATE dish SET is_available = 0 WHERE id = $id,并且把这个操作记到操作日志表里。更稳的做法是加一个「沽清同步」按钮,店长统一确认后再批量落库,防止服务员误触。
5.5 金额计算出现 0.30000000000000004
现象:38.5 元的锅底加 1.5 元的蘸料,合计显示 40.000000000000004 元。原因:用了double或float存金额,二进制浮点数精度有限。解决:所有金额字段用decimal,数据库字段用DECIMAL(10,2);计算时统一换算成「分」做整数运算。这个坑早期项目踩过一次,后来定了铁规矩:凡是涉及钱的地方,出现double直接打回重写。
6. 从单机到多机:验证方法、升级路径与三个实用技巧
6.1 先手工对账验证,再考虑上线
单机版写完,别急着装到店里。我习惯先做一轮「虚拟门店」验证:建一个测试数据库,按真实流程跑五笔订单——两桌正常点菜结账、一桌加菜三次、一桌打折加抹零、一桌中途作废、一桌拼桌。每跑完一笔,手工拿计算器把金额算一遍,跟系统final_amount比对,差一分钱都要查清楚。验证的重点不是界面好不好看,而是每一笔金额能不能从明细一直追溯到头表。这一步过了,系统才敢放到真店。
6.2 多机版最小改动:连接串与自增 ID 获取
门店要做大,必然从一台收银机变两台三台。升级路径上,SQLite 单文件数据库不能支撑多机同时写,最平滑的切换是换到 SQL Server Express,免费且和 C# 生态贴合。改动位置其实只有两处:第一,App.config里的连接字符串从 SQLite 换成 SQL Server;第二,所有last_insert_rowid()换成SCOPE_IDENTITY()。好在第 3 章的数据访问层已经把 SQL 集中在 DAL,全局搜索替换即可。至于桌台状态这类并发写,加一个简单的UPDATE table_info SET status = 1 WHERE id = $id AND status = 0,用受影响行数判断是否被其他收银机抢先,这是最朴素的乐观锁。
6.3 三个实用技巧:日志表、图片预加载、变色提醒
第一个技巧是操作日志表。点菜系统最容易扯皮的是「这菜不是我点的」。加一张operation_log表,记录每次加菜、改菜、结账动作的操作员、时间、桌台和详情,一分钟就能查到操作链。第二个技巧是菜品图片预加载。火锅店菜单图多,每次点分类都现加载会让界面卡顿,我通常在程序启动时把dish表里所有图片按 ID 缓存进内存,界面只做字典查询,点菜秒开。第三个技巧是桌台状态变色提醒:空台绿色、已开台红色、待清台黄色,收银员扫一眼就能判断哪桌能开,不需要逐个点开看。
做门店系统这几年,印象最深的一次翻车是给一家火锅店做换台功能,没做前置校验,高峰期两桌并单,后厨出了两百多的菜,客人不认账,最后店里自己赔了。从那以后我定了个习惯:凡是改变状态的按钮,入口必须校验、操作必须落日志、金额必须留快照。你照着这份思路把一个 C# 点菜系统从表结构做到对账闭环,哪怕只跑在单机上,也已经能顶住一家小店每天的翻台压力。希望帮到你。
本文还有配套的精品资源,点击获取