每年的毕业设计季,总有人来问我一个特别经典的问题:“老师,能不能推荐一个不难、又能出活的题目?”如果你也是这个状态,那Django小型超市管理系统基本是一个不会翻车的选择。这个题目业务足够清楚:卖货、进货、盘点、统计,每个人逛超市都见过,需求根本不用靠猜;技术上又正好落在Django最舒服的区域——ORM管数据,模板渲染页面,自带Admin后台还能兜底。做完手里会有一套带登录、商品管理、收银、库存预警、日报表的小型系统,拿去答辩完全够量。
我见过不少同学下载了编号27379那套源码,但打开压缩包就懵了,不知道先看哪个文件。这篇内容我就按做毕设辅导时帮大家落地的思路写,从功能拆分、数据库建模、核心业务实现一直聊到部署避坑。如果你是源码使用者,可以对照着理解每个模块的作用;如果你想从零写一版,照着下面步骤走,同样能搭出一套可以演示的完整项目。
1. 项目整体设计与功能拆解
1.1 为什么这个题目适合做毕业设计
毕业设计最容易踩的两个坑,一个是需求太虚,一个是技术太杂。小型超市管理系统恰好躲开了这两个坑。超市的业务是真实存在的,商品要有分类、条码、进价、售价、库存;员工要分管理员、收银员、仓管员;日常要处理收银、入库、退货、盘点、统计。每一个功能都能在现实中找到对应场景,写需求分析、画用例图的时候非常顺手,老师一问业务逻辑你也能答得上来。
另一个好处是规模可控。Django的MTV架构天然适合这种纯业务系统:Model层定义表结构,Template层负责页面,View层穿起来处理请求。最终看起来模块多、代码量大,但实际拆开都是非常标准的Django写法。答辩时老师大概率会问“你的项目有哪些亮点”,你就可以说库存和销售扣减用了事务控制、权限用了装饰器拦截、统计用了ORM聚合,这些点都是有含金量的。
1.2 功能模块与角色权限设计
在做功能拆分前,先要想清楚系统里有哪些角色。我建议保留三种角色:管理员、收银员、仓管员。管理员看全部门店的日常数据,管理员工账号和商品信息;收银员只操作收银台和查看当天自己的流水;仓管员负责商品入库、库存盘点和预警处理。当然,如果源码里只有管理员和收银员两种角色,也不必强行加,毕设不是越复杂越好,核心流程跑通比角色堆砌重要。
按这个角色划分,功能模块可以这样拆:
- 登录认证:账号密码登录,区分角色并跳转到不同首页
- 商品管理:商品分类维护、商品信息的增删改查、条件搜索(按名称、条码、分类)
- 采购入库:录入进货单,选择商品和数量,自动更新库存和成本价
- 收银管理:搜索商品加入购物车,结算生成销售单,打印小票(或展示小票页面)
- 库存管理:库存列表、低库存预警、盘点调整
- 统计报表:按日/按时间段统计销售额、订单数、销售排行,可视化展示
- 后台管理:用Django Admin管理所有基础数据,紧急时手动修正数据
这个范围听起来不小,但实现起来都是一些常见的CRUD加上几个业务视图。真正有技术含量的其实就两个地方:收银时怎么保证库存不超卖,以及统计报表怎么写ORM聚合。后面我会重点拆这两个部分。
1.3 技术选型:Django + SQLite + Bootstrap的组合逻辑
为什么用Django而不是Flask或者Spring Boot?最关键的原因是Django自带的东西足够多。一套毕业设计系统,如果要自己组合Flask加SQLAlchemy加Werkzeug加Jinja2,光配置就劝退一堆人。Django把ORM、表单验证、认证系统、Admin后台、模板引擎全打包好了,你只需要写业务代码。
数据库我建议开发阶段直接用SQLite,零配置,一个文件搞定。有些同学担心答辩现场没法演示MySQL,SQLite反而更省心。如果学校强制要求MySQL,Django切换也非常简单,改一下DATABASES配置,再执行迁移命令就可以,模型代码完全不用动。前端方面不要自己写复杂的JavaScript,用Bootstrap搭页面骨架,引入Chart.js画图表,再配合jQuery发几个Ajax请求就够了。这套组合足够撑起“功能完整、界面体面”的毕业设计。
2. 数据库模型设计:让表结构一次到位
2.1 核心模型代码解析
模型是整个系统的地基。我见过不少同学一开始表设计得太随意,后面写业务逻辑时每个视图都在救火。这里给大家一套比较稳的模型结构,基本可以应对大多数小型超市的场景。
分类和商品是最基础的两张表。商品表里直接放一个库存字段,这种做法在商品数量不多的前提下是合理的,查询库存时不需要额外关联库存表,简单高效。如果商品种类达到几千上万,才需要考虑独立的库存明细表,但那是后话。
from django.db import models from django.contrib.auth.models import User class Category(models.Model): name = models.CharField(max_length=50, unique=True, verbose_name="分类名称") class Meta: verbose_name = "商品分类" verbose_name_plural = "商品分类" def __str__(self): return self.name class Product(models.Model): name = models.CharField(max_length=100, verbose_name="商品名称") barcode = models.CharField(max_length=50, blank=True, verbose_name="条形码") category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="products", verbose_name="分类") price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name="售价") cost = models.DecimalField(max_digits=8, decimal_places=2, verbose_name="进价") stock = models.IntegerField(default=0, verbose_name="库存数量") low_stock = models.IntegerField(default=10, verbose_name="低库存预警值") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") class Meta: verbose_name = "商品" verbose_name_plural = "商品" def __str__(self): return self.name销售订单和订单明细是收银模块的核心。订单主表记录单号、收银员、总金额和时间;明细表记录每一样商品的售价、数量和小计。这里有一个容易被忽略的细节:订单明细里的商品名和价格一定要单独存一份,不要通过外键去关联商品表取实时价格。因为商品价格后面可能调整,但历史订单里的价格必须保持下单时的快照。这个设计直接关系到统计报表的准确性。
class SaleOrder(models.Model): order_no = models.CharField(max_length=32, unique=True, verbose_name="订单号") cashier = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="sale_orders", verbose_name="收银员") total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="订单总额") created_at = models.DateTimeField(auto_now_add=True, verbose_name="下单时间") class Meta: verbose_name = "销售订单" verbose_name_plural = "销售订单" def __str__(self): return self.order_no class SaleOrderItem(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="商品") product_name = models.CharField(max_length=100, verbose_name="商品名称快照") price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name="成交单价") quantity = models.IntegerField(verbose_name="数量") subtotal = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="小计") class Meta: verbose_name = "订单明细" verbose_name_plural = "订单明细" def save(self, *args, **kwargs): self.product_name = self.product.name self.subtotal = self.price * self.quantity super().save(*args, **kwargs)入库单可以设计成类似订单的模式:一个入库主表记录批次号、经手人和入库时间,一个入库明细表记录每个商品的入库数量。入库时要在事务里更新Product.stock。如果只是做毕业设计,也可以直接用Admin后台手动录入入库流水,然后同步改一下库存。但从业务完整性角度,我仍然建议单独做一个“采购入库”页面。
2.2 模型设计中的三个关键决策
第一,价格字段要用DecimalField而不是FloatField。Float是二进制浮点数,0.1加0.2这种计算会出现精度误差,做金额运算之后账会平不上。DecimalField虽然写起来字长了些,但算钱必须用它。第二个决策,外键的on_delete参数不要图省事全用CASCADE。商品被订单明细引用后,如果把商品删掉,历史订单就变成“死链”,所以订单明细里的product外键应当用PROTECT,防止误删。而订单删除时订单明细跟着删是合理的,所以SaleOrderItem的order外键用CASCADE。第三个决策,所有需要展示业务时间的字段都用DateTimeField,并配合Django的时区设置。销售统计按天做筛选时,DateTimeField远比分开存日期和时间方便。
2.3 外键删除策略与数据完整性
刚接触Django的同学最容易踩的坑就是删除数据时遇到RestrictedError或者ProtectedError,然后一脸懵。实际上这是Django在保护你的数据。比如Product被SaleOrderItem用PROTECT引用后,你想在后台删一个商品,会被拦截,因为系统不允许删除“有历史销售记录”的商品。这时候正确的做法是给商品加一个is_active字段,下架商品时把is_active设为False,而不是物理删除。这样既保留了历史数据,又保证库存和订单的关联不会断裂。
3. 登录权限与用户角色实现
3.1 用Django自带的Auth模块还是自建用户表
答案是:如果需求没有复杂到需要手机号登录、微信登录,那就直接用Django自带的auth.User。这个用户模型已经内置了用户名、密码哈希、邮箱、权限位、登录状态等能力,扩展一个profile或者直接在User上增加role字段就够了。极少有毕设需要自定义用户模型,所以不要为了“显得高级”去重写用户表,容易把自己绕进去。
在自带User的基础上区分角色,最省事的方式是添加一个user_type整型字段:
from django.contrib.auth.models import User from django.db import models class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") user_type = models.SmallIntegerField( choices=((1, "管理员"), (2, "收银员"), (3, "仓管员")), default=2, verbose_name="用户类型", )如果嫌Profile表麻烦,也可以直接给User模型加字段,但那样需要自定义User,还要改动AUTH_USER_MODEL配置,复杂度会上升。相比之下,用OneToOne的Profile是成本最低的方案。登录之后把user.profile.user_type取出来放在session里,后续页面判断角色就很方便。
3.2 通过装饰器和中间件控制访问权限
权限控制千万不要只在前端隐藏按钮,必须在后端视图层做拦截。最简单的做法就是写几个装饰器,或者在视图函数里判断request.user的信息。Django自带的login_required装饰器可以要求用户登录,但没法细分角色,所以我们会再包一层。
from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied def staff_required(user_type): def decorator(view_func): from functools import wraps @wraps(view_func) def wrapper(request, *args, **kwargs): if not request.user.is_authenticated: return redirect("login") if request.user.profile.user_type != user_type: raise PermissionDenied return view_func(request, *args, **kwargs) return wrapper return decorator比如收银台视图加上@staff_required(2),仓管员就无法访问;商品管理和后台统计加上管理员权限,收银员就无法篡改基础数据。Proxy、中间件这些概念不用全都上,一个装饰器组合已经能解决大部分毕设场景。要注意的是,如果使用了Django Admin,Admin默认允许所有is_staff用户访问,建议把收银员、仓管员账号的is_staff设为False,只给管理员开后台权限。
4. 收银、库存与事务:核心业务怎么实现
4.1 收银台完整流程
收银台应该是超市系统里操作频率最高的页面。我习惯把它设计成一页式:左边是商品搜索区,输入条码或商品名,搜索后展示实时库存和售价;中间是当前购物车列表;右边显示总金额和结算按钮。这个流程不需要页面跳转,用少量Ajax就能完成。
前端步骤大致是:
- 收银员输入商品条码,按回车,前端发起搜索请求。
- 后端检查商品存在且库存大于0,返回商品名称、价格、库存信息。
- 前端把商品追加到购物车列表,重复添加同一商品则数量加1。
- 点击结算,把购物车数据POST到后端订单创建接口。
- 后端校验库存,创建SaleOrder和SaleOrderItem,扣减库存,返回订单号和金额。
- 前端跳转到小票页面,展示订单详细信息。
这里最容易出问题的就是第5步。如果多个收银员同时售卖同一件商品,两个请求同时读到库存余量是1,然后都判断“库存足够”,都可能创建订单,最终超卖。所以库存扣减不能是普通的读-判断-写,必须放进数据库事务并加锁。
4.2 库存扣减的并发安全处理
Django的ORM提供了两种常用的方式处理并发扣减库存。第一种是select_for_update(),在事务中锁定选中的记录,直到事务结束才释放锁。第二种是使用F()表达式做原子更新,让数据库直接在SQL层完成加减。两种方案各有适用场景。
我先说select_for_update()的写法:
from django.db import transaction from django.shortcuts import get_object_or_404 @transaction.atomic def create_order(request): cart_items = request.POST.getlist("items") # 假设每个元素包含 product_id 和 quantity total = 0 order = SaleOrder.objects.create(cashier=request.user, order_no=generate_order_no(), total_amount=0) for item in cart_items: product = Product.objects.select_for_update().get(pk=item["product_id"]) if product.stock < item["quantity"]: raise ValueError(f"商品 {product.name} 库存不足") SaleOrderItem.objects.create( order=order, product=product, product_name=product.name, price=product.price, quantity=item["quantity"], ) product.stock -= item["quantity"] product.save() total += product.price * item["quantity"] order.total_amount = total order.save() return orderselect_for_update()会锁定商品行,其他事务必须等待这个事务提交或回滚后才能继续操作。这种做法逻辑清晰,适合验库存后还需要做一系列业务处理的场景。要注意的是,select_for_update()必须在事务内使用,而且SQLite在低并发下表现OK,放到MySQL下锁机制也是有效的。
第二种用F()表达式的方式更简洁:
from django.db.models import F, Q affected = Product.objects.filter(pk=product_id, stock__gte=quantity).update(stock=F("stock") - quantity) if affected == 0: raise ValueError("库存不足")这个写法的巧妙之处在于把扣减和库存充足判断放在同一条UPDATE语句里,数据库层面保证不会超卖。update()返回受影响的行数,如果为0就说明库存不足,整个事务应该回滚。它不需要显式加锁,性能更好,适合对库存进行简单加减的场景。
实际项目中,我倾向于在收银逻辑里先使用select_for_update()把商品锁住,然后做订单明细创建,等一切成功后再提交事务。这样在“创建明细”和“扣库存”之间不会插入其他事务。如果只是做简单的批量库存调整,直接用F()表达式更省心。
4.3 入库与库存预警
入库逻辑比收银简单,但同样要注意事务。仓管员填写入库单后,系统应该自动完成两件事:新增入库流水,并增加商品库存。如果两步分开执行,中途一旦报错,库存和流水就对不上。代码如下:
from django.db import transaction @transaction.atomic def stock_in(request): product = Product.objects.select_for_update().get(pk=request.POST["product_id"]) quantity = int(request.POST["quantity"]) ProductStockIn.objects.create( product=product, operator=request.user, quantity=quantity, cost_price=product.cost, ) product.stock += quantity product.save() return JsonResponse({"ok": True, "stock": product.stock})库存预警是一个偏展示的需求,不需要实时任务,只要在商品列表页和首页展示低于预警值的商品即可。判断时用一个筛选条件Product.objects.filter(stock__lt=F("low_stock")),然后把这些商品统计出来放到一个通知栏里。如果想做成右上角小红点那种提示,可以在Base模板的context_processors里把低库存商品数量注入所有页面,这样一个全局提示器就完成了。
5. 销售统计与可视化展示
5.1 用ORM完成聚合统计
统计报表是答辩时最能吸引老师注意力的模块。Django的ORM聚合非常强大,写起来也不复杂。假设我们要统计某天的销售总额、订单数量和订单明细数:
from django.db.models import Sum, Count from django.utils import timezone from datetime import timedelta today = timezone.localtime().date() orders_today = SaleOrder.objects.filter(created_at__date=today) summary = orders_today.aggregate( total_amount=Sum("total_amount"), total_orders=Count("id"), ) # summary: {"total_amount": Decimal("1234.56"), "total_orders": 12}如果是统计近7天每天销售额,需要按日期分组:
from django.db.models.functions import TruncDate last_7_days = timezone.now() - timedelta(days=6) daily = ( SaleOrder.objects.filter(created_at__date__gte=last_7_days.date()) .annotate(day=TruncDate("created_at")) .values("day") .annotate(total=Sum("total_amount"), order_count=Count("id")) .order_by("day") )这个查询会返回一个包含日期、总金额、订单数的列表,直接把它序列化成JSON传给前端即可绘制折线图。还有商品销售排行:
top_products = ( SaleOrderItem.objects.values("product_id", "product_name") .annotate(total_quantity=Sum("quantity"), total_sales=Sum("subtotal")) .order_by("-total_quantity")[:10] )注意这里的product_name字段是我们在订单明细里存的快照,所以即使商品后来改名或者删除,历史统计也不会乱。
5.2 前端图表渲染
图表推荐用Chart.js,它是纯前端库,不需要服务器端渲染图片,接口返回JSON数组,前端拿去画就行。以柱状图为例,后端视图返回一个render(request, "report.html", {"daily_json": json.dumps(daily_list)}),前端在模板里把数据解析成图表。
fetch("/report/api/") .then((resp) => resp.json()) .then((data) => { new Chart(document.getElementById("salesChart"), { type: "bar", data: { labels: data.map((item) => item.day), datasets: [{ label: "销售额", data: data.map((item) => item.total), backgroundColor: "rgba(54, 162, 235, 0.6)", }], }, }); });如果图一多,模板文件会变得乱七八糟,可以把渲染图表的JS放在单独的reports.js里,通过{% static %}引入。需要注意的是日期格式化,因为Django返回的日期可能是datetime.date类型,JSON序列化时要用datetime_encoder或者直接转成字符串。我习惯在视图里直接格式化成2025-03-01这种字符串,省去前端处理的麻烦。
6. 项目部署与答辩前检查清单
6.1 本地运行与数据库迁移
拿到源码后,第一件事不是急着跑,而是先看README和requirements.txt。Django项目通常需要执行下面几步:
pip install -r requirements.txt python manage.py makemigrations python manage.py migrate python manage.py createsuperuser python manage.py runserver如果源码里已经带了数据库文件(比如db.sqlite3),并且数据库里已经有测试数据,你可以直接跑runserver登录后台。但为了安全,建议还是执行一遍migrate,确保数据表结构和当前代码一致。遇到No migrations to apply不一定意味着表都建好了,要检查一下django_migrations表里有没有对应的迁移记录。
有时候源码里会包含多个app,比如product、sale、user等,执行makemigrations时最好指定app名,避免把一些不需要的app也生成迁移文件:
python manage.py makemigrations product sale6.2 部署上线关键配置
毕设一般是用演示机或者自己的笔记本跑,但如果要打包给答辩老师看,建议把settings.py里的关键项调一下。DEBUG要设为False,ALLOWED_HOSTS要填上机器IP或*。不过要注意,如果DEBUG=False且没有配置静态文件服务,页面CSS和JS可能全部丢失。最省事的方案是在urls.py里加一段静态文件服务配置:
from django.conf import settings from django.conf.urls.static import static urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)然后执行python manage.py collectstatic收集所有静态文件。这个方法虽然官方不推荐在生产环境使用,但毕设演示完全够用。
另一个容易踩的坑是时区和时间格式。建议在settings.py里设置:
TIME_ZONE = "Asia/Shanghai" USE_TZ = True如果不设置,销售统计按created_at__date筛选时,会因为UTC时间偏差而导致当天数据少算或者多算。我建议在写入时间时使用timezone.localtime()来转换,避免凌晨单子算到前一天。
6.3 答辩演示前的准备
演示的时候先准备好一个干净的登录界面,然后按业务顺序走流程:登录进入管理员首页,看库存预警,添加或修改分类,录入入库单,到收银台完成一笔模拟购买,回到统计报表页面看到销售额变化。这一套走下来,业务闭环就完整了。演示前要准备两到三个测试账号,分别演示不同角色的权限区别,比如让收银员登录后看到某个按钮是灰色的,这就是权限控制的实物证据。
如果导师问“你这个系统用了哪些设计模式”,可以从Django MTV模式、Factory模式(ORM)以及装饰器模式谈起。但不要只会背概念,要能把“订单明细保存商品快照”说成是为了保证历史数据的可追溯性,把“select_for_update加锁”说成是解决并发超卖。这些话术准备一下,答辩时就稳了。
7. 常见问题与避坑实录
7.1 典型报错一览与解决办法
在我指导学生做这类系统的过程中,遇到最多的不是复杂业务问题,而是各种“低级但致命”的报错。下面整理一个速查表,直接对着找解决方案即可。
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
NoReverseMatch at / | URL路由或者模板中的{% url %}写错 | 检查urls.py中的name和模板标签,确保名称匹配 |
OperationalError: no such table: sale_saleorder | 迁移没有执行 | 运行python manage.py makemigrations和migrate |
AttributeError: 'Product' object has no attribute 'items' | related_name设置错误 | 检查外键的related_name,查反向外键要使用product.saleorderitem_set或自定义的related_name |
ValueError: invalid literal for int() with base 10 | 表单提交的不是整数 | 在视图里使用int()之前先判断isdigit(),或者用form.cleaned_data |
Static files are missing | DEBUG为False时静态文件未收集 | 设置STATIC_ROOT并运行collectstatic,或启动开发服务器时使用--insecure参数 |
CSRF token missing or incorrect | 表单未渲染csrf_token | 在HTML的<form>标签内加{% csrf_token %},或者在Ajax请求头加上X-CSRFToken |
DataError: value too long | 字段长度不足 | 把CharField的max_length调大,再做一次迁移 |
OperationalError: database is locked | SQLite在高并发写入时卡住 | 收银和入库操作放到事务中,降低并发或切换MySQL测试 |
7.2 几点实操心得
第一,不要一上来就改大而全的系统。如果你拿到的源码功能很丰富,先跑通“商品-入库-收银-统计”这条主链路,再去折腾角色权限、图表样式。主链路通了,这个项目已经能撑住80%的答辩场景。
第二,遇到报错一定要学会看Traceback。Django的报错其实很友好,它会明确告诉你哪个文件、哪一行、什么原因。不要一报错就发群里问,先把最后几行复制到搜索引擎,90%以上都能找到答案。操作过程中如果改了模型,记得第一时间makemigrations,否则后续查询会报模型和表结构不一致的错。
第三,代码里要有意识地留注释。不仅是给老师看,也是给自己看。比如在库存扣减的视图函数上写“这里用select_for_update防止超卖”,答辩时你直接指给你注释说“这是我重点处理过的并发问题”,这就是加分项。不要害怕代码简单,逻辑完整、注释清晰、能跑通闭环,比堆砌一堆花哨但无用的功能更受老师认可。
最后再分享一个我的个人习惯:做完项目后,把整个源码复制一份,删掉db.sqlite3,重新执行一遍迁移和创建测试数据。很多同学觉得自己电脑上能跑就万事大吉,结果到了演示机器上因为数据库文件带路径、静态文件路径不对、Python版本不一致各种翻车。从零跑通一次,才算真正紧张过项目。这一个小习惯,能帮你避开答辩当天80%的意外。