如何用 Django 从零搭建 ERP:数据模型、工作流与系统架构

August 24, 2026

对大多数公司来说,Odoo 和 ERPNext 已经能够很好地解决 ERP 的问题。如果你的业务流程能落在标准模块配置选项的 20% 之内,那么定制现有平台几乎总是比从零构建更划算——我们之前撰写的 ERPNext 实施指南、以及关于 ERP 项目为何失败的文章,正是基于这个原因。

但确实存在一类真实场景,定制开发反而更合适:你的核心业务流程本身就是竞争优势,无法映射到任何标准模块(比如需要按 FIFO 批次分配的大宗商品交易台、需要与地磅系统集成的仓储运营、拥有专有良率成本核算模型的制造商),或者你需要把 ERP 直接嵌入到你所销售的产品中,而不是作为内部工具使用。本文正是为这类场景而写:讨论的是如何在 Django 中真正搭建出 ERP 风格的系统结构,而不是"要不要自建"这个问题。

以下内容全部是架构与设计模式,而非一套完整可运行的系统——目标是帮你避开那些通常要等到第三、第四种业务单据类型出现时才会暴露的错误。

一、应用结构:按业务领域划分,而非按技术层划分

早期最常见的错误,是把所有模型都塞进一个单独的 core 应用里。从第一天起就按业务领域拆分——早期几乎不需要额外成本,却能省去后期的大规模重写:

flowchart TD
    A["parties<br/>Customer, Supplier, Contact"] --> E["sales<br/>Quotation, SalesOrder, Invoice"]
    A --> F["purchasing<br/>PurchaseOrder, Receipt, Bill"]
    B["inventory<br/>Item, Warehouse, StockLedgerEntry"] --> E
    B --> F
    C["accounting<br/>Account, GLEntry, Period"] --> E
    C --> F
    D["core<br/>SubmittableDocument, NamingSeries, AuditLog"] --> E
    D --> F
    D --> B
    D --> C

core 只存放共享的抽象基类,不包含任何业务逻辑。parties 将客户与供应商统一为同一个底层模型(现实世界中的大多数实体,在不同时期两种身份都可能兼有),这样可以避免销售与采购模块中地址、联系人逻辑重复的经典问题。

二、可提交单据(Submittable Document)模式

这是从 ERPNext 这类成熟 ERP 系统中值得"借鉴"的最有价值的模式,而 Django 并不会免费提供这个能力——你需要自己实现。核心思想是:业务单据(Sales Order、Invoice、Payment)在 draft → submitted → cancelled 之间流转,只有已提交的单据才被允许触发副作用(库存变动、总账过账)。草稿状态可以自由编辑,已提交的单据则基本不可再修改。

# core/models.py
from django.db import models
from django.core.exceptions import ValidationError

class DocStatus(models.IntegerChoices):
    DRAFT = 0, "Draft"
    SUBMITTED = 1, "Submitted"
    CANCELLED = 2, "Cancelled"

class SubmittableDocument(models.Model):
    docstatus = models.IntegerField(choices=DocStatus.choices, default=DocStatus.DRAFT)
    submitted_at = models.DateTimeField(null=True, blank=True)
    submitted_by = models.ForeignKey(
        "auth.User", null=True, blank=True, on_delete=models.PROTECT,
        related_name="+",
    )

    class Meta:
        abstract = True

    def save(self, *args, **kwargs):
        if self.pk:
            original = type(self).objects.get(pk=self.pk)
            if original.docstatus == DocStatus.SUBMITTED and self.docstatus == DocStatus.SUBMITTED:
                raise ValidationError("Submitted documents cannot be edited. Cancel and amend instead.")
        super().save(*args, **kwargs)

    def submit(self, user):
        if self.docstatus != DocStatus.DRAFT:
            raise ValidationError("Only draft documents can be submitted.")
        self.on_submit()
        self.docstatus = DocStatus.SUBMITTED
        self.submitted_by = user
        self.save()

    def on_submit(self):
        """Override in subclasses to post GL entries, move stock, etc."""
        raise NotImplementedError

