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 ถึงล้มเหลว ก่อนตัดสินใจสร้างใหม่ทั้งหมด เพราะการสนทนาแบบ "เราต้องการความกำหนดเอง" จำนวนไม่น้อยกลับกลายเป็นปัญหาเรื่องการกำหนดค่าเท่านั้น
รายการตรวจสอบก่อนเขียนโมเดลแรกของคุณ
- คุณแยกแอปตามโดเมนธุรกิจ ไม่ใช่ตามชั้นเทคนิคแล้วหรือยัง?
- โมเดลธุรกรรมทุกตัวมีวงจรชีวิต draft/submit/cancel หรือไม่ และเอกสารที่ submit แล้วไม่สามารถแก้ไขได้จริงหรือไม่?
- บัญชีแยกประเภท (ถ้ามี) เป็นแบบ append-only หรือไม่ โดยแก้ไขผ่าน reversing entry?
- การกำหนดเลขที่เอกสารปลอดภัยภายใต้การ submit พร้อมกันหรือไม่ (
select_for_updateไม่ใช่ อ่าน-บวก-บันทึก)? - เวิร์กโฟลว์การอนุมัติแยกออกจากวงจรชีวิต submit/cancel หรือไม่?
- ผลข้างเคียงที่ช้าและไม่สำคัญเร่งด่วนถูกส่งไปที่ Celery แทนที่จะบล็อกคำขอ submit หรือไม่?
- ตรรกะสิทธิ์เป็น role-based เป็นค่าเริ่มต้นหรือไม่ และมีการเพิ่มกฎ row-level เฉพาะที่มีเหตุผลรองรับเท่านั้นหรือไม่?
- คุณได้ยืนยันอีกครั้งแล้วหรือยังว่าไม่มีแพลตฟอร์มที่มีอยู่แล้ว (Odoo, ERPNext) ที่ทำสิ่งนี้ได้ 80% อยู่แล้ว?
แหล่งที่มา:
- Django Documentation — Model Meta options
- Django Documentation — select_for_update
- django-fsm — GitHub
- Django REST Framework — Writable nested serializers
บทความล่าสุด
- การลงทะเบียน Passkey คือช่องโหว่ใหม่ขององค์กรคุณ: บทเรียนจาก Pass-the-Passkey สำหรับผู้ดูแล IdP 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