Django 2.0 中如何创建支持添加多个 Items 的 invoice?
结论
在 Django 中,可以把 Invoice 作为主表,每件商品分别保存为一条 InvoiceItem 记录,再通过 ForeignKey 建立一对多关系。使用 CBV 创建发票时,由 CreateView 处理发票主表单,并配合 inlineformset_factory 一次提交多条商品明细。
如果用户会分多次添加商品,实际项目通常会先维护购物车,结算时再生成发票。如果需要在同一个页面填写发票和多条商品明细,内联 Formset 更合适。
数据模型
不要在 Invoice 中直接保存商品列表。每行商品都应是一条独立的 InvoiceItem 记录:
# models.py
from decimal import Decimal
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
def __str__(self):
return self.name
class Invoice(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
customer_name = models.CharField(max_length=200)
@property
def total(self):
return sum(
(item.subtotal for item in self.items.all()),
Decimal('0.00')
)
def __str__(self):
return 'Invoice {}'.format(self.pk)
class InvoiceItem(models.Model):
invoice = models.ForeignKey(
Invoice,
related_name='items',
on_delete=models.CASCADE
)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT
)
quantity = models.PositiveIntegerField(default=1)
# 保存开票时的成交价,避免 Product.price 变化后影响历史发票
unit_price = models.DecimalField(
max_digits=10,
decimal_places=2
)
@property
def subtotal(self):
return self.unit_price * self.quantity
def __str__(self):
return '{} × {}'.format(self.product, self.quantity)
对应的关系如下:
Invoice 1 ──── N InvoiceItem N ──── 1 Product
通过 invoice.items.all() 可以取得一张发票的全部明细。
商品外键使用 on_delete=models.PROTECT 后,只要商品仍被发票引用,就不能直接删除。是否采用这种策略,需要结合具体业务规则判断。
创建表单和内联 Formset
# forms.py
from django import forms
from django.forms import inlineformset_factory
from .models import Invoice, InvoiceItem
class InvoiceForm(forms.ModelForm):
class Meta:
model = Invoice
fields = ['customer_name']
class InvoiceItemForm(forms.ModelForm):
class Meta:
model = InvoiceItem
fields = ['product', 'quantity']
InvoiceItemFormSet = inlineformset_factory(
Invoice,
InvoiceItem,
form=InvoiceItemForm,
extra=3,
can_delete=True
)
extra=3 表示页面初始显示三行空白的商品明细,并不限制一张发票最多只能包含三件商品。
这里没有把 unit_price 暴露给用户。价格应由服务器根据所选商品确定,不能直接采用浏览器提交的价格。
使用 CreateView 同时保存发票和明细
# views.py
from django.db import transaction
from django.urls import reverse_lazy
from django.views.generic import CreateView
from .forms import InvoiceForm, InvoiceItemFormSet
from .models import Invoice
class InvoiceCreateView(CreateView):
model = Invoice
form_class = InvoiceForm
template_name = 'invoices/invoice_form.html'
success_url = reverse_lazy('invoice-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.method == 'POST':
context['item_formset'] = InvoiceItemFormSet(
self.request.POST,
instance=getattr(self, 'object', None)
)
else:
context['item_formset'] = InvoiceItemFormSet()
return context
def form_valid(self, form):
context = self.get_context_data(form=form)
item_formset = context['item_formset']
# 主表单和明细表单必须同时通过验证
if not item_formset.is_valid():
return self.form_invalid(form)
with transaction.atomic():
self.object = form.save()
# 此时 Invoice 已有主键,可以保存关联的 InvoiceItem
item_formset.instance = self.object
invoice_items = item_formset.save(commit=False)
for deleted_item in item_formset.deleted_objects:
deleted_item.delete()
for invoice_item in invoice_items:
invoice_item.invoice = self.object
invoice_item.unit_price = invoice_item.product.price
invoice_item.save()
item_formset.save_m2m()
return super().form_valid(form)
保存时按以下顺序处理:
- 验证
InvoiceForm。 - 验证所有
InvoiceItemForm。 - 保存
Invoice并取得主键。 - 将该
Invoice设置为 Formset 的instance。 - 保存每一条
InvoiceItem。 - 使用数据库事务,让整张发票要么全部保存,要么全部回滚。
如果不使用 transaction.atomic(),可能出现发票已经创建,但部分明细保存失败的情况。
模板
<!-- templates/invoices/invoice_form.html -->
<form method="post">
{% csrf_token %}
{{ form.non_field_errors }}
{{ form.as_p }}
<h2>商品明细</h2>
{{ item_formset.management_form }}
{{ item_formset.non_form_errors }}
<div id="invoice-items">
{% for item_form in item_formset %}
<div class="invoice-item">
{{ item_form.non_field_errors }}
{{ item_form.as_p }}
</div>
{% endfor %}
</div>
<button type="submit">创建发票</button>
</form>
management_form 不能省略。它包含 TOTAL_FORMS、INITIAL_FORMS 等隐藏字段,Django 根据这些字段判断提交了多少条明细。
URL 配置
# urls.py
from django.urls import path
from .views import InvoiceCreateView
urlpatterns = [
path(
'invoices/create/',
InvoiceCreateView.as_view(),
name='invoice-create'
),
]
动态增加商品行
如果固定的三行不够,可以在前端利用 Formset 的 empty_form 动态添加表单。添加时需要把 __prefix__ 替换为当前表单编号,并同步增加 TOTAL_FORMS。
先在模板中准备一个隐藏模板:
<template id="empty-item-form">
<div class="invoice-item">
{{ item_formset.empty_form.as_p }}
</div>
</template>
<button type="button" id="add-item">添加商品</button>
然后加入 JavaScript:
<script>
document.getElementById('add-item').addEventListener('click', function () {
const totalForms = document.getElementById('id_items-TOTAL_FORMS');
const formIndex = parseInt(totalForms.value, 10);
const template = document
.getElementById('empty-item-form')
.innerHTML
.replace(/__prefix__/g, formIndex);
document
.getElementById('invoice-items')
.insertAdjacentHTML('beforeend', template);
totalForms.value = formIndex + 1;
});
</script>
默认前缀通常取自 InvoiceItem.invoice 的 related_name。这里的值是 items,所以隐藏字段 ID 为 id_items-TOTAL_FORMS。如果显式设置了 prefix,模板和视图必须使用相同的前缀。
电商场景中的更合理流程
如果用户在浏览商品时逐个点击”加入”,通常不应每次都直接修改正式发票。更常见的流程是:
商品 → 购物车 → 确认价格和库存 → 创建订单 → 支付 → 生成发票
购物车既可以保存在 Session 中,也可以使用 Cart 和 CartItem 模型。用户结算时,再将购物车内容复制到 Invoice 和 InvoiceItem。
这样能明确区分两种状态:
- 购物车内容可以随时修改。
- 发票生成后,商品名称、数量、成交单价和税额应保存为历史快照。
因此,inlineformset_factory 适合后台录单或在单个页面中创建发票,购物车流程更适合面向消费者的 e-shop。无论采用哪种方式,最终都应使用相同的一对多数据结构。
备注:内容仅供参考。
