skills/sergio-bershadsky/ai/frappe-doctype

frappe-doctype

SKILL.md

Frappe DocType Creation

Create a production-ready Frappe v15 DocType with complete controller implementation, service layer integration, repository pattern, and test coverage.

When to Use

  • Creating a new DocType for a Frappe application
  • Need proper controller with lifecycle hooks
  • Want service layer for business logic separation
  • Require repository for clean data access
  • Building submittable/amendable documents

Arguments

/frappe-doctype <doctype_name> [--module <module>] [--submittable] [--child]

Examples:

/frappe-doctype Sales Order
/frappe-doctype Invoice Item --child
/frappe-doctype Purchase Request --submittable

Procedure

Step 1: Gather DocType Requirements

Ask the user for:

  1. DocType Name (Title Case, e.g., "Sales Order")
  2. Module (which module this belongs to)
  3. DocType Type:
    • Standard (regular CRUD document)
    • Submittable (has workflow: Draft → Submitted → Cancelled)
    • Child Table (embedded in parent documents)
    • Single (configuration/settings document)
  4. Key Fields (at least the primary fields needed)
  5. Naming Pattern:
    • Autoname (series like SO-.YYYY.-.#####)
    • Field-based (use a specific field value)
    • Prompt (user enters name)

Step 2: Analyze and Design

Based on requirements, determine:

  • Field types and properties
  • Link relationships to other DocTypes
  • Required indexes for performance
  • Permission model (roles that can access)
  • Workflow requirements

Step 3: Generate DocType JSON

Create the DocType definition <doctype_folder>/<doctype_name>.json:

{
  "name": "<DocType Name>",
  "module": "<Module>",
  "doctype": "DocType",
  "naming_rule": "By \"Naming Series\" field",
  "autoname": "naming_series:",
  "is_submittable": 0,
  "is_tree": 0,
  "istable": 0,
  "editable_grid": 1,
  "track_changes": 1,
  "track_seen": 1,
  "engine": "InnoDB",
  "fields": [
    {
      "fieldname": "naming_series",
      "fieldtype": "Select",
      "label": "Series",
      "options": "<PREFIX>-.YYYY.-.#####",
      "reqd": 1,
      "in_list_view": 0
    },
    {
      "fieldname": "title",
      "fieldtype": "Data",
      "label": "Title",
      "reqd": 1,
      "in_list_view": 1,
      "in_standard_filter": 1
    },
    {
      "fieldname": "status",
      "fieldtype": "Select",
      "label": "Status",
      "options": "\nDraft\nPending\nCompleted\nCancelled",
      "default": "Draft",
      "in_list_view": 1,
      "in_standard_filter": 1
    },
    {
      "fieldname": "column_break_1",
      "fieldtype": "Column Break"
    },
    {
      "fieldname": "date",
      "fieldtype": "Date",
      "label": "Date",
      "default": "Today",
      "reqd": 1,
      "in_list_view": 1
    },
    {
      "fieldname": "section_break_details",
      "fieldtype": "Section Break",
      "label": "Details"
    },
    {
      "fieldname": "description",
      "fieldtype": "Text Editor",
      "label": "Description"
    },
    {
      "fieldname": "amended_from",
      "fieldtype": "Link",
      "label": "Amended From",
      "no_copy": 1,
      "options": "<DocType Name>",
      "print_hide": 1,
      "read_only": 1
    }
  ],
  "permissions": [
    {
      "role": "System Manager",
      "read": 1,
      "write": 1,
      "create": 1,
      "delete": 1,
      "submit": 0,
      "cancel": 0,
      "amend": 0
    }
  ],
  "sort_field": "modified",
  "sort_order": "DESC",
  "title_field": "title"
}

Step 4: Generate Controller with v15 Type Annotations

Create <doctype_folder>/<doctype_name>.py:

# Copyright (c) <year>, <author> and contributors
# For license information, please see license.txt

import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.docstatus import DocStatus  # v15: Helper for docstatus checks
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from frappe.types import DF
    # Import child table types if needed
    # from <app>.<module>.doctype.<child_doctype>.<child_doctype> import <ChildDocType>


class <DocTypeName>(Document):
    """
    <DocType Name> - <brief description>

    Lifecycle:
        Draft → (validate) → Saved → (submit) → Submitted → (cancel) → Cancelled
    """

    # begin: auto-generated types
    # This section is auto-generated by Frappe. Do not modify manually.
    if TYPE_CHECKING:
        amended_from: DF.Link | None
        date: DF.Date
        description: DF.TextEditor | None
        naming_series: DF.Literal["<PREFIX>-.YYYY.-.#####"]
        status: DF.Literal["", "Draft", "Pending", "Completed", "Cancelled"]
        title: DF.Data
    # end: auto-generated types

    def before_validate(self) -> None:
        """Auto-set default values before validation."""
        self._set_defaults()

    def validate(self) -> None:
        """Validate document before save. Throw exception to prevent saving."""
        self._validate_business_rules()

    def before_save(self) -> None:
        """Called before document is saved to database."""
        self._update_status()

    def after_insert(self) -> None:
        """Called after new document is inserted."""
        self._notify_creation()

    def on_update(self) -> None:
        """Called when existing document is updated."""
        pass

    def before_submit(self) -> None:
        """Called before document submission. Validate submission requirements."""
        self._validate_submit_conditions()

    def on_submit(self) -> None:
        """Called after document submission. Create dependent records."""
        self._process_submission()

    def before_cancel(self) -> None:
        """Validate cancellation conditions."""
        self._validate_cancel_conditions()

    def on_cancel(self) -> None:
        """Handle cancellation cleanup."""
        self._process_cancellation()

    def on_trash(self) -> None:
        """Called when document is deleted. Cleanup related data."""
        pass

    # ──────────────────────────────────────────────────────────────────────────
    # Private Methods
    # ──────────────────────────────────────────────────────────────────────────

    def _set_defaults(self) -> None:
        """Set default values for fields."""
        if not self.date:
            self.date = frappe.utils.today()

    def _validate_business_rules(self) -> None:
        """Validate business rules specific to this DocType."""
        if not self.title:
            frappe.throw(_("Title is required"))

    def _update_status(self) -> None:
        """Update status based on document state using DocStatus helper."""
        # v15: Use DocStatus helper for readable status checks
        if self.docstatus.is_draft() and not self.status:
            self.status = "Draft"

    def _notify_creation(self) -> None:
        """Send notifications after creation."""
        # frappe.publish_realtime("new_<doctype>", {"name": self.name})
        pass

    def _validate_submit_conditions(self) -> None:
        """Check all conditions required for submission."""
        pass

    def _process_submission(self) -> None:
        """Process document submission - create GL entries, update stocks, etc."""
        self.db_set("status", "Completed")

    def _validate_cancel_conditions(self) -> None:
        """Check if document can be cancelled."""
        pass

    def _process_cancellation(self) -> None:
        """Reverse submission effects."""
        self.db_set("status", "Cancelled")

    # ──────────────────────────────────────────────────────────────────────────
    # Public API Methods (call from services or whitelisted methods)
    # ──────────────────────────────────────────────────────────────────────────

    def get_summary(self) -> dict:
        """Return document summary for API responses."""
        return {
            "name": self.name,
            "title": self.title,
            "status": self.status,
            "date": str(self.date)
        }


# ──────────────────────────────────────────────────────────────────────────────
# Whitelisted Methods (accessible via REST API)
# ──────────────────────────────────────────────────────────────────────────────

@frappe.whitelist()
def get_<doctype_snake>_summary(name: str) -> dict:
    """
    Get document summary.

    Args:
        name: Document name

    Returns:
        Document summary dict
    """
    doc = frappe.get_doc("<DocType Name>", name)
    doc.check_permission("read")
    return doc.get_summary()

Step 5: Generate Service Layer

Create <app>/<module>/services/<doctype_snake>_service.py:

"""
<DocType Name> Service

Business logic for <DocType Name> operations.
"""

import frappe
from frappe import _
from typing import Optional
from <app>.<module>.services.base import BaseService
from <app>.<module>.repositories.<doctype_snake>_repository import <DocTypeName>Repository


class <DocTypeName>Service(BaseService):
    """
    Service class for <DocType Name> business logic.

    All business rules and complex operations should be implemented here,
    not in the DocType controller.
    """

    def __init__(self):
        super().__init__()
        self.repo = <DocTypeName>Repository()

    def create(self, data: dict) -> dict:
        """
        Create a new <DocType Name>.

        Args:
            data: Document data

        Returns:
            Created document summary

        Raises:
            frappe.ValidationError: If validation fails
        """
        self.check_permission("<DocType Name>", "create", throw=True)
        self.validate_mandatory(data, ["title", "date"])

        doc = self.repo.create(data)
        self.log_activity("<DocType Name>", doc.name, "Created")

        return doc.get_summary()

    def update(self, name: str, data: dict) -> dict:
        """
        Update existing <DocType Name>.

        Args:
            name: Document name
            data: Fields to update

        Returns:
            Updated document summary
        """
        doc = self.repo.get_or_throw(name, for_update=True)
        self.check_permission("<DocType Name>", "write", doc=doc, throw=True)

        # Business validation
        if doc.status == "Completed":
            frappe.throw(_("Cannot modify completed documents"))

        doc.update(data)
        doc.save()
        self.log_activity("<DocType Name>", name, "Updated", data)

        return doc.get_summary()

    def submit(self, name: str) -> dict:
        """
        Submit document for processing.

        Args:
            name: Document name

        Returns:
            Submitted document summary
        """
        doc = self.repo.get_or_throw(name, for_update=True)
        self.check_permission("<DocType Name>", "submit", doc=doc, throw=True)

        # Pre-submit validation
        self._validate_submission(doc)

        doc.submit()
        return doc.get_summary()

    def cancel(self, name: str, reason: Optional[str] = None) -> dict:
        """
        Cancel submitted document.

        Args:
            name: Document name
            reason: Cancellation reason

        Returns:
            Cancelled document summary
        """
        doc = self.repo.get_or_throw(name, for_update=True)
        self.check_permission("<DocType Name>", "cancel", doc=doc, throw=True)

        if reason:
            frappe.db.set_value("<DocType Name>", name, "cancellation_reason", reason)

        doc.cancel()
        self.log_activity("<DocType Name>", name, "Cancelled", {"reason": reason})

        return doc.get_summary()

    def get_dashboard_stats(self) -> dict:
        """Get statistics for dashboard."""
        return {
            "total": self.repo.get_count(),
            "draft": self.repo.get_count({"status": "Draft"}),
            "pending": self.repo.get_count({"status": "Pending"}),
            "completed": self.repo.get_count({"status": "Completed"})
        }

    def _validate_submission(self, doc) -> None:
        """Validate all requirements for submission."""
        if doc.docstatus != 0:
            frappe.throw(_("Document must be in draft state to submit"))

Step 6: Generate Repository

Create <app>/<module>/repositories/<doctype_snake>_repository.py:

"""
<DocType Name> Repository

Data access layer for <DocType Name>.
"""

import frappe
from frappe.query_builder import DocType
from typing import Optional
from <app>.<module>.repositories.base import BaseRepository
from <app>.<module>.doctype.<doctype_folder>.<doctype_snake> import <DocTypeName>


class <DocTypeName>Repository(BaseRepository[<DocTypeName>]):
    """
    Repository for <DocType Name> database operations.
    """

    doctype = "<DocType Name>"

    def get_by_status(
        self,
        status: str,
        limit: int = 20,
        offset: int = 0
    ) -> list[dict]:
        """Get documents by status."""
        return self.get_list(
            filters={"status": status},
            fields=["name", "title", "date", "status", "owner"],
            order_by="date desc",
            limit=limit,
            offset=offset
        )

    def get_recent(self, days: int = 7) -> list[dict]:
        """Get documents created in the last N days."""
        from_date = frappe.utils.add_days(frappe.utils.today(), -days)
        return self.get_list(
            filters={"creation": [">=", from_date]},
            fields=["name", "title", "date", "status", "creation"],
            order_by="creation desc"
        )

    def search(
        self,
        query: str,
        filters: Optional[dict] = None,
        limit: int = 20
    ) -> list[dict]:
        """Full-text search on title and description."""
        base_filters = filters or {}
        base_filters["title"] = ["like", f"%{query}%"]

        return self.get_list(
            filters=base_filters,
            fields=["name", "title", "date", "status"],
            limit=limit
        )

    def get_with_related(self, name: str) -> dict:
        """Get document with related data."""
        doc = self.get_or_throw(name)
        return {
            **doc.as_dict(),
            # Add related data here
            # "items": self._get_items(name),
            # "comments": self._get_comments(name)
        }

    def bulk_update_status(self, names: list[str], status: str) -> int:
        """Bulk update status for multiple documents."""
        dt = DocType(self.doctype)
        return (
            frappe.qb.update(dt)
            .set(dt.status, status)
            .set(dt.modified, frappe.utils.now())
            .set(dt.modified_by, frappe.session.user)
            .where(dt.name.isin(names))
            .run()
        )

Step 7: Generate Test File

Create <doctype_folder>/test_<doctype_snake>.py:

# Copyright (c) <year>, <author> and contributors
# For license information, please see license.txt

import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from <app>.<module>.services.<doctype_snake>_service import <DocTypeName>Service


class Test<DocTypeName>(IntegrationTestCase):
    """Integration tests for <DocType Name>."""

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.service = <DocTypeName>Service()

    def test_create_document(self):
        """Test document creation via service."""
        data = {
            "title": "Test Document",
            "date": frappe.utils.today()
        }
        result = self.service.create(data)

        self.assertIsNotNone(result.get("name"))
        self.assertEqual(result.get("title"), "Test Document")

    def test_create_requires_mandatory_fields(self):
        """Test that mandatory fields are validated."""
        with self.assertRaises(frappe.ValidationError):
            self.service.create({})

    def test_submit_document(self):
        """Test document submission."""
        # Create draft
        doc = frappe.get_doc({
            "doctype": "<DocType Name>",
            "title": "Submit Test",
            "date": frappe.utils.today()
        }).insert()

        # Submit via service
        result = self.service.submit(doc.name)
        self.assertEqual(result.get("status"), "Completed")

    def test_cannot_modify_completed(self):
        """Test that completed documents cannot be modified."""
        doc = frappe.get_doc({
            "doctype": "<DocType Name>",
            "title": "Completed Test",
            "date": frappe.utils.today(),
            "status": "Completed"
        }).insert()

        with self.assertRaises(frappe.ValidationError):
            self.service.update(doc.name, {"title": "New Title"})

    def test_get_dashboard_stats(self):
        """Test dashboard statistics."""
        stats = self.service.get_dashboard_stats()

        self.assertIn("total", stats)
        self.assertIn("draft", stats)
        self.assertIn("completed", stats)


class Unit<DocTypeName>(UnitTestCase):
    """Unit tests for <DocType Name> (no database)."""

    def test_validation_logic(self):
        """Test validation without database."""
        pass

Step 8: Show Preview and Confirm

## DocType Creation Preview

**DocType:** <DocType Name>
**Module:** <Module>
**Type:** Standard | Submittable | Child Table

### Files to Create:

📁 <module>/doctype/<doctype_folder>/
├── 📄 <doctype_snake>.json       # DocType definition
├── 📄 <doctype_snake>.py         # Controller with hooks
├── 📄 <doctype_snake>.js         # Client-side script
└── 📄 test_<doctype_snake>.py    # Test cases

📁 <module>/services/
└── 📄 <doctype_snake>_service.py # Business logic

📁 <module>/repositories/
└── 📄 <doctype_snake>_repository.py # Data access

### Fields:

| Field | Type | Required |
|-------|------|----------|
| naming_series | Select | Yes |
| title | Data | Yes |
| status | Select | No |
| date | Date | Yes |
| description | Text Editor | No |

---
Create this DocType with all layers?

Step 9: Execute and Verify

After approval, create all files and run:

bench --site <site> migrate
bench --site <site> run-tests --doctype "<DocType Name>"

Output Format

## DocType Created

**Name:** <DocType Name>
**Path:** <app>/<module>/doctype/<doctype_folder>/

### Files Created:
- ✅ <doctype_snake>.json
- ✅ <doctype_snake>.py (controller)
- ✅ <doctype_snake>.js (client)
- ✅ test_<doctype_snake>.py
- ✅ <doctype_snake>_service.py
- ✅ <doctype_snake>_repository.py

### Next Steps:
1. Run `bench --site <site> migrate` to create database table
2. Add permissions in DocType settings
3. Create any child tables needed
4. Run tests: `bench --site <site> run-tests --doctype "<DocType Name>"`

Rules

  1. v15 Type Annotations — Always include TYPE_CHECKING block with type hints
  2. Multi-Layer Pattern — Create service and repository for every DocType
  3. No Business Logic in Controller — Controllers call services, services implement logic
  4. Comprehensive Tests — Every DocType must have test coverage
  5. Proper Naming — DocType folder/file names must be snake_case
  6. ALWAYS Confirm — Never create files without explicit user approval
  7. Index Planning — Add indexes for frequently filtered fields
Weekly Installs
35
GitHub Stars
4
First Seen
Jan 25, 2026
Installed on
gemini-cli31
github-copilot31
codex31
opencode31
kimi-cli28
amp28