做超市采购销存系统,很多人一上来就想写代码,但真正动手之后才发现,最难的从来不是ORM怎么用、页面怎么渲染,而是业务链条怎么串起来。这套基于Python的超市购物商城采购销存系统,我前后花了两周时间完整落地,技术栈就是PyCharm + Django/Flask + SQLite/MySQL这一套,覆盖商品管理、供应商管理、采购入库、销售出库、库存台账、低库存预警和销售报表这些核心模块。这篇文章把整个设计和实现过程完整拆开讲,包括数据库结构怎么定、事务为什么必须写、库存为什么不能简单做加减法、并发下单怎么防超卖,以及实际运行中踩过的各种坑。无论你是拿它做毕业设计、课程项目,还是真要给一家小超市做信息化改造,这套思路都值得直接参考。
1. 先理业务,再写代码:采购销存系统的整体设计思路
1.1 超市采购销存的三条核心链路
做采购销存系统之前,一定要先把超市的日常业务抽象成几条清晰的链路,否则代码写一半就会发现各种逻辑互相打架。我最终把整个系统收敛成三条主线:
- 采购链路:新增供应商 → 创建采购单 → 采购单审核确认 → 商品入库 → 库存增加。
- 销售链路:顾客选购商品 → 生成销售单 → 扣减库存 → 库存减少 → 统计销售额。
- 库存链路:所有出入库动作都写进库存流水表,库存表只做实时汇总,同时按阈值触发补货预警。
这三条链路听起来简单,但真正实施时有一个关键点容易被忽略:采购入库不能改完库存就结束,必须同时把“谁在什么时候通过哪个单子导致库存变了多少”记录下来。也就是说,库存不是简单的一个数字字段,而是一条带完整审计信息的流水账。这个思路决定了后面数据库怎么设计,也决定了系统在出问题的时候能不能查得清。
另外,既然标题里带了“购物商城”这几个字,说明销售端不应该只是一个收银台页面,还应该有商品浏览、购物车、订单确认这一套流程。我在设计时把前台商城和后台管理分成了两个模块,前台走商品展示和购物车,后台走采购、库存、订单和报表,这样职责清晰,测试和后续扩展都方便。
1.2 为什么选 Python + Django/Flask,而不是其他方案
很多人纠结这套系统到底用 Java 还是 Python,用 Django 还是 Flask。我的结论很直接:如果你要做的是功能完整的业务系统,优先选 Django;如果你只是想验证某个想法或者系统规模很小,Flask 也够用。
Django 最大的优势是自带 Admin 后台、ORM、表单和认证体系。采购销存这种系统有一大堆数据维护操作,比如商品信息录入、供应商资料修改、库存调整,如果全部手写页面,工作量会翻好几倍。Django Admin 一开,这些维护页面几乎零成本就有了,我只需要把主要精力放在采购、销售、库存这些核心业务逻辑上。
Flask 的特点是轻、灵活,路由和视图都是自己控制的,但实现同样的功能,你要自己去集成 SQLAlchemy、自己写分页、自己搭后台。如果你熟悉 Flask,并且项目不需要复杂的权限和后台管理,它完全能胜任。标题里两个框架都提到了,我在后面的内容里会给出两种框架下的项目结构和实现差异,方便你自己取舍。
数据库方面,开发阶段我用的 SQLite,零配置、开箱即用,PyCharm 里直接就能看到 db.sqlite3 文件。但要注意,如果要做并发测试或者真正部署到门店,建议换 MySQL,原因是 SQLite 对并发写支持很弱,行锁也并不是真正意义上的锁,这在后面防超卖的章节我会详细讲。
2. 数据库设计:五张核心表撑起整个系统
2.1 商品与供应商是地基
数据库是整个系统的地基,我最终把核心表收敛为五张:供应商表、商品表、采购单、销售单,加上库存表和库存流水表。后两张表虽然功能不同,但在业务上是一对,我通常把它们看成一组。
供应商表和商品表是主数据,属于一切业务的起点。供应商表一般就存名称、联系人、电话、地址。商品表麻烦一点,除了名称、条码、分类、单位,还必须有采购价、售价和预警库存这三个字段。条码我特意做了唯一约束,因为超市商品的扫码录入非常依赖这个字段,条码重复会导致商品串货,价格混乱。
from django.db import models class Supplier(models.Model): name = models.CharField('供应商名称', max_length=100) contact = models.CharField('联系人', max_length=50) phone = models.CharField('联系电话', max_length=20) address = models.CharField('地址', max_length=200, blank=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.name class Product(models.Model): name = models.CharField('商品名称', max_length=100) barcode = models.CharField('商品条码', max_length=32, unique=True) category = models.CharField('商品分类', max_length=50) unit = models.CharField('单位', max_length=10, default='件') purchase_price = models.DecimalField('采购价', max_digits=10, decimal_places=2) sale_price = models.DecimalField('零售价', max_digits=10, decimal_places=2) warning_stock = models.IntegerField('预警库存', default=10) supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True) def __str__(self): return self.name这里有个细节值得说明:价格字段用 DecimalField 而不是 FloatField。浮点数在 Python 内部是二进制表示的,0.1 + 0.2 的结果并不是 0.3,用在金额计算上会出问题。我之前见过有同学用 float 存价格,月底对账时差了几分钱,查了半天才发现是精度问题。金额必须用 DecimalField,数据库层面存成定点数,计算时配合 Python 的 decimal 模块,才能保证分毫不差。
2.2 采购、销售与库存变动的闭环
采购单和销售单的结构是类似的,都是“主表 + 明细表”的组合。主表记录单号、往来单位、总金额、状态、创建时间,明细表记录每个商品的采购或销售数量、成交单价。设计上用一个外键关联,并且明细表通过 related_name 回查,这样在主表对象上可以直接用 order.items.all() 取出所有明细。
库存这块是我最想强调的地方。库存表 Stock 只做当前数量的快照,真正重要的是 StockFlow 库存流水表,它记录了每一次库存变动的来龙去脉:哪个商品、什么类型(采购入库、销售出库、盘点调整)、变动前数量、变动量、变动后数量、关联单号、发生时间。有了这张流水表,任何时刻都可以回答“这个商品的库存为什么是这个数”这个问题,对账、排查、报表都靠它。
class Stock(models.Model): product = models.OneToOneField(Product, on_delete=models.CASCADE) quantity = models.IntegerField('当前库存', default=0) updated_at = models.DateTimeField(auto_now=True) class StockFlow(models.Model): FLOW_TYPE = ( ('purchase', '采购入库'), ('sale', '销售出库'), ('adjust', '盘点调整'), ('return', '退货入库'), ) product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name='flows') flow_type = models.CharField('流水类型', max_length=10, choices=FLOW_TYPE) before_quantity = models.IntegerField('变动前数量') quantity_change = models.IntegerField('变动数量') after_quantity = models.IntegerField('变动后数量') related_no = models.CharField('关联单号', max_length=32) created_at = models.DateTimeField(auto_now_add=True)这种设计有一个很重要的好处:库存表只是流水表的一个汇总视图,即使某天库存数据被改乱了,只要流水还在,就能通过重放流水重建出正确的库存。我在实际项目里就靠这个机制救过一次场,有一位同事误操作把库存表里的数量批量清零了,因为流水表完整,几分钟就把数据恢复回来了。
2.3 库存预警的阈值怎么定
预警阈值这个字段默认设为10,但真实场景里每个品类的补货周期不一样。矿泉水卖得快、进货频率高,预警值可以设高一些;一些慢消品,比如礼盒装零食,预警值就可以设低一些。所以在设计上,预警库存是商品表的一个字段,而不是全系统统一写死的一个常量,这样才能针对不同商品单独配置。
预警查询的逻辑也不复杂,就是找出“当前库存 <= 预警库存”的商品列表,然后在前端用醒目的颜色标出来。真正容易被忽略的是,预警判断的时机要考虑库存刚刚变动之后,也就是采购入库和销售出库的保存流程里都要顺手查一遍,否则预警信息更新不及时,补货计划就全乱套了。
3. PyCharm 下的环境搭建与项目骨架
3.1 创建虚拟环境与依赖管理
环境搭建这块很多人不重视,直接在系统 Python 里装包,结果不同项目之间的依赖互相冲突,改起来非常痛苦。我强烈建议项目一开始就在 PyCharm 里创建独立的虚拟环境。
在 PyCharm 中新建项目时,解释器类型选择 Virtualenv,PyCharm 会自动生成一个 .venv 目录。之后所有依赖都通过 Terminal 里激活的虚拟环境安装。Django 项目需要安装的核心依赖不多:django、mysqlclient(如果用 MySQL)、django-cors-headers(如果前后端分离需要)、openpyxl(如果要导出Excel报表)。Flask 项目则换成 flask、flask-sqlalchemy、flask-login 这套。
依赖管理上,项目根目录一定要放一个 requirements.txt,每次安装新包后都用 pip freeze > requirements.txt 同步导出。这样换机器、给答辩老师演示、部署到服务器时,一条 pip install -r requirements.txt 就能把环境完整拉起来。这个小习惯能省掉后面一大半环境问题。
3.2 Django 与 Flask 的项目结构差异
Django 项目初始化后是分层的:项目目录(同名配置目录)负责全局配置,一个个 app 负责具体业务模块。我的划分方法是:
- products:商品与分类
- suppliers:供应商
- purchases:采购单与采购入库
- sales:销售单与购物车
- stocks:库存与流水
每个 app 内部再拆 models.py、views.py、urls.py,逻辑界限非常清楚。Flask 没有 app 的概念,但我建议你还是用 Blueprint 模拟出类似的模块化结构,否则所有路由堆在一个文件里,写不到两千行代码就乱得不行了。
一个常见误区是把所有业务代码都塞到 models.py 里,视图层也很“肥”。我的习惯是:models 只放数据字段和基础方法,业务逻辑(比如入库操作里包含的验单、改库存、写流水这一整串动作)单独放到 services.py 里。这样每个函数的职责单一,后面写测试也方便。
3.3 关键配置:数据库与静态文件
settings.py 里有几个配置值得单独说一下。
数据库配置在开发阶段保持默认的 SQLite 即可,但如果要切换到 MySQL,要把 ENGINE 改成 django.db.backends.mysql,并填上数据库名、用户名、密码和主机地址,同时注意 MySQL 的字符集要设置成 utf8mb4,否则遇到生僻字或者 emoji 会报错。
静态文件这块是初学者经常卡住的地方。开发时 Django 用 django.contrib.staticfiles 自动找静态文件,但一旦把 DEBUG 设为 False,静态文件就不会自动提供了,需要在 urls.py 里做特殊处理,或者用 whitenoise 中间件。我在部署测试环境时吃过这个亏,页面打开是一堆没有样式的裸 HTML,排查了半天才发现是静态文件没配好。
还有一个容易被忽视的配置是时区。Django 默认的 TIME_ZONE 是 UTC,而超市的业务时间显然是中国时间。在 settings.py 里把 TIME_ZONE 改成 'Asia/Shanghai',并设置 USE_TZ = True,这样数据库存的虽然是 UTC 时间,但 Django 在读取和渲染时会自动转为本地时间,报表按天分组时才不会出现“早上八点前的销售被算到前一天”这种诡异问题。
4. 核心模块实现:采购入库、销售出库与库存看板
4.1 采购入库的完整流程与事务处理
采购入库是整个系统里最典型的一段业务逻辑。流程是:采购员创建采购单,填入供应商和若干明细;审核确认后,系统校验采购单还没入库过,然后逐个商品增加库存,写流水,最后把采购单状态改成已入库。
这段逻辑看起来不复杂,但它涉及多张表的写操作,必须放在同一个数据库事务里。什么叫事务?你可以理解成一组操作“要么全部成功,要么全部失败”。比如采购单有两条明细,第一条入库成功了,第二条因为商品被删除而失败了,如果没有事务,就会出现库存只加了一半、采购单状态也没更新的脏数据。这在财务对账时是灾难。
Django 里用 transaction.atomic 装饰器或上下文管理器非常方便:
from django.db import transaction from django.utils import timezone def generate_order_no(prefix): return prefix + timezone.now().strftime('%Y%m%d%H%M%S') + str(random.randint(1000, 9999)) @transaction.atomic def confirm_purchase_order(order_id): order = PurchaseOrder.objects.select_for_update().get(id=order_id) if order.status == 'confirmed': raise ValueError('该采购单已入库,请勿重复操作') for item in order.items.select_for_update(): stock, created = Stock.objects.get_or_create( product=item.product, defaults={'quantity': 0} ) before = stock.quantity item.product.purchase_price = item.price item.product.save() stock.quantity += item.quantity stock.save() StockFlow.objects.create( product=item.product, flow_type='purchase', before_quantity=before, quantity_change=item.quantity, after_quantity=stock.quantity, related_no=order.order_no, ) order.status = 'confirmed' order.save()注意事务里的几个细节。第一,select_for_update() 会对被查询的行加锁,防止同一个采购单被两个管理员同时确认,否则就会出现重复入库。第二,采购确认时我顺便更新了商品的最新采购价,这样做的好处是后续计算毛利时用的是最近一次真实采购成本,而不是商品创建时的初始价格。第三,流水表里同时记录了 before 和 after,这样对账时可以直接看到每条流水的差额是不是等于变动数量,很方便核对。
4.2 销售出库与库存扣减的防超卖设计
销售出库和采购入库在事务处理上是对称的,但多了一个需要特别注意的问题:超卖。所谓超卖,就是库存只剩3件,却同时有两个订单各买了2件,如果不加控制,系统会让两单都成功,库存变成负数。
超卖的根源在于并发。两个请求同时读到库存是3,都判断“3 >= 2”成立,然后各自扣减2,最后库存变成-1。解决思路是让“读库存、判断、扣减”这三个动作变成原子操作,即在同一时刻只能有一个请求操作这个商品的库存行。
在 Django 中做法是查询时加 select_for_update(),让数据库层面的行锁来保证互斥:
@transaction.atomic def create_sale_order(cart_items): total_amount = 0 for ci in cart_items: stock = Stock.objects.select_for_update().get(product_id=ci['product_id']) if stock.quantity < ci['quantity']: raise ValueError(f"商品 {stock.product.name} 库存不足,仅剩 {stock.quantity} 件") before = stock.quantity stock.quantity -= ci['quantity'] stock.save() StockFlow.objects.create( product=stock.product, flow_type='sale', before_quantity=before, quantity_change=-ci['quantity'], after_quantity=stock.quantity, related_no=sale_obj.order_no, ) total_amount += stock.product.sale_price * ci['quantity']这里有一个我在项目里实际踩过的坑:SQLite 并不支持真正的行级锁,select_for_update() 在 SQLite 下不会生效,并发测试时照样超卖。所以开发阶段用 SQLite 没问题,但一旦需要在真实环境跑,必须换成 MySQL 或 PostgreSQL,并且数据库事务隔离级别要保持默认的 REPEATABLE READ 或 READ COMMITTED。我在测试阶段用 SQLite 跑并发脚本模拟几十个订单同时提交,库存直接变负数,换到 MySQL 之后同样脚本跑几十轮都稳稳的。
4.3 首页看板与低库存预警
系统的首页我做成一个管理看板,集中展示最需要关注的信息:今日销售额、今日订单数、低库存商品数量、最近七天的销售趋势。这些数据都来自流水表的聚合查询,比如今日销售额就是对当天状态已支付销售单的 total_amount 求和。
低库存预警的核心查询是:
low_stock_products = Product.objects.filter(stock__quantity__lte=F('warning_stock'))这里用到了 F 表达式做数据库端的字段比较,而不是把所有商品加载到内存里循环判断。数据量小的时候两种写法差别不大,但商品一旦上千,F 表达式的性能优势就非常明显了,这也是我一直提倡的:能用数据库完成的计算,就不要拿 Python 循环硬扛。
销售趋势的图表我用了 ECharts,后端只提供一个 JSON 接口,返回每天的销售额列表,前端画折线图。接口返回的数据注意用 Django 的 JsonResponse,并且在序列化时把 Decimal 金额转成字符串,否则会报 Object of type Decimal is not JSON serializable 的错误。
5. 实操中踩过的坑与排查记录
5.1 事务没写完整,对账差出三毛钱
第一个大坑就是事务边界没画对。最初版本我在采购入库时只更新了库存和流水,采购单状态字段的更新被放在了事务外面,代码大概是采购逻辑写完后回到了视图函数里才执行 order.status = 'confirmed'。结果有一次程序在处理流水生成时报错,事务回滚了,库存和流水都没变,但视图外那句状态更新却执行了,采购单显示已入库,库存却纹丝不动。
这类问题排查起来非常隐蔽,直到月底对账才发现某个商品明明有采购记录,库存却对不上。从那以后我给自己定了一条铁律:凡是跨多张表的写操作,必须整体塞进一个 atomic 事务里,并且事务内做完所有状态变更,不允许在事务外再补一笔写操作。
5.2 并发测试暴露出的超卖问题
第二个坑就是前面提到的 SQLite 假锁问题。我最初在开发环境模拟多人同时下单,用 Python 的 threading 开了 20 个线程同时买同一件只剩 5 件库存的商品,结果 20 个请求全部成功,库存变成 -15。这个测试直观地暴露了行锁的必要性,也让我把数据库换成 MySQL 的优先级提到了最高。
如果你也打算写并发测试脚本,我建议不要在 Django 自带的开发服务器上测,因为它是串行处理请求的。正确做法是用 pytest + 多线程直接调用业务函数,或者用真实的并发压测工具,否则根本测不出问题。
5.3 环境迁移时的经典报错
项目做到后期换机器,或者给同学部署演示环境时,最容易出现三类问题:
- 虚拟环境没激活,直接跑 python manage.py,报 ModuleNotFoundError: No module named 'django'。
- requirements.txt 里的包版本不统一,比如本地是 Django 4.2,演示机器上装到了 Django 5.x,部分 API 行为不一致。
- MySQL 版本的驱动问题,Windows 下 pip install mysqlclient 经常编译失败,解决办法是装预编译的 wheel 包。
这些问题的共同特点是解决起来都不难,但会不断打断你的思路。用虚拟环境 + 锁定版本的 requirements.txt + 一份简短的部署说明文档,三步就能把这类问题压到最低。
5.4 问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 库存变成负数 | 并发扣减时没有行锁,或 SQLite 锁不生效 | 迁移到 MySQL,查询时加 select_for_update |
| Decimal 序列化报错 | JsonResponse 无法直接处理 Decimal | 先转换成字符串或 float,再返回 |
| 页面无样式 | DEBUG=False 后静态文件未配置 | 配置静态文件服务中间件或指定目录 |
| Admin 后台中文乱码 | 数据库字符集不是 utf8mb4 | 建库时指定 utf8mb4 字符集 |
| 迁移时提示字段冲突 | 多次修改模型后迁移文件混乱 | 先清理迁移记录,再重新生成 makemigrations |
| 采购单重复入库 | 未检查状态字段或未加锁 | 事务内锁定单据行,检查状态并防止重复确认 |
6. 从课程设计走向真实商超:权限、日志与扩展思路
6.1 分级权限与操作日志
如果一个系统只给你一个人用,权限管理确实可有可无。但真正在超市环境里,收银员、采购员、仓库管理员、店长是不同角色,不能让收银员有改采购价的权限,也不能让采购员能看到完整的商品成本毛利表。Django 自带的用户系统和 Group 权限这一层就够了:建三个组,分别授予对应的模型增删改查权限,视图里用 @permission_required 装饰器做控制。
操作日志是我强烈建议加的模块。凡是新增、修改、删除库存和相关单据,都往日志表写一条记录,内容包括操作人、操作时间、操作内容、旧的数值和新的数值。之前有一次库存数据异常,就是靠日志表倒查定位到一个管理员误操作把销售单改成退款导致的。没有日志,这类问题基本只能靠猜。
6.2 数据备份与报表导出
超市系统最不能丢的是数据。开发阶段还好,一旦上线,每天关店前都应该备份数据库。MySQL 下一条 mysqldump 命令就够了,写成一个定时脚本,保留最近七天的备份文件。这个动作成本极低,但关键时刻能救命,我身边就有朋友因为没备份,硬盘坏了之后全部数据找不回来的真实案例。
报表导出我用的是 openpyxl,按时间范围把销售流水汇总成 Excel,包含商品名称、条码、数量、金额、毛利。导出功能在 PyCharm 里调试时注意文件要保存到绝对路径,不要用相对路径,否则会生成到你意想不到的目录里。
6.3 这套系统还能往哪个方向扩
做完最基础的五张表之后,这个系统的扩展空间其实很大。比如加一个会员模块,按累计消费金额设置折扣等级;或者加一个多门店模块,在商品、库存、流水上增加门店维度字段,就能支持连锁场景;再或者接入扫码枪,前台收银直接用扫码输入条码,自动带出商品信息和价格。
我个人在实际操作中的体会是:系统贵在稳定和可追溯,而不是功能多得眼花缭乱。把采购、销售、库存这三条链路用流水表串扎实了,事务边界画对了,这套系统就已经解决了超市日常运营中最核心的痛点。后续所有的扩展,都是在这个稳定的骨架上往外长肉而已。最后再分享一个小技巧:每次改动完数据库模型之后,第一时间跑 makemigrations 和 migrate,并且把迁移文件提交到版本管理里,千万不要隔几天再统一迁移,迁移文件一乱,排查的时间比你省下来的时间多得多。