เรียนรู้ 5 กลไกการตั้งชื่อเอกสารใน Frappe Framework ตั้งแต่ naming_series, Document Naming Rules, override_doctype_class ไปจนถึง Custom Variables รองรับปี พ.ศ.

การตั้งชื่อเอกสาร (Document Naming) เป็นหัวใจของระบบ ERP — เลขที่ใบแจ้งหนี้ เลขที่ใบสั่งซื้อ หรือเลขที่เอกสารภายใน ล้วนต้องถูกต้อง ไม่ซ้ำกัน และเป็นไปตามรูปแบบที่ธุรกิจกำหนด บทความนี้เป็นคู่มือฉบับสมบูรณ์ที่รวม 5 กลไก Naming Series ใน Frappe Framework — ตั้งแต่พื้นฐานจนถึง Advanced Pattern อย่าง Buddhist Era Year, Conditional Naming ตาม Department และ Continuous Counter ที่ไม่ Reset
สำหรับธุรกิจไทยที่ใช้ ERPNext ความต้องการตั้งชื่อเอกสารมักซับซ้อนกว่าค่า Default:
26-04-0001 (ปี-แผนก IT-ลำดับ)Frappe มีกลไกหลายระดับให้เลือกใช้ — แต่ละกลไกเหมาะกับ Use Case ต่างกัน:
เมื่อสร้างเอกสารใหม่ Frappe จะหาชื่อตามลำดับนี้:
| ลำดับ | กลไก | คำอธิบาย |
|---|---|---|
| 1 (สูงสุด) | override_doctype_class + autoname() | Method ในคลาสลูก กำหนด self.name โดยตรง |
| 2 | Document Naming Rules | Prefix + Counter ตาม Conditions |
| 3 | autoname property บน DocType | เช่น naming_series:, field:fieldname, hash |
| 4 (ต่ำสุด) | naming_series field value | User เลือก Series จาก Dropdown |
คำเตือนสำคัญ:
doc_eventsautoname hook ไม่ทำงานอย่างน่าเชื่อถือ สำหรับ DocType ที่มีautoname = "naming_series:"— naming_series จะเข้ามาแทนที่ ใช้override_doctype_classเสมอ
naming_series_variables HookFrappe มี Built-in Variables สำหรับ Naming Series เช่น YY, MM, DD แต่สำหรับธุรกิจไทยที่ต้องใช้ ปีพุทธศักราช จำเป็นต้องสร้าง Custom Variables เพิ่ม
| Token | ตัวอย่าง | แหล่งข้อมูล |
|---|---|---|
#### | 0001 (auto-increment) | Counter ต่อ Prefix |
YY | 26 | วันที่ปัจจุบัน |
YYYY | 2026 | วันที่ปัจจุบัน |
MM | 02 | วันที่ปัจจุบัน |
DD | 28 | วันที่ปัจจุบัน |
WW | 09 | สัปดาห์ปัจจุบัน |
FY | 2025-2026 | ปีการเงิน ERPNext |
{fieldname} | field value | ค่า Field บนเอกสาร |
ลงทะเบียนใน hooks.py ของ Base App (App ที่ใช้ร่วมกันทุกบริษัท):
# hooks.py (ใน base/global app เช่น thai_business_suite)
naming_series_variables = {
"BEYY": "thai_business_suite.overrides.naming.parse_buddhist_year",
"DBEYY": "thai_business_suite.overrides.naming.parse_document_buddhist_year",
}ใช้ Convention เดียวกับ D prefix ของ TBS:
BEYY = ปี พ.ศ. จาก วันที่ปัจจุบัน (เหมือน YY ของ Frappe)DBEYY = ปี พ.ศ. จาก วันที่บนเอกสาร (เหมือน DYY ของ TBS)สำหรับเอกสารย้อนหลัง (Backdated Documents) ให้ใช้
DBEYYเสมอ เพราะจะดึงวันที่จากposting_dateหรือtransaction_dateของเอกสาร
Parts ที่คั่นด้วย . (จุด) จะถูกประมวลผลแยกกันแล้วต่อกัน:
DYY.-02-.##### → parts: ["DYY", "-02-", "#####"]
→ "26" + "-02-" + "00001" = "26-02-00001"
TS.DYY.DMM.#### → parts: ["TS", "DYY", "DMM", "####"]
→ "TS" + "26" + "02" + "0001" = "TS26020001"
XS.BEYY./.#### → parts: ["XS", "BEYY", "/", "####"]
→ "XS" + "69" + "/" + "0001" = "XS69/0001"
สำคัญมาก: Counter
####ต้องคั่นด้วย.จาก Parts อื่นDMM####จะไม่ทำงาน (ถูกมองเป็น Literal String)
ใช้เมื่อ DocType เดียวกันต้องมี หลาย Naming Pattern ตามค่า Field บนเอกสาร เช่น Material Request ที่แต่ละแผนกใช้เลขที่ต่างกัน
Document Naming Rule แต่ละ Rule ประกอบด้วย:
document_type — DocType เป้าหมายprefix — Pattern ของ Naming Series (รองรับ Custom Variables)prefix_digits — จำนวนหลักของ Counterpriority — Rule ที่ Priority สูงกว่าจะถูกตรวจก่อนconditions — เงื่อนไข Field=Value ที่ต้อง Matchcounter — ค่า Counter ปัจจุบัน (Auto-increment)สร้าง Rule สำหรับแต่ละแผนก:
ผลลัพธ์: 26-01-0001 (แผนก Safety, ปี 2026), 26-04-0001 (แผนก IT, ปี 2026)
ใช้ Priority ต่ำกว่าและไม่มี Conditions — สำหรับเอกสารที่ไม่ Match กับ Rule ใด:
ข้อควรระวัง: Counter ของ Document Naming Rules จะ Reset เมื่อ Prefix เปลี่ยน เช่น เมื่อขึ้นปีใหม่ DYY เปลี่ยนจาก 25 เป็น 26 → Counter เริ่มที่ 1 ใหม่
ถ้าต้องการ Counter ที่ไม่ Reset → ใช้กลไกที่ 3
override_doctype_class (Continuous Counter)ใช้เมื่อ Counter ต้อง ไม่ Reset ตลอดไป — เช่น เลขที่ใบกำกับภาษีตามกฎหมายไทยที่ต้องวิ่งต่อเนื่อง ไม่ข้ามเลข แม้เปลี่ยนปี
override_doctype_class?| วิธี | ทำงานกับ naming_series: meta? | ลักษณะ |
|---|---|---|
override_doctype_class + autoname(self) | ใช่ (การันตี) | Frappe เรียก self.autoname() โดยตรง |
doc_events autoname hook | ไม่ (ไม่น่าเชื่อถือ) | naming_series เข้ามาแทนที่ |
| Document Naming Rules | ใช่ | แต่ Counter Reset ตาม Prefix |
# hooks.py (ใน custom app)
override_doctype_class = {
"Sales Invoice": "your_app.custom.sales_invoice.CustomSalesInvoice",
}FOR UPDATE ล็อค Row ป้องกัน Race Condition เมื่อสร้างเอกสารพร้อมกันหลายคนtabSeries เป็นตาราง Built-in ของ Frappe ที่ออกแบบมาสำหรับ Atomic Countersuper().autoname() ให้ Return Invoice กลับไปใช้ naming_series ปกติสำหรับ DocType ที่ใช้ Naming Pattern เดียว และไม่ต้องการ Logic พิเศษ เช่น Purchase Order ที่ใช้ PO.DYY.DMM.#### ทุกกรณี
/app/document-naming-settingsข้อสำคัญ: หน้านี้ควรจัดการโดย Base/Global App — ไม่ควรให้ Custom App แต่ละตัวแก้ไข
| สถานการณ์ | กลไกที่เหมาะ | ที่ไหน |
|---|---|---|
| Naming Pattern เดียวต่อ DocType | Document Naming Settings UI | ตั้งค่ามือ (ไม่ต้องเขียนโค้ด) |
| หลาย Pattern ตามค่า Field | Document Naming Rules | Setup Script ใน Custom App |
| Counter ต่อเนื่อง (ไม่ Reset) | override_doctype_class | Custom App |
| ตัวแปรใหม่ (เช่น DBEYY) | naming_series_variables hook | Base/Global App |
Document Naming Rules ใช้ Conditions ได้เฉพาะ Field ที่อยู่ บนเอกสารนั้น ถ้าต้องการ Naming ตามค่า Field จาก เอกสารที่เชื่อมโยง (เช่น Pick List ต้องใช้ order_type จาก Sales Order) ต้องเพิ่ม Custom Field แล้ว Auto-populate
ตอนนี้ Document Naming Rules สามารถใช้ custom_order_type เป็น Condition ได้
หลักการ:
ใช้ MANAGED_PREFIXES list เพื่อระบุ Rules ที่ App สร้างไว้ สำหรับลบตอน Uninstall:
หมายเหตุ: Document Naming Rule ไม่มี column
description— ไม่สามารถใช้ description เพื่อระบุ Rules ของ App ได้ ต้องใช้MANAGED_PREFIXESแทน
doc_events autoname ไม่ทำงาน ทำไม?ถ้า DocType มี autoname = "naming_series:" ใน Meta ระบบ naming_series จะเข้ามาทำงานก่อน doc_events autoname hook ทำให้ Hook ไม่ถูกเรียก ใช้ override_doctype_class กับ autoname(self) method แทน — Frappe จะเรียก method นี้โดยตรงบน Class Instance ทุกครั้ง
Document Naming Rules reset counter เมื่อ Prefix เปลี่ยน (เช่น ปีใหม่ DYY เปลี่ยนค่า → counter เริ่ม 1 ใหม่) สำหรับ Counter ที่ต่อเนื่องตลอดไป ใช้ override_doctype_class กับ tabSeries (กลไกที่ 3)
ปฏิทินไทยบวก 543 กับ ค.ศ.: 2026 CE = 2569 BE สำหรับ 2 หลัก: str(year + 543)[-2:] → "69"
การเปลี่ยน naming_series_variables, override_doctype_class, หรือ doc_events ต้อง restart server ทุกครั้ง bench clear-cache อย่างเดียวไม่พอ
/ ในชื่อเอกสารได้ไหม?ได้ Frappe รองรับ / ในชื่อเอกสาร (เช่น XS69/0001) URL encoding ถูก handle อัตโนมัติ
Custom Variable Parser ได้รับ doc=None ตอน Preview ใน UI ต้อง Handle กรณีนี้ด้วยการ return ค่า placeholder จากวันที่ปัจจุบัน
| ปัญหา | อาการ | วิธีแก้ |
|---|---|---|
doc_events autoname ไม่ทำงาน | ได้ naming_series ปกติ | ใช้ override_doctype_class แทน |
| Counter reset ทุกปี | เลขที่เริ่ม 0001 ตอนขึ้นปีใหม่ | ใช้ override_doctype_class + tabSeries |
DMM#### ไม่ทำงาน | ได้ literal string DMM#### | ต้องใช้ DMM.#### (คั่นด้วยจุด) |
| Custom Variable ไม่ทำงาน | ได้ literal string เช่น BEYY | ตรวจ hooks.py, restart server |
naming_series_variables ซ้ำกัน | ขัดแย้งระหว่าง Apps | ใส่ไว้ใน Base App ที่เดียว |
| Hook เปลี่ยนแล้วไม่เห็นผล | ค่าเก่ายังใช้อยู่ | bench restart (ไม่ใช่แค่ clear-cache) |
| Document Naming Rule ไม่มี description column | Error ตอน filter | ใช้ MANAGED_PREFIXES list แทน |
| Preview แสดง Error | Custom variable crash ตอน doc=None | Handle if not doc: ใน Parser |
# overrides/naming.py
from datetime import datetime
def parse_buddhist_year(doc, pattern):
"""BEYY → ปี พ.ศ. 2 หลักจากวันที่ปัจจุบัน
2026 CE → 2569 BE → '69'"""
be_year = datetime.now().year + 543
return str(be_year)[-2:]
def parse_document_buddhist_year(doc, pattern):
"""DBEYY → ปี พ.ศ. 2 หลักจากวันที่บนเอกสาร
2026 CE → 2569 BE → '69'"""
if not doc:
# Preview mode (หน้า Document Naming Settings)
be_year = datetime.now().year + 543
return str(be_year)[-2:]
doc_date = get_document_date(doc)
if not doc_date:
be_year = datetime.now().year + 543
return str(be_year)[-2:]
be_year = doc_date.year + 543
return str(be_year)[-2:]DEPARTMENT_CODES = {
"Safety - COMP": "01",
"Production - COMP": "02",
"IT - COMP": "04",
"Accounting - COMP": "20",
}
for dept_name, dept_code in DEPARTMENT_CODES.items():
_create_naming_rule(
document_type="Material Request",
prefix=f"DYY.-{dept_code}-",
prefix_digits=4,
priority=10,
conditions=[{
"field": "custom_department",
"condition": "=",
"value": dept_name,
}],
)import frappe
def _create_naming_rule(document_type, prefix, prefix_digits, priority, conditions):
"""สร้าง Document Naming Rule ตัวเดียว"""
# ข้ามถ้ามีอยู่แล้ว
existing = frappe.db.get_value(
"Document Naming Rule",
{"document_type": document_type, "prefix": prefix, "disabled": 0},
"name",
)
if existing:
return
doc = frappe.new_doc("Document Naming Rule")
doc.document_type = document_type
doc.prefix = prefix
doc.prefix_digits = prefix_digits
doc.priority = priority
for cond in conditions:
doc.append("conditions", {
"field": cond["field"],
"condition": cond["condition"],
"value": cond["value"],
})
doc.insert(ignore_permissions=True)# Priority 10: Rule เฉพาะ
_create_naming_rule("Sales Order", "DYY.DMM", 4, priority=10,
conditions=[{"field": "order_type", "condition": "=", "value": "Sales"}])
# Priority 5: Catch-all (ไม่มี conditions)
_create_naming_rule("Sales Order", "TS.DYY.DMM", 4, priority=5,
conditions=[])# custom/sales_invoice.py
import frappe
from frappe.utils import cint, getdate, today
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def autoname(self):
"""
Custom naming: IV{DBEYY}{6-digit continuous counter}
ตัวอย่าง: IV69082680 (IV prefix + ปี พ.ศ. 69, counter 082680)
Counter ต่อเนื่องข้ามปี — ไม่ Reset
Return Invoice ใช้ naming_series ปกติ
"""
if self.is_return:
return super().autoname()
posting_date = getdate(self.get("posting_date") or today())
be_yy = str(posting_date.year + 543)[-2:]
# Atomic counter ผ่าน tabSeries
series_key = "MY_APP_SI_COUNTER"
current = frappe.db.sql(
"SELECT `current` FROM `tabSeries` WHERE `name`=%s FOR UPDATE",
series_key,
)
if not current:
frappe.db.sql(
"INSERT INTO `tabSeries` (`name`, `current`) VALUES (%s, 0)",
series_key,
)
counter = 1
else:
counter = cint(current[0][0]) + 1
frappe.db.sql(
"UPDATE `tabSeries` SET `current`=%s WHERE `name`=%s",
(counter, series_key),
)
self.name = f"IV{be_yy}{counter:06d}"def _init_counter():
"""กำหนดค่าเริ่มต้น Counter ให้ตรงกับเอกสารที่มีอยู่"""
series_key = "MY_APP_SI_COUNTER"
existing = frappe.db.sql(
"SELECT `current` FROM `tabSeries` WHERE `name`=%s", series_key
)
if existing:
return # มีค่าอยู่แล้ว
# หาค่า Counter สูงสุดจากเอกสารที่มี
max_counter = frappe.db.sql("""
SELECT MAX(CAST(SUBSTRING(name, 3) AS UNSIGNED))
FROM `tabSales Invoice`
WHERE name REGEXP '^[0-9]{8,}$'
""")
initial = int(max_counter[0][0]) if max_counter and max_counter[0][0] else 0
frappe.db.sql(
"INSERT INTO `tabSeries` (`name`, `current`) VALUES (%s, %s)",
(series_key, initial),
)PICK_LIST_CUSTOM_FIELDS = {
"Pick List": [
{
"fieldname": "custom_order_type",
"fieldtype": "Link",
"label": "Order Type",
"options": "Sales Order Type",
"read_only": 1,
"in_standard_filter": 1,
"insert_after": "company",
},
],
}# hooks.py
doc_events = {
"Pick List": {
"before_save": "your_app.custom.pick_list.before_save",
},
}
# custom/pick_list.py
import frappe
def before_save(doc, method=None):
"""Auto-populate custom_order_type จาก Sales Order ที่เชื่อมโยง"""
if doc.get("custom_order_type"):
return
for item in doc.get("locations") or []:
if item.get("sales_order"):
order_type = frappe.db.get_value(
"Sales Order", item.sales_order, "order_type"
)
if order_type:
doc.custom_order_type = order_type
breakMANAGED_PREFIXES = (
"DYY.-01-", # MR dept 01
"DYY.-02-", # MR dept 02
"TS.DYY.DMM", # SO catch-all
"XS.DBEYY./", # Pick List Sample
)
def cleanup():
"""ลบ Document Naming Rules ทั้งหมดที่ App นี้สร้าง"""
all_rules = frappe.get_all(
"Document Naming Rule",
filters={"disabled": 0},
fields=["name", "prefix"],
)
deleted = 0
for rule in all_rules:
if rule.prefix in MANAGED_PREFIXES:
frappe.delete_doc("Document Naming Rule", rule.name, force=True)
deleted += 1
frappe.db.commit()
print(f"ลบ {deleted} naming rules สำเร็จ")