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:
- DocType Name (Title Case, e.g., "Sales Order")
- Module (which module this belongs to)
- DocType Type:
- Standard (regular CRUD document)
- Submittable (has workflow: Draft → Submitted → Cancelled)
- Child Table (embedded in parent documents)
- Single (configuration/settings document)
- Key Fields (at least the primary fields needed)
- Naming Pattern:
- Autoname (series like
SO-.YYYY.-.#####) - Field-based (use a specific field value)
- Prompt (user enters name)
- Autoname (series like
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
- v15 Type Annotations — Always include
TYPE_CHECKINGblock with type hints - Multi-Layer Pattern — Create service and repository for every DocType
- No Business Logic in Controller — Controllers call services, services implement logic
- Comprehensive Tests — Every DocType must have test coverage
- Proper Naming — DocType folder/file names must be snake_case
- ALWAYS Confirm — Never create files without explicit user approval
- Index Planning — Add indexes for frequently filtered fields
Weekly Installs
35
Repository
sergio-bershadsky/aiGitHub Stars
4
First Seen
Jan 25, 2026
Security Audits
Installed on
gemini-cli31
github-copilot31
codex31
opencode31
kimi-cli28
amp28