mcpField
F
frappe-impl-controllers

frappe-impl-controllers

github.com/impertio-studio开发198123 次安装30 周前更新
代码仓库

关于 frappe-impl-controllers

面向 Frappe 开发者的 DocType 控制器实现指南,涵盖从新建控制器到钩子、验证、测试及扩展 ERPNext 的工作流。重点说明生命周期顺序与常见陷阱,帮助在自定义应用中实现服务端逻辑。

主要功能

  1. 01创建 DocType 控制器
  2. 02选择生命周期钩子
  3. 03验证数据与检测变更
  4. 04自定义命名和文档提交
  5. 05扩展 ERPNext 控制器
  6. 06编写与运行测试

适用场景

  1. 01为 DocType 添加服务端验证和计算
  2. 02处理保存后的通知或关联文档
  3. 03定制 ERPNext 标准 DocType 行为

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/impertio-studio/frappe_claude_skill_package --skill frappe-impl-controllers

安装后在 Claude Code 中输入/frappe-impl-controllers确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
anti-patterns.md
decision-tree.md
examples.md
workflows.md

详细介绍

文档控制器 — 实现工作流

使用完整的 Python 能力构建服务端 DocType 逻辑的分步工作流。准确语法请参阅 frappe-syntax-controllers。

版本:v14/v15/v16 | v15+:支持自动生成类型注解

快速决策:控制器还是 Server Script?

NEED full Python (imports, classes, generators)?     → Controller
NEED external libraries (requests, pandas)?          → Controller
NEED try/except with rollback?                       → Controller
NEED frappe.enqueue() for background jobs?           → Controller
NEED to extend standard ERPNext DocType?             → Controller
Quick validation without custom app?                 → Server Script
Simple auto-fill or notification?                    → Server Script

规则:需要自定义应用时,始终使用控制器。进行无代码原型设计时,始终使用 Server Scripts。

工作流 1:创建新控制器

步骤 1:通过 Frappe UI 或 bench new-doctype 创建 DocType

步骤 2:文件会自动生成于:

apps/myapp/myapp/{module}/doctype/{doctype_name}/{doctype_name}.py

步骤 3:实现控制器类:

import frappe
from frappe import _
from frappe.model.document import Document

class MyDocType(Document):
    def validate(self):
        self.validate_dates()
        self.calculate_totals()

    def validate_dates(self):
        if self.from_date and self.to_date and self.from_date > self.to_date:
            frappe.throw(_("From Date cannot be after To Date"))

    def calculate_totals(self):
        self.total = sum(item.amount for item in self.items)

步骤 4:运行 bench restart(或在开发环境中使用 bench watch 热重载)

命名约定:DocType「Sales Order」→ 类 SalesOrder,文件 sales_order.py

工作流 2:选择合适的钩子

WHAT DO YOU WANT?
├── Validate data / calculate fields before save?
│   └── validate — changes to self ARE saved
│
├── Action AFTER save (emails, linked docs, logs)?
│   └── on_update — changes to self NOT saved (use db_set)
│
├── Only for NEW documents?
│   └── after_insert
│
├── Before/after SUBMIT?
│   ├── Check before submit → before_submit
│   └── Ledger entries after → on_submit
│
├── Before/after CANCEL?
│   ├── Prevent cancel → before_cancel
│   └── Reverse entries → on_cancel
│
├── Before DELETE?
│   └── on_trash (throw to prevent)
│
├── Custom document naming?
│   └── autoname
│
└── Detect ANY change (including db_set)?
    └── on_change

所有钩子及其执行顺序,请参阅 references/decision-tree.md。

重要:validate 与 on_update 的区别

方面validateon_update
执行时机写入数据库之前写入数据库之后
self.x = y 会保存吗?是否 — 使用 db_set
能否通过 throw 中止?能已保存
get_doc_before_save()可用可用
用途验证、计算通知、关联文档
# WRONG — changes in on_update are NOT saved
def on_update(self):
    self.status = "Completed"  # LOST!

# CORRECT — use db_set
def on_update(self):
    frappe.db.set_value(self.doctype, self.name, "status", "Completed")

工作流 3:收集错误的验证

def validate(self):
    errors = []
    if not self.items:
        errors.append(_("At least one item is required"))
    for item in self.items:
        if item.qty <= 0:
            errors.append(_("Row {0}: Qty must be positive").format(item.idx))
    if self.from_date > self.to_date:
        errors.append(_("From Date cannot be after To Date"))
    if errors:
        frappe.throw("<br>".join(errors))

工作流 4:检测字段变化

def validate(self):
    old = self.get_doc_before_save()
    if old and old.status != self.status:
        self.flags.status_changed = True
        self.status_changed_on = frappe.utils.now()

def on_update(self):
    if self.flags.get('status_changed'):
        self.notify_status_change()

规则:始终使用 self.flags 在钩子之间传递数据。绝不要依赖外部状态。

工作流 5:自定义命名(autoname)

from frappe.model.naming import getseries

def autoname(self):
    # Format: PRJ-CUST-2025-001
    code = (self.customer or "GEN")[:4].upper()
    year = frappe.utils.getdate(self.start_date or frappe.utils.today()).year
    prefix = f"PRJ-{code}-{year}-"
    self.name = getseries(prefix, 3)

替代方案 — before_naming:

def before_naming(self):
    if self.is_priority:
        self.naming_series = "PRIORITY-.#####"
    else:
        self.naming_series = "STD-.#####"

工作流 6:可提交文档

