Django Billing System 数据库设计:Product、Article 与 Order
结论
Product 保存商品的公共信息。订单中的购买数量不应直接放在 Product 中,而应与商品、成交单价一起保存在独立的订单明细模型中,例如 OrderItem。
模型关系如下:
Order 1 ─── N OrderItem N ─── 1 Product
OrderItem 分别通过 ForeignKey 关联 Order 和 Product。不建议让 Order 直接使用 ManyToManyField(Product),因为一条订单明细通常只属于一个订单,不会由多个订单共享。
为什么数量不能直接放在 Product 中
Product 表示商品,例如“机械键盘”。OrderItem 表示一次具体交易中的明细,例如“订单 A 购买了 2 个机械键盘”。
同一商品可以出现在多个订单中,每个订单购买的数量也可能不同:
| Order | Product | Quantity |
|---|---|---|
| A001 | 机械键盘 | 2 |
| A002 | 机械键盘 | 5 |
如果把 qty 放在 Product 中,只能保存一个数量,无法判断这个数量属于哪个订单。
订单明细表中重复出现 product_id 是正常的,因为每一行记录的是一笔不同的交易。商品名称、描述等公共信息仍然只保存在 Product 中。
如果 qty 表示库存数量,应改用含义明确的字段名,例如 stock_quantity,也可以单独建立库存表。库存数量和订单购买数量不应混用。
推荐的 Django 模型
from django.db import models
from django.db.models import Q
class Product(models.Model):
name = models.CharField(max_length=200)
current_price = models.DecimalField(max_digits=10, decimal_places=2)
def __str__(self):
return self.name
class Order(models.Model):
number = models.CharField(max_length=20, unique=True)
client = models.ForeignKey(
"Client",
on_delete=models.PROTECT,
related_name="orders",
)
invent = models.ForeignKey(
"Invent",
on_delete=models.PROTECT,
related_name="orders",
)
created_at = models.DateTimeField(auto_now_add=True)
products = models.ManyToManyField(
Product,
through="OrderItem",
related_name="orders",
)
def __str__(self):
return self.number
class OrderItem(models.Model):
order = models.ForeignKey(
Order,
on_delete=models.CASCADE,
related_name="items",
)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
related_name="order_items",
)
quantity = models.PositiveIntegerField()
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
constraints = [
models.CheckConstraint(
condition=Q(quantity__gt=0),
name="order_item_quantity_gt_zero",
),
models.UniqueConstraint(
fields=["order", "product"],
name="unique_product_per_order",
),
]
@property
def subtotal(self):
return self.unit_price * self.quantity
这里的 ManyToManyField 并非必需。它通过 through="OrderItem" 提供了便捷的商品查询接口,真正保存订单关系和数量的仍是 OrderItem:
order.items.all()
order.products.all()
product.order_items.all()
如果不需要 order.products.all() 这样的快捷查询,可以删除 Order.products 字段,只保留两个 ForeignKey。
创建订单及明细
from decimal import Decimal
from django.db import transaction
@transaction.atomic
def create_order(client, invent, product, quantity):
order = Order.objects.create(
number="A001",
client=client,
invent=invent,
)
OrderItem.objects.create(
order=order,
product=product,
quantity=quantity,
unit_price=Decimal(product.current_price),
)
return order
如果同一商品在一个订单中只能出现一次,可以保留 UniqueConstraint。再次增加该商品时,更新已有明细即可:
item, created = OrderItem.objects.get_or_create(
order=order,
product=product,
defaults={
"quantity": 2,
"unit_price": product.current_price,
},
)
if not created:
item.quantity += 2
item.save(update_fields=["quantity"])
如果同一商品可能因折扣、批次或税率不同,在一个订单中出现多行,就不要添加 unique_product_per_order 约束。另一种做法是将批次、价格类型等字段纳入唯一约束。
为什么订单明细需要保存成交单价
如果只在 Product 中保存当前价格,历史账单可能失真。假设商品下单时的价格是 100.00,后来调整为 120.00,历史订单仍应显示原成交价 100.00。
各字段的职责如下:
Product.current_price表示当前销售价格。OrderItem.unit_price表示下单时确定的成交单价。- 折扣、税率等影响账单的值,也应根据业务需要保存在订单或订单明细中。
成交价格快照是必要的交易记录,不属于应当消除的数据重复。
删除行为与字段类型
OrderItem.order 适合使用 CASCADE,删除订单时一并删除其明细。
OrderItem.product 通常使用 PROTECT,防止物理删除已经出现在历史订单中的商品。商品下架可以通过状态字段处理:
is_active = models.BooleanField(default=True)
金额通常应使用 DecimalField,不宜使用 IntegerField 或 FloatField,以免出现浮点数精度问题。如果系统明确以“分”作为最小货币单位,也可以使用整数,但必须在整个系统中保持一致。
通常不需要手动声明 AutoField(primary_key=True),Django 会自动创建主键。订单编号也未必需要作为数据库主键。使用默认主键,并为 number 添加 unique=True,以后修改编号格式或建立外键会更方便。
各模型最终负责的数据如下:
Product:商品目录和当前价格。Order:客户、订单号、时间等订单头信息。OrderItem:所属订单、商品、购买数量和成交单价。- 库存模型:实际库存数量及其变动记录。
备注:内容仅供参考。
