简介面向超市日常运营场景的 Python 桌面管理项目基于 Python 3.9 与 Tkinter 构建图形界面使用 SQLite3 完成商品数据的持久化存储并引入 openpyxl、pandas 实现 Excel 报表导出和销售统计。系统按 controller、view、Dao 等模块目录分层帮助初学者理解界面、业务逻辑与数据访问分离的设计思路适合课程设计、期末项目或毕业设计参考。压缩包共 21 个文件含 15 个 Python 源文件、4 个界面 GIF 图、1 个 Markdown 说明文档和 1 个数据库文件整体仅 201KB结构紧凑便于快速下载和本地运行。功能覆盖商品信息增删改查、库存自动调整、销售记录登记、按名称或类别查询、报表生成等通过 pandas 可对销售数据进行聚合统计openpyxl 则便于导出 Excel 报表例如按类别统计销售额、生成库存不足预警表配合说明文档和界面预览图片能够快速跑通完整流程并对照目录理解各模块从界面到数据库的联动方式。已有 1028 人学习下载可作为课程作业或二次开发的基础版本进一步扩展权限管理、进货统计或数据可视化。1. 一家小超市的账为什么值得用 Python 写套系统我接过一个真实需求社区超市的老板娘三个店员轮班每天用 Excel 记账库存靠月底盘点经常出现货架上还有东西但系统显示已售罄或者系统说还有 5 件库里其实早就空了。这种规模的门店上进销存 SaaS 太贵太重Excel 又确实管不住并发盘点和交接班。最后我给她做了一套本地运行的 Python 超市信息管理系统GUI 用 tkinter数据存 SQLite单机部署不联网数据文件就是一个 .db 文件备份靠复制粘贴。这个技术选型看起来不算新潮但在这个场景下非常稳。这篇文章就是把这套系统的设计思路、建表逻辑、界面布局、业务代码和真实踩坑经历完整拆开适合两类人一是有一门 Python 基础、想做一个完整项目练手的初学者二是真的需要一套本地单机进销存工具、又不想付年费的小店经营者。读完你能照着复现一个能跑的商品管理加进销存加收银的最小系统。2. 先把数据层立住SQLite 建模与四个必做字段做这种带界面的业务系统最常见的翻车顺序是先画界面再写按钮最后才想数据库。结果界面改了七八版数据表也跟着推倒重来。我的习惯是第一步先建 SQLite 数据表定死字段类型和约束界面再难看的系统只要数据不乱就还能用。2.1 权限与登录表为什么用户表要先于商品表设计超市信息管理系统里最容易被新手忽略的是一张员工表。很多人在开发时只想着商品和库存登录功能放到最后硬加上去结果用户表设计得很随意。我一般第一张表就建用户表因为后面所有的入库、收银、退货记录都要关联操作人没有用户表这些业务表就只能存一个文本框里敲进去的名字数据一多就乱而且没法追究哪笔操作是谁做的。用户表的设计遵循最小够用原则CREATE TABLE IF NOT EXISTS tb_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 店员 CHECK(role IN (管理员,店长,店员)), is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) );字段含义拆开说username 加 UNIQUE 约束保证登录名不重复password 直接用明文存储是在偷懒但本地单机场景里我的折中方案是至少做一次 SHA-256 哈希再入库不要明文。role 字段控制菜单可见性这个超市系统里管理员能看到报表和员工管理页店员只能操作收银和商品查询。is_active 是软删除标记员工离职了不要物理删行否则历史订单里“操作人”就断了。created_at 用 SQLite 的 datetime(now,localtime) 存本地时间这个细节很重要因为 SQLite 默认的 now 是 UTC 时间直接存会比你本地时间晚 8 小时。2.2 商品、入库、销售三张核心表字段和约束一次说清商品表是整个系统的地基。超市商品有两个特点同一款饮料有不同规格比如 500ml 和 2L 的雪碧条码不一样同一件商品在促销时价格会变但历史入库价和销售价要被留存。所以商品表里不能只存“当前价格”要把规格、条码、单位、默认进价、默认售价、库存下限都拆成独立字段。CREATE TABLE tb_product ( id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT UNIQUE, name TEXT NOT NULL, spec TEXT, unit TEXT NOT NULL DEFAULT 件, category TEXT DEFAULT 未分类, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0, stock INTEGER NOT NULL DEFAULT 0, stock_lower_limit INTEGER NOT NULL DEFAULT 5, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)), updated_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) );这里有个经验商品库存字段 stock 看起来可以直接存在商品表里但严谨的做法是库存只做“缓存”真正的库存依据是入库表和销售表。原因很直接——如果某天你发现库存数字不对没有流水记录根本查不出是哪里错了。所以我在设计时把库存冗余到商品表里方便查询同时用入库表和销售表来追溯每次变动一旦对不上账就重算库存。入库表记录每一批货的来源CREATE TABLE tb_stock_in ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL CHECK(quantity 0), unit_price DECIMAL(10,2) NOT NULL, supplier TEXT, operator_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) );销售表拆成主表和明细表主表存一笔订单的整体信息明细表存这笔订单里具体买了哪些商品。拆开的好处是统计客单价、流水条数、退换货时都能精确定位。CREATE TABLE tb_order ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, total_amount DECIMAL(10,2) NOT NULL, pay_method TEXT DEFAULT 现金, operator_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) ); CREATE TABLE tb_order_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, product_name TEXT, quantity INTEGER NOT NULL CHECK(quantity 0), price DECIMAL(10,2) NOT NULL );2.3 初始化数据库建表脚本与三条防数据漂移的规则建完表之后必须有一份能一键执行的 init_db.py。我通常会写成一个函数在程序启动时调用用 IF NOT EXISTS 保证重复执行不报错。先把所有建表语句放进一个列表然后逐条执行省得维护一个巨大的 sql 文件。import sqlite3 import hashlib DB_PATH pos.db def hash_password(password: str) - str: return hashlib.sha256(password.encode(utf-8)).hexdigest() def init_db(): conn sqlite3.connect(DB_PATH) cursor conn.cursor() tables [ CREATE TABLE IF NOT EXISTS tb_user (...), CREATE TABLE IF NOT EXISTS tb_product (...), CREATE TABLE IF NOT EXISTS tb_stock_in (...), CREATE TABLE IF NOT EXISTS tb_order (...), CREATE TABLE IF NOT EXISTS tb_order_item (...) ] for sql in tables: cursor.execute(sql) admin_exists cursor.execute( SELECT COUNT(*) FROM tb_user WHERE usernameadmin ).fetchone()[0] if admin_exists 0: cursor.execute( INSERT INTO tb_user(username, password, role) VALUES(?,?,?), (admin, hash_password(123456), 管理员) ) conn.commit() conn.close() if __name__ __main__: init_db()逻辑说明这里把建表和初始化管理员账号放在一起。hash_password 函数对默认密码做哈希而不是明文入库。count 判断是一种常见做法避免每次启动都重复插入管理员。参数说明DB_PATH 定义在文件顶部方便换成绝对路径。admin 初始密码 123456 必须提示使用者第一次登录后修改。这里的建表脚本只是演示骨架实际项目里每一张表的字段我都建议在注释里写明用途否则三个月后你自己回来看代码都记不清 stock 和 stock_upper 哪个是上限。我做了三年多的本地数据库系统总结出三条防数据漂移的规则写在这里给你参考。第一条规则金额一律用 DECIMAL 或整数分存储绝对不要用 FLOAT。这个应该不用多解释0.1 加 0.2 等于 0.30000000000000004 在 Python 里是常态存金额会把对账逼疯。第二条规则库存不要直接 UPDATE而是通过“入库单 销售明细”回算。商品表里的 stock 字段就当缓存每次入库、下单时更新它但每晚定时任务里重算一次全部库存发现不一致就告警。第三条规则凡是会被追溯的字段都要记 created_at 和 operator_id。哪怕现在觉得没用等出问题的时候这就是救命数据。3. 界面层用 tkinter 搭建商品管理页面的最小可运行代码数据层奠定之后进入 tkinter 界面层。tkinter 做管理系统界面确实不如 Electron 好看但它最大的优势是Python 自带不需要额外安装 GUI 框架打包体积小。这套超市系统的界面我采用的是经典三块式布局——左侧导航、右侧内容、底部状态栏符合收银员的操作直觉。3.1 页面骨架主窗口加侧边导航加内容区的布局套路不要把所有控件直接堆在主窗口里否则按钮一多就乱。常见的做法是主窗口用 grid 布局第 0 行放顶部标题栏第 1 行放主体区域主体区域左侧用 Frame 包导航按钮右侧用 Frame 包内容区。内容区通过切换 Frame 的 grid 或 pack 来控制显隐。import tkinter as tk from tkinter import ttk class App(tk.Tk): def __init__(self): super().__init__() self.title(超市信息管理系统) self.geometry(1000x600) self._build_layout() def _build_layout(self): container tk.Frame(self) container.pack(fillboth, expandTrue) nav_frame tk.Frame(container, width160, bg#2c3e50) nav_frame.pack(sideleft, filly) self.content_frame tk.Frame(container, bg#f8f9fa) self.content_frame.pack(sideright, fillboth, expandTrue) nav_buttons [商品管理, 入库管理, 收银台, 订单查询, 报表统计, 员工管理] for text in nav_buttons: btn tk.Button( nav_frame, texttext, bg#34495e, fgwhite, reliefflat, anchorw, padx10, pady8, commandlambda ttext: self.switch_page(t) ) btn.pack(fillx, pady1) def switch_page(self, page_name): for widget in self.content_frame.winfo_children(): widget.destroy() label tk.Label(self.content_frame, textf{page_name} 页面, font(微软雅黑, 16)) label.pack(pady20) if __name__ __main__: app App() app.mainloop()逻辑说明这里用 tk.Tk 作为主窗口类继承的方式方便在整个系统里共享数据库连接和用户信息。switch_page 方法先清空 content_frame 里的所有子控件再填充新页面这是最朴素的“页面切换”方案帧率不高但操作简单足够管理系统的交互频率。参数说明nav_frame 设置 width160用 pack 布局时框宽不会自动扩张但里面按钮的 fillx 会顺着父容器宽度铺满。bg#2c3e50 是偏深蓝灰的导航底色。reliefflat 去掉按钮的 3D 边框扁平化风格在 tkinter 里比默认的凸起样式清爽得多。lambda ttext 这个细节要关注如果直接写 commandlambda: self.switch_page(text)循环结束后 text 永远指向最后一个按钮的名称这是经典的作用域坑。3.2 商品列表与新增编辑表单StringVar 与 Treeview 的配合商品管理页是这套系统里交互最频繁的页面。左侧是 Treeview 商品列表右侧是新增和编辑表单。Treeview 是 ttk 组件里最适合展示表格数据的控件比 Listbox 好看支持多列排序。def build_product_page(self): panel tk.Frame(self.content_frame, bg#f8f9fa) panel.pack(fillboth, expandTrue, padx10, pady10) columns (id, barcode, name, spec, unit, purchase_price, sale_price, stock) self.tree ttk.Treeview(panel, columnscolumns, showheadings, height18) headings {id: ID, barcode: 条码, name: 商品名称, spec: 规格, unit: 单位, purchase_price: 进价, sale_price: 售价, stock: 库存} for col in columns: self.tree.heading(col, textheadings[col]) width 60 if col id else 120 self.tree.column(col, widthwidth, anchorcenter) self.tree.pack(sideleft, fillboth, expandTrue) form_frame tk.Frame(panel, width220, bg#ffffff) form_frame.pack(sideright, filly, padx(10, 0)) self.var_barcode tk.StringVar() self.var_name tk.StringVar() self.var_spec tk.StringVar() self.var_price tk.StringVar() fields [(条码, self.var_barcode), (名称, self.var_name), (规格, self.var_spec), (售价, self.var_price)] for i, (label, var) in enumerate(fields): tk.Label(form_frame, textlabel, bgwhite).grid(rowi, column0, stickyw, pady5) tk.Entry(form_frame, textvariablevar, width18).grid(rowi, column1, pady5) tk.Button(form_frame, text保存商品, commandself.save_product).grid(row4, columnspan2, pady10)逻辑说明StringVar 是 tkinter 的变量类界面输入框的值绑定在 StringVar 上保存时直接 var.get() 拿值。用 StringVar 而不是直接 Entry.get() 的理由是后面做表单回填时只需要 var.set(值) 就能把数据填回输入框不用逐控件操作。Treeview 的 columns 元组定义了列的字段标识headings 是列标题的显示文本两者必须顺序对应。showheadings 是去掉 Treeview 第一列默认的树形标记只展示纯表格。参数说明anchorcenter 让每列内容居中显示对单价和库存这类数字列比较友好。width120 是给名称列的初始宽度实际使用时 Treeview 支持用户拖拽列宽。height18 表示列表显示 18 行配合窗口 600 的高度刚好不出现滚动条悬空。form_frame 固定 width220里面的控件用 grid 布局比 pack 更适合表单这种“标签 输入框”的排列模式。3.3 表单校验避免把空字符串和超长数字写进数据库写后台管理系统时前端校验永远不能省。哪怕是内部工具只要有人会用就会有人往金额框里输入“abc”。校验函数放在保存之前不合格就弹出 messagebox 并中断保存。def validate_product_form(self) - bool: barcode self.var_barcode.get().strip() name self.var_name.get().strip() price self.var_price.get().strip() if not barcode or not name: messagebox.showwarning(校验失败, 条码和商品名称不能为空) return False try: price_val float(price) if price_val 0: raise ValueError self.var_price.set(f{price_val:.2f}) except ValueError: messagebox.showwarning(校验失败, 售价必须是大于 0 的数字) return False return True逻辑说明strip() 去掉首尾空格避免用户复制数据时带入换行符。价格用 float 转一次转换失败说明输入不是数字。转成功后再用 set 格式化回两位小数这步同时完成了数据清洗用户填 8 保存后自动变成 8.00。参数说明messagebox.showwarning 的第一个参数是窗口标题栏文字第二个是正文内容。这个函数的返回值不用接收但记得在文件头部导入 from tkinter import messagebox。校验通过返回 True调用方再执行 SQL不通过直接 return 中断后续流程。我在实际开发中还加过一个条码位数校验商品条码通常是 13 位 EAN-13但有些散称商品的店内码是 13 位以内所以没写死位数只校验“不能包含汉字和空格”。过于严格的格式校验对真实用户反而是负体验。4. 业务层进销存与订单收银的联动逻辑界面和数据表都有了接下来把业务规则写清楚。这个系统的核心业务就三件事入库、收银、查报表。每件事的代码写起来不难难的是保证数据库在异常情况下不出错。4.1 入库逻辑让库存变更跟着一条 insert 记录走入库操作的正确姿势是“一个事务里同时做两件事”往 tb_stock_in 插入一条入库记录同时 UPDATE tb_product 的 stock 字段。前文说过 stock 是缓存但这里为了界面查询实时性必须同时更新它。def save_stock_in(product_id: int, quantity: int, unit_price: float, operator_id: int, supplier: str ): conn sqlite3.connect(DB_PATH) try: conn.execute(BEGIN) conn.execute( INSERT INTO tb_stock_in(product_id, quantity, unit_price, supplier, operator_id) VALUES(?,?,?,?,?), (product_id, quantity, unit_price, supplier, operator_id) ) cursor conn.execute( UPDATE tb_product SET stock stock ?, updated_at datetime(now,localtime) WHERE id ?, (quantity, product_id) ) if cursor.rowcount 0: raise ValueError(f商品 ID {product_id} 不存在) conn.commit() return True except Exception as e: conn.rollback() messagebox.showerror(入库失败, str(e)) return False finally: conn.close()逻辑说明conn.execute(BEGIN) 是显式开启事务。SQLite 默认是自动提交模式一条语句一个事务如果不手动 BEGIN两个操作之间如果第二步失败了第一步已经写进库库存记录在案但商品表没加数量数据就漂了。rollback 用来撤销已执行的 insert保证要么全成功要么全失败。参数说明cursor.rowcount 表示 UPDATE 影响的行数如果商品 ID 不存在是 0主动抛异常触发回滚比静默失败要安全得多。这种手动 BEGIN 的写法在 tkinter 事件回调里是安全的因为 SQLite 单连接顺序执行不需要考虑多线程并发写。4.2 收银逻辑一次下单四张表要同时改对收银是这套系统里最不能出错的功能。一次完整的下单操作涉及往 tb_order 插主单、往 tb_order_item 插一条或多条明细、把每个商品的库存减掉。这三步任何一步失败都不能留下半截数据。def checkout(cart: list, pay_method: str, operator_id: int): conn sqlite3.connect(DB_PATH) try: conn.execute(BEGIN) order_no datetime.now().strftime(%Y%m%d%H%M%S) str(random.randint(100, 999)) total 0 for item in cart: total item[price] * item[qty] cursor conn.execute( INSERT INTO tb_order(order_no, total_amount, pay_method, operator_id) VALUES(?,?,?,?), (order_no, total, pay_method, operator_id) ) order_id cursor.lastrowid for item in cart: conn.execute( INSERT INTO tb_order_item(order_id, product_id, product_name, quantity, price) VALUES(?,?,?,?,?), (order_id, item[pid], item[name], item[qty], item[price]) ) result conn.execute( UPDATE tb_product SET stock stock - ?, updated_at datetime(now,localtime) WHERE id ? AND stock ?, (item[qty], item[pid], item[qty]) ) if result.rowcount 0: raise ValueError(f商品 {item[name]} 库存不足当前库存 {get_stock(item[pid])}) conn.commit() return order_no, total except Exception as e: conn.rollback() messagebox.showerror(下单失败, str(e)) return None, 0 finally: conn.close()逻辑说明这里用了一个重要的技巧——UPDATE 语句中加上 stock 数量的条件。这样数据库层面就做了库存校验如果库存不够rowcount 为 0抛出异常回滚整个订单。这种写法比“先 SELECT 查库存再判断”要安全因为后者存在时间差虽然单机系统不会有并发但养成这种习惯后写多用户系统也不慌。参数说明order_no 用时间戳加随机三位数拼接保证唯一。临界情况是同秒内两笔订单加 random 三位数基本可以避免重复。total 在前端展示时可以用 Decimal 控制精度这里简单用 float 乘加最后在 SQL 里存 DECIMAL 类型会自动转。cart 参数是列表套字典的结构每个字典必须有 pid、name、qty、price 四个键这个约定要在调用方统一。4.3 报表统计用最笨的 SQL 聚合做出昨天和本月销售额报表模块不适合写太复杂的 ORM 或者缓存逻辑。我的做法是直接写 SQL 聚合语句怎么直观怎么写。超市老板要看的无非三个数今天卖了多少、这个月卖了多少、哪些商品卖得最多。def sales_report(conn, day_offset: int 0) - dict: day (datetime.now() - timedelta(daysday_offset)).strftime(%Y-%m-%d) total conn.execute( SELECT COALESCE(SUM(total_amount), 0) FROM tb_order WHERE date(created_at) ?, (day,) ).fetchone()[0] count conn.execute( SELECT COUNT(*) FROM tb_order WHERE date(created_at) ?, (day,) ).fetchone()[0] top_products conn.execute( SELECT t.product_name, SUM(t.quantity) AS qty FROM tb_order_item t JOIN tb_order o ON t.order_id o.id WHERE date(o.created_at) ? GROUP BY t.product_id ORDER BY qty DESC LIMIT 5, (day,) ).fetchall() return {date: day, total: total, order_count: count, top_products: top_products}逻辑说明date(created_at) 是 SQLite 的日期函数可以把 2024-06-01 14:23:00 截到 2024-06-01再和传入的日期字符串比较。COALESCE 包裹 SUM 是为了在没有订单时返回 0 而不是 None。TOP 5 的排行在明细表上做 GROUP BY再 JOIN 主表拿到日期条件因为明细表本身不存时间必须回到主表取。参数说明day_offset 为 0 表示今天为 1 表示昨天为 -1 表示明天这个参数设计成负数边界也成立虽然实际用不到。报表页面的 UI 就是接收这个返回值用 Label 展示 total 和 count用 Treeview 展示 top_products。这套 SQL 在数据量到十万条订单时依然秒开单机小店完全够用。5. 避坑指南tkinter 加业务系统最容易翻车的 5 个地方前面都是顺着开发流程讲这里专开一章讲血泪经验。这些坑我全都踩过有些翻了车后花了一整天才定位到原因。5.1 现象一点按钮界面卡死窗口一直在转圈等待原因按钮回调里写了耗时操作比如在保存入库时同时执行了一个大数据量的报表查询tkinter 的主循环被阻塞整个窗口进入“假死”状态。单线程的 tkinter 应用里所有耗时操作都会卡界面这不是你没写对是架构限制。解决把耗时任务丢到 threading 线程里执行再用 queue 或 after 机制把结果传回主线程更新界面。注意tkinter 的控件不能在线程里直接修改否则会偶尔崩溃。import threading import queue def save_product_async(self): q queue.Queue() def worker(): try: self.save_product() q.put((ok, 保存成功)) except Exception as e: q.put((error, str(e))) threading.Thread(targetworker, daemonTrue).start() self.after(100, lambda: self._poll_result(q)) def _poll_result(self, q): try: status, msg q.get_nowait() messagebox.showinfo(提示, msg) except queue.Empty: self.after(100, lambda: self._poll_result(q))逻辑说明worker 函数在新线程里执行原来的保存逻辑完成后把消息放进队列。主线程的 _poll_result 每隔 100 毫秒尝试从队列取一次结果取到就弹窗取不到就继续轮询。after 是 tkinter 主循环提供的定时器接口只能在主线程调用。参数说明daemonTrue 让线程随主程序退出而结束避免关闭窗口时残留线程阻塞进程退出。after(100, ...) 的 100 是毫秒太小会频繁调度浪费 CPU太大会让界面反馈迟钝100 是实测比较舒服的值。5.2 现象二多个窗口同时打开改了一个另一个不刷新原因直接用 tk.Toplevel 建新窗口后在新窗口里修改了商品信息旧窗口的 Treeview 没有自动感知数据变化。很多人以为 Treeview 绑定数据源后会自动刷新其实不会。解决关闭子窗口或者保存成功后主动调用父窗口的加载函数。我通常会在子窗口保存成功的分支里调用 self.master.refresh_product_list()。前提是子窗口创建时把父窗口对象传进来通过 master 引用调用它的方法。还有一种写法是让父窗口的 refresh 方法绑定到子窗口的 WM_DELETE_WINDOW 协议上这样关闭子窗口时也会刷新。两种做法都行关键是不要指望 tkinter 帮你自动同步。5.3 现象三数据库路径写死相对路径打包后变成 exe 打不开原因开发时 DB_PATH pos.db文件在项目文件夹下没问题用 PyInstaller 打成单文件 exe 后程序运行目录变成了临时解压目录数据库文件写到那里用户一关闭程序文件就被清理第二天打开数据全没了。解决把数据库路径放到用户的 AppData 目录或者程序所在目录的固定子文件里。我用的是 sys.executable 的父目录这样数据文件就在 exe 旁边用户备份只需要复制一个目录。import sys import os def get_db_path(): if getattr(sys, frozen, False): base_dir os.path.dirname(sys.executable) else: base_dir os.path.dirname(os.path.abspath(__file__)) return os.path.join(base_dir, data, pos.db)逻辑说明sys.frozen 是 PyInstaller 打包后的标记属性存在时说明当前运行的是 exe取 exe 所在目录不存在时说明是源码运行取当前工作目录。data 子目录如果不存在需要 os.makedirs 先创建否则 sqlite3.connect 会报错。这条建议提前写在代码里而不是等到打包后踩坑再补救。5.4 现象四Treeview 数据没刷新显示的是上次的旧数据原因往 Treeview 里插入数据没有先清空导致每次加载都 append 在旧数据后面看起来像“没更新”。还有一个隐蔽原因给 Treeview 设置了同样的 iid数据确实变了但界面没重绘。解决每次重新加载数据前调用for row in tree.get_children(): tree.delete(row)。把这段清空逻辑和插入逻辑封装成一个 load 函数任何数据变更后调用一次。注意 get_children() 返回的是元组不能在遍历的同时删除要先用 list() 转成列表或者直接遍历元组这不算坑但容易在写的时候忽略。5.5 现象五SQLite 报 database is locked 错误原因连接没有及时关闭或者同一个连接被多个线程同时使用。tkinter 应用里按钮回调如果开了线程写库另一个线程正在读同一个连接就会出现锁冲突。SQLite 的锁是文件级的写入期间其他连接不能写只能读。解决规范每个数据库操作函数自己建立连接用完后在 finally 里 close不让连接跨函数传递。如果确实需要多线程访问connect 时设置timeout10让 SQLite 在锁冲突时等待而不是立即报错。conn sqlite3.connect(DB_PATH, timeout10)参数说明timeout 单位是秒10 秒意味着遇到锁时最多等 10 秒超过就抛异常。别设太大否则程序会卡住 30 秒才报错用户体验极差。日常单机小店基本不存在并发写出现锁基本都是连接泄漏导致优先排查是不是有连接没关。6. 进阶交付把系统交给非编程人员前最后做的三件事系统写完跑通了代码也能增删改查了这时候还不算结束。交付给小店老板和员工才是真正检验工程质量的时候。这里说三个我在最后阶段一定会做的事每件都让我少挨不少骂。第一件事用 PyInstaller 打包成单个 exe并把数据库文件和配置文件放在 exe 旁边同时写一个 start.bat 启动脚本。bat 内容一行echo off start pos.exe打包命令里注意加--noconsole隐藏黑色的命令行窗口否则员工打开程序会被旁边那个黑框吓到。用--onefile打成单文件分发方便但启动速度会慢一点因为每次运行都需要自解压到临时目录。如果嫌启动慢用--onedir模式一个文件夹扔给老板让他整个文件夹拷到桌面就行。我打包时用的参数大致是pyinstaller --noconsole --onefile --name 超市收银系统 main.py第二件事写一个简单的自动备份机制。数据文件只有一个 pos.db备份方案不用做增量直接定时复制最稳妥。我在主程序里加了一个启动时备份的逻辑def auto_backup(): backup_dir os.path.join(base_dir, backup) os.makedirs(backup_dir, exist_okTrue) backup_file os.path.join(backup_dir, fpos_{datetime.now().strftime(%Y%m%d)}.db) if os.path.exists(DB_PATH) and not os.path.exists(backup_file): shutil.copy2(DB_PATH, backup_file)逻辑说明每天第一次启动时把当前的 pos.db 复制一份以日期命名的备份到 backup 目录。用 copy2 保留文件元数据。如果当天备份已存在就不覆盖保留历史版本。这套方案运行半年后 backup 文件夹里就是半年的数据快照每个月拿一个时间点备份传给老板的电脑里存档防止电脑硬盘损坏导致全丢。注意备份时机要在数据库连接打开之前否则可能复制到正在写入的中间状态文件。第三件事给系统加操作日志。超市管理系统的数据和钱挂钩员工操作必须留痕。建一张 tb_log 表字段只有 id、user_id、action、detail、created_at。每个关键动作里面写一条 log比如登录成功、入库、下单、修改商品价格。不需要做专门的界面展示就在程序目录下生成一个 log.txt用追加模式写入。对账出问题的时候打开日志文件能看到谁在几点几分改了哪个商品的价格。def write_log(user_id: int, action: str, detail: str): with open(app.log, a, encodingutf-8) as f: f.write(f[{datetime.now().strftime(%Y-%m-%d %H:%M:%S)}] user{user_id} {action} {detail}\n)参数说明encodingutf-8 必须写Windows 下默认编码是 gbk写日志的时候会遇到 UnicodeEncodeError日志在中文 Windows 上根本打不开。追加模式 a 保证不覆盖历史内容日志文件要定期清理不然一年下来几十 MB开着烫手虽然不占多少空间但非常影响别人看日志的心情。这套系统做下来我最大的体会是tkinter 从来不是瓶颈它能完美承载进销存这种低交互频率的业务瓶颈在于你是否先想清楚了数据结构和异常处理。每个界面操作背后对应什么 SQL、事务从哪开始到哪结束、数据库文件放在哪、失败时怎么回滚这些问题在设计阶段想明白写代码就是翻译工作。开发前把这几件事列一张检查单你交付出去的系统就不会是玩具而是一个真正能每天开机用的工具。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
从 TDD 到自动化测试:改善 GitHub 开源项目代码质量的完整实践 从 TDD 到自动化测试:改善 GitHub 开源项目代码质量的完整实践 【免费下载链接】github GitHub 漫游指南- a Chinese ebook on how to build a good project on Github. Explore the users behavior. Find some thing interest. 项目地址: https://gitcode.com/ph… · 2026/9/23 22:47:56
C++ WebSocket源码包落地指南:从依赖识别到编译运行 简介:这是基于C与原生socket实现的WebSocket服务器源码包,面向有一定C网络编程基础、需要自行搭建或理解WebSocket协议的开发者。程序中实现了协议握手、基于帧格式的数据解码与传输,并附有在线测试网页的使用说明,便于验证服务器… · 2026/9/23 22:47:56
DeepSeek R1本地部署+知识库搭建实战:从Ollama到RAG全指南 简介:不少大模型爱好者正在寻找DeepSeek R1本地离线部署与私有知识库搭建的完整方案。这份PDF教程定位非常清晰,面向具备基础计算机操作能力的开发者和普通用户,完整演示了从安装Ollama、拉取合适的DeepSeek R1模型,到通过Cherry-… · 2026/9/23 22:47:56
交通银行总行软件开发岗第一次面试复盘:问题、回答与改进建议 1. 为什么我决定把这次面试完整记录下来面试这件事,大多数人面完就翻篇了,尤其是第一次面试,很多人觉得表现不够好,恨不得把那段记忆直接删掉。我一开始也是这么想的。交通银行总行软件开发岗的一面结束后,我坐在回学校… · 2026/9/23 23:24:56
SCMA多用户检测的PM-MPA算法解析:从MPA改进到MATLAB仿真实践 简介:面向5G非正交多址接入研究,这份MATLAB源码包提供了SCMA系统中PM-MPA检测算法的完整实现,适合通信专业学生、算法工程师以及多用户检测研究者用于原理验证与性能评估。资源共5个文件,包含4个M脚本和1个ZIP压缩包,整… · 2026/9/23 23:24:56
MATLAB三维热传导有限元建模与瞬态求解实战 简介:本资源面向数学建模初学者与竞赛备赛学生,聚焦三维热传导问题的数值求解与可视化实践,解决实际工程中复杂温度场建模难、结果呈现抽象等痛点。压缩包共2个文件(13KB),含MATLAB核心代码文件(… · 2026/9/23 23:24:50
基因融合检测全解析:从产生机制到RNA-seq鉴定方法 基因融合这个概念,第一次接触的人多半会觉得它离自己很远——听起来像是只有肿瘤基因组学或者罕见病研究里才会用到的冷门术语。但实际情况恰恰相反:只要你做过RNA-seq数据分析、跑过融合基因检测流程、或者哪怕只是看过几份肿瘤患者的临床报告ÿ… · 2026/9/23 23:24:50
交通流时序预测实战:从数据清洗到多模型对比部署 简介:这是一份面向计算机专业本科生的交通流量预测实战项目资源,适用于毕业设计、期末大作业及机器学习课程实践,聚焦Python环境下基于时序数据建模的智能交通分析场景。资源包含267个文件,主体为7个核心Python脚本(含… · 2026/9/23 23:24:50
3招搞定手机怎么下载微信面试难题实战项目解析 3招搞定手机怎么下载微信面试难题实战项目解析 面试被问“手机怎么下载微信”背后的原理,90%的人答不上来。别笑,这看似弱智的问题,实则是考察你对移动应用分发机制、安全校验及网络协议理解的试金石。我带过不少校招新人,他们背了八股文,却连一个A… · 2026/9/23 0:00:03
你有新短消息请注意查收:3个新手避坑指南搞定消息系统选型 你有新短消息请注意查收:3个新手避坑指南搞定消息系统选型 面试被问“高并发下如何保证消息不丢失”,你张口就是“用Redis”,结果面试官追问“如果Redis宕机了怎么办”,你瞬间卡壳。这种场景太常见了,很多新手在背八股文时,只记住了技术名词… · 2026/9/23 0:00:29