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

资讯详情

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

Python超市收银系统实战:从购物车到库存扣减的完整实现

Python超市收银系统实战:从购物车到库存扣减的完整实现 简介一套基于Python开发的超市收银系统设计源码面向超市管理者、Python学习者和中小商户致力于解决收银效率、商品信息维护、销售数据汇总、权限控制等日常运营问题。系统设计遵循模块化和面向对象原则核心可分为商品信息管理、收银结算、销售统计、用户权限、数据库接口五个子模块。资源以zip压缩包形式提供大小约2.42MB内含135个文件其中46个py源文件承载主要业务逻辑53个pyc编译文件供加速调用22张jpg图片用于界面展示或说明性配图另有若干配置文件、图标及sqlite数据库整体目录结构清晰易读。实现上运用tkinter、pygame等库搭建图形界面并完成数据库交互适合开展课程设计、毕业程序设计或小型超市信息化升级。目前已有1027人浏览学习借鉴价值较高。通过深度阅读源码可以掌握Python桌面应用开发、数据持久化、多角色权限管理等实用技能同时也能为后续扩展移动支付、会员积分等现代功能提供基础。1. 收银系统为什么难在“人”而不是“货”超市收银系统是一个典型的“看起来简单、落地全是细节”的Python项目。写一个能录入商品、算出总价、打印小票的脚本半天就够了但如果目标是把它放到真实收银台让收银员不培训也能上手、不会因为一个误操作把库存改崩难度就翻倍了。这也是网上大量“Python超市收银系统源码”让人下载后跑不起来、跑起来不会用、用起来不顺手的原因源码只是把“收银”这两个字实现了却把“超市”的环境给丢了。这个标题真正值得做的事是做一个围绕POS场景的完整解决方案商品条码或检索录入、临时购物车、折扣和会员价、库存扣减与回滚、日结报表、小票打印。适合的人群是学完Python基础、想用实战项目巩固面向对象和GUI开发的初学者以及在选型或授课时需要一套可扩展参考的工程师。系统可以不带扫码枪跑也支持扫码枪输入主流程的难点反而在于“库存扣减”和“界面反馈”之间的一致性——让我按照一线开发者的思路拆一版能落地的设计方案。2. 拆解收银核心从购物车到库存扣减的关系模型2.1 为什么不能用Python字典随手存购物车收银系统第一版最容易踩的坑是拿一个Python dict当购物车{苹果: 3, 可乐: 2}。看起来省事但它把“商品实体”“库存数量”“购物车行项目”混在了一起。一旦要支持“同一商品在不同时间价格不同”“按条码查不到时手动输入”“退货扣回库存”这些真实需求这个dict就会变成一坨无法维护的业务逻辑。常见的做法是先明确四个概念商品档案Product固定的条码、名称、单位、参考售价库存流水StockLog每一次入库、出库、退货、盘点的数量变化购物车项目CartItem本次交易里“当前”的价格、数量、小计订单与订单明细SaleOrder / SaleItem交易落库后的快照避免商品未来改价影响历史数据这样的拆分意味着一个总价的表达式不再依赖某个全局变量而是从订单ID反查明细再计算。Python面向对象在这个场景下不是炫技而是给后面加会员功能、促销功能留出扩展边界。2.2 数据层选型SQLite是收银系统的最优起步方案很多收银源码会号称“使用MySQL”来显得专业。但超市收银机的运行环境通常是一台Windows工控机网络不稳定MySQL在断电和并发上反而更麻烦。更务实的方案是Python标准库自带的sqlite3单文件、零配置、支持事务。设计库存表和商品表时我一般会加上“防呆”约束条码唯一、库存不能小于0。下面是一个建表脚本放在项目初始化的第一段执行。CREATE TABLE IF NOT EXISTS products ( barcode TEXT PRIMARY KEY, -- 条码通常是69开头的EAN-13 name TEXT NOT NULL, unit_price REAL NOT NULL CHECK (unit_price 0), stock INTEGER NOT NULL DEFAULT 0 CHECK (stock 0), category TEXT DEFAULT 未分类, create_time TEXT DEFAULT (datetime(now, localtime)) ); CREATE TABLE IF NOT EXISTS stock_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT NOT NULL, change_qty INTEGER NOT NULL, -- 正数为入库、负数为扣减或退货 reason TEXT NOT NULL, -- 采购入库 / 销售扣减 / 退货回补 create_time TEXT DEFAULT (datetime(now, localtime)) ); CREATE TABLE IF NOT EXISTS sale_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, total_amount REAL NOT NULL, pay_method TEXT DEFAULT cash, -- cash / wechat / alipay create_time TEXT DEFAULT (datetime(now, localtime)) ); CREATE TABLE IF NOT EXISTS sale_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, barcode TEXT NOT NULL, name TEXT NOT NULL, price REAL NOT NULL, -- 成交时的单价快照 qty INTEGER NOT NULL );这段SQL解决了收银系统最核心的一个问题历史单据的“快照”。sale_items里的价格是成交时复制过来的即使以后商品涨价旧订单的报表也不会被影响。stock_logs记录了每次变动的原因出现库存对不上时可以直接查流水排查而不是靠猜。代码逻辑上Python操作这些表的代码应该集中在一个DatabaseManager类里所有对connection的操作都走with self.conn:开启事务。特别注意sqlite3默认的isolation_level是也就是说执行execute后会隐式开启事务必须显式commit。写代码时保持这个习惯查询和写操作分开写操作全部封装成save_purchase、create_sale_order这类方法避免在GUI事件函数里拼SQL。2.3 库存扣减的正确姿势先锁后减再记流水收银台看起来是单机操作但会产生并发问题收银员在键盘上飞快地按回车同一件商品的两次销售如果都先SELECT stock再UPDATE理论上可能同时进入判断把最后一个库存卖给两个顾客。SQLite本身对写操作是串行的但两个连接之间的“读-改-写”仍有竞态。规避办法是使用原子的更新语句UPDATE products SET stock stock - ? WHERE barcode ? AND stock ?;然后用cursor.rowcount判断影响的行数。如果为0证明库存不足直接回滚事务并提示用户换货。这个方式比“先查再改”少了一次网络往返也更不容易写错。每次扣减成功再往stock_logs插入一条change_qty为负的流水两个操作必须放在同一个with self.conn:块里。下面给出一个采购入库的参考实现收银系统在“一键退货”时回补库存也复用这个函数只是reason传参不同。def add_stock(self, barcode: str, qty: int, reason: str 采购入库) - bool: if qty 0: return False with self.conn: cur self.conn.execute( UPDATE products SET stock stock ? WHERE barcode ?, (qty, barcode), ) if cur.rowcount 0: raise ValueError(f条码 {barcode} 不存在) self.conn.execute( INSERT INTO stock_logs (barcode, change_qty, reason) VALUES (?, ?, ?), (barcode, qty, reason), ) return Truewith self.conn:块内一旦出现异常SQLite会自动回滚库存和流水不会出现“更新了库存但没记流水”的中间状态。调用侧只需要捕获ValueError和sqlite3.Error弹窗给出友好提示。3. 用Tkinter搭建收银界面布局决定收银员的手速3.1 为什么选Tkinter而不是PyQtPython的GUI选择里PyQt功能强、界面现代但它引入的信号槽机制和打包体积对收银项目是负担。Tkinter是标准库的一部分没有额外依赖win系统上一台老掉牙的收银机也能跑得动。对于超市收银场景界面控件不需要多炫需要的是“键盘能完成全部操作”。一套实用的收银界面布局包含四个区域顶部是当前商品信息条中间左侧是购物车列表Treeview多列显示品名、单价、数量、小计右侧是被扫进来的商品信息和大字总价底部是“结算”“清空”“退货”“挂单”四个按钮。Treeview是Tkinter表格控件中最适合展示购物车明细的组件设置columns时必须把#0这一列隐藏否则表格第一列会出现多余的树形节点序号。self.cart ttk.Treeview( master, columns(name, price, qty, subtotal), showheadings, height18, ) self.cart.heading(name, text品名) self.cart.heading(price, text单价) self.cart.heading(qty, text数量) self.cart.heading(subtotal, text小计) self.cart.column(name, width180, anchorw) self.cart.column(price, width80, anchore) self.cart.column(qty, width60, anchorcenter) self.cart.column(subtotal, width100, anchore)showheadings是这里的关键参数它告诉Treeview只显示列标题不显示树节点。设置anchor让数字右对齐、品名左对齐符合表格阅读习惯。收银员最关心的是总价区域所以总价Label要用大号字体和醒目颜色字体大小推荐36号以上黑底红字或白底黑字保证在阳光直射的收银台也能看清。3.2 扫码录入的核心逻辑回车即完成一次扫描扫码枪的本质是一个“会快速敲键盘的输入设备”它的输出会以回车结尾。所以收银系统处理扫码不需要监听特殊硬件接口只需要在条码输入框里绑定回车事件即可。关键设计是扫码成功后自动把焦点还给出输入框让下一件商品能直接扫码不需要鼠标点击。def on_barcode_entered(self, event): barcode self.barcode_var.get().strip() if not barcode: return product self.db.get_product(barcode) if product is None: messagebox.showwarning(未识别, f条码 {barcode} 未在商品库中登记) self.barcode_var.set() return self.add_to_cart(product) self.barcode_var.set() self.barcode_entry.focus_set()这段代码绑定的事件来源可以是扫码枪也可以是收银员手动按回车。如果条码在商品库里查不到系统不会直接拒绝输入而是先警告再由收银员决定是否手动录入商品或跳转到建档界面。这里我把get_product的结果设计成sqlite3.Row或字典方便之后扩展。要注意每次扫码完成都要把输入框内容清空并重新focus_set()这是保证连续扫码不串货的关键。add_to_cart函数里有一个收银系统特有的细节如果同一商品被扫了两次正确的交互是在购物车中把数量加1而不是新增一行。修改Treeview的数据不能用item直接覆盖要先记录当前选中行删除旧行再插入新行或者用self.cart.insert(end, values(...))插入后再对相同条码的行做数量累加。更符合业务的做法是购物车数据用Python list维护Treeview只负责展示增删改都操作list后刷新Treeview这样数据层和界面层互不污染。3.3 折扣与会员价在购物车里做“价格快照”真实超市收银一定会遇到促销某商品本周八折、会员专享价、买二送一。设计上我把折扣逻辑放在加入购物车之后、未结算之前意味着购物车里的价格是“当前最优价”结算时直接把价格写入订单明细不需要回头再算一遍。常见做法是提供两种折扣入口整单折扣收银员口头告知店长后在结算确认弹窗里手动输入总价折扣率如9折只影响当前订单的total_amount单品折扣选中购物车某一行右键或快捷键调出改价窗口修改该商品的行单价并在Treeview中标记为“折”改价会直接影响收银对账所以每次改价我都要在sale_order表里额外记录一个discount_detail的文本字段保存{商品条码: 原价-折扣价}的JSON序列化结果方便日后对账和申诉。SQLite的TEXT字段足够存这类短JSON字符串不需要引入额外的表。4. 结算、日结与数据备份收银系统的“收尾工程”4.1 结算事务排错一个从未被执行过的commit结算是收银系统最不能出错的操作。一次结算至少做三件事计算总价、生成订单、扣减库存。这三件事必须在一个SQLite事务里完成。下面的checkout函数是核心代码注意它返回订单ID方便后续打印小票。def checkout(self, cart: list, pay_method: str cash) - int: with self.conn: cur self.conn.execute( INSERT INTO sale_orders (total_amount, pay_method) VALUES (?, ?), ( round(sum(item[subtotal] for item in cart), 2), pay_method, ), ) order_id cur.lastrowid for item in cart: self.conn.execute( UPDATE products SET stock stock - ? WHERE barcode ? AND stock ?, (item[qty], item[barcode], item[qty]), ) if cur.rowcount 0: raise StockNotEnoughError(item[barcode]) self.conn.execute( INSERT INTO sale_items (order_id, barcode, name, price, qty) VALUES (?, ?, ?, ?, ?), (order_id, item[barcode], item[name], item[price], item[qty]), ) self.conn.execute( INSERT INTO stock_logs (barcode, change_qty, reason) VALUES (?, ?, 销售扣减), (item[barcode], -item[qty]), ) return order_id这里每个UPDATE语句都带了AND stock ?扣减不到就raise异常外层由GUI调用者捕获。闭店前的日结称为“交接班结账”核心是统计当天所有订单的支付方式汇总、商品销量排行和总流水。最方便的实现是查询sale_orders和sale_items按create_time的日期筛选。SELECT pay_method, COUNT(*) AS order_count, SUM(total_amount) AS amount FROM sale_orders WHERE substr(create_time, 1, 10) ? GROUP BY pay_method;第二个实用查询是当日商品销售排行SELECT barcode, name, SUM(qty) AS total_qty, SUM(price * qty) AS total_sales FROM sale_items WHERE order_id IN (SELECT id FROM sale_orders WHERE substr(create_time, 1, 10) ?) GROUP BY barcode ORDER BY total_sales DESC LIMIT 10;用substr(create_time, 1, 10)匹配日期而不是用LIKE 2025-01-01%因为前者可以利用索引虽然SQLite体量下差别不大但对编码习惯有要求。4.2 多线程与界面卡死的正确解法收银系统在结算时最怕什么程序卡住。如果在Tkinter主线程里直接执行数据库写入和打印任务碰到打印机缺纸或网络共享盘慢界面就会进入“假死”状态。收银员第一反应是疯狂点击结果触发多次结算。解决思路是把耗时操作丢进threading.Thread但不要直接在线程里改GUI。常见做法是主线程只负责收集参数启动线程去执行checkout线程完成后通过queue.Queue把结果传回主循环主线程再更新Treeview和总价。def start_checkout(self): if not self.shopping_cart: return self.checkout_btn.config(statedisabled) def worker(): try: order_id self.db.checkout(self.shopping_cart) self.result_queue.put((ok, order_id)) except Exception as exc: self.result_queue.put((err, str(exc))) threading.Thread(targetworker, daemonTrue).start() self.after(100, self.poll_checkout_result) def poll_checkout_result(self): try: status, payload self.result_queue.get_nowait() except queue.Empty: self.after(100, self.poll_checkout_result) return if status ok: self.cart_view.clear() self.total_var.set(0.00) messagebox.showinfo(成功, f订单 {payload} 已完成) else: messagebox.showerror(库存不足, payload) self.checkout_btn.config(statenormal)4.3 SQLite数据库备份的笨办法比自定义导出更可靠超市打烊后最重要的事是数据备份。不要写“导出为Excel”功能因为Excel格式在写入时可能遇到编码问题以及导出中途断电导致文件损坏更安全的方式是直接用Python把db文件复制到备份目录日期作为文件名。import shutil import os from datetime import datetime backup_dir rD:\pos_backup # 收银机本地盘或局域网共享盘 os.makedirs(backup_dir, exist_okTrue) stamp datetime.now().strftime(%Y%m%d_%H%M%S) dst os.path.join(backup_dir, fpos_data_{stamp}.db) shutil.copy2(rD:\pos_data\pos.db, dst)Windows共享盘容易因为权限和断线导致偶发IO错误所以备份后立刻验证文件是否存在以及大小是否大于0。SQLite的备份也可以用sqlite3.Connection.backup()但生产环境直接复制文件配合WAL模式前记得先执行PRAGMA wal_checkpoint。5. 打印小票的缺省坑LTP打印小票最常用的三种进纸方案小票打印是最容易暴露收银系统不成熟的地方。Python操作小票打印机最常用的是python-escpos库它在普通Tkinter工程里只需要两行依赖pip install python-escpos。以USB连接的热敏打印机为例代码结构是固定的。from escpos.printer import Usb # Vendor ID和Product ID通过lsusb或Windows设备管理器查看 p Usb(0x0416, 0x5011, timeout0, in_ep0x81, out_ep0x03) p.set(aligncenter, width2, height2) p.textln(XX超市) p.set(alignleft, width1, height1) p.textln(单号: 20250115001) p.textln(时间: 2025-01-15 18:30:22) for item in cart: p.textln(f{item[name]} x{item[qty]} {item[subtotal]:.2f}) p.textln(- * 32) p.textln(f合计: {total:.2f}) p.cut()注意收银小票机是“自动走纸”的打印完必须调用p.cut()或p.pulse()自动切纸否则下一张小票会顶在上一张后面需要人工撕降低收银效率。遇到“打印一部分卡住”的问题大概率是编码不匹配热敏打印机GBK与UTF-8中文混用escpos库的encoding参数要设置为gbk而不是默认的cp437。如果手头没有真实打印机开发调试期可以加一个“预览小票”按钮把要打印的文本先渲染到Tkinter的Text空间里确认排版无误再开打印机这样节省大量测试耗材。6. 把系统跑在收银机上用PyInstaller打包成exe的验证手段收银系统的最终形态不是python main.py在IDE里运行而是一个双击即启的exe。PyInstaller是当前Python打包工具里最成熟的选择不支持交叉编译也就是说在Windows上打包只能在Windows环境进行。pip install pyinstaller pyinstaller -F -w -n PosSystem main.py-w是去掉控制台黑窗口-F是打包成单文件。单文件模式看似方便但启动时需要解压到临时目录启动变慢且容易被杀毒软件误报。对于收银机这种固定运行环境我更推荐用-D打成目录配合--add-data把数据库模板文件一起带进去启动速度和兼容性更好。pyinstaller -D -w --add-data templates/init_db.sql;templates -n PosSystem main.pyWindows下--add-data参数内部用分号分隔源路径和目标路径这和Linux下的冒号不同经常有开发者在这踩坑。打包完成后把dist/PosSystem整个目录拷贝到收银机桌面先做一次全流程验证新建商品、扫码录入、结算、打印小票、日结查询、备份数据六步全部通过才可以交付。收银机建议关闭Windows自动更新防止重启或驱动更新打断收银过程。数据库文件不要放在exe同目录而应该放到固定的数据目录例如C:\PosData避免程序更新时覆盖数据。最后给数据库文件加一个PRAGMA journal_modeWAL让读写可以并发收银高峰期不会因为一次日结查询就锁住整个系统。本文还有配套的精品资源点击获取
返回列表