提到连锁超市的“商城 + 进销存 + 员工 + 分析”这套系统,我刚听到标题的第一反应是:又是一个典型的“什么都得要”的综合管理项目。但只要做过类似业务系统的人都知道,这类项目表面上功能庞杂,实际上业务流程是高度可抽象的:门店管货、员工管人、订单管交易、报表管决策。用 Python + Django 做,本质上就是在把这一套“人、货、场”的流转过程用代码重新表达一遍。
这篇文章我想从项目设计的角度完整过一遍:从需求拆解、模块划分、数据库建模,到下单扣库存、采购入库、员工权限、销售分析这些核心环节的实现思路,再到分布式锁、并发扣减、N+1查询、统计报表慢这些实际会踩的坑。所有内容基于我做过的一个模拟连锁超市项目,没有用具体品牌和真实数据,但逻辑和代码都是可以直接抄作业的水平。
这个系统适合谁看?第一类是准备自己写毕业设计或者找工作练手项目的人,第二类是想把超市进销存从 Excel 搬到 Web 化的中小型连锁运营团队,第三类是已经会用 Django 但没怎么接触过“进销存”这套业务逻辑的后端开发。我会尽量把每一步的“为什么这么做”也讲清楚,而不只是贴一堆代码。
1. 需求和模块拆解:先弄清楚这套系统到底在管什么
很多人拿到这种标题就开始建表写代码,结果写到一半发现门店、仓库、库存、销售、员工这些概念互相纠缠,改来改去。我个人的习惯是先把业务对象画一遍,把“谁在什么场景下操作什么数据”列清楚,再动数据库。
这个项目核心管理的对象可以归纳成六类:
- 门店 Store:连锁超市的物理网点,库存和销售都要按门店隔离。
- 商品 Product / SKU:商品基础信息,包含条码、分类、零售价、成本价、单位。
- 库存 Inventory:门店加商品维度的实时库存数量,以及安全库存预警线。
- 订单与订单明细 Order / OrderItem:线上商城下单,对应线下 POS 结算逻辑。
- 采购与调拨:供应商供货、门店采购入库、门店间调拨、盘点盈亏。
- 员工与权限:员工档案、账号权限、绩效相关数据。
由这六类对象,自然能推出系统的四个大模块:销售商城、进销存、员工管理、数据分析。它们不是孤立的,比如商城下了一单,立刻要影响该门店的库存;采购入库了,统计报表里的入库成本就要跟着变。所以在架构上,我倾向于做一个 Django 单项目多 App 的布局,而不是把所有代码塞进一个 app 里。
项目结构可以这样设计:
code复制store_chain/
├── manage.py
├── config/ # 项目配置
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── apps/
│ ├── mall/ # 商城:商品展示、购物车、订单
│ ├── inventory/ # 进销存:采购、入库、库存、调拨、盘点
│ ├── employee/ # 员工:档案、排班、考勤、绩效
│ ├── analytics/ # 分析:报表、图表、导出
│ └── common/ # 公共:基础模型、工具函数、中间件
这个拆分的好处有两点。第一,各业务模块之间只通过 Django 的 model 层和 service 层交互,不会出现视图函数互相 import 的乱象;第二,做数据隔离时比较容易统一控制。你可以在每个 app 里定义自己负责的模型,但“门店”和“商品”这种公共基础模型一定放在 common 或者独立 app 里,避免循环依赖。
1.1 销售商城模块的边界
商城模块最核心的职责是“把商品卖出去”:商品列表、商品详情、购物车、订单确认、支付回调(或模拟支付)、订单状态流转。
这里有个容易被忽略的设计点:商城展示的库存,不应该是实时查数据库里的 Inventory 然后 count 一下。当商品数量超过几千个,每个商品都实时联查库存,数据库压力很大。更好的做法是:商品表存一个 display_stock 字段,下单成功之后批量更新这个字段;真正的可用库存以 Inventory 表为准,库存表是控制超卖的关键。
1.2 进销存模块的边界
进销存是这套系统的“地基”,也是整个项目里逻辑最重的部分。采购入库会影响库存,销售出库会扣减库存,调拨会同时改变两个门店的库存,盘点会把账实差异修正到 Inventory 里。
所有这些动作,在设计上都要围绕“库存流水 InventoryLedger”来展开。库存表只存当前数量,每一次变动都记一条流水,做到“有据可查”。前端查到库存不对时,可以直接根据流水反推是哪张单据导致的问题。这是我强烈建议从一开始就做的表,不要为了省事省掉。
1.3 员工与分析模块的边界
员工模块首先是配套账号体系。Django 自带的 auth 用户表要扩展,加一个 OneToOne 关联到员工档案表。员工档案包含姓名、手机号、所属门店、岗位、入职日期、基本工资等。权限控制要按门店隔离:区域经理能看多家门店,店长只能看本店,普通员工只能操作自己的单据。
分析模块更像是一个“数据消费方”:从哪些表取数,用什么粒度汇总,如何展示。我的经验是,分析报表不能在做完前三个模块之后再补,而是在建表时就要预留 summary 级别的汇总表,比如每日门店销售统计表。否则真到了几万条订单明细再临时聚合,查询会慢到让人崩溃。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模与核心字段解析
Django 的 ORM 适合快速建模,但建模型前必须想清楚几个问题:哪些字段需要索引、哪些关系用外键还是复数外键、哪些业务字段要加唯一约束。下面是我常用的核心模型设计,字段名和注释可以直接复用。
2.1 门店、商品、库存三张基础表
python复制class Store(models.Model):
name = models.CharField("门店名称", max_length=128)
code = models.CharField("门店编码", max_length=32, unique=True)
address = models.CharField("门店地址", max_length=255, blank=True)
status = models.BooleanField("是否营业", default=True)
created_time = models.DateTimeField("创建时间", auto_now_add=True)
class Meta:
db_table = "base_store"
class Category(models.Model):
name = models.CharField("分类名称", max_length=64)
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.PROTECT)
class Product(models.Model):
name = models.CharField("商品名称", max_length=128)
barcode = models.CharField("条码", max_length=32, db_index=True)
category = models.ForeignKey(Category, on_delete=models.PROTECT)
unit = models.CharField("单位", max_length=16, default="件")
price_retail = models.DecimalField("零售价", max_digits=10, decimal_places=2)
price_cost = models.DecimalField("成本价", max_digits=10, decimal_places=2)
warn_stock = models.IntegerField("安全库存预警值", default=10)
display_stock = models.IntegerField("商城展示库存", default=0)
is_active = models.BooleanField("上架状态", default=True)
def __str__(self):
return self.name
class Inventory(models.Model):
store = models.ForeignKey(Store, on_delete=models.CASCADE, verbose_name="门店")
product = models.ForeignKey(Product, on_delete=models.CASCADE, verbose_name="商品")
quantity = models.IntegerField("实际库存数量", default=0)
last_in_time = models.DateTimeField("最近入库时间", null=True, blank=True)
last_out_time = models.DateTimeField("最近出库时间", null=True, blank=True)
class Meta:
unique_together = ("store", "product")
这里要解释几个“为什么”:
一是 barcode 加索引但不加唯一约束。同一商品在多个门店共用同一个条码,但不同门店库存是分开的,所以条码不能全局唯一。实际超市里还会有“同种商品不同规格”的存在,例如 500ml 和 1L 的饮料条码不同,那它们就是不同 Product。
二是 Inventory 一定要加 unique_together("store", "product")。这一条唯一约束是防脏数据的关键。如果没有它,同一个门店同一个商品可能出现多条库存记录,后续事务扣减时会非常混乱。
三是货币字段用 DecimalField 而不是 FloatField。Float 在计算金额和库存成本时会有精度问题,这是进销存系统的红线,不要省这点规范。
2.2 采购、订单、库存流水
进销存的核心是“单证流转”。采购单、销售单、调拨单、盘点单都是“单据头 + 单据明细”的结构。我以采购单和销售订单为例:
python复制class PurchaseOrder(models.Model):
order_no = models.CharField("采购单号", max_length=32, unique=True)
store = models.ForeignKey(Store, on_delete=models.PROTECT)
supplier = models.ForeignKey("Supplier", on_delete=models.PROTECT)
status = models.CharField("状态", max_length=16, choices=(
("draft", "草稿"), ("pending", "待入库"),
("done", "已入库"), ("cancelled", "已作废"),
), default="draft")
total_amount = models.DecimalField("采购总金额", max_digits=12, decimal_places=2, default=0)
created_by = models.ForeignKey("auth.User", on_delete=models.SET_NULL, null=True)
created_time = models.DateTimeField(auto_now_add=True)
class PurchaseOrderItem(models.Model):
order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, on_delete=models.PROTECT)
qty = models.IntegerField("采购数量")
price = models.DecimalField("进价", max_digits=10, decimal_places=2)
line_amount = models.DecimalField("行金额", max_digits=12, decimal_places=2)
class InventoryLedger(models.Model):
store = models.ForeignKey(Store, on_delete=models.PROTECT)
product = models.ForeignKey(Product, on_delete=models.PROTECT)
change_type = models.CharField("变动类型", max_length=20, choices=(
("purchase_in", "采购入库"),
("sale_out", "销售出库"),
("sale_return", "销售退货"),
("transfer_in", "调拨入库"),
("transfer_out", "调拨出库"),
("stocktake", "盘点调整"),
))
change_qty = models.IntegerField("变动数量")
before_qty = models.IntegerField("变动前数量")
after_qty = models.IntegerField("变动后数量")
ref_type = models.CharField("来源单据类型", max_length=30)
ref_id = models.IntegerField("来源单据ID")
created_time = models.DateTimeField("发生时间", auto_now_add=True)
库存流水表的 ref_type + ref_id 是“多态关联”的简化做法,虽然没法用外键保证完整性,但在业务系统里很常见,因为它避免了为每一类单据建一张流水表。实际使用中我会在写入流水时用同一个 service 函数保证事务一致性,脏数据的概率很低。
2.3 订单模型与状态流转
python复制class Order(models.Model):
order_no = models.CharField("订单号", max_length=32, unique=True)
store = models.ForeignKey(Store, on_delete=models.PROTECT)
member = models.ForeignKey("Member", null=True, blank=True, on_delete=models.SET_NULL)
status = models.CharField("订单状态", max_length=16, choices=(
("unpaid", "待支付"),
("paid", "已支付"),
("completed", "已完成"),
("cancelled", "已取消"),
("refunding", "退款中"),
("refunded", "已退款"),
), default="unpaid")
total_amount = models.DecimalField("订单总金额", max_digits=12, decimal_places=2, default=0)
pay_time = models.DateTimeField("支付时间", null=True, blank=True)
created_time = models.DateTimeField("下单时间", auto_now_add=True)
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, on_delete=models.PROTECT)
product_name = models.CharField("商品名称快照", max_length=128)
price = models.DecimalField("成交单价", max_digits=10, decimal_places=2)
cost = models.DecimalField("成本单价快照", max_digits=10, decimal_places=2)
qty = models.IntegerField("数量")
line_amount = models.DecimalField("行金额", max_digits=12, decimal_places=2)
有个细节值得注意:订单明细里一定要保存 product_name 和 cost 这两个“快照”字段。因为商品改名、零售价调整、成本价调整都是正常业务,如果报表里全是关联查询,历史订单的展示和利润统计就会跟着变。快照字段能把“下单那一刻”的商品信息和成本锁定下来,这是很多新手容易忽略的建模问题。
3. 核心功能模块实现要点
模块划分清楚之后,就要逐个实现。这一节我会挑四个核心闭环来讲:下单扣库存、采购入库、员工权限隔离、数据分析。
3.1 下单扣库存的正确姿势
线上商城销售流程是:选商品、加购物车、确认订单、支付、扣库存。最容易出错的是“扣库存”的时机。很多人会在购物车加入时就把库存锁了,那会导致用户没付款商品也被占用。正确做法是:只在提交订单时锁定库存,支付成功后再真正扣减库存;如果超时未支付,订单取消,释放锁定。
用 Django ORM 来实现安全的扣库存,核心是 select_for_update() 配合 transaction.atomic()。
python复制from django.db import transaction
from django.db.models import F
def create_order_from_cart(user, store_id, cart_items):
# cart_items: [{"product_id": 1, "qty": 2}, ...]
with transaction.atomic():
for item in cart_items:
inv = Inventory.objects.select_for_update().get(
store_id=store_id,
product_id=item["product_id"]
)
if inv.quantity < item["qty"]:
raise ValueError(f"商品 {item['product_id']} 库存不足")
inv.quantity = F("quantity") - item["qty"]
inv.save()
order = Order.objects.create(...)
for item in cart_items:
OrderItem.objects.create(order=order, ...)
这段代码有三个关键点:
transaction.atomic():整个循环要么全部成功,要么全部回滚。不会出现订单创建成功但库存没扣,或者库存扣了订单失败。select_for_update():数据库会对被命中的行加行级锁,防止两个请求同时读到同一个库存数量,这是防止超卖的根本手段。F("quantity") - item["qty"]:让数据库在行锁保护下执行原子更新,避免“read-modify-write”的竞态。
在实际并发测试中,我用 50 个线程同时抢购 10 件的商品,最终只有 10 个请求成功,库存没有变成负数。这个方案在单体 Django 项目里是够用的。如果门店规模大、并发很高,才需要考虑把库存扣减抽成独立服务或引入 Redis 递减,但对于这个项目来说没必要,反而会增加一致性问题。
3.2 采购入库与单据状态流转
采购入库的 Agent 是“采购单”。门店店长根据缺货情况创建采购单,提交给供应商,供应商送货后仓库确认入库。入库时要生成三样东西:采购单状态更新为“已入库”、Inventory 数量增加、InventoryLedger 增加一条流水。
模仿销售扣库存的逻辑,采购入库的核心代码如下:
python复制@transaction.atomic
def confirm_purchase_order(order_id):
order = PurchaseOrder.objects.select_for_update().get(id=order_id)
if order.status == "done":
raise ValueError("该采购单已完成入库,不能重复操作")
for item in order.items.select_for_update():
inv, _ = Inventory.objects.get_or_create(
store=order.store, product=item.product,
defaults={"quantity": 0}
)
inv.quantity = F("quantity") + item.qty
inv.save()
InventoryLedger.objects.create(
store=order.store, product=item.product,
change_type="purchase_in", change_qty=item.qty,
before_qty=0, after_qty=0,
ref_type="PurchaseOrder", ref_id=order.id,
)
order.status = "done"
order.save(update_fields=["status"])
同样的逻辑,销售退货、调拨、盘点都复用它,只是 change_type 和方向不同。调拨需要特别注意:调出和调入必须是同一个事务,且要更新两个门店的库存记录,否则可能出现一边扣了另一边没加的情况。
3.3 员工权限与门店数据隔离
Django 自带的 User 模型只有认证能力,真正的业务权限要靠自己扩展。我的做法是:
- 用户表关联员工档案 Employee,Employee 上保存
store外键和role字段。 - 自定义一个权限装饰器或中间件,每次请求时解析当前用户的可见门店列表。
- 所有涉及门店的模型查询都强制带上
store_id__in=user_visible_stores。
示例中间件:
python复制class StoreScopeMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = request.user
if user.is_authenticated:
if user.profile.role == "admin":
request.visible_store_ids = list(Store.objects.values_list("id", flat=True))
elif user.profile.role == "manager":
request.visible_store_ids = user.profile.managed_stores.values_list("id", flat=True)
else:
request.visible_store_ids = [user.profile.store_id]
else:
request.visible_store_ids = []
return self.get_response(request)
这样的话,视图里写 Inventory.objects.filter(store_id__in=request.visible_store_ids) 就不会越权看到别的门店数据。要注意,这种软过滤只在应用层生效,如果直接通过 ORM console 或者 Django Admin 访问,还是能看到全部数据,所以 Admin 后台要单独限制,或者不让普通员工进 Admin。
3.4 数据分析:从聚合查询到可视化
分析模块不能所有数据都实时从明细表聚合,那样查询效率太低。我的方案是先做三层数据:
- 第一层是原始流水表(InventoryLedger、OrderItem)。
- 第二层是每日汇总表,例如
DailySalesSummary,记录每个门店每天的总销售额、订单数、客单价、毛利。 - 第三层是给前端图表用的接口,直接从汇总表取数。
汇总任务可以用 Django 管理命令的形式跑定时任务,也可以用 Celery beat。如果是练手项目,建议用管理命令加系统 crontab 或简单每分钟调一次,先把功能跑起来。
写一个简单的日汇总管理命令:
python复制# management/commands/daily_summary.py
from django.core.management.base import BaseCommand
from django.db.models import Sum, F
from django.utils import timezone
from apps.mall.models import Order
from apps.analytics.models import DailySalesSummary
class Command(BaseCommand):
def handle(self, *args, **options):
today = timezone.localdate()
summary_data = (
Order.objects.filter(
status="paid", pay_time__date=today
)
.values("store_id")
.annotate(
total_amount=Sum("total_amount"),
order_count=Sum("total_amount"),
)
)
# 这一步如果数据量大,建议用 insert_on_conflict 或者先删后插
for row in summary_data:
DailySalesSummary.objects.update_or_create(
date=today, store_id=row["store_id"],
defaults={
"total_amount": row["total_amount"],
"order_count": row["order_count"],
},
)
对应的图表接口,返回给前端的格式一般是:
json复制{
"labels": ["2025-01-01", "2025-01-02"],
"datasets": [
{"name": "门店A", "data": [12000, 15000]},
{"name": "门店B", "data": [8000, 10000]}
]
}
前端用 ECharts 渲染成折线图,库存预警用柱状图,品类销售占比用饼图。图表能做到这个程度,已经足以让管理层看到“哪个店卖得好、哪个商品库存积压、哪个月是淡季”这些信息了。
4. 实操环境与关键代码片段
下面给出一个从零搭建这套系统的实操路线。环境版本我建议:Ubuntu 22.04 或 Windows 11 均可,Python 3.10/3.11,Django 4.2.x,MySQL 8.0 或 PostgreSQL 15,前端用 Django 内置模板 + Bootstrap 5 + ECharts 5。如果希望前后端分离,可以加 Django REST Framework,但本项目用服务端渲染模板会更简单。
4.1 环境初始化
bash复制mkdir store_chain
cd store_chain
python3 -m venv venv
source venv/bin/activate
pip install django==4.2.* mysqlclient # 或 psycopg2-binary
django-admin startproject config .
python manage.py startapp mall
python manage.py startapp inventory
python manage.py startapp employee
python manage.py startapp analytics
最好把 apps 统一放到一个目录下,再在 settings.py 里配置:
python复制import sys
sys.path.insert(0, BASE_DIR / "apps")
INSTALLED_APPS = [
...
"mall", "inventory", "employee", "analytics", "common",
]
4.2 数据迁移与初始数据
写模型之前,先配置数据库连接,然后依次执行:
bash复制python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
这里要提醒一点:DecimalField 字段的 max_digits 和 decimal_places 一定要在迁移前想清楚。以订单金额为例,max_digits=12, decimal_places=2 能表示的最大值是 9999999999.99 也就是近一百亿,对单个订单来说绝对够用。库存数量字段用 IntegerField,某些商品会按“箱”“包”等大单位,一般不会超过 21 亿,足够。
4.3 商品列表与购物车流程
购物车建议后端存储,而不是只存在 localStorage。后端存储的好处是用户换设备购物车不丢,而且服务端可以校验商品状态和价格。
python复制class Cart(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
qty = models.IntegerField("数量", default=1)
store = models.ForeignKey(Store, on_delete=models.CASCADE)
created_time = models.DateTimeField(auto_now_add=True)
购物车商品展示时,要实时查询价格。Django 模板中可以直接在视图里构建“商品 + 小计金额”的列表。
python复制def cart_view(request):
cart_items = request.user.cart_set.select_related("product").all()
cart_lines = []
total = 0
for item in cart_items:
line_total = item.product.price_retail * item.qty
total += line_total
cart_lines.append({"item": item, "line_total": line_total})
return render(request, "mall/cart.html", {"cart_lines": cart_lines, "total": total})
下单时再根据购物车构建订单,同时把商品名称、单价、成本写入 OrderItem 快照。这里一定要在事务里重新从数据库读取商品价格,不能用页面提交过来的价格。这是基本安全要求。
4.4 库存预警与周转率计算
进销存系统最实用的分析是库存预警。预警规则可以有两种:
- 最低库存阈值:每个 Product 上有个
warn_stock字段,当库存小于等于该值时,生成缺货预警。 - 按销售速度计算“可售天数”:比如某商品日均销售 20 件,当前库存 50 件,可售天数约 2.5 天,低于 7 天就预警。
日均销量的计算:
python复制from django.db.models import Sum
from datetime import timedelta
from django.utils import timezone
def avg_daily_sales(store_id, product_id, days=30):
start_date = timezone.localdate() - timedelta(days=days)
total_qty = OrderItem.objects.filter(
order__store_id=store_id,
order__status="paid",
order__pay_time__date__gte=start_date,
product_id=product_id,
).aggregate(total=Sum("qty"))["total"] or 0
return total_qty / days
库存周转率的公式是:周转率 = 期间销售成本 / 平均库存成本。平均库存可以简单用期初加期末除以二,严谨一点可以用每日库存加权平均。公式本身不难,难的是平均库存成本的计算,因为每次采购的成本不同。如果没做移动加权平均,可以用最后一次采购成本代替,报表精度对门店管理够用。
4.5 导出报表
使用 openpyxl 库生成 Excel 报表比较成熟,接口返回 HttpResponse 附带 Content-Disposition 即可。
bash复制pip install openpyxl
python复制from openpyxl import Workbook
from django.http import HttpResponse
def export_sales_report(request):
wb = Workbook()
ws = wb.active
ws.title = "销售日报"
ws.append(["日期", "门店", "销售额", "订单数"])
for row in sales_data:
ws.append([row["date"], row["store_name"], row["total_amount"], row["order_count"]])
resp = HttpResponse(
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
resp["Content-Disposition"] = "attachment; filename=sales_report.xlsx"
wb.save(resp)
return resp
5. 常见问题与排查技巧实录
无论设计多么仔细,实际开发过程中一定会有各种坑。我把这个项目里最容易踩的六个问题整理成速查表,每个都附解决思路。
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| 并发下单导致库存变负数 | 查询和扣减之间没有加锁 | 将扣减方法包在 transaction.atomic() 中,使用 select_for_update() 锁定库存行 |
| 首页商品列表加载慢 | 多表关联查询产生了 N+1 查询 | 使用 select_related("category")、prefetch_related("inventory_set") 减少 SQL 次数 |
| 报表页卡死,几万条明细聚合慢 | 每次都扫描 OrderItem 全表 | 建立每日汇总表,只查汇总表,必要时按月分表或加索引 |
| 同一个门店重复产生商品库存记录 | Inventory 缺少唯一约束 | 在 Meta 中设置 unique_together=("store", "product"),并做数据清洗 |
| Django Admin 后台中文乱码 | 数据库连接字符集配置不对 | MySQL 连接参数加 charset="utf8mb4",SQLite 基本无此问题 |
| 订单取消后库存没有释放 | 订单状态流转没有触发库存回补 | 在 cancel_order 中统一调用库存回补 service,不能分散到多个视图 |
5.1 并发超卖问题实际排查
我第一版下单代码就写成了先查库存再减库存,没有加锁。压测时 50 个并发请求抢 10 件商品,出现 32 个成功订单,库存变成负数。排查过程是:看数据库日志,发现多个事务同时读到 quantity=10,然后各自减一写回,最后覆盖成了负数。解法和前面一致:行锁 + 事务。注意 select_for_update() 必须放在 atomic() 里才能生效,否则 Django 会在事务结束时立刻释放锁,依然会超卖。
5.2 商品列表 N+1 查询优化
商品列表的 ORM 如果这样写:
python复制product_list = Product.objects.all()
for p in product_list:
print(p.category.name)
就会为每个商品额外产生一条 category 查询,100 个商品就有 101 条 SQL。优化后:
python复制product_list = Product.objects.select_related("category").all()
同理在订单明细里查商品名,可以用 prefetch_related。实际项目里我把列表接口的 SQL 数量从一百多条降到几条,响应时间从 800ms 降到 80ms 左右,这个优化性价比极高。
5.3 门店数据隔离的权限漏洞
权限隔离最容易出的问题不是视图层,而是 Django Admin。如果普通员工账号被错误地授予了 Admin 权限,他可以绕过中间件直接查看所有门店数据。所以部署时至少做到:
- 普通员工完全没有
is_staff=True。 - 店长只有
is_staff=True且通过自定义 ModelAdmin 的get_queryset限制门店。 - 只有区域经理或总部管理员能访问全部数据。
5.4 库存流水对账技巧
实际运营时,库存盘点经常出现账实不符。我的建议是每天早上跑一次“库存异常检查”任务,对比 InventoryLedger 流水和 Inventory 当前数,列出差异超过阈值的商品。这个方法能快速定位是漏记了采购单还是订单退款没回补,比人工翻 Excel 高效太多。
5.5 财务报表精度问题
毛利 = 销售收入 - 销售成本。如果 OrderItem 里的 cost 是实时关联 Product 表,历史订单的成本会随着采购价变化而变化,导致毛利虚高或虚低。解决方式就是在写入订单明细时把当时的成本快照存下来。这也是我在模型设计里特意补了 cost 字段的原因。
5.6 前端模板渲染注意事项
服务端渲染模板要注意模板语法的转义和逻辑控制。商品描述字段如果直接渲染,容易产生 XSS 风险。Django template 自带 escape,但用 safe 过滤器时务必确认字段来源可信。同时,商城页面的价格展示建议格式化成两位小数,用模板过滤器 floatformat:2,避免显示 19.9000000001 这种浮点值。
6. 从单体到扩展方向的思考
这个项目做完之后,如果再往前走一步,可以考虑几个扩展方向。
第一是移动端适配。商城端做成响应式布局,最方便;要更接近原生体验,可以套一层 H5 壳,或者把商品查询接口用 DRF 暴露出来,给小程序调用。
第二是支付对接。项目前期可以用模拟支付回调,正式上线时接入第三方支付平台的扫码支付和退款接口。关键点是回调验签和订单状态幂等处理,支付回调第一次成功处理,后面重复回调要返回“已处理”而不能再扣一次库存。
第三是库存预测。利用过去三个月的销售数据做简单的移动平均预测,给采购补货建议。Django 里每天跑一个管理命令,计算每个门店、每个商品的建议补货量 = 日均销量 × 补货周期 − 当前库存 − 在途采购量。这个功能一旦上线,采购员的工作量能减少一大半。
第四是权限和审计增强。进销存系统最怕错账,所有入库、出库、调拨、盘点操作都应该记录操作人和操作时间,最好再加一个操作日志表,记录“谁在什么时候把什么数据从 A 改成了 B”。这个表面上增加工作量,但真遇到纠纷时是保护开发者和运维者的关键数据。
第五是部署层。正式环境用 Nginx + Gunicorn + MySQL,加上 Docker Compose 编排,用 Celery 跑定时汇总和超时订单关闭。练手项目可以先不搞容器化,但有条件的话建议直接把 Dockerfile 写上,后续部署会省很多事。
坦白说,我第一版做的时候也走了不少弯路,最深刻的体会就是:进销存系统千万别把“扣库存”和“记流水”分开写,一定要在同一个事务里完成;报表需求一定要在表结构设计阶段预留汇总表位置,否则后面会被慢查询折磨到怀疑人生。这些坑我都踩了一遍,希望这篇文章能帮你避开。
最后再分享一个小技巧:开发时把 Django 的 DEBUG=True 打开,配合 Django Debug Toolbar,能看到每个接口的 SQL 次数和耗时。我优化性能时几乎不看浏览器 Network,直接盯 SQL 面板,每多一次查询都是一个优化机会。这套系统做完以后,整体 SQL 数量压到 10 条以内,后台页面基本都是秒开。
