对大多数公司来说,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
每一个业务单据模型(SalesOrder、PurchaseInvoice、Payment)都继承 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 项目为何失败的文章;相当一部分"我们需要定制开发"的讨论,最后发现其实只是配置问题。
写下第一个模型之前的检查清单
- 应用是否按业务领域而非技术层进行了划分?
- 每一个业务单据模型是否都有草稿/提交/取消的生命周期?已提交的单据是否真正不可修改?
- 如果有总账,它是否是只追加(append-only)的,修正是否通过反向冲销分录完成?
- 单据编号在并发提交下是否安全(使用
select_for_update,而不是"读取—递增—保存")? - 审批工作流是否与提交/取消生命周期相互分离?
- 缓慢且非关键的副作用是否已经交给 Celery,而不是阻塞提交请求?
- 权限逻辑默认是否基于角色?行级规则是否只在有正当理由的地方才添加?
- 是否已经再次确认过,现有平台(Odoo、ERPNext)不能已经覆盖了这其中 80% 的需求?
来源:
- Django Documentation — Model Meta options
- Django Documentation — select_for_update
- django-fsm — GitHub
- Django REST Framework — Writable nested serializers
最新文章
- 通行密钥的「注册」才是新的攻击面:Pass-the-Passkey 对身份提供商配置意味着什么 August 22, 2026
- 你的攻击者可能已经是一个 AI Agent 了:OpenAI 与 Hugging Face 事件给 SOC 团队的启示 August 17, 2026
- OCPI 2.2.1 实现指南:如何构建 Locations、Sessions 与 CDR 模块 August 7, 2026
- OCPI 详解:CPO 与 eMSP 要实现电动车漫游充电,究竟需要搭建什么 August 7, 2026
- 内陆的海鲈鱼:为远离海洋的海水鱼搭建自动投喂系统 July 31, 2026
- 你的车间在说五种方言:为什么单靠OPC UA解决不了协议碎片化问题 July 30, 2026