วิธีสร้างระบบ ERP ตั้งแต่ต้นด้วย Django: โมเดลข้อมูล เวิร์กโฟลว์ และสถาปัตยกรรม

August 24, 2026

Odoo และ ERPNext แก้ปัญหา ERP ให้กับบริษัทส่วนใหญ่ได้อยู่แล้ว หากกระบวนการทำงานของคุณครอบคลุมอยู่ในตัวเลือกการกำหนดค่าของโมดูลมาตรฐานสัก 20% การปรับแต่งแพลตฟอร์มที่มีอยู่แล้วมักจะถูกกว่าการสร้างใหม่เสมอ — เราเคยเขียนเรื่องการติดตั้งใช้งาน ERPNext และทำไมโครงการ ERP ถึงล้มเหลว ไว้ด้วยเหตุผลนี้เอง

แต่ก็มีบางกรณีจริงๆ ที่การสร้างระบบกำหนดเองชนะ นั่นคือเมื่อกระบวนการหลักของคุณ คือ ความได้เปรียบทางการแข่งขัน และไม่สามารถแมปเข้ากับโมดูลมาตรฐานใดๆ ได้เลย (เช่น โต๊ะซื้อขายสินค้าโภคภัณฑ์ที่ต้องจัดสรรล็อตแบบ FIFO การดำเนินงานคลังสินค้าที่ต้องเชื่อมกับเครื่องชั่งรถบรรทุก หรือโรงงานที่มีโมเดลคำนวณต้นทุนผลผลิตแบบเฉพาะตัว) หรือคุณต้องฝัง ERP เข้าไปในผลิตภัณฑ์ที่คุณขาย แทนที่จะใช้เป็นเครื่องมือภายในเท่านั้น คู่มือนี้เขียนขึ้นสำหรับกรณีนั้น — วิธีจัดโครงสร้างระบบแบบ ERP ใน Django จริงๆ ไม่ใช่คำถามว่า "ควรทำหรือไม่"

เนื้อหาด้านล่างทั้งหมดเป็นสถาปัตยกรรมและรูปแบบการออกแบบ ไม่ใช่ระบบที่ใช้งานได้สมบูรณ์ — เป้าหมายคือช่วยให้คุณหลีกเลี่ยงข้อผิดพลาดที่มักจะโผล่มาให้เห็นก็ต่อเมื่อคุณสร้างเอกสารทางธุรกิจประเภทที่สามหรือสี่แล้วเท่านั้น

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 ได้

นี่คือรูปแบบที่มีค่าที่สุดที่ควร "ยืม" มาจาก ERP ที่เติบโตเต็มที่แล้วอย่าง ERPNext และ 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

โมเดลธุรกรรมทุกตัว (SalesOrder, PurchaseInvoice, Payment) จะสืบทอด SubmittableDocument และ implement on_submit() เอง รูปแบบเดียวนี้คือสิ่งที่ทำให้คุณมี audit trail แทนที่จะเป็นสเปรดชีตที่มีสิทธิ์ลบได้ — และเป็นส่วนที่ทีมงานมักข้ามไปเวลาสร้างระบบใกล้เคียง ERP ด้วย Django จนกระทั่งผู้ตรวจสอบบัญชีถามว่าทำไมยอดเงินในใบแจ้งหนี้ที่ผ่านการโพสต์แล้วถึงเปลี่ยนไป

3. บัญชีคู่ในฐานะโมเดลระดับหนึ่ง ไม่ใช่ของแถม

หากระบบกำหนดเองของคุณเกี่ยวข้องกับเงินไม่ว่าจะแบบใด ให้ออกแบบบัญชีแยกประเภททั่วไป (general ledger) เป็นตารางที่ไม่เปลี่ยนแปลงและเพิ่มได้อย่างเดียว (append-only) ตั้งแต่ต้น การย้อนกลับมาใส่ระบบบัญชีคู่ในระบบที่เคยติดตามยอดคงเหลือด้วยฟิลด์ตัวเลขที่แก้ไขได้ ไม่ใช่การ migrate แต่คือการเขียนใหม่ทั้งหมด

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() และไม่มีการอัปเดตอีกเลย — การแก้ไขทำผ่านรายการกลับรายการ (reversing entry) บวกกับรายการใหม่ที่ถูกต้อง เช่นเดียวกับที่สมุดบัญชีกระดาษต้องทำ วิธีนี้ทำให้การกระทบยอดและการตรวจสอบสามารถสืบย้อนได้ง่าย และทำให้คำถาม "ทำไมงบดุลของฉันไม่สมดุล" กลายเป็นคิวรีที่แก้ได้ แทนที่จะเป็นการสืบสวนทางนิติเวช

