Python超市管理系统:tkinter+SQLite轻量级实战项目
简介这是一套基于Python开发的轻量级超市管理系统源码面向Python初学者与课程设计实践者聚焦日常运营中的员工管理、客户信息维护及登录权限控制等核心业务场景。资源共13个文件包含4个核心Python模块如login.py实现密码验证、employee.py封装员工操作、customer.py处理顾客数据、7个文本类文件含日志out.log与warnlog.txt、账户存储file_login.txt、商品与信息记录goods.txt等以及1份README.md项目说明文档整体压缩包仅8KB结构清晰、即下即用。已有332人学习下载适合用于理解GUI界面搭建Tkinter风格、文件持久化存储、模块化编程思想及基础日志记录机制。读者可完整掌握从登录验证到多角色业务逻辑的代码组织方式并通过日志与配置文件反向推演系统运行流程是入门级业务系统开发的典型参考范例。1. 一个能当天跑通、第二天就能改出自己门店风格的Python超市管理系统上周帮某高校实验室搭实训环境带学生用这个Python超市管理系统做课程设计——从拉代码、配环境到完成「会员折扣库存预警销售汇总」三个功能模块只用了不到4小时。它不是玩具Demo有真实商品分类树、支持多角色登录管理员/收银员/仓管、数据库用SQLite开箱即用连打印小票的模拟逻辑都写好了。如果你正卡在「学完Python基础却不知道能做什么项目」或者手头有个小超市、社区便利店要快速搭个内部管理工具又不想碰Java Spring Boot那种重型框架那这个项目就是为你准备的。它用最朴素的tkinter做界面核心逻辑全在models/和controllers/里分层清晰没有魔法函数、不藏黑匣子改个价格字段或加个导出按钮你改三行代码就能看到效果。别被“管理系统”四个字吓住——它本质是个结构干净、边界明确、拿来就能喂数据的Python工程不是教科书里的抽象模型。2. 环境准备与项目结构速览5分钟看清骨架在哪2.1 依赖安装只装3个包拒绝版本玄学这个项目对环境极其友好不需要虚拟环境强制隔离当然你习惯用venv也完全OK核心依赖就三个pip install tkinter pandas openpyxl提示tkinter是Python标准库Windows/macOS/Linux自带pandas用于销售报表生成openpyxl专攻Excel导出。别装xlrd——它不支持.xlsx写入会翻车。为什么没选PyQt或wxPython因为tkinter零依赖、启动快、UI控件够用。我试过用PyQt重写登录页结果学生反馈“点开要等两秒像卡顿”而tkinter版双击即开。这不是技术降级是场景适配超市收银台的电脑配置普遍不高响应速度比炫酷动效重要十倍。2.2 目录结构解剖6个文件夹讲清职责边界项目解压后是标准的Python包结构重点看这6个目录src/ ├── __init__.py ├── models/ # 数据模型层商品、会员、订单的类定义 SQLite操作封装 ├── views/ # 界面层每个窗口一个.py文件LoginView.py, MainView.py... ├── controllers/ # 控制层连接model和view的胶水代码如LoginController处理登录校验 ├── utils/ # 工具层日期格式化、小票打印模拟、Excel导出函数 ├── assets/ # 静态资源图标、logo图片可直接替换为自家门店logo └── main.py # 入口初始化数据库 启动登录窗口注意models/下的database.py——它不是ORM而是用原生sqlite3写的轻量封装。比如ProductModel.get_by_category()方法底层就是一句SELECT * FROM products WHERE category?没抽象成QuerySet参数直接传tuple。好处是你查SQL日志时一眼看懂执行了什么调试时print(sql)就能复现问题。2.3 数据库初始化第一次运行自动建表不手动敲SQL项目启动时python src/main.py会自动检测data/supermarket.db是否存在。如果不存在执行models/database.py里的init_db()函数def init_db(): conn sqlite3.connect(data/supermarket.db) cursor conn.cursor() # 商品表含库存预警阈值字段这是业务关键点 cursor.execute( CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL, price REAL NOT NULL, stock INTEGER NOT NULL DEFAULT 0, min_stock INTEGER NOT NULL DEFAULT 5, -- 库存低于此值触发预警 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) # 会员表支持积分和等级青铜/白银/黄金 cursor.execute( CREATE TABLE IF NOT EXISTS members ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT UNIQUE, points INTEGER DEFAULT 0, level TEXT DEFAULT 青铜, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() conn.close()注意min_stock字段是库存预警的核心。很多新手会忽略它导致“库存不足”提示永远不弹。这个字段在添加商品时默认填5你可以在views/ProductAddView.py的表单里找到对应输入框改成3或10都行——改完重启程序立即生效。2.4 启动流程链路从双击main.py到看见登录框的7个步骤main.py执行调用models.database.init_db()检查data/目录若不存在则os.makedirs(data)连接SQLite执行建表SQL见2.3节初始化controllers.LoginController实例LoginController加载views.LoginViewtkinter.Toplevel窗口LoginView渲染用户名/密码输入框 登录按钮点击按钮 → 触发LoginController.handle_login()→ 查询数据库验证 → 成功则销毁登录窗创建主窗口这个链路没有异步、没有事件循环嵌套每一步都是同步阻塞。好处是你加一行print(step 3: db connected)就能准确定位卡在哪。我曾帮A同学排查“点击登录没反应”发现是第4步LoginController构造函数里少写了self.view view导致后续self.view.destroy()报NoneType错误——这种问题在复杂框架里要翻半天源码在这里print三行就定位。2.5 核心配置项3个文件控制全局行为项目里没有config.yaml或.env所有可配置项集中在src/utils/constants.py定义常量# 小票打印模拟的字符宽度适配不同打印机 RECEIPT_WIDTH 32 # 会员等级升级阈值积分 LEVEL_UP_POINTS { 青铜: 0, 白银: 500, 黄金: 2000 }src/models/database.py数据库路径DB_PATH data/supermarket.db # 改这里可换数据库位置src/views/MainView.py主窗口标题和尺寸WINDOW_TITLE XX社区超市管理系统 v1.2 # 改这里换标题 WINDOW_SIZE 1200x700 # 宽高像素值适配1366x768屏提示改WINDOW_SIZE时别写成1200*700——tkinter认x不认*写错会导致窗口变成一条线。这是血泪经验A同学改完说“界面没了”最后发现是星号打错了。3. 核心模块拆解一个销售单从录入到入库的完整链路3.1 收银流程SaleController如何把扫码枪数据变成数据库记录当收银员在主界面点击「新建销售单」系统创建SaleController实例。关键逻辑在handle_item_scan()方法def handle_item_scan(self, barcode: str): # 步骤1根据条码查商品实际项目中barcode字段需提前录入 product ProductModel.get_by_barcode(barcode) if not product: messagebox.showerror(错误, f未找到条码 {barcode} 对应的商品) return # 步骤2检查库存是否充足 if product.stock 0: messagebox.showwarning(库存不足, f{product.name} 库存为0请及时补货) return # 步骤3更新购物车列表内存中 self.cart_items.append({ id: product.id, name: product.name, price: product.price, quantity: 1, subtotal: product.price }) # 步骤4扣减库存注意此时还没写库只是预扣 product.stock - 1 # 步骤5刷新界面显示 self.view.update_cart_display(self.cart_items)这段代码暴露了两个关键设计点库存预扣机制product.stock - 1是在内存对象上操作不是直接UPDATE数据库。这样做的好处是允许用户删掉购物车某项时“回滚”库存——避免传统方案中“先扣再删”导致库存变负数。条码查询依赖get_by_barcode()但原始项目products表没建barcode字段你需要手动在models/database.py的建表SQL里加一列barcode TEXT UNIQUE, -- 在name TEXT NOT NULL后面插入这一行然后在ProductModel类里补上get_by_barcode()方法参考get_by_id写法。这是项目留的改造入口不是Bug。3.2 库存预警模块StockAlertService如何每30秒扫一次危险品预警不是靠前端定时器而是后台独立线程。utils/alert_service.py里class StockAlertService: def __init__(self, interval30): self.interval interval self.is_running False self.thread None def start(self): if self.is_running: return self.is_running True self.thread threading.Thread(targetself._check_loop, daemonTrue) self.thread.start() def _check_loop(self): while self.is_running: # 查所有库存低于min_stock的商品 low_stock_items ProductModel.get_low_stock_items() if low_stock_items: # 弹窗提醒仅当主窗口存在时 if hasattr(self, main_window) and self.main_window.winfo_exists(): self._show_alert(low_stock_items) time.sleep(self.interval) # 每30秒检查一次注意daemonTrue确保主线程退出时后台线程自动结束避免程序关不掉。我见过有同学把daemonFalse结果关了主窗口命令行还在刷“checking stock...”得CtrlC强杀。3.3 销售报表生成ReportGenerator用pandas导出Excel的3个坑点击「导出日报表」时ReportController.export_daily_report()调用def export_daily_report(self, date_str: str): # 1. 从数据库查当日所有销售记录关联商品名、会员名 sales SaleModel.get_sales_by_date(date_str) # 2. 转成pandas DataFrame关键列名必须和Excel表头一致 df pd.DataFrame(sales, columns[ 销售单号, 商品名称, 数量, 单价, 小计, 会员姓名, 会员等级, 收银员, 时间 ]) # 3. 导出到Excelopenpyxl引擎支持样式 filename fsales_report_{date_str}.xlsx with pd.ExcelWriter(filename, engineopenpyxl) as writer: df.to_excel(writer, indexFalse, sheet_name日报表) # 设置列宽否则中文挤在一起 worksheet writer.sheets[日报表] for column in [A, B, C, D, E, F, G, H, I]: worksheet.column_dimensions[column].width 12常见翻车点坑1columns参数顺序必须和sales查询结果字段顺序严格一致否则数据错列。建议先print(sales[0])看元组结构。坑2openpyxl不支持直接设置中文列宽要用column_dimensions[column].width逐列设。坑3导出路径默认在当前目录想存到reports/文件夹改filename freports/sales_report_{date_str}.xlsx但要先os.makedirs(reports, exist_okTrue)。3.4 会员等级自动升级MemberService的积分累加策略会员消费时SaleController.complete_sale()会调用def complete_sale(self): # ... 其他逻辑 if self.current_member: # 消费1元1积分四舍五入取整 points_earned round(sum(item[subtotal] for item in self.cart_items)) MemberModel.add_points(self.current_member.id, points_earned) # 检查是否升级 updated_member MemberModel.get_by_id(self.current_member.id) new_level self._get_new_level(updated_member.points) if new_level ! updated_member.level: MemberModel.update_level(updated_member.id, new_level) messagebox.showinfo(恭喜, f升级为{new_level}会员)_get_new_level()逻辑在utils/member_utils.pydef get_level_by_points(points: int) - str: if points 2000: return 黄金 elif points 500: return 白银 else: return 青铜提示升级逻辑是硬编码在函数里不是查配置表。如果你想改成“白银升黄金需3000分”直接改 2000就行不用动数据库。3.5 小票打印模拟ReceiptPrinter如何用纯文本还原热敏纸效果utils/receipt_printer.py不调用打印机驱动而是生成带边框的文本def generate_receipt(self, sale_data: dict) - str: lines [] lines.append( * 32) lines.append( XX社区超市) lines.append( 电话138****1234) lines.append(- * 32) # 商品明细左对齐商品名右对齐价格 for item in sale_data[items]: name_part item[name][:12] ... if len(item[name]) 15 else item[name] price_part f{item[price]:.2f} lines.append(f{name_part:18}{price_part:8}) lines.append(- * 32) lines.append(f合计{sale_data[total]:.2f}元) lines.append(f会员卡号{sale_data.get(member_id, 无)}) lines.append( * 32) return \n.join(lines)输出效果 XX社区超市 电话138****1234 -------------------------------- 苹果 5.50 五常大米 48.00 -------------------------------- 合计53.50元 会员卡号1001 注意name_part做了截断处理超15字加...因为热敏纸宽度有限。你改RECEIPT_WIDTH 40的话记得同步调整name_part的切片长度否则排版错乱。4. 常见问题排查5个真实踩坑记录与解法4.1 现象点击登录按钮无反应控制台无报错原因views/LoginView.py中登录按钮的command参数绑定错误。原始代码可能是self.login_btn tk.Button(..., commandself.controller.handle_login) # ❌ 少了括号正确写法必须带括号self.login_btn tk.Button(..., commandlambda: self.controller.handle_login()) # ✅解决tkinter的command参数需要可调用对象handle_login是方法名对象handle_login()才是调用。不加括号等于把方法本身当参数传进去点击时不会执行。4.2 现象添加商品后库存显示为负数原因controllers/ProductController.add_product()里stock字段被赋值为字符串而非整数。例如product_data { name: self.view.name_entry.get(), stock: self.view.stock_entry.get(), # ❌ get()返回str需int() } ProductModel.create(**product_data)解决在create()方法内或传参前强转stock: int(self.view.stock_entry.get().strip() or 0),并加try/except捕获ValueError否则输字母直接崩溃。4.3 现象导出Excel时报错KeyError: openpyxl原因pandas版本过高2.0默认弃用openpyxl引擎需显式指定。解决在ReportGenerator.export_daily_report()中pd.ExcelWriter必须加engineopenpyxlwith pd.ExcelWriter(filename, engineopenpyxl) as writer: # ✅ 必须写4.4 现象修改constants.py里的RECEIPT_WIDTH后小票文字不居中原因ReceiptPrinter.generate_receipt()中标题行用 XX社区超市硬编码空格没随RECEIPT_WIDTH动态计算。解决改为动态居中title XX社区超市 padding (self.width - len(title)) // 2 lines.append( * padding title)其中self.width从constants.RECEIPT_WIDTH读取。4.5 现象切换不同会员结账时积分没累加原因SaleController.complete_sale()中self.current_member在每次结账后未重置为None导致下次结账仍用上一个会员ID。解决在complete_sale()末尾加self.current_member None # ✅ 重置避免状态残留5. 二次开发实战给系统加「供应商管理」模块的完整路径5.1 需求分析为什么供应商模块不能简单复制商品逻辑某便利店老板提需求“我要知道鸡蛋是从‘XX农场’进的货牛奶是‘YY乳业’进货价、联系人、电话都要记”。表面看是“多加一张表”但实际涉及三个耦合点数据耦合商品表需新增supplier_id外键否则无法关联流程耦合进货入库时要同时选商品供应商进货价不能只填商品报表耦合老板要看“本月XX农场供货总额”需跨商品、进货单、供应商三表JOIN。如果直接在products表加supplier_name文本字段短期快长期必翻车——供应商改名、多个商品同供应商时无法去重。必须建独立suppliers表。5.2 数据库改造4步完成表结构升级Step 1建供应商表在models/database.py的init_db()里CREATE TABLE products之后加cursor.execute( CREATE TABLE IF NOT EXISTS suppliers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, contact_person TEXT, phone TEXT, address TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) )Step 2扩商品表加外键修改products建表SQL在created_at字段前加supplier_id INTEGER, FOREIGN KEY (supplier_id) REFERENCES suppliers (id)Step 3更新ProductModel类在models/product.py中create()方法增加supplier_id参数classmethod def create(cls, name, category, price, stock, supplier_idNone): conn sqlite3.connect(DB_PATH) cursor conn.cursor() cursor.execute( INSERT INTO products (name, category, price, stock, supplier_id) VALUES (?, ?, ?, ?, ?), (name, category, price, stock, supplier_id) ) conn.commit() conn.close()Step 4初始化测试数据在init_db()末尾加几行种子数据方便调试# 插入测试供应商 cursor.execute(INSERT OR IGNORE INTO suppliers (name, contact_person, phone) VALUES (?, ?, ?), (XX农场, 张经理, 139****5678)) cursor.execute(INSERT OR IGNORE INTO suppliers (name, contact_person, phone) VALUES (?, ?, ?), (YY乳业, 李总监, 138****9012)) conn.commit()5.3 界面与逻辑补全3个文件联动修改文件1views/SupplierAddView.py新增import tkinter as tk from tkinter import ttk, messagebox from controllers.SupplierController import SupplierController class SupplierAddView: def __init__(self, parent, controller: SupplierController): self.window tk.Toplevel(parent) self.window.title(添加供应商) self.controller controller # 表单字段 tk.Label(self.window, text供应商名称).grid(row0, column0, padx5, pady5, stickye) self.name_entry tk.Entry(self.window, width30) self.name_entry.grid(row0, column1, padx5, pady5) tk.Label(self.window, text联系人).grid(row1, column0, padx5, pady5, stickye) self.contact_entry tk.Entry(self.window, width30) self.contact_entry.grid(row1, column1, padx5, pady5) tk.Button(self.window, text保存, commandself._save).grid(row2, column0, columnspan2, pady10) def _save(self): data { name: self.name_entry.get().strip(), contact_person: self.contact_entry.get().strip() } if not data[name]: messagebox.showerror(错误, 供应商名称不能为空) return self.controller.add_supplier(data) self.window.destroy()文件2controllers/SupplierController.py新增from models.supplier import SupplierModel class SupplierController: def __init__(self): pass def add_supplier(self, data: dict): try: SupplierModel.create(**data) messagebox.showinfo(成功, 供应商添加成功) except Exception as e: messagebox.showerror(错误, f添加失败{str(e)})文件3models/supplier.py新增import sqlite3 from utils.constants import DB_PATH class SupplierModel: classmethod def create(cls, name, contact_person, phone, address): conn sqlite3.connect(DB_PATH) cursor conn.cursor() cursor.execute( INSERT INTO suppliers (name, contact_person, phone, address) VALUES (?, ?, ?, ?), (name, contact_person, phone, address) ) conn.commit() conn.close() classmethod def get_all(cls): conn sqlite3.connect(DB_PATH) cursor conn.cursor() cursor.execute(SELECT id, name, contact_person, phone FROM suppliers ORDER BY name) rows cursor.fetchall() conn.close() return rows5.4 关联商品录入改造ProductAddView支持下拉选择在views/ProductAddView.py中找到商品类别输入框下方插入供应商选择# 原有代码category输入框 tk.Label(self.window, text商品类别).grid(...) self.category_entry tk.Entry(...) self.category_entry.grid(...) # 新增供应商下拉框 tk.Label(self.window, text供应商).grid(row4, column0, padx5, pady5, stickye) self.supplier_var tk.StringVar() self.supplier_combo ttk.Combobox( self.window, textvariableself.supplier_var, valuesself._load_suppliers(), # 加载供应商列表 statereadonly, width27 ) self.supplier_combo.grid(row4, column1, padx5, pady5) # 加载供应商方法 def _load_suppliers(self): from models.supplier import SupplierModel suppliers SupplierModel.get_all() # 返回格式[XX农场 (ID:1), YY乳业 (ID:2)] return [f{s[1]} (ID:{s[0]}) for s in suppliers]然后在_save()方法中解析选中的ID# 解析供应商ID从XX农场 (ID:1)提取1 supplier_text self.supplier_var.get() if supplier_text: supplier_id int(supplier_text.split((ID:)[-1].rstrip())) else: supplier_id None ProductModel.create( nameself.name_entry.get(), categoryself.category_entry.get(), pricefloat(self.price_entry.get()), stockint(self.stock_entry.get()), supplier_idsupplier_id # ✅ 传入外键 )5.5 报表扩展在销售报表中增加「供应商」列修改ReportGenerator.export_daily_report()在SQL查询中JOINsuppliers表# 原查询 SELECT s.id, p.name, s.quantity, s.unit_price... # 改为 SELECT s.id, p.name, s.quantity, s.unit_price, sp.name as supplier_name... FROM sales s JOIN products p ON s.product_id p.id LEFT JOIN suppliers sp ON p.supplier_id sp.id WHERE s.sale_date ?在DataFrame列名中加入供应商columns[..., 供应商, ...]导出时自动包含该列。从那以后我每次加新模块都强制走一遍“数据库建表→模型类→控制器→视图→关联点”五步闭环哪怕只是加个字段。漏掉任何一环第二天维护时就得花两小时找“为什么界面上有输入框但数据没存进去”。希望帮到你。本文还有配套的精品资源点击获取