DRAFT (docstatus=0) → submit() → SUBMITTED (docstatus=1) → cancel() → CANCELLED (docstatus=2)

submit():  validate → before_submit → [DB: docstatus=1] → on_update → on_submit
cancel():  before_cancel → [DB: docstatus=2] → on_cancel
class PurchaseOrder(Document):
    def validate(self):
        self.validate_items()
        self.calculate_totals()

    def before_submit(self):
        # ONLY submit-specific checks here
        if self.total > 100000 and not self.manager_approval:
            frappe.throw(_("Manager approval required for POs over 100,000"))

    def on_submit(self):
        self.update_ordered_qty()
        self.create_purchase_receipt_draft()

    def before_cancel(self):
        if frappe.db.exists("Purchase Invoice",
                {"purchase_order": self.name, "docstatus": 1}):
            frappe.throw(_("Cancel linked invoices first"))

    def on_cancel(self):
        self.reverse_ordered_qty()

规则:绝不要在 validate 和 before_submit 之间重复验证。validate 始终在 before_submit 之前运行。

工作流 7:重写标准 ERPNext 控制器

方法 A:完全重写(hooks.py)
# hooks.py
override_doctype_class = {
    "Sales Invoice": "myapp.overrides.CustomSalesInvoice"
}

# myapp/overrides.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice

class CustomSalesInvoice(SalesInvoice):
    def validate(self):
        super().validate()  # ALWAYS call parent first
        self.custom_validation()
方法 B:事件处理器(更安全,不重写类)
# hooks.py
doc_events = {
    "Sales Invoice": {
        "validate": "myapp.events.validate_sales_invoice",
    }
}

# myapp/events.py
def validate_sales_invoice(doc, method=None):
    if doc.grand_total < 0:
        frappe.throw(_("Invalid total"))
方法 C:extend_doctype_class(v16+)
# hooks.py
extend_doctype_class = {
    "Sales Invoice": "myapp.extends.SalesInvoiceExtend"
}

# myapp/extends.py — Only methods to add/override
class SalesInvoiceExtend:
    def custom_method(self):
        pass

规则:重写时始终调用 super().validate()。绝不要跳过父类方法 — 标准 ERPNext 逻辑依赖这些方法。

工作流 8:白名单方法(客户端可调用)

class Quotation(Document):
    @frappe.whitelist()
    def apply_discount(self, discount_percent):
        if discount_percent < 0 or discount_percent > 100:
            frappe.throw(_("Discount must be 0-100"))
        self.discount_amount = self.total * (discount_percent / 100)
        self.grand_total = self.total - self.discount_amount
        self.save()
        return {"grand_total": self.grand_total}

客户端调用:

frm.call('apply_discount', { discount_percent: 10 }).then(r => {
    frm.reload_doc();
});

工作流 9:标志系统

# Document-level flags (built-in)
doc.flags.ignore_permissions = True    # Bypass permission checks
doc.flags.ignore_validate = True       # Skip validate() hook
doc.flags.ignore_mandatory = True      # Skip required field check

# Custom flags for inter-hook communication
def validate(self):
    if self.is_urgent:
        self.flags.needs_notification = True

def on_update(self):
    if self.flags.get('needs_notification'):
        self.notify_team()

工作流 10:测试控制器

# tests/test_my_doctype.py
import frappe
from frappe.tests.utils import FrappeTestCase

class TestMyDocType(FrappeTestCase):
    def test_validate_dates(self):
        doc = frappe.get_doc({
            "doctype": "My DocType",
            "from_date": "2025-01-10",
            "to_date": "2025-01-01"  # Before from_date
        })
        self.assertRaises(frappe.ValidationError, doc.insert)

    def test_calculate_totals(self):
        doc = frappe.get_doc({
            "doctype": "My DocType",
            "items": [
                {"item": "A", "qty": 2, "rate": 100},
                {"item": "B", "qty": 3, "rate": 50}
            ]
        })
        doc.insert()
        self.assertEqual(doc.total, 350)

运行:bench run-tests --module myapp.module.doctype.my_doctype.test_my_doctype

执行顺序参考

INSERT
before_insert → before_naming → autoname → before_validate →
validate → before_save → [DB INSERT] → after_insert →
on_update → on_change
SAVE(现有文档)
before_validate → validate → before_save → [DB UPDATE] →
on_update → on_change
SUBMIT
validate → before_submit → [DB: docstatus=1] →
on_update → on_submit → on_change

反模式快速检查

不要这样做应这样做
在 on_update 中使用 self.x = y使用 frappe.db.set_value(...)
在 on_update 中使用 self.save()会导致无限循环
在钩子中使用 frappe.db.commit()交由框架处理
在 validate 中执行繁重操作在 on_update 中使用 frappe.enqueue()
跳过 super().validate()始终先调用父类方法
在循环中使用 frappe.get_doc()使用 frappe.get_cached_doc()
硬编码阈值使用 Settings DocType

完整列表请参阅 references/anti-patterns.md。

相关技能

  • frappe-syntax-controllers — 钩子的准确签名和 API
  • frappe-errors-controllers — 错误处理模式
  • frappe-impl-serverscripts — 适用 Server Script 的场景
  • frappe-syntax-hooks — hooks.py 配置
  • frappe-core-database — frappe.db.* 操作

所有钩子请参阅 references/decision-tree.md。 扩展模式请参阅 references/workflows.md。 完整可运行示例请参阅 references/examples.md。

评论

支持 Markdown