ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python超市管理系统:tkinter+SQLite轻量级实战项目

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个步骤

  1. main.py执行,调用models.database.init_db()
  2. 检查data/目录,若不存在则os.makedirs('data')
  3. 连接SQLite,执行建表SQL(见2.3节)
  4. 初始化controllers.LoginController实例
  5. LoginController加载views.LoginView(tkinter.Toplevel窗口)
  6. LoginView渲染用户名/密码输入框 + 登录按钮
  7. 点击按钮 → 触发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)

这段代码暴露了两个关键设计点:

  1. 库存预扣机制:product.stock -= 1是在内存对象上操作,不是直接UPDATE数据库。这样做的好处是允许用户删掉购物车某项时“回滚”库存——避免传统方案中“先扣再删”导致库存变负数。
  2. 条码查询依赖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, interval=30): 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(target=self._check_loop, daemon=True) 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秒检查一次

注意:daemon=True确保主线程退出时后台线程自动结束,避免程序关不掉。我见过有同学把daemon=False,结果关了主窗口,命令行还在刷“checking stock...”,得Ctrl+C强杀。

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. 导出到Excel(openpyxl引擎,支持样式) filename = f"sales_report_{date_str}.xlsx" with pd.ExcelWriter(filename, engine='openpyxl') as writer: df.to_excel(writer, index=False, sheet_name='日报表') # 设置列宽(否则中文挤在一起) worksheet = writer.sheets['日报表'] for column in ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']: worksheet.column_dimensions[column].width = 12

常见翻车点:

  • 坑1:columns=参数顺序必须和sales查询结果字段顺序严格一致,否则数据错列。建议先print(sales[0])看元组结构。
  • 坑2:openpyxl不支持直接设置中文列宽,要用column_dimensions[column].width逐列设。
  • 坑3:导出路径默认在当前目录,想存到reports/文件夹?改filename = f"reports/sales_report_{date_str}.xlsx",但要先os.makedirs('reports', exist_ok=True)。

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.py:

def 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(..., command=self.controller.handle_login) # ❌ 少了括号

正确写法必须带括号:

self.login_btn = tk.Button(..., command=lambda: 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必须加engine='openpyxl':

with pd.ExcelWriter(filename, engine='openpyxl') 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_id=None): 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个文件联动修改

文件1:views/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(row=0, column=0, padx=5, pady=5, sticky='e') self.name_entry = tk.Entry(self.window, width=30) self.name_entry.grid(row=0, column=1, padx=5, pady=5) tk.Label(self.window, text="联系人:").grid(row=1, column=0, padx=5, pady=5, sticky='e') self.contact_entry = tk.Entry(self.window, width=30) self.contact_entry.grid(row=1, column=1, padx=5, pady=5) tk.Button(self.window, text="保存", command=self._save).grid(row=2, column=0, columnspan=2, pady=10) 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()

文件2:controllers/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)}")

文件3:models/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 rows

5.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(row=4, column=0, padx=5, pady=5, sticky='e') self.supplier_var = tk.StringVar() self.supplier_combo = ttk.Combobox( self.window, textvariable=self.supplier_var, values=self._load_suppliers(), # 加载供应商列表 state="readonly", width=27 ) self.supplier_combo.grid(row=4, column=1, padx=5, pady=5) # 加载供应商方法 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( name=self.name_entry.get(), category=self.category_entry.get(), price=float(self.price_entry.get()), stock=int(self.stock_entry.get()), supplier_id=supplier_id # ✅ 传入外键 )

5.5 报表扩展:在销售报表中增加「供应商」列

修改ReportGenerator.export_daily_report():

  1. 在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 = ?
  2. 在DataFrame列名中加入'供应商':
    columns=[..., '供应商', ...]
  3. 导出时自动包含该列。

从那以后我每次加新模块,都强制走一遍“数据库建表→模型类→控制器→视图→关联点”五步闭环,哪怕只是加个字段。漏掉任何一环,第二天维护时就得花两小时找“为什么界面上有输入框,但数据没存进去”。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进