简介:这是一份面向Python初学者与进阶学习者的超市管理系统实战源码,围绕员工管理、客户管理、登录验证与商品库存追踪等日常运营场景展开,适合想通过完整业务项目巩固GUI编程、数据管理与模块化设计的读者。压缩包共13个文件,约8KB,以7个txt数据与配置文本、4个py功能模块、1个md说明文档和1个log日志为主,分别承担账户信息存储、业务逻辑实现、项目说明与运行记录等职责。目前已有332人学习下载。读者可从中获取登录验证、员工与顾客信息管理、文件持久化读写、异常处理与日志记录等关键实现思路,并借助README快速理解项目结构与运行方式,适合作为课程设计、毕业设计或自学练手时的参考案例,帮助把零散语法知识串联为可运行的业务系统。
1. 从一张 Excel 台账说起:python超市管理系统到底在管什么
很多中小超市的进销存起点,是一张越滚越大的 Excel 台账。早上进货改一列,中午收银改一列,晚上盘点发现对不上,只能靠回忆找哪一步填错了。python超市管理系统要解决的,正是这种「数据散、对账慢、库存靠猜」的状态:把商品、库存、收银、会员这几件事收进一个程序里,让每一次入库和销售都留下可追溯的记录。
它适合谁?一是刚学完 Python 基础、想找一个能跑通完整业务闭环的练手项目的人;二是小超市、便利店、社区店的经营者,想用一套自己能改的轻量工具替代手工记账。技术栈上,常见做法是 Flask 或 FastAPI 做后端、SQLite 或 MySQL 存数据、HTML 加一点前端模板做界面,纯命令行版本也能先跑通逻辑。这一章先把边界划清楚,后面几章再一步步落到表结构、接口和排错上。
2. 先把数据模型定死:四张核心表撑起进销存
2.1 为什么商品、库存、订单、会员要拆开
新手最容易犯的错,是把所有字段塞进一张「商品表」:商品名、进价、售价、库存数量、供应商、会员折扣全挤在一起。这样写前几天很爽,一旦要做「同一商品多次进货、不同批次进价不同」或者「一个订单含多个商品」,表结构立刻崩掉。正确的做法是按职责拆表,让每张表只描述一类事实。
商品表(product)描述「卖什么」,只存静态属性:条码、名称、分类、售价、当前库存。库存流水表(stock_log)描述「库存怎么变的」,每次进货、销售、退货、报损都插一条记录,带正负数量和发生时间。订单表(order)和订单明细表(order_item)描述「卖了什么」,主表存订单号、总价、时间、会员,明细表存每一行商品和数量。会员表(member)存手机号、姓名、积分、折扣等级。
这样拆的好处是:库存永远可以由流水累加算出来,和商品表里的冗余字段做交叉校验,一旦对不上就说明有代码绕过了流水直接改库存——这正是排查数据问题的关键线索。
2.2 建表 SQL 与字段类型选择
下面这段 SQL 用 SQLite 语法,MySQL 只需把AUTOINCREMENT换成AUTO_INCREMENT、TEXT换成VARCHAR即可。金额字段统一用整数存「分」,避免浮点误差,这是收银系统里最容易被忽视又最致命的细节。
-- 商品表:只存静态属性,库存字段作为冗余缓存 CREATE TABLE product ( id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT NOT NULL UNIQUE, -- 条码,扫码枪直接录入 name TEXT NOT NULL, category TEXT DEFAULT '未分类', price_cent INTEGER NOT NULL, -- 售价,单位:分 stock INTEGER NOT NULL DEFAULT 0,-- 当前库存,由流水维护 created_at TEXT NOT NULL ); -- 库存流水:每一次库存变动都留痕,quantity 可正可负 CREATE TABLE stock_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, change_qty INTEGER NOT NULL, -- 进货为正,销售为负 reason TEXT NOT NULL, -- purchase/sale/return/loss ref_order TEXT, -- 关联订单号,可空 created_at TEXT NOT NULL, FOREIGN KEY (product_id) REFERENCES product(id) ); -- 订单主表 CREATE TABLE "order" ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, total_cent INTEGER NOT NULL, member_id INTEGER, created_at TEXT NOT NULL ); -- 订单明细 CREATE TABLE order_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL, product_id INTEGER NOT NULL, qty INTEGER NOT NULL, price_cent INTEGER NOT NULL -- 成交单价,防止后续改价影响历史 );字段说明里几个点值得单独拎出来:price_cent用分而不是元,是因为0.1 + 0.2 != 0.3在收银场景会直接导致对账差几分钱;order_item里冗余存price_cent,是为了商品调价后历史订单金额不变;stock_log.ref_order让每一次库存变动都能追到具体订单,退货时按订单号反查非常方便。
2.3 用 Python 初始化数据库并插入种子数据
建完表要能一键初始化,否则每次换环境都要手动执行 SQL。下面这段脚本用标准库sqlite3,不依赖任何第三方包,新手可以直接复制运行。
import sqlite3 from datetime import datetime def init_db(path="market.db"): conn = sqlite3.connect(path) cur = conn.cursor() # 执行上面的建表语句,executescript 支持多条 with open("schema.sql", "r", encoding="utf-8") as f: cur.executescript(f.read()) # 插入两条种子商品,方便立刻测试 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur.executemany( "INSERT INTO product (barcode, name, category, price_cent, stock, created_at) " "VALUES (?, ?, ?, ?, ?, ?)", [ ("690000000001", "矿泉水 550ml", "饮料", 200, 100, now), ("690000000002", "抽纸 3层", "日用品", 850, 50, now), ], ) conn.commit() conn.close() if __name__ == "__main__": init_db() print("数据库初始化完成")逻辑上,executescript会一次性执行整个 schema 文件,适合初始化;executemany批量插入比循环单条插入快得多,种子数据量小时差别不明显,但养成习惯没坏处。参数说明:path是数据库文件路径,SQLite 会自动创建文件;barcode加了唯一约束,重复插入会抛IntegrityError,这正好可以用来做「商品是否已存在」的判断。
3. 收银与库存扣减:一个事务里必须做完的事
3.1 为什么扣库存和写订单不能分开提交
收银的核心动作是:生成订单、写订单明细、扣减库存、写库存流水。这四步如果分四次提交,中间任何一步失败都会留下脏数据——比如订单生成了但库存没扣,第二天盘点就少货。正确做法是把它们放进同一个数据库事务,要么全成功,要么全回滚。
SQLite 默认是自动提交模式,需要显式BEGIN。下面这段代码演示一次完整收银,注意try/except里rollback的位置。
import sqlite3 from datetime import datetime def checkout(conn, items, member_id=None): """ items: [{"barcode": "690000000001", "qty": 2}, ...] 返回订单号;任何一步失败则整体回滚 """ cur = conn.cursor() order_no = "SO" + datetime.now().strftime("%Y%m%d%H%M%S%f")[:17] total = 0 try: conn.execute("BEGIN") for it in items: cur.execute( "SELECT id, price_cent, stock FROM product WHERE barcode=?", (it["barcode"],), ) row = cur.fetchone() if row is None: raise ValueError(f"条码不存在: {it['barcode']}") pid, price, stock = row if stock < it["qty"]: raise ValueError(f"库存不足: {it['barcode']} 剩 {stock}") total += price * it["qty"] # 写明细 cur.execute( "INSERT INTO order_item (order_no, product_id, qty, price_cent) " "VALUES (?, ?, ?, ?)", (order_no, pid, it["qty"], price), ) # 扣库存 + 写流水 cur.execute( "UPDATE product SET stock = stock - ? WHERE id = ?", (it["qty"], pid), ) cur.execute( "INSERT INTO stock_log (product_id, change_qty, reason, ref_order, created_at) " "VALUES (?, ?, 'sale', ?, ?)", (pid, -it["qty"], order_no, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) # 写订单主表 cur.execute( 'INSERT INTO "order" (order_no, total_cent, member_id, created_at) ' "VALUES (?, ?, ?, ?)", (order_no, total, member_id, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) conn.commit() return order_no except Exception: conn.rollback() raise关键点有三个。第一,BEGIN之后所有写操作都在同一事务里,commit之前对别的连接不可见。第二,库存检查放在事务内,避免「检查时够、扣减时不够」的并发问题;SQLite 写操作会加锁,高并发场景要换成 MySQL 的行锁或乐观锁。第三,order_no用时间戳加微秒生成,实际生产建议用「日期 + 自增序列」或雪花算法,避免同一微秒内两单撞号。
3.2 库存对账:用流水反算,找出被绕过的修改
库存字段是冗余缓存,真正的账在stock_log里。写一个对账函数,把每个商品的流水求和,和product.stock比对,不一致就说明有代码直接改了库存没写流水。
def reconcile(conn): cur = conn.cursor() cur.execute(""" SELECT p.id, p.name, p.stock, COALESCE(SUM(s.change_qty), 0) AS calc_stock FROM product p LEFT JOIN stock_log s ON s.product_id = p.id GROUP BY p.id HAVING p.stock != calc_stock """) return cur.fetchall()LEFT JOIN保证没有流水的商品也会被查出来(COALESCE把 NULL 转成 0)。HAVING过滤出不一致的行。这个函数建议做成定时任务,每天闭店后跑一次,输出差异清单。参数上,如果商品数量上万,stock_log会很大,可以只对最近 30 天有变动的商品做全量对账,历史数据按月归档。
3.3 退货与报损怎么走同一套流水
退货和报损本质都是库存变动,只是reason不同、方向不同。退货是change_qty为正、reason='return',报损是负、reason='loss'。把它们统一走一个adjust_stock函数,避免每种业务各写一套扣减逻辑。
def adjust_stock(conn, product_id, qty, reason, ref_order=None): """qty 为正表示入库,为负表示出库""" now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with conn: # with 语句自动 commit/rollback conn.execute( "UPDATE product SET stock = stock + ? WHERE id = ?", (qty, product_id), ) conn.execute( "INSERT INTO stock_log (product_id, change_qty, reason, ref_order, created_at) " "VALUES (?, ?, ?, ?, ?)", (product_id, qty, reason, ref_order, now), )with conn是 sqlite3 的上下文管理器,正常结束自动提交,抛异常自动回滚,比手写try/except简洁。注意它和显式BEGIN不要混用,否则事务边界会乱。退货时先按订单号查order_item拿到原价和数量,再调这个函数,保证退款金额和库存同步。
4. 避坑与排查:那些让库存对不上的常见问题
4.1 浮点数存金额导致对账差几分钱
现象:订单总价和明细累加差 0.01 元,日积月累对账永远差一点。原因:用FLOAT或 Pythonfloat存金额,0.1 + 0.2得到0.30000000000000004。解决:所有金额用整数分存储,展示时再除以 100。如果历史数据已经是浮点,写一个迁移脚本UPDATE product SET price_cent = ROUND(price * 100),迁移前先备份数据库文件。
4.2 并发收银时库存扣成负数
现象:两台收银机同时卖最后一件商品,两边都显示成功,库存变成 -1。原因:库存检查在事务外,或者用了「先查后改」但没加锁。解决:把检查和扣减放进同一事务;SQLite 下用BEGIN IMMEDIATE提前拿写锁;MySQL 下用UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?,根据rowcount判断是否扣减成功,失败就提示库存不足。
4.3 条码重复导致商品被覆盖
现象:新商品入库后,老商品信息变了。原因:barcode没加唯一约束,或者插入时用了INSERT OR REPLACE。解决:建表时给barcode加UNIQUE;插入用普通INSERT,捕获IntegrityError后提示「条码已存在,请检查是否重复录入」。如果确实要更新,走单独的UPDATE接口,不要用 replace 一把梭。
4.4 时间字段用本地时间导致跨时区混乱
现象:服务器迁移或改时区后,订单时间对不上。原因:created_at存的是本地时间字符串,没有时区信息。解决:统一存 UTC 时间戳(整数秒)或带时区的 ISO 字符串,展示时再转本地时区。SQLite 可以用strftime('%s','now')存 Unix 时间戳。已经用本地时间的,迁移时按原时区偏移量换算,别直接当 UTC 处理。
4.5 忘记写流水直接改库存
现象:对账函数报出一堆差异,但业务上没发现异常。原因:某段代码为了「图快」直接UPDATE product SET stock = ?,绕过了stock_log。解决:把库存修改收敛到adjust_stock一个函数里,代码评审时搜索所有UPDATE product SET stock的出现位置;对账函数做成每日定时任务,差异超过阈值就告警。
5. 从能跑到好用:报表、权限与部署的三个进阶技巧
系统能收银之后,真正决定它能不能长期用的是报表和权限。先说报表。超市最关心的三个数字是:日销售额、毛利、库存周转。日销售额直接从order表按日期SUM(total_cent);毛利需要知道进价,可以在stock_log里给进货流水加一个unit_cost_cent字段,销售时按先进先出匹配成本,简化版可以直接用商品表里的「最近进价」估算。库存周转用「某周期内销售数量 / 平均库存」,低于阈值的商品标出来做促销。
def daily_report(conn, day): """day 格式 2025-01-01""" cur = conn.cursor() cur.execute(""" SELECT COUNT(*) AS orders, SUM(total_cent) AS revenue FROM "order" WHERE substr(created_at, 1, 10) = ? """, (day,)) orders, revenue = cur.fetchone() cur.execute(""" SELECT p.name, SUM(oi.qty) AS sold FROM order_item oi JOIN "order" o ON o.order_no = oi.order_no JOIN product p ON p.id = oi.product_id WHERE substr(o.created_at, 1, 10) = ? GROUP BY p.id ORDER BY sold DESC LIMIT 10 """, (day,)) top = cur.fetchall() return {"orders": orders, "revenue_cent": revenue or 0, "top": top}substr(created_at, 1, 10)截取日期部分,适合created_at存字符串的场景;如果存的是时间戳,改用date(created_at, 'unixepoch')。revenue or 0处理当天没有订单时SUM返回NULL的情况。这个函数可以直接挂到 Flask 的/report/daily路由上,前端用表格渲染。
再说权限。收银员只能开单和查自己的订单,店长能看报表和改价,管理员能改商品和删数据。最简做法是在member表旁边加一张user表,存用户名、密码哈希(用hashlib.pbkdf2_hmac,别存明文)、角色字段。每个接口入口检查角色,用装饰器统一处理,避免每个函数里写一遍if role != 'admin'。
import functools from flask import request, jsonify def require_role(*roles): def deco(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): role = request.headers.get("X-Role", "cashier") if role not in roles: return jsonify({"error": "无权限"}), 403 return fn(*args, **kwargs) return wrapper return deco @app.route("/product/<int:pid>", methods=["DELETE"]) @require_role("admin") def delete_product(pid): ...functools.wraps保留原函数名,方便调试;角色从请求头取只是演示,生产环境应该用登录后签发的 token 解析。最后说部署。SQLite 适合单机、日订单几百笔的场景,直接python app.py跑就行;订单上千、多台收银机就要换 MySQL 或 PostgreSQL,用gunicorn加nginx部署,数据库连接池大小按并发收银机数量乘以 2 配置。备份策略上,SQLite 用sqlite3 market.db ".backup backup.db"热备份,MySQL 用mysqldump加定时任务,备份文件至少保留 30 天。
我自己踩过最深的一个坑,是早期图省事把库存扣减写在订单提交之后,结果一次断电导致订单在、库存没扣,盘点了三天才找齐差异。从那以后我定了个习惯:凡是涉及金额和库存的写操作,先问一句「这几步在不在同一个事务里」,不在就先改结构再写业务。希望帮到你。
本文还有配套的精品资源,点击获取