
简介这是一份面向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_pathmarket.db, backup_dirbackup): if not os.path.exists(backup_dir): os.makedirs(backup_dir) today datetime.date.today().isoformat() backup_path os.path.join(backup_dir, fmarket_{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看看会不会全表扫描能加索引解决的就别动表结构。系统稳定比功能多重要得多。希望帮到你。本文还有配套的精品资源点击获取