
简介这套基于Python Django的超市进销存销售管理系统毕业设计源码面向计算机专业毕业生、课程设计学生及Django Web开发入门者。项目完整覆盖商品管理、库存管理、销售管理、供应商与客户管理、账务管理等模块支持销售记录与报表统计可帮助学生快速掌握Django框架下的业务系统开发流程理解ORM模型映射、类视图、模板渲染、URL路由及内置Admin后台等核心技术点。压缩包共65个文件其中39个Python文件承载后端逻辑16个HTML模板构成前端页面另含SQL数据库初始化脚本、依赖清单、配置文件和部署说明整体仅65KB轻量而完整便于在校生快速阅读。已有103人学习下载。资源附带可直接导入的数据库脚本和本地运行配置导入后即可启动体验完整功能目录按照应用模块、模板、静态资源和日志分层组织结构清晰方便查阅与二次开发也可作为课堂演示项目。无论用于毕业设计答辩还是作为Django进阶练习均具有较高参考价值。1. 从进销存到Django超市销售管理系统到底在解决什么问题超市进销存系统核心是回答三个问题货从哪里来、货到哪里去、仓库还剩多少。落到业务上就是采购、销售、库存三张主表但真正的难点在于三者互相咬合——一次销售要同时写销售单和扣库存一次采购入库要同时写采购单和加库存任何一个环节断了账面和实物就对不上。Django 的 ORM 和事务机制恰好把这套联动变成了可维护的代码这也是为什么毕业设计里这类题目常年排在 Python 选题前几位。毕业设计里的超市进销存导师通常看三件事模型设计是否合理、事务边界是否清楚、报表能不能直接用于经营决策。超市场景和普通进销存最大的差异是单品多、单价低、日交易流水大退货和盘点频率远高于其他零售业态。如果照搬图书管理系统的思路把删除做成物理删除、把价格用浮点数存后期对账一定会出问题。下文以一份完整的 Django 超市进销存项目为骨架从表结构设计、采购销售核心业务、Admin 后台定制、报表实现到宝塔部署逐步展开每个环节都给出可复现的代码和参数说明。新手能顺着步骤搭起来有经验的开发者可以直接看锁、事务和部署部分的边界取舍。2. 表结构设计把超市进销存拆成可落地的 Django 模型进销存系统的地基是模型设计表结构错了后面所有业务代码都在补窟窿。这一章从基础档案到单据流水逐层拆解。2.1 商品、供应商与分类的基础档案表先看最基础的三张表分类、供应商、商品。商品表的核心字段不只有名称和价格还要考虑编码、条码、单位、预警库存这些实际运营字段它们是搜索和报表的地基。from django.db import models class Category(models.Model): name models.CharField(分类名称, max_length32, uniqueTrue) sort_order models.IntegerField(排序, default0) class Supplier(models.Model): name models.CharField(供应商名称, max_length64) contact models.CharField(联系人, max_length32, blankTrue) phone models.CharField(联系电话, max_length20, blankTrue) class Product(models.Model): category models.ForeignKey(Category, on_deletemodels.PROTECT, verbose_name分类) supplier models.ForeignKey(Supplier, on_deletemodels.PROTECT, verbose_name供应商) code models.CharField(商品编码, max_length32, uniqueTrue) barcode models.CharField(条码, max_length32, db_indexTrue) name models.CharField(商品名称, max_length64) unit models.CharField(单位, max_length8, default件) purchase_price models.DecimalField(进价, max_digits10, decimal_places2) sale_price models.DecimalField(售价, max_digits10, decimal_places2) warn_stock models.IntegerField(预警库存, default10)外键用on_deletemodels.PROTECT而不是CASCADE这是进销存系统的第一个关键取舍。商品一旦被历史采购单或销售单引用CASCADE会把历史记录连根删掉账面立刻失真PROTECT会在删除时抛出ProtectedError强制你把商品改成停用状态而不是物理删除保证审计链路完整。价格用DecimalField而不是FloatField是为了避开浮点误差。单价、金额、库存数量这类数值在进销存里必须精确DecimalField指定max_digits和decimal_places之后MySQL 底层用定点数存储计算结果是确定性的。barcode加db_indexTrue是因为超市收银和后台查询最常用的就是条码精确匹配没有索引在几万条商品里会明显变慢。2.2 采购单、销售单与流水明细的关联设计基础档案描述商品是什么真正产生数据的是采购单和销售单。设计这类单据时标准做法是「主单 明细」两张表主单记录单据号、日期、往来单位、总金额明细记录每一行商品的数量、单价和行金额。主单是单据的头部信息明细是单据的行项目一个主单对应多个明细。class PurchaseOrder(models.Model): no models.CharField(采购单号, max_length32, uniqueTrue) supplier models.ForeignKey(Supplier, on_deletemodels.PROTECT, verbose_name供应商) order_date models.DateTimeField(auto_now_addTrue, verbose_name采购日期) total_amount models.DecimalField(总金额, max_digits12, decimal_places2, default0) status models.CharField(状态, max_length16, defaultdraft) class PurchaseItem(models.Model): order models.ForeignKey(PurchaseOrder, related_nameitems, on_deletemodels.CASCADE, verbose_name采购单) product models.ForeignKey(Product, on_deletemodels.PROTECT, verbose_name商品) quantity models.IntegerField(数量, default1) price models.DecimalField(进价, max_digits10, decimal_places2) amount models.DecimalField(金额, max_digits12, decimal_places2)related_nameitems的作用是让order.items.all()直接拿到当前单据的全部明细反向查询不用写_set后缀。明细order外键反而用CASCADE是因为明细的生命周期完全依附于主单主单删除时明细没有独立保留价值这和外键指向商品用PROTECT是两个不同的设计意图。主单和明细在同一个事务里写入这是进销存最基本的一致性要求。销售单结构完全相同只是把supplier换成customer或者直接不挂往来单位超市零售没有客户档案price取商品当前售价。两张表放一起说是因为 Django 代码里它们几乎是复制关系差别只在业务约束上。2.3 库存模型为什么不直接在商品表上加数量字段很多第一次做进销存的人习惯在 Product 上加一个quantity字段表示当前库存。这个设计在小规模演示项目里没问题但一旦涉及出入库并发和批次追踪就会卡住。库存是动态数据商品是静态档案把它们分离是更稳的建模方式。class Stock(models.Model): product models.OneToOneField(Product, on_deletemodels.CASCADE, verbose_name商品) quantity models.IntegerField(现有库存, default0) locked_quantity models.IntegerField(锁定库存, default0) updated_at models.DateTimeField(auto_nowTrue) property def available(self): return self.quantity - self.locked_quantityOneToOneField保证每个商品只有一条库存记录查询库存时直接product.stock.quantity或按商品反查不需要聚合。locked_quantity是给「下单锁定、支付扣减」场景准备的后台手工单、团购预定先锁库存支付成功再真正扣减超市收银不走订单流程但这个字段提前留下不会增加复杂度反而让库存查询多了一个可用量口径。available属性返回可售数量业务代码里所有库存够不够的判断都应该基于它而不是quantity。库存模型这一层设计到位后第二章的模型骨架就齐了。下一章进入真正的业务核心——采购入库、销售出库和退货这些操作的本质是同时改多张表必须用事务和锁来保证一致性。3. 核心业务实现进货入库、销售出库与 Django 事务边界进销存的代码量大部分不在增删改查而在改一张表的同时改另一张表。采购入库要写单、写明细、加库存、更新成本价销售出库要写单、写明细、减库存退货还要冲减原单据。这一章把三类操作逐个拆开讲清楚事务边界和锁的用法。3.1 采购入库的原子操作一个事务里完成四件事采购入库不能只更新库存还要更新商品的最近进价否则后续毛利报表全部失真。这四个动作必须包在同一个事务里任何一个失败都要整体回滚。from django.db import transaction from django.db.models import F transaction.atomic def purchase_confirm(order): 采购单确认入库写库存、更新进价、刷新单据状态 for item in order.items.select_for_update(): stock, created Stock.objects.select_for_update().get_or_create( productitem.product, defaults{quantity: 0} ) stock.quantity F(quantity) item.quantity stock.save(force_updateTrue) Product.objects.filter(pkitem.product_id).update( purchase_priceitem.price ) order.status done order.save(update_fields[status])select_for_update()对查询出来的行加数据库层排他锁防止两个采购单同时给同一个商品加库存时互相覆盖。F(quantity) item.quantity让数据库在写入时自己完成自增计算而不是先在 Python 里读出旧值再加避免并发下读到过期值后覆盖别人的写入。get_or_create是为了兼容新商品首次入库时库存记录还不存在的情况。这个函数里有三个容易踩的坑。第一stock.save()之后立即读stock.quantity拿到的还是旧对象里的值因为F()表达式只生成数据库端的更新语句不会回写 Python 内存对象如果后续逻辑需要用到新数量必须在save()之后调用stock.refresh_from_db()。第二Product.objects.update()不走 save 信号、不更新时间字段这里故意用它是为了避免触发多余的自动更新逻辑。第三order.items.select_for_update()锁的是明细行真正需要锁的是Stock行所以循环体内还要对Stock再锁一次锁的粒度要落在被修改的数据上。3.2 销售出库与防超卖锁、判、扣三步缺一不可销售出库最怕超卖——两个收银台同时卖最后一瓶水数据库里库存只剩 1两个请求都判断库存够结果就卖出 2 瓶。防超卖靠的不是运气是事务加行锁。transaction.atomic def sale_checkout(items_data): 销售出库锁库存、扣数量、写销售单缺一步回滚 sale_items [] for row in items_data: product Product.objects.select_for_update().get(pkrow[product_id]) stock Stock.objects.select_for_update().get(productproduct) if stock.quantity - stock.locked_quantity row[quantity]: raise ValueError(f库存不足{product.name}) stock.quantity F(quantity) - row[quantity] stock.save(force_updateTrue) sale_items.append(SalesItem( productproduct, quantityrow[quantity], priceproduct.sale_price )) order SalesOrder.objects.create(total_amountsum( s.quantity * s.price for s in sale_items )) SalesItem.objects.bulk_create([ s.with_order(order) for s in sale_items ]) return order判断stock.quantity - stock.locked_quantity用的是可售数量不是现有库存这样已经把锁定部分刨除。超卖判断必须在select_for_update()锁到行之后、修改之前完成锁-判-扣三步必须在同一个事务里之间不能有任何让出锁的 IO 或外部调用。如果判断和扣减之间插进了别的请求后到的请求会阻塞在行锁上等前一个事务提交后才读到更新后的值超卖自然被挡在锁外。bulk_create把多条明细拼成一条 INSERT 语句执行比逐条save()快一个量级。注意这里SalesItem还没有设置order外键with_order是先把单号挂到对象上再批量插入避免在循环里逐条操作数据库。事务隔离级别默认是 READ COMMITTEDselect_for_update在这个级别上额外加排他锁效果等价于串行化这批库存的修改。3.3 退货处理回补库存而不是删除销售单超市退换货频率高退货逻辑如果设计成把销售单删掉日终对账时销售额和实收永远对不上。正确的做法是新增一条退货流水冲减原销售明细的可退数量同时回补库存。transaction.atomic def product_return(sale_item_id, quantity): 销售退货校验退货上限、回补库存、记录退货流水 sale_item SalesItem.objects.select_for_update().get(pksale_item_id) if quantity 0 or quantity sale_item.quantity - sale_item.returned_quantity: raise ValueError(退货数量超出可退数量) Stock.objects.filter(productsale_item.product).update( quantityF(quantity) quantity ) ReturnRecord.objects.create( sale_itemsale_item, quantityquantity, amountsale_item.price * quantity ) sale_item.returned_quantity quantity sale_item.save(update_fields[returned_quantity])sale_item.returned_quantity是销售明细上的累计退货数用它来判断可退上限比去查退货表聚合要快因为聚合查询每次都要扫描所有退货记录。这里锁住明细行后退货和正常出库在同一把锁上排队不会出现先退后卖导致库存虚高的情况。退货金额按原销售单价计算不进实时价格保证与收银小票一致。事务和锁解决了一致性问题但进销存系统还有一个绕不开的需求——给管理员用的后台操作和给老板看的报表。下一章就把 Django Admin 和 ORM 聚合报表一起解决。4. 后台管理Django Admin 二次定制与日销售报表实现毕业设计里的后台绝大多数需求用 Django Admin 就能覆盖没必要单独写一套 Vue 管理端。重点是把默认的英文界面调成业务人员能看懂、常用操作能一步到位的样子。4.1 Admin 列表页定制搜索、筛选、状态列一次配齐Admin 的ModelAdmin配置决定了后台的使用体验。超市商品有几万条列表页必须能按名称、编码、条码三个维度搜按分类和供应商筛还要直接看到当前库存和预警状态。from django.contrib import admin from .models import Product, Stock, PurchaseOrder, SalesOrder class ProductAdmin(admin.ModelAdmin): list_display [code, name, category, sale_price, stock_quantity, stock_status] search_fields [name, code, barcode] list_filter [category, supplier] list_per_page 20 def stock_quantity(self, obj): return getattr(obj.stock, quantity, 0) stock_quantity.short_description 当前库存 def stock_status(self, obj): qty getattr(obj.stock, quantity, 0) return 预警 if qty obj.warn_stock else 正常 stock_status.short_description 库存状态 class StockAdmin(admin.ModelAdmin): list_display [product, quantity, locked_quantity, updated_at] search_fields [product__name, product__code] list_per_page 20 admin.site.register(Product, ProductAdmin) admin.site.register(Stock, StockAdmin)search_fields会被编译成多个 LIKE 查询并用 OR 连接字段必须有索引才不拖慢列表页——商品表里code是 unique 自带索引barcode建了db_indexname没有建所以靠它搜索时会全表扫。stock_quantity和stock_status是方法字段只能展示不能排序因为数据库里没有对应列如果商品量级到十万以上列表页会明显变慢解决办法是给 Product 冗余一个quantity字段并用信号或定时任务同步或者把 list 页改成只显示库存表。方法字段里用getattr(obj.stock, quantity, 0)而不是obj.stock.quantity是为了处理商品还没有库存记录的情况——直接访问会抛RelatedObjectDoesNotExistgetattr 带默认值则安全返回 0。4.2 用 ORM 聚合函数生成日销售报表与毛利分析报表是进销存的脸面。日销售报表不需要写复杂 SQLDjango ORM 的values配合annotate足够而且生成的 SQL 还是参数化查询不存在拼接注入的问题。from django.db.models import Sum, F, Count from django.db.models.functions import TruncDate def daily_sales_report(date): 按日期统计每个商品的销量、销售额、毛利 rows (SalesItem.objects .filter(order__order_date__datedate) .values(product__name, product__purchase_price) .annotate( sold_qtySum(quantity), sold_amountSum(F(quantity) * F(price)), cost_amountSum(F(quantity) * F(product__purchase_price)) ) .annotate(profitF(sold_amount) - F(cost_amount)) .order_by(-sold_amount)) return rowsTruncDate和__date查找的区别值得注意__date是过滤条件把字段截断到日期后再比较适合按天筛选TruncDate是分组函数用于把时间字段降维到日期维度。这里用__date过滤用values分组两种方式各司其职。F(quantity) * F(price)在数据库层做乘法比把数据拉到 Python 循环算快一个量级数据量大时不至于把 Django 进程的 CPU 打满。三行 annotate 的先后顺序有讲究第一次annotate算出销量、销售额、成本第二次annotate基于前三个别名做减法得到毛利。Django 允许后续 annotate 引用前面定义的别名但注意这个引用最终会生成嵌套子查询字段多了会拖慢 SQL 执行。利润字段同样在数据库端算报表接口可以把这段逻辑直接包成 Django REST Framework 的ListAPIView前端接上 echarts 就能画柱状图。4.3 库存预警与 admin action 批量盘点预警逻辑不单独写页面直接在 Admin 动作里实现管理者按进货、销售、库存进入点几下就能完成日结操作。def stock_warning(modeladmin, request, queryset): 给低于预警线的商品打标记 low_stock queryset.filter(quantity__lteF(product__warn_stock)) modeladmin.message_user(request, f当前有 {low_stock.count()} 个商品库存不足) stock_warning.short_description 标出库存不足商品 class StockAdmin(admin.ModelAdmin): actions [stock_warning]F(product__warn_stock)能在一条 SQL 里完成现有库存与商品预警库存的跨表比较不用先在 Python 里拉出所有商品再逐条判断。Admin action 的第一个参数modeladmin是当前注册的模型管理类第二个是request第三个是勾选的 QuerySet在函数内部用modeladmin.message_user给操作者回显结果是 Admin 里最标准的反馈方式。4.4 前后台分离时用一个 mixin 统一返回 JSON如果毕业设计想加一个手机端页面或微信小程序后台需要提供 JSON 接口。REST Framework 是常见选择但只加一个只读报表接口时手写一个轻量 mixin 更直接不引入重依赖。import json from django.http import JsonResponse from django.views import View class JsonViewMixin(View): def parse_body(self, request): try: return json.loads(request.body or {}) except json.JSONDecodeError: return {} def ok(self, dataNone): return JsonResponse({code: 0, data: data}) def fail(self, message, code1): return JsonResponse({code: code, message: message})这个 mixin 把请求体解析、成功失败响应收敛成三个方法子视图继承后只需要关心业务逻辑。json.loads(request.body or {})是为了兼容空 body 的 POST 请求避免在读空字符串时抛JSONDecodeError。接口层做好统一返回格式后小程序和后台页面可以共用同一套业务代码这是进销存系统里性价比很高的一步。5. 用宝塔在 Linux 上部署 Django 进销存项目配置参数与并发验收毕业设计答辩和实际交付都绕不开部署。Django 自带的runserver只能用于开发上生产的标准组合是 Nginx Gunicorn MySQL宝塔面板可以把这三步变成图形化操作减少新手在环境上的挫败感。5.1 宝塔部署 Django 项目的四条命令在宝塔的「Python 项目管理器」新建项目Python 版本选 3.10 或以上框架类型选 Django然后在项目目录里依次执行下面的操作pip install -r requirements.txt python manage.py migrate python manage.py collectstatic --noinputcollectstatic是部署流程里最容易漏掉的一步。Django Admin 自带的 CSS、JS 存放在 Django 安装目录里默认不会出现在项目目录下必须执行 collectstatic 把它们复制到STATIC_ROOT指定的目录再让 Nginx 的location /static/指向这个目录。漏掉之后的表现是 Admin 页面只剩纯 HTML 没有样式很多人会误以为是静态文件路径写错。Gunicorn 的启动命令推荐这样写gunicorn supermarket_ims.wsgi:application --bind 127.0.0.1:8001 --workers 3--bind 127.0.0.1:8001指向本机端口外面再套一层 Nginx 做反向代理和静态文件服务。--workers 3是按2 * CPU 核数 1的经验值给的进销存系统是典型的 I/O 密集应用Worker 太多反而会因为数据库连接数暴涨把 MySQL 压垮。超市日流水几千单的规模3 个 worker 完全够用。Gunicorn 默认超时 30 秒如果某个导出报表的接口超过这个时间会被强杀这时候优先优化查询本身而不是简单把 timeout 加长。5.2 MySQL 连接池与字符集的三个必调参数Django 默认每个请求都新建一个 MySQL 连接请求结束后断开在连接频繁创建销毁的场景下开销很大。生产配置里加CONN_MAX_AGE可以明显减少握手次数。DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: supermarket_ims, USER: django_user, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, CONN_MAX_AGE: 60, OPTIONS: {charset: utf8mb4}, ATOMIC_REQUESTS: False, } }CONN_MAX_AGE: 60让一个连接在 60 秒内被多个请求复用压测时能看到吞吐明显上升。charset: utf8mb4是必须项否则商品名里出现 emoji 字符时 MySQL 直接报Incorrect string value超市商品里混生鲜和进口商品这个坑很容易踩到。ATOMIC_REQUESTS保持 False因为上一章的sale_checkout内部已经用transaction.atomic精确控制了事务边界全局包事务反而会让只读接口也占用连接更久。SECRET_KEY在生产环境必须从环境变量读取不能硬编码在 settings.py 里——项目一旦交到别人手上维护密钥留在代码文件里等于把会话加密钥匙公开了。5.3 用并发脚本验收防超卖是否真的生效部署完成后不要急着开演示先做一次并发扣库存测试。这是验证整个系统正确性最快的手段比读一遍代码更能发现问题。from concurrent.futures import ThreadPoolExecutor import requests def buy_one(): try: resp requests.post(http://127.0.0.1:8001/api/sale/, json{ items: [{product_id: 1, quantity: 1}] }, timeout10) return resp.status_code, resp.json().get(code) except Exception as exc: return 500, str(exc) with ThreadPoolExecutor(max_workers8) as pool: results list(pool.map(lambda i: buy_one(), range(8))) print(results)准备一个库存只有 1 的商品跑 8 个并发请求同时下单正确的结果是恰好 1 个返回成功、其余 7 个返回库存不足。如果出现多个成功说明select_for_update的锁没有真正生效优先排查两处第一扣库存的代码是否真的在transaction.atomic()块内select_for_update必须在事务里才有效第二MySQL 表引擎是不是 MyISAM——MyISAM 不支持行锁Django 不会报错锁会静默降级为表锁甚至完全不锁这是最隐蔽的问题。用一条SHOW TABLE STATUS LIKE app_stock就能确认引擎Engine列显示InnoDB才是正常的。这个并发测试脚本可以留在项目里作为验收用例答辩时现场跑一遍比任何截图都有说服力。本文还有配套的精品资源点击获取