4. การกำหนดเลขที่เอกสารและกับดักเรื่อง concurrency

ทุกเอกสารต้องการรหัสที่มนุษย์อ่านได้ (SO-2026-00042) วิธีที่ไร้เดียงสาที่สุด — อ่านเลขล่าสุด บวกหนึ่ง แล้วบันทึก — คือ race condition ภายใต้โหลดที่เกิดขึ้นพร้อมกัน Sales Order สองใบที่ submit ในวินาทีเดียวกันอาจได้เลขเดียวกัน ควรใช้แถวลำดับเฉพาะร่วมกับ 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 แต่สายการอนุมัติ (ฉบับร่าง → รอผู้จัดการอนุมัติ → อนุมัติแล้ว) เป็นเรื่องแยกต่างหาก และไม่ควร hardcode ไว้ในโมเดลของคุณ ไลบรารี finite-state-machine (django-fsm หรือสิ่งที่เขียนขึ้นเอง) ช่วยให้กฎการเปลี่ยนสถานะและการตรวจสอบสิทธิ์อยู่ในที่เดียว แทนที่จะกระจัดกระจายอยู่ใน view ต่างๆ:

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 สำหรับเวิร์กโฟลว์การอนุมัติของมนุษย์ และใช้ docstatus สำหรับวงจรชีวิตระดับระบบ draft/submit/cancel — การปนกันสองอย่างนี้เป็นสาเหตุทั่วไปของสถานะที่สับสนและทดสอบยาก

6. การบันทึกผลข้างเคียงแบบอะซิงโครนัส

on_submit() ของ Sales Invoice อาจต้องบันทึก GL entry อัปเดตสต๊อก คำนวณยอดค้างชำระของลูกค้าใหม่ และส่งการแจ้งเตือน การทำทั้งหมดนี้แบบ synchronous ภายใน HTTP request คือสิ่งที่ทำให้ปุ่ม "Submit" รู้สึกช้าภายใต้โหลดสูง — ปัญหาเดียวกับที่เราเคยกล่าวถึงในคู่มือประสิทธิภาพ ERP สำหรับ Odoo และ ERPNext ก็เกิดขึ้นกับระบบที่คุณสร้างเองเช่นกัน

ควรแยกส่วน: ส่วนที่ต้อง atomic ไปกับ transaction การ submit (GL entry, stock ledger — เพราะผู้ใช้ไม่ควรเห็นสถานะ "Submitted" บนเอกสารที่จริงๆ แล้วไม่ได้ถูกโพสต์) ให้คงเป็น synchronous อยู่ภายใน database transaction ส่วนที่เป็น best-effort (การแจ้งเตือนทางอีเมล การสร้าง PDF การคำนวณ analytics ใหม่) ให้ส่งไปที่ 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. สิทธิ์การเข้าถึง: เริ่มจาก role-based ก่อน แล้วค่อยใช้ row-level เฉพาะที่คุ้มค่า

ระบบ Group/Permission ที่มากับ Django จัดการเรื่อง "ผู้ใช้คนนี้ submit Sales Order ได้ไหม" ได้ดีอยู่แล้ว ส่วนสิทธิ์ระดับแถว ("ผู้ใช้คนนี้เห็น เฉพาะ Sales Order ในเขตของตัวเองไหม") เป็นฟีเจอร์ที่หนักกว่ามาก — จะใช้ django-guardian หรือฟิลเตอร์ queryset ที่เขียนเองบน manager พื้นฐานก็ได้ทั้งคู่ แต่ทุกกฎ row-level ที่คุณเพิ่มเข้าไปคือต้นทุนของ query plan ที่จะทบต้นเมื่อขยายขนาด ตามกับดักแบบ N+1 และการประเมินทีละแถวแบบเดียวกับที่เกิดในการปรับแต่ง Odoo/ERPNext ควรตั้งค่าเริ่มต้นเป็น role-based และเพิ่ม row-level scoping เฉพาะกับโมเดลที่มันเป็นความต้องการทางธุรกิจจริงๆ ไม่ใช่ใส่ไว้ทุกที่ "เผื่อไว้ก่อน"