每一个业务单据模型(SalesOrderPurchaseInvoicePayment)都继承 SubmittableDocument,并实现自己的 on_submit()。正是这一个模式,让系统拥有审计轨迹,而不是一张带删除权限的电子表格——也是团队用 Django 搭建类 ERP 系统时最容易跳过的一环,直到审计人员问起,为什么一张已过账发票的金额发生了变化。

三、把复式记账当作一等公民建模,而不是事后补丁

只要你的定制系统涉及资金,就应该从一开始把总账设计成不可变、只追加(append-only)的表。等到系统已经用可变的整数字段来追踪余额之后,再回过头去补上复式记账,那不是一次迁移,而是一次重写。

erDiagram
    ACCOUNT ||--o{ GL_ENTRY : posts_to
    PARTY ||--o{ SALES_INVOICE : billed_to
    SALES_INVOICE ||--|{ SALES_INVOICE_ITEM : contains
    SALES_INVOICE ||--o{ GL_ENTRY : generates
    PAYMENT ||--o{ GL_ENTRY : generates
    PAYMENT ||--o{ SALES_INVOICE : settles
# accounting/models.py
class GLEntry(models.Model):
    account = models.ForeignKey("Account", on_delete=models.PROTECT)
    debit = models.DecimalField(max_digits=18, decimal_places=2, default=0)
    credit = models.DecimalField(max_digits=18, decimal_places=2, default=0)
    posting_date = models.DateField()
    voucher_type = models.CharField(max_length=50)   # "Sales Invoice", "Payment", ...
    voucher_id = models.PositiveIntegerField()
    party = models.ForeignKey("parties.Party", null=True, on_delete=models.PROTECT)

    class Meta:
        indexes = [
            models.Index(fields=["account", "posting_date"]),
            models.Index(fields=["voucher_type", "voucher_id"]),
        ]

每一条 GLEntry 记录只在 on_submit() 中创建一次,此后永远不再更新——更正的方式是新增一条反向冲销分录,再加上一条正确的新分录,这与纸质账本的做法完全一致。这使得对账与审计变得可以直接追溯,也让"为什么我的资产负债表不平"从一场法务式的排查,变成一条可以直接执行的查询。

四、编号规则与并发陷阱

每一张单据都需要一个人类可读的编号(如 SO-2026-00042)。最朴素的做法——读取最后一个编号、加一、保存——在并发负载下就是一个竞态条件:同一秒内提交的两张 Sales Order 完全可能抢到同一个编号。应该使用专用的序列记录,并配合 select_for_update():

def get_next_number(series_key: str) -> str:
    with transaction.atomic():
        series, _ = NamingSeries.objects.select_for_update().get_or_create(
            key=series_key, defaults={"current": 0}
        )
        series.current += 1
        series.save()
        return f"{series_key}-{series.current:05d}"

这是一段很小的代码,一旦被省略,却会在生产环境中造成极不成比例的麻烦——重复的发票编号,是那种通常在审计过程中才会暴露、而不是在测试阶段就能发现的 bug。

五、审批工作流与状态

编号规则和 docstatus 覆盖的是提交/取消的生命周期;而审批链(草稿 → 待经理审批 → 已批准)是另一个层面的关注点,不应该硬编码进模型里。使用有限状态机库(django-fsm 或自行实现的等价方案),可以把状态迁移规则和权限校验集中在一处,而不是散落在各个视图里:

from django_fsm import FSMField, transition

class PurchaseRequisition(SubmittableDocument):
    state = FSMField(default="draft")

    @transition(field=state, source="draft", target="pending_approval")
    def request_approval(self):
        pass

    @transition(field=state, source="pending_approval", target="approved",
                permission=lambda instance, user: user.has_perm("purchasing.approve_requisition"))
    def approve(self):
        pass

用状态机处理人工审批工作流,用 docstatus 处理系统层面的草稿/提交/取消生命周期——把这两者混为一谈,是造成状态混乱、难以测试的常见根源。

六、把过账副作用异步化

一张 Sales Invoice 的 on_submit() 可能需要过账总账分录、更新库存、重新计算客户应收余额、并触发通知。如果把这一切都放在 HTTP 请求内同步执行,负载升高时"提交"按钮就会明显变慢——这正是我们此前针对 Odoo 和 ERPNext 撰写的 ERP 性能指南中提到的同一个问题,放在自建系统上同样成立。

应该做拆分:必须与提交事务保持原子性的部分(总账分录、库存台账——因为用户绝不应该在单据实际未过账的情况下看到"已提交"状态)保留在数据库事务内同步执行;而尽力而为即可的部分(邮件通知、PDF 生成、分析数据重新计算)交给 Celery:

def on_submit(self):
    with transaction.atomic():
        self._post_gl_entries()
        self._update_stock_ledger()
    send_invoice_notification.delay(self.id)   # Celery task, fire-and-forget

七、权限设计:默认基于角色,行级权限只在真正划算时才用

Django 内置的 Group/Permission 体系完全可以胜任"这个用户能否提交 Sales Order"这类判断。而行级权限("这个用户是否只能看到自己所属区域的 Sales Order")则是一个重得多的功能——无论是使用 django-guardian,还是在基础 manager 上编写自定义 queryset 过滤器,都可以实现,但你添加的每一条行级规则,都会在规模扩大时叠加成查询计划的额外开销,这与 Odoo/ERPNext 定制开发中常见的 N+1 和逐行评估陷阱是同一种性质的问题。默认使用基于角色的权限,只在真正有业务需求的具体模型上才添加行级作用域,而不是"图个安心"就到处都加。

八、使用 Django REST Framework 构建 API 层

如果 ERP 需要移动端应用、门户网站,或第三方系统集成(而大多数系统最终都会需要),就应该通过 DRF 而不仅仅是服务端渲染的视图来暴露单据的生命周期——这会迫使你在状态机与展示层之间做出更清晰的分离:

class SalesOrderViewSet(viewsets.ModelViewSet):
    queryset = SalesOrder.objects.all()
    serializer_class = SalesOrderSerializer

    @action(detail=True, methods=["post"])
    def submit(self, request, pk=None):
        order = self.get_object()
        order.submit(user=request.user)
        return Response(SalesOrderSerializer(order).data)

明细行(SalesOrderItem)可以很好地用 DRF 的可写嵌套序列化器来处理,但无论客户端传来什么数据,都应该在服务端重新校验合计金额——绝不能信任客户端计算出的明细小计或税额。

九、不采购成熟产品,意味着你要承担什么

自己构建意味着你从此拥有:永无止境的数据库迁移、安全补丁维护、ERPNext/Odoo 开箱即用就自带的报表构建工具,以及成熟平台早已踩过并修复过的每一个税务与舍入逻辑边界情况。选择自建的正当理由,应该是你的核心业务流程确实前所未有——而不是"Odoo 用起来太重"或"我们想要完全的掌控权"。如果真正的动机其实是对现有平台定制过程的痛苦,那么在下决心从零构建之前,值得重新翻看一下我们关于 ERP 项目为何失败的文章;相当一部分"我们需要定制开发"的讨论,最后发现其实只是配置问题。

写下第一个模型之前的检查清单

  1. 应用是否按业务领域而非技术层进行了划分?
  2. 每一个业务单据模型是否都有草稿/提交/取消的生命周期?已提交的单据是否真正不可修改?
  3. 如果有总账,它是否是只追加(append-only)的,修正是否通过反向冲销分录完成?
  4. 单据编号在并发提交下是否安全(使用 select_for_update,而不是"读取—递增—保存")?
  5. 审批工作流是否与提交/取消生命周期相互分离?
  6. 缓慢且非关键的副作用是否已经交给 Celery,而不是阻塞提交请求?
  7. 权限逻辑默认是否基于角色?行级规则是否只在有正当理由的地方才添加?
  8. 是否已经再次确认过,现有平台(Odoo、ERPNext)不能已经覆盖了这其中 80% 的需求?

来源:

Ready to talk about your project?

Share goals and constraints. We'll assemble architects and engineers to move fast with you.

Get in touch