PAYATHON 2026

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)

保存时按以下顺序处理:

  1. 验证 InvoiceForm。
  2. 验证所有 InvoiceItemForm。
  3. 保存 Invoice 并取得主键。
  4. 将该 Invoice 设置为 Formset 的 instance。
  5. 保存每一条 InvoiceItem。
  6. 使用数据库事务,让整张发票要么全部保存,要么全部回滚。

如果不使用 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。无论采用哪种方式,最终都应使用相同的一对多数据结构。

备注:内容仅供参考。