Djangoでゼロから作るERP:データモデル、ワークフロー、アーキテクチャ

August 24, 2026

OdooやERPNextは、ほとんどの企業にとってERPの課題を十分に解決してくれます。自社の業務プロセスが標準モジュールの設定項目の20%程度に収まるのであれば、既存プラットフォームをカスタマイズするほうが、ゼロから構築するよりもほぼ確実に安上がりです——私たちがERPNextの導入ガイドやERPプロジェクトが失敗する理由について記事を書いてきたのも、まさにこの理由からです。

しかし実際には、独自開発が勝るケースも一定数存在します。自社のコアプロセス自体が競争優位性そのものであり、標準モジュールにマッピングできない場合(FIFOロット引当が必要なコモディティトレーディングデスク、計量器連携が必要なデポ運営、独自の歩留まり原価計算モデルを持つ製造業者など)、あるいはERPを社内ツールとしてではなく、販売する製品自体に組み込む必要がある場合です。本稿はそうしたケースのためのものです——「作るべきか」ではなく、Djangoで実際にERPライクなシステムをどう構造化するかを扱います。

以下はすべてアーキテクチャとパターンであり、そのまま動く完成システムではありません。目的は、業務ドキュメントの種類が3つ目、4つ目に増えた頃になって初めて表面化するような失敗を、あらかじめ避けてもらうことです。

1. アプリ構成:技術層ではなく業務ドメインで分割する

初期段階でもっとも多い間違いは、すべてのモデルを単一の 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 は顧客と仕入先を一つの基底モデルに統合します(実世界のエンティティの多くは、時期によってその両方の立場を取り得るため)。これにより、販売と購買でアドレス・連絡先ロジックが重複するという典型的な問題を避けられます。

2. Submit可能なドキュメントパターン

これはERPNextのような成熟したERPから盗むべき最も価値のあるパターンであり、Djangoが標準で提供してくれるものではないため、自分で構築する必要があります。核となる考え方は、取引系ドキュメント(Sales Order、Invoice、Payment)が draft → submitted → cancelled という状態を遷移し、submit済みのドキュメントだけが副作用(在庫移動、GL記帳)を起こせるようにすることです。下書きは自由に編集でき、submit済みのドキュメントは事実上変更不可になります。

# 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でこの種のシステムを構築するチームが最も見落としがちな部分——監査担当者から「提出済みの請求書の金額がなぜ変わったのか」と聞かれるまで気づかれないことが多い部分です。

3. 複式簿記を後付けではなく第一級のモデルとして扱う

カスタムシステムが少しでも金銭を扱うなら、最初から総勘定元帳(GL)を不変かつ追記専用(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() の中で一度だけ作成され、二度と更新されません——修正は紙の元帳と同じように、反対仕訳と新しい正しい仕訳の追加によって行います。これにより照合や監査の追跡が容易になり、「なぜ貸借対照表が合わないのか」という問題が、フォレンジック調査ではなく解決可能なクエリになります。

4. 採番とシーケンス、そして並行処理の落とし穴

すべてのドキュメントには人間が読める識別子(SO-2026-00042)が必要です。最後の番号を読み取り、インクリメントして保存するという素朴な方法は、同時実行負荷下では競合状態(レースコンディション)を引き起こします。同じ秒に提出された2つの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}"

これは省略すると本番環境で不釣り合いなほど大きな痛みを生む小さなコードです。重複した請求書番号は、テスト中ではなく監査の最中に表面化する類のバグです。

5. ワークフローと承認ステータス

Naming Seriesとdocstatusはsubmit/cancelのライフサイクルをカバーしますが、承認チェーン(下書き→マネージャー承認待ち→承認済み)は別の関心事であり、モデルにハードコードすべきではありません。有限状態機械(FSM)ライブラリ(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

人間による承認ワークフローにはFSMを、システムレベルのdraft/submit/cancelライフサイクルには docstatus を使い分けましょう。この2つを混同することは、わかりにくく、テストしづらい状態を生む典型的な原因です。

6. 副作用の非同期化

Sales Invoiceの on_submit() は、GL記帳、在庫更新、顧客の未回収残高の再計算、通知の送信などを行う必要があるかもしれません。これらすべてをHTTPリクエスト内で同期的に処理すると、負荷がかかったときに「Submit」ボタンが重く感じられる原因になります——これは、私たちがOdooとERPNext向けに公開したERPパフォーマンスガイドで取り上げた問題と同じで、自作システムにもそのまま当てはまります。

処理を分割しましょう。submitトランザクションと不可分でなければならない部分(GL記帳、在庫元帳——実際には記帳されていないのに「Submitted」と表示されるのはユーザーにとって致命的だからです)はデータベーストランザクション内で同期的に保ち、ベストエフォートで構わない部分(メール通知、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

7. 権限管理:まずロールベース、行レベルはコストに見合う場合のみ

Django標準の Group/Permission の仕組みで、「このユーザーはSales Orderをsubmitできるか」という判定は十分にこなせます。一方、行レベルの権限(「このユーザーは自分の担当地域のSales Orderだけを見られるか」)ははるかに重い機能です。django-guardian や、ベースマネージャーに独自のqueryset フィルタを実装する方法のどちらでも実現できますが、追加する行レベルルール一つひとつが、規模拡大時に積み重なるクエリプランのコストになります——これはOdoo/ERPNextのカスタマイズで見られるN+1や行ごとの評価の罠と同じ構造です。デフォルトはロールベースにし、行レベルのスコープは、それが実際にビジネス上必要なモデルにだけ限定して追加しましょう。「念のため」あらゆる場所に入れるべきではありません。

8. 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のwritable nested serializerでうまくシリアライズできますが、クライアントから送られてくる値にかかわらず、合計金額はサーバー側で必ず検証してください——クライアント側で計算された明細合計や税額を信用してはいけません。

9. 既製品を使わないことで背負うことになるもの

自作するということは、以降ずっとマイグレーション、セキュリティパッチ適用、そしてERPNext/Odooが最初から備えているレポート作成ツール、さらには成熟したプラットフォームがすでに遭遇し修正済みの税務・端数処理のあらゆるエッジケースを自分で抱え続けることを意味します。自作を選ぶ正当な理由は、自社のコアワークフローが本当に前例のないものであることであり、「Odooは重く感じた」「完全にコントロールしたい」といった理由ではありません。もし本当の動機が既存プラットフォームのカスタマイズの苦労にあるのなら、ゼロからの構築に踏み切る前に「ERPプロジェクトが失敗する理由」を見直す価値があります。「カスタマイズが必要だ」という会話の少なからぬ部分が、実は設定の問題に過ぎないことが驚くほど多いものです。

最初のモデルを書く前のチェックリスト

  1. アプリは技術層ではなく業務ドメインごとに分割されているか?
  2. すべての取引系モデルにdraft/submit/cancelのライフサイクルがあり、submit済みドキュメントは実際に変更不可になっているか?
  3. (もしあれば)元帳はappend-onlyになっており、修正は反対仕訳で行われているか?
  4. ドキュメント採番は同時submitに対して安全か(読み取り→インクリメント→保存ではなく select_for_update)?
  5. 承認ワークフローはsubmit/cancelのライフサイクルと分離されているか?
  6. 遅くて重要度の低い副作用は、submitリクエストをブロックせず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