8. ชั้น API ด้วย Django REST Framework

หาก ERP ต้องการแอปมือถือ พอร์ทัล หรือการเชื่อมต่อกับบุคคลที่สาม (และส่วนใหญ่ในที่สุดก็ต้องการ) ให้เปิดเผยวงจรชีวิตของเอกสารผ่าน DRF แทนที่จะใช้เฉพาะ view ที่เรนเดอร์จากฝั่งเซิร์ฟเวอร์เท่านั้น — วิธีนี้บังคับให้เกิดการแยกที่ชัดเจนขึ้นระหว่าง state machine กับชั้นการแสดงผล:

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) สามารถ serialize ได้ดีด้วย writable nested serializer ของ DRF แต่ควร validate ยอดรวมที่ฝั่งเซิร์ฟเวอร์เสมอ ไม่ว่าไคลเอนต์จะส่งอะไรมาก็ตาม — อย่าไว้ใจยอดรวมรายการหรือจำนวนภาษีที่คำนวณจากฝั่งไคลเอนต์เด็ดขาด

9. สิ่งที่คุณต้องรับผิดชอบเมื่อเลือกไม่ซื้อระบบสำเร็จรูป

การสร้างระบบเองหมายความว่าตอนนี้คุณเป็นเจ้าของ: migration ตลอดไป การแพตช์ด้านความปลอดภัย เครื่องมือสร้างรายงานที่ ERPNext/Odoo มีมาให้ตั้งแต่แรก และกรณีขอบเขต (edge case) ทุกแบบในตรรกะภาษีและการปัดเศษที่แพลตฟอร์มที่เติบโตเต็มที่แล้วเจอและแก้ไปแล้ว เหตุผลที่ซื่อสัตย์ในการสร้างระบบเองคือกระบวนการหลักของคุณแปลกใหม่จริงๆ — ไม่ใช่เพราะ "Odoo รู้สึกหนักไป" หรือ "เราอยากควบคุมเต็มที่" หากแรงจูงใจที่แท้จริงคือความเจ็บปวดจากการปรับแต่งแพลตฟอร์มที่มีอยู่ ควรกลับไปอ่านทำไมโครงการ ERP ถึงล้มเหลว ก่อนตัดสินใจสร้างใหม่ทั้งหมด เพราะการสนทนาแบบ "เราต้องการความกำหนดเอง" จำนวนไม่น้อยกลับกลายเป็นปัญหาเรื่องการกำหนดค่าเท่านั้น

รายการตรวจสอบก่อนเขียนโมเดลแรกของคุณ

  1. คุณแยกแอปตามโดเมนธุรกิจ ไม่ใช่ตามชั้นเทคนิคแล้วหรือยัง?
  2. โมเดลธุรกรรมทุกตัวมีวงจรชีวิต draft/submit/cancel หรือไม่ และเอกสารที่ submit แล้วไม่สามารถแก้ไขได้จริงหรือไม่?
  3. บัญชีแยกประเภท (ถ้ามี) เป็นแบบ append-only หรือไม่ โดยแก้ไขผ่าน reversing entry?
  4. การกำหนดเลขที่เอกสารปลอดภัยภายใต้การ submit พร้อมกันหรือไม่ (select_for_update ไม่ใช่ อ่าน-บวก-บันทึก)?
  5. เวิร์กโฟลว์การอนุมัติแยกออกจากวงจรชีวิต submit/cancel หรือไม่?
  6. ผลข้างเคียงที่ช้าและไม่สำคัญเร่งด่วนถูกส่งไปที่ Celery แทนที่จะบล็อกคำขอ submit หรือไม่?
  7. ตรรกะสิทธิ์เป็น role-based เป็นค่าเริ่มต้นหรือไม่ และมีการเพิ่มกฎ row-level เฉพาะที่มีเหตุผลรองรับเท่านั้นหรือไม่?
  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