简介:这是一份面向Python初学者与进阶学习者的超市管理系统项目源码,适合用于课程设计、毕业设计参考或GUI与业务逻辑综合练习。系统围绕超市日常运营展开,涵盖员工信息管理、顾客信息管理、登录验证、商品库存与销售数据记录等模块,并配有日志输出与警告记录,便于理解异常处理与程序监控思路。压缩包共13个文件,以7个txt数据与配置文本、4个py功能模块、1个md说明文档和1个log日志文件为主,整体约8KB,结构轻量,便于快速阅读与二次修改。其中py文件分别承担员工、顾客、登录界面与登录核心逻辑,txt文件用于持久化存储账户及业务数据,md文档提供项目介绍与使用说明。已有332人学习下载,读者可借此掌握Tkinter界面设计、文件读写、模块化拆分、登录校验与logging日志等实用技能,并对照完整目录结构理解一个业务系统的组织方式。
1. 从一张手写进货单说起:python超市管理系统到底在管什么
很多小超市、社区便利店、校园小卖部的日常是这样的:进货靠手写单子,卖货靠收银机打小票,库存靠每周盘一次,月底对账靠翻本子。老板知道大概赚了多少,但说不清哪个品类压了货、哪个商品快过期、哪个供应商的进价悄悄涨了。python超市管理系统要解决的,就是把这套“凭感觉”的流程,变成一套能查、能算、能预警的数据流。
它不是一个收银软件那么简单。收银只是入口,真正值钱的是后台那几张表:商品表、库存表、进货表、销售表、供应商表。系统要干的事,是在每次扫码卖出、每次进货入库、每次退货报损的时候,自动更新这些表,并且让老板在手机上就能看到“今天卖了多少钱、哪些货该补了、哪些货再放就过期了”。适合谁?适合会一点 Python、想给自己或小店铺做一套轻量管理工具的开发者,也适合拿它当课程设计或练手项目的学生。这一篇不讲虚的,从表结构设计到能跑起来的代码,再到上线后最容易翻车的地方,一步步拆开。
2. 先定表结构再写代码:五张核心表怎么设计才不返工
2.1 为什么商品表和库存表必须分开
新手最容易犯的错,是把商品信息和库存数量塞进同一张表。看起来省事,实际后患无穷。商品表存的是“这个商品是什么”——条码、名称、分类、规格、进价、售价、供应商。库存表存的是“这个商品现在有多少”——当前数量、安全库存、最后入库时间、最后出库时间。分开之后,调价不会影响库存记录,盘点也不会覆盖商品资料。
更关键的是,库存是会变的,商品资料相对稳定。分开设计后,每次销售只需要更新库存表的数量字段,商品表几乎不动。如果合在一起,每次改价都要锁整行,并发一上来就容易出问题。常见做法是商品表用条码做主键,库存表用商品 ID 加仓库 ID 做联合主键,这样以后要扩展多门店也不会推倒重来。
2.2 建表 SQL 与字段说明
下面这套表结构是我在几个小项目里反复用过的,字段不多,但够用。数据库用 SQLite 起步,后期换 MySQL 也只需要改连接串。
-- 商品表:存商品基础资料 CREATE TABLE product ( id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT UNIQUE NOT NULL, -- 条码,扫码枪直接读 name TEXT NOT NULL, -- 商品名称 category TEXT, -- 分类,如饮料、零食 spec TEXT, -- 规格,如 500ml purchase_price REAL DEFAULT 0, -- 参考进价 sale_price REAL DEFAULT 0, -- 售价 supplier_id INTEGER, -- 关联供应商 created_at TEXT DEFAULT (datetime('now', 'localtime')) ); -- 库存表:只存数量,不存商品资料 CREATE TABLE inventory ( product_id INTEGER NOT NULL, warehouse_id INTEGER DEFAULT 1, -- 默认单仓库 quantity INTEGER DEFAULT 0, -- 当前库存 safety_stock INTEGER DEFAULT 5, -- 安全库存,低于就预警 last_in_at TEXT, -- 最后入库时间 last_out_at TEXT, -- 最后出库时间 PRIMARY KEY (product_id, warehouse_id) ); -- 进货表:每次进货一条记录 CREATE TABLE purchase_order ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, unit_price REAL NOT NULL, -- 本次实际进价 supplier_id INTEGER, operator TEXT, -- 操作人 created_at TEXT DEFAULT (datetime('now', 'localtime')) ); -- 销售表:每次卖出明细 CREATE TABLE sale_order ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, unit_price REAL NOT NULL, -- 实际售价 total_amount REAL NOT NULL, -- 小计 created_at TEXT DEFAULT (datetime('now', 'localtime')) ); -- 供应商表 CREATE TABLE supplier ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, contact TEXT, phone TEXT );逻辑说明:商品表用barcode做唯一约束,是因为扫码枪输入的就是条码,直接拿它查商品最快。库存表用product_id + warehouse_id做联合主键,是为了以后开分店时不用改表结构。进货表和销售表都只记流水,不直接改库存,库存的增减由业务代码在事务里完成。参数方面,safety_stock默认给 5,实际按品类调,饮料可以设 10,日用品设 3 就够。
2.3 用 Python 建库并插入测试数据
建好表之后,先塞几条测试数据,后面调试才有东西可查。下面这段代码用标准库sqlite3,不需要额外装包。
import sqlite3 conn = sqlite3.connect('market.db') cur = conn.cursor() # 建表语句略,实际使用时把上面的 SQL 依次执行 # 插入供应商 cur.execute("INSERT INTO supplier (name, contact, phone) VALUES (?, ?, ?)", ('某食品供应商', 'A同学', '13800000000')) # 插入商品 cur.execute("""INSERT INTO product (barcode, name, category, spec, purchase_price, sale_price, supplier_id) VALUES (?, ?, ?, ?, ?, ?, ?)""", ('6901234567890', '某品牌矿泉水', '饮料', '500ml', 1.2, 2.0, 1)) # 初始化库存 cur.execute("INSERT INTO inventory (product_id, quantity, safety_stock) VALUES (?, ?, ?)", (1, 50, 10)) conn.commit() conn.close()逻辑说明:sqlite3的?占位符能防止 SQL 注入,虽然内部系统风险低,但习惯要养好。conn.commit()必须调用,否则数据不会落盘。测试数据里条码用了一个常见的 13 位格式,实际项目里要确保扫码枪输出的条码和这里存的格式一致,有些扫码枪会带回车换行,需要在代码里strip()掉。
3. 进货、销售、库存预警:三个核心业务怎么串起来
3.1 进货入库:数量增加与进价更新
进货不是简单地把库存加一下。实际场景里,同一种商品每次进价可能不同,系统要记录本次进价,同时更新商品表里的参考进价。更重要的是,进货和库存更新必须在同一个事务里,否则可能出现“进货记录写了但库存没加”的脏数据。
def purchase(conn, product_id, quantity, unit_price, supplier_id, operator): cur = conn.cursor() try: conn.execute('BEGIN') # 写进货流水 cur.execute("""INSERT INTO purchase_order (product_id, quantity, unit_price, supplier_id, operator) VALUES (?, ?, ?, ?, ?)""", (product_id, quantity, unit_price, supplier_id, operator)) # 更新库存 cur.execute("""UPDATE inventory SET quantity = quantity + ?, last_in_at = datetime('now', 'localtime') WHERE product_id = ?""", (quantity, product_id)) # 更新商品参考进价 cur.execute("UPDATE product SET purchase_price = ? WHERE id = ?", (unit_price, product_id)) conn.commit() except Exception as e: conn.rollback() raise e逻辑说明:BEGIN显式开启事务,commit和rollback成对出现。库存更新用quantity = quantity + ?而不是先查再写,避免并发下的丢失更新。last_in_at用 SQLite 的datetime函数直接生成,省去 Python 端格式化。参数operator建议存操作人标识,方便以后追责。
3.2 销售出库:先查库存再扣减
销售比进货多一步:必须检查库存够不够。如果库存不足还允许卖出,盘点时就会出现负库存,对账直接崩。下面这个函数在事务里先查后扣,并且把销售流水和库存更新绑在一起。
def sale(conn, product_id, quantity, unit_price): cur = conn.cursor() try: conn.execute('BEGIN') # 查当前库存 cur.execute("SELECT quantity FROM inventory WHERE product_id = ?", (product_id,)) row = cur.fetchone() if not row or row[0] < quantity: raise ValueError('库存不足') # 写销售流水 total = quantity * unit_price cur.execute("""INSERT INTO sale_order (product_id, quantity, unit_price, total_amount) VALUES (?, ?, ?, ?)""", (product_id, quantity, unit_price, total)) # 扣减库存 cur.execute("""UPDATE inventory SET quantity = quantity - ?, last_out_at = datetime('now', 'localtime') WHERE product_id = ?""", (quantity, product_id)) conn.commit() return total except Exception as e: conn.rollback() raise e逻辑说明:SELECT和UPDATE在同一个事务里,SQLite 默认的隔离级别能保证在这之间不会有其他写入插进来。如果换成 MySQL,建议把SELECT改成SELECT ... FOR UPDATE来锁行。total_amount在 Python 端算好再存,避免每次查询都要乘一遍。库存不足时抛ValueError,上层捕获后提示收银员。
3.3 库存预警:低于安全库存自动列出
预警不需要定时任务,每次打开管理页面时查一次就行。SQL 里直接比较quantity和safety_stock,把低于安全线的商品连名称一起查出来。
def low_stock_list(conn): cur = conn.cursor() cur.execute("""SELECT p.name, p.barcode, i.quantity, i.safety_stock FROM inventory i JOIN product p ON p.id = i.product_id WHERE i.quantity < i.safety_stock ORDER BY i.quantity ASC""") return cur.fetchall()逻辑说明:JOIN把商品名称带出来,方便直接展示。ORDER BY i.quantity ASC让最缺的排最前面。如果商品多,可以加LIMIT 20只显示最紧急的。这个查询走的是库存表的主键索引,速度很快,几千条数据毫秒级返回。
4. 避坑与排查:上线后最容易翻车的五个地方
4.1 扫码枪输入带回车导致条码查不到
现象:扫码枪扫同一个商品,有时能查到,有时查不到,手动输入条码却正常。原因:部分扫码枪在条码末尾自动追加回车符\r或换行符\n,存进数据库的条码带了不可见字符,查询时用干净条码自然匹配不上。解决:在接收扫码输入的入口统一做barcode.strip(),并且在建表时对barcode字段加TRIM约束或触发器。更稳妥的做法是在插入前用正则清洗,只保留数字和字母。
4.2 浮点数算钱出现一分钱误差
现象:销售流水里total_amount偶尔出现5.999999999这种值,对账时差几分钱。原因:SQLite 和 Python 的float都是二进制浮点,0.1 + 0.2不等于0.3是经典问题。解决:金额字段全部用整数存“分”,展示时再除以 100。或者用decimal.Decimal做运算,存的时候转成字符串。我一般直接在数据库里用INTEGER存分,省心。
4.3 并发销售导致库存扣成负数
现象:两个收银台同时卖同一件商品,库存只剩 1 件,结果两边都卖出去了,库存变成 -1。原因:先查后扣的间隙里,另一个事务也查到了同样的库存。解决:SQLite 下把事务隔离级别调高,或者直接用一条UPDATE inventory SET quantity = quantity - ? WHERE product_id = ? AND quantity >= ?,根据rowcount判断是否扣减成功。MySQL 下用SELECT ... FOR UPDATE锁行。
4.4 商品删除后历史流水查不到名称
现象:删掉一个不再卖的商品后,之前的销售记录里商品名称变成空白。原因:销售表只存了product_id,商品被物理删除后JOIN不到名称。解决:商品不要物理删除,加一个is_active字段做逻辑删除。如果非要删,销售表里冗余存一份商品名称和条码,牺牲一点空间换历史可查。
4.5 数据库文件被锁导致写入失败
现象:程序运行一段时间后报database is locked。原因:SQLite 默认允许并发读,但写是串行的,多个连接同时写就会锁。解决:确保每个写操作都及时commit或rollback,不要长时间开着事务。连接用完就关,或者用连接池限制并发数。如果写入频繁,直接换 MySQL 或 PostgreSQL,别在 SQLite 上硬扛。
5. 从能跑到好用:三个让系统更值钱的进阶技巧
5.1 用视图把常用报表固化下来
每次查“今日销售额”都写一遍JOIN和GROUP BY很烦,而且容易写错。直接在数据库里建视图,查询时当普通表用。
CREATE VIEW daily_sales AS SELECT date(created_at) AS sale_date, COUNT(*) AS order_count, SUM(quantity) AS total_quantity, SUM(total_amount) AS total_amount FROM sale_order GROUP BY date(created_at);逻辑说明:date()函数把时间戳截断到天,GROUP BY按天汇总。视图不存数据,每次查询实时计算,数据量不大时完全够用。如果流水上百万条,再考虑物化视图或定时汇总表。查的时候直接SELECT * FROM daily_sales ORDER BY sale_date DESC LIMIT 7,最近一周的销售一目了然。
5.2 用触发器兜住库存一致性
业务代码里手动更新库存,难免有漏掉的时候。加一个触发器,在销售流水插入后自动扣库存,在进货流水插入后自动加库存,相当于给数据一致性上了双保险。
CREATE TRIGGER trg_sale_after_insert AFTER INSERT ON sale_order BEGIN UPDATE inventory SET quantity = quantity - NEW.quantity, last_out_at = datetime('now', 'localtime') WHERE product_id = NEW.product_id; END;逻辑说明:NEW.quantity是刚插入的销售数量。触发器在同一个事务里执行,如果库存不够,UPDATE会把数量扣成负数,所以还需要配合CHECK约束或业务层校验。触发器的好处是即使有人直接操作数据库,库存也不会漏更新。坏处是调试时容易忽略它的存在,建议在文档里写清楚哪些表有触发器。
5.3 用定时备份防止数据丢失
小超市的电脑可能突然断电,SQLite 文件损坏了哭都来不及。最简单的备份方案是每天定时把market.db复制一份,按日期命名。
import shutil import datetime import os def backup_db(db_path='market.db', backup_dir='backup'): if not os.path.exists(backup_dir): os.makedirs(backup_dir) today = datetime.date.today().isoformat() backup_path = os.path.join(backup_dir, f'market_{today}.db') shutil.copy2(db_path, backup_path) # 只保留最近 30 天 for f in os.listdir(backup_dir): if f.startswith('market_') and f.endswith('.db'): date_part = f.replace('market_', '').replace('.db', '') if (datetime.date.today() - datetime.date.fromisoformat(date_part)).days > 30: os.remove(os.path.join(backup_dir, f))逻辑说明:shutil.copy2保留文件元数据,比copyfile更完整。备份文件按日期命名,方便回滚到任意一天。保留 30 天是拍脑袋定的,实际按磁盘空间调。这个函数可以挂在每天关店后的定时任务里,或者用系统的计划任务调用。注意备份时最好先conn.commit()确保没有未落盘的事务。
5.4 一个我踩过的坑:别在收银高峰期改表结构
有一次我在下午客流高峰时给商品表加了一个字段,结果 SQLite 的ALTER TABLE锁住了整个库,收银台全部卡死,排队排到门口。血泪经验:任何表结构变更、索引重建、大批量数据导入,都放到关店后做。如果非要在线改,先用EXPLAIN QUERY PLAN看看会不会全表扫描,能加索引解决的就别动表结构。系统稳定比功能多重要得多。
希望帮到你。
本文还有配套的精品资源,点击获取