做小型超市管理系统,用Django绝对是绕不开的主流选择。这套源码包(归档编号27379)代码量不算大,却把超市管理的核心业务都覆盖了:商品分类、库存、销售下单、会员、统计报表,该有的都有。我按自己踩过的坑重新理顺了一遍,这篇文章就把整个项目从需求拆解、数据库设计、核心业务实现到部署避坑,完整讲清楚。如果你正准备做课设、练手,或者想用Django写一个能真正跑起来的业务系统,这篇可以直接当参考。
先说清楚定位:这种“小型超市管理系统”不是给全家福、大润发那种量级用的,它的目标是解决一家小店或者学习场景里的进销存问题。店里有多少商品、库存还剩多少、今天卖了多少钱、哪些商品快要过期,这些事用一张Excel表格也能管,但一旦超过几百条记录,人就会开始出错。用Django写一套系统,本质上是把这摊事交给数据库和代码去管。
得益于Django自带Admin后台、ORM和认证体系,这类系统不会像从零手写一个Web框架那样劝退新人。你要做的核心工作,是把超市的业务模型翻译成代码模型,再配上几个页面,一套能用的系统就出来了。这篇源码是完整的,我建议你先跑起来,再对照文章慢慢改。
1. 项目整体设计与业务拆解
1.1 超市管理系统的几个核心模块
不要把超市系统想复杂,剥掉各种花哨功能,真正天天要用的就四件事:商品、进货、销售、查账。围绕这四件事,数据模型可以拆成下面几块:
- 用户与权限:老板看报表,收银员下单,库管管进货。Django自带User模型和Admin后台,直接扩展就行。
- 商品与分类:这是所有业务的地基。商品要有唯一编码、名称、进价、售价、库存数量、单位、状态。
- 进货与库存:每次进货都生成一条入库记录,库存数随之增加。这里必须保留历史记录,方便以后对账。
- 销售订单与订单明细:一次收银可能包含多个商品,订单主表记录总金额、收银员、会员折扣,明细表记录每个商品的成交价和数量。
- 会员与统计:会员可以打折、积分,但这块可大可小。小型系统先做会员表和简单积分即可,重点是销售统计报表。
我在实际开发时,习惯先把这些模块画在一张纸上,标清楚哪个模块需要哪些字段,模块之间是什么关系。代码写到一半再改模型,代价远高于一开始多想五分钟。这套源码的模型设计算比较标准的,后面我会详细拆。
1.2 为什么选择Django而不是Flask或Spring
总有新手会问:这么小的系统,为什么不用Flask?Flask确实轻,但它的轻也意味着很多事得自己干。你要自己对接数据库ORM、自己写登录会话、自己写后台管理界面。对一个业务逻辑为主的管理系统来说,这些重复劳动完全没有必要。
Django的优势是全家桶:
- ORM写起来像操作Python对象,不需要手拼SQL,外键关系也好维护。
- Admin后台是白送的,商品表、订单表注册进去,马上就能增删改查。
- 自带用户认证、Session、CSRF防护,比自己在Flask里堆依赖靠谱得多。
- Django模板直接渲染服务端页面,小系统用不着前后端分离,开发效率极高。
至于Spring Boot,做企业级中后台是很好,但对一次课设或小店系统来说,环境配置和代码体量都偏重。我当时选择Django,说白了就是“用最少的代码,做最完整的事”。
1.3 源码目录结构与创建App的顺序
拿到源码后,先看目录结构,这会帮你快速理解项目的骨架。一个标准的Django项目,通常长这样:
supermarket_project/ ├── manage.py ├── requirements.txt ├── supermarket/ # 项目配置目录 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── goods/ # 商品与分类 │ ├── models.py │ ├── views.py │ ├── urls.py │ └── admin.py ├── order/ # 销售订单与购物车逻辑 │ ├── models.py │ ├── views.py │ └── urls.py ├── member/ # 会员管理 │ ├── models.py │ └── views.py ├── report/ # 统计报表 │ └── views.py ├── static/ # 静态文件 └── templates/ # 页面模板创建App的顺序也有讲究。先做商品,再做订单,最后做报表,这是一个从数据基础到业务再到底层应用的过程。新手容易拿到项目就到处乱建App,结果文件之间互相引用,很容易把自己绕晕。正确做法是先看manage.py所在目录,进入虚拟环境后,一条条执行:
python manage.py startapp goods python manage.py startapp order python manage.py startapp member python manage.py startapp report每建一个App,记得在settings.py的INSTALLED_APPS里注册。这一步漏了,后面迁移时表就建不出来,这是新手最常见的翻车点。
2. 数据库建模与核心业务实现
2.1 数据模型之间的外键关系与代码实现
数据库建模是这套源码最值得精读的部分。我直接放核心模型代码,你可以对照着源码理解。
先看商品分类和商品模型:
from django.db import models from django.utils import timezone class Category(models.Model): name = models.CharField('分类名称', max_length=50, unique=True) sort_order = models.IntegerField('排序', default=0) class Meta: verbose_name = '商品分类' verbose_name_plural = verbose_name def __str__(self): return self.name class Product(models.Model): STATUS_CHOICES = ( ('on', '上架'), ('off', '下架'), ) code = models.CharField('商品编码', max_length=32, unique=True) name = models.CharField('商品名称', max_length=100) category = models.ForeignKey(Category, on_delete=models.PROTECT, verbose_name='分类') purchase_price = models.DecimalField('进价', max_digits=10, decimal_places=2) sale_price = models.DecimalField('售价', max_digits=10, decimal_places=2) stock = models.IntegerField('库存', default=0) unit = models.CharField('单位', max_length=10, default='瓶') status = models.CharField('状态', max_length=4, choices=STATUS_CHOICES, default='on') created_at = models.DateTimeField('创建时间', default=timezone.now)为什么要用DecimalField而不是FloatField?因为钱这种东西,用浮点数存会出现0.1 + 0.2不等于0.3的尴尬问题。DecimalField在数据库层面用定点数存储,计算金额时才不会丢精度。这一点新手一定要养成习惯,凡是涉及金额的字段,一律DecimalField。
分类和商品用ForeignKey连接,on_delete=models.PROTECT的含义是:如果还有商品挂在这个分类下,这个分类不允许直接删除。这比默认的CASCADE更安全,不然手一滑删掉分类,整批商品跟着没了,超市商品数据可不是闹着玩的。
订单和订单明细也是典型的一对多关系:
class SaleOrder(models.Model): order_no = models.CharField('订单号', max_length=32, unique=True) total_amount = models.DecimalField('总金额', max_digits=12, decimal_places=2, default=0) member = models.ForeignKey('member.Member', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='会员') operator = models.ForeignKey('auth.User', null=True, on_delete=models.SET_NULL, verbose_name='收银员') created_at = models.DateTimeField('销售时间', default=timezone.now) class SaleItem(models.Model): order = models.ForeignKey(SaleOrder, on_delete=models.CASCADE, related_name='items', verbose_name='订单') product = models.ForeignKey(Product, on_delete=models.PROTECT, verbose_name='商品') quantity = models.IntegerField('数量', default=1) price = models.DecimalField('成交单价', max_digits=10, decimal_places=2) subtotal = models.DecimalField('小计', max_digits=12, decimal_places=2)这里设计上有一个值得学习的细节:SaleItem里price字段存的不是Product.sale_price的快照吗?为什么不能直接关联商品表,下单时临时去查?因为商品价格会变。如果某天可乐从3块涨到3块5,之前的订单明细如果还用外键去取当前价格,历史数据就全错了。把成交价冗余存到OrderItem里,才能保证每一笔历史订单都是当时真实的价格。这是业务系统里常见的“快照”思想,你在源码里看到类似的字段,不要觉得是多余。
2.2 库存扣减与防止超卖的逻辑
超市系统最担心的就是库存对不上。一个商品被卖了100件,数据库里库存却只少了80件,月底盘货对不上账,店主就该找你麻烦了。
最简单粗暴的写法是先查库存,判断够不够,然后扣减保存:
product = Product.objects.get(pk=product_id) if product.stock >= quantity: product.stock -= quantity product.save()这个写法在单机、单用户环境下没问题,但如果有两个收银员几乎同时下单,就可能出现两个请求都读到stock=10,都判断“够卖”,最后库存变成负数。这就是并发超卖。
Django里解决这个问题,最常用的方案是select_for_update加上事务:
from django.db import transaction @transaction.atomic def create_sale_order(request): cart = get_cart(request) for item in cart: product = Product.objects.select_for_update().get(pk=item['product_id']) if product.stock < item['quantity']: raise ValueError(f'{product.name} 库存不足') product.stock -= item['quantity'] product.save(update_fields=['stock']) # 继续创建订单...select_for_update会对数据库里这一行记录加锁,直到事务结束。第二个请求再进来时,必须等第一个事务提交完才能读到最新库存。这样就把并发超卖的问题挡在数据库层面了。
小型超市的并发量不算吓人,但写代码时还是应该养成这个习惯,不然以后系统用户一多,库存对不上账就是火灾。源码里如果没做这个处理,你自己补上也不难。
2.3 用户认证与Cookie、Token的处理方式
Django自带了一套基于Session的登录认证,对小系统来说,这是最省心的选择。登录页面提交用户名密码,调用Django的authenticate和login,框架会自动设置Session Cookie,后续每个请求都会自动带上用户身份。
源码里有些登录相关的代码会涉及Cookie,我看过不少新手在这里被绕晕,简单理一下:
- Session认证:服务端把用户状态存在session表里,浏览器只存一个sessionid的Cookie,用户信息不直接暴露。Django默认用签名Cookie保存session数据,所以settings.py里SECRET_KEY一旦泄露,攻击者可以伪造Session,这个密钥绝不能提交到公开仓库。
- Token认证:前端拿到一个token字符串,后续请求把它放在Header里发送。Django本身不带Token体系,通常配合Django REST Framework的TokenAuthentication或SimpleJWT使用。
这套源码如果只是纯页面渲染,用Session就够。如果你后面要写小程序端、POS机端,才需要考虑Token。需要特别提醒的是,无论哪种方式,密码都不能明文保存,Django的User模型默认使用PBKDF2算法哈希密码,这点框架已经帮你处理好了,你自己写登录逻辑时千万不要把密码直接存进数据库。
另外,生产环境务必开启HTTPS,否则Cookie里的sessionid被截走,用户登录态就等于被劫持了。这是托管环境里最容易忽视的安全项。
3. 后台管理、业务页面与URL路由的落地
3.1 用Django Admin快速搭建管理后台
Django最吸引人的地方之一就是Admin后台。写好模型后,在admin.py里注册一下就能获得完整的管理界面。源码里大概是这样:
from django.contrib import admin from .models import Category, Product @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): list_display = ('name', 'sort_order') search_fields = ('name',) @admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = ('code', 'name', 'category', 'sale_price', 'stock', 'status') list_filter = ('category', 'status') search_fields = ('code', 'name') list_editable = ('sale_price', 'stock')几个小技巧是文档里不常细讲的:
- list_display决定列表页显示哪些列,不要放太多字段,不然页面很挤。
- list_editable直接在列表页改库存和价格,对库管来说效率提升明显。但这个字段不能出现在第一个位置,否则Django会报错。
- list_filter配合search_fields,让3000条商品也能在几秒内定位到目标。
- 针对库存预警,还可以重写get_queryset或者在列表页给低库存加红色标记,但基础版先不用搞太花哨。
Admin后台可以作为管理员的专用入口,而收银员日常使用的是另一个收银页面。这样职责分离,后台权限控制在Django Group里就可以分配。
3.2 核心视图与URL路由配置
接下来看业务页面怎么用视图串起来。这套http://源码的核心页面包括:商品列表、收银台、订单列表、库存报表。我用Django类视图写一个商品列表:
from django.views.generic import ListView from .models import Product class ProductListView(ListView): model = Product template_name = 'goods/product_list.html' context_object_name = 'products' paginate_by = 10 def get_queryset(self): queryset = super().get_queryset() keyword = self.request.GET.get('keyword', '').strip() category_id = self.request.GET.get('category', '') if keyword: queryset = queryset.filter(name__icontains=keyword) if category_id: queryset = queryset.filter(category_id=category_id) return querysetURL里要把关键字和分类传递到模板,翻页时才能保持搜索条件。模板里的分页链接写成:
<a href="?keyword={{ request.GET.keyword }}&category={{ request.GET.category }}&page={{ page_obj.next_page_number }}">下一页</a>如果不带上前面的查询参数,你会发现翻到第二页后搜索条件全丢了。这个坑我见过无数次,记住一句话:翻页链接要把除了page之外的所有当前参数原样带上。
再比如收银台的下单视图,用函数视图可能更好理解流程:
from django.shortcuts import render, redirect from django.contrib.auth.decorators import login_required @login_required def cashier(request): if request.method == 'POST': product_code = request.POST.get('barcode') or request.POST.get('product_code') quantity = int(request.POST.get('quantity', 1)) cart = request.session.get('cart', {}) cart[product_code] = cart.get(product_code, 0) + quantity request.session['cart'] = cart return redirect('cashier') return render(request, 'order/cashier.html')这里用Session当作临时购物车,收银员扫码或输入商品编码,点击加入购物车,最后统一结算。这种方式的好处是不用数据库临时表,刷新页面购物车还在,而且天然是每个收银员独立的会话。
注意,Session购物车保存的是编码,不是商品对象本身。结算时再逐条去数据库查最新价格和库存,这样避免页面显示时价格和结算时价格不一致。原理和前面说的订单快照一样:一切以结算时刻的数据为准。
3.3 模板继承与静态资源处理
页面不用前后端分离,Django模板一套搞定。一个项目的模板结构我建议这样组织:
templates/ ├── base.html ├── goods/ │ └── product_list.html ├── order/ │ ├── cashier.html │ ├── cart.html │ └── order_list.html └── report/ └── sales_report.htmlbase.html里写公共的导航栏和样式引入,子页面用extends去继承。
{% extends "base.html" %} {% block content %} <div class="card"> <table class="table table-bordered"> <thead><tr><th>商品编码</th><th>名称</th><th>售价</th><th>状态</th></tr></thead> <tbody> {% for product in products %} <tr> <td>{{ product.code }}</td> <td>{{ product.name }}</td> <td>{{ product.sale_price }}</td> <td>{{ product.get_status_display }}</td> </tr> {% endfor %} </tbody> </table> </div> {% endblock %}静态文件在settings.py里有一个微妙的路径问题。DEBUG=True时,Django会自动找static目录;部署上线时,必须用collectstatic把静态文件收集到一个统一目录,交给Nginx托管。如果只在自己电脑上跑,可以不去管它,但要想清楚区别,不然销量页的CSS老是加载不出来。
4. 统计报表与销售数据的可视化
4.1 用ORM聚合函数做日报表
报表是超市系统的价值体现。老板看一眼今天的销售总额、卖得最多的商品、毛利率,心里就有底了。用Django的ORM做统计非常顺手,比如按天统计销售额:
from django.db.models import Sum, Count, F from django.db.models.functions import TruncDate from order.models import SaleOrder, SaleItem def daily_sales(request): today = request.GET.get('date') if not today: today = timezone.localdate() items = (SaleItem.objects .filter(order__created_at__date=today) .values('product__name') .annotate(total_qty=Sum('quantity'), total_sales=Sum('subtotal')) .order_by('-total_sales')) total = SaleOrder.objects.filter(created_at__date=today).aggregate( total_amount=Sum('total_amount'), order_count=Count('id'), ) return render(request, 'report/daily.html', {'items': items, 'total': total, 'today': today})details可以再算毛利率,只需要在SaleItem里多存一份purchase_price快照,或者在Product里现查。我推荐第一种,理由和价格快照一样,进价也会变,历史利润必须按历史进价算。
聚合查询后返回的是普通dict,不是模型对象,模板里用item.product__name这种别名访问。新手很容易在这里犯迷糊,拿一个普通dict去点product.name,然后报AttributeError。
4.2 低成本图表方案
报表页面光有表格还不够直观,我一般会在页面里插入一个简单的柱状图。既然不用前端工程化,最简单的方案是生成JSON数据塞给前端,用Chart.js画图。在视图里把聚合结果转成JSON:
import json from django.http import JsonResponse def sales_chart_api(request): """返回近7日销售数据,前端图表调用""" data = (SaleOrder.objects .filter(created_at__date__gte=timezone.localdate() - timezone.timedelta(days=6)) .annotate(day=TruncDate('created_at__date')) .values('day') .annotate(total=Sum('total_amount')) .order_by('day')) return JsonResponse({'days': [item['day'].isoformat() for item in data], 'totals': [float(item['total']) for item in data]})模板里用fetch调这个接口,再渲染成柱状图。这样做的好处是接口复用,以后就算要把系统改造成前后端分离,报表接口也已经备好了。
有一点要提醒:浮点数转换时,float(Decimal)会丢失精度,但这里只是用于前端展示,影响不大。如果是要导出的账单,仍然要用Decimal,不要乱转。
5. 源码部署、常见问题与避坑指南
5.1 从源码到本地跑起来
拿到源码第一件事,是让它在本地跑起来。我的建议是按下面几步走:
# 1. 创建虚拟环境 python -m venv venv # 2. Windows激活虚拟环境 venv\Scripts\activate # 3. 安装依赖 pip install -r requirements.txt # 4. 数据库迁移 python manage.py makemigrations python manage.py migrate # 5. 创建超级管理员 python manage.py createsuperuser # 6. 启动开发服务器 python manage.py runserverrequirements.txt里通常包含Django、Pillow、mysqlclient这些常见依赖。如果源码用了MySQL,本地没有MySQL环境,建议先改到SQLite跑通再说。settings.py里把DATABASES换成:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } }SQLite对本地学习和演示完全够用,不用额外装数据库实例。需要提醒的是,这一步改完再执行migrate,不要在有MySQL环境生的迁移记录里反复横跳,不然会出现migrations冲突。
5.2 常见错误速查表
我把实际运行中容易碰到的报错整理成了表格,碰到直接对照排查。
| 错误现象 | 可能原因 | 处理办法 |
|---|---|---|
AppRegistryNotReady | settings.py的INSTALLED_APPS没配好或循环导入 | 检查App注册顺序和import位置 |
no such table | 没有执行migrate | 依次执行makemigrations和migrate |
relation does not exist | 换数据库后没重新迁移 | 备份数据后重新迁移 |
| 页面500但控制台没报错 | DEBUG=False或没看日志文件 | 设置DEBUG=True先看详细日志,排完再改回来 |
| 上传图片404 | MEDIA_ROOT和MEDIA_URL没配对 | 确认settings里配置,并在urls.py里加static/media路由 |
| 登录后页面跳不回原页面 | next参数没处理 | 检查login_required和LOGIN_URL配置 |
| 时间差8小时 | USE_TZ=True但TIME_ZONE不匹配 | 本地项目可直接USE_TZ=False,或把TIME_ZONE设为'Asia/Shanghai' |
5.3 Django中删除对象的正确姿势
热搜词里有一个是“Django 执行查询-删除对象”,这确实是新手最容易搞坏数据的地方。Django里删除有两条路:
第一种是实例删除:
product = Product.objects.get(pk=1) product.delete()第二种是查询集批量删除:
Product.objects.filter(category__name='饮料').delete()两种方式都会触发数据库的级联删除。如果模型上有外键指向这个对象,且on_delete=models.CASCADE,关联对象也会被删掉。这很危险,比如把一个分类删了,分类下的所有商品可能全没了。我之前建议用PROTECT,就是这个原因。
如果业务上好多数据不能真删,建议用软删除:给模型加一个is_active布尔字段,删除时只把is_active改成False,查询时默认filter(is_active=True)。这套源码里商品有上下架状态,其实就是一种简化版的软删除。订单这类历史数据更是坚决不能物理删除,最多做“作废”标记。
5.4 性能优化:查询数量控制与索引
小型系统数据量不大,但也别写得过于随意。最容易出现的问题是在模板循环里查询数据库,比如商品列表里每个商品都去查一次订单表有没有被卖过:
{% for product in products %} {{ product.saleitem_set.count }} {% endfor %}如果有100条商品,这个循环就会额外执行100条SQL。正确做法是在查询时用annotate一次性算好:
from django.db.models import Count products = Product.objects.annotate(sales_count=Count('saleitem'))模板里直接用product.sales_count,SQL查询次数从101条降到1条。这个技巧虽然基础,但对新手来说提升最明显。
数据库索引也很关键。商品编码、订单号、销售时间这几个字段经常被查询和排序,建议加db_index=True:
order_no = models.CharField('订单号', max_length=32, unique=True, db_index=True) created_at = models.DateTimeField('销售时间', default=timezone.now, db_index=True)索引不是越多越好,写入频繁的字段加太多索引会拖慢速度。对小型系统,给高频查询字段加上就够了。
6. 源码阅读顺序与二次开发方向
6.1 建议的源码阅读顺序
不必从头到尾逐行读,我的习惯是:先读models.py理解数据表,再读urls.py理解路由,然后读views.py理解业务,最后才看模板。这个顺序是从数据到流程再到展示,从底层往上层走,脑子会清楚得多。
读models时,把每个ForeignKey都看作一种关系提醒,想一想这条关系在实际超市场景里是什么意思。比如SaleOrder.member指向Member,表示“这张订单属于某个会员”;Product.category指向Category,表示“这个商品挂在哪个分类下”。把所有关系理一遍,整个系统的业务逻辑就通了。
6.2 可以继续扩展的功能
这套源码的基础打得不错,真要扩展,我建议按优先级做下面几件事:
- 会员积分与折扣:下单时根据会员等级自动计算折扣,积分按订单金额累计。
- 库存上下限预警:库存低于阈值时在Admin后台标红,甚至给管理员发通知。
- 供应商与进货单管理:当前只有入库记录,可以考虑加供应商表,把进货与供应商关联起来。
- 导出Excel日报表:用openpyxl把销售数据导出成Excel,老板月底对账会很开心。
- 多收银员与交接班:订单表绑定operator,加一个收银员交班时间范围统计,方便算小票。
每次扩展,核心还是先改模型,再写视图和页面。如果源码最后变成了一个“加字段五分钟、改页面两小时”的项目,不要急,这很正常,业务系统的复杂度就是这样一点一点累积起来的。你真正要做的,是保持每一层清晰,不在一个地方堆太多逻辑。
我用这套Django源码把超市管理系统跑起来、改明白之后,最大的感受是:Django真正的门槛不在框架语法,而在业务建模。把超市里的商品、库存、订单、会员想清楚,写代码反而是水到渠成的事。后面我自己做其他系统时也沿用了这套思路,先画模型关系,再写业务逻辑,最后调页面,整个过程顺畅很多。
如果你也准备拿这套源码做二次开发,建议先按文章顺序把自己项目的核心模块画出来,不要一上来就改代码。读完models,跑一遍Admin,再用收银页走一遍下单流程,整个系统的脉搏就摸清了。最后提醒一句:自己的项目,每次调整数据库模型后,记得重新执行makemigrations和migrate,不然改动不会生效。这是所有Django开发者都交过的学费。