Compare commits

...

10 Commits

Author SHA1 Message Date
Dipen Gala
87b65d09df fix: use transaction_date for Purchase Order in target variance helper
The shared get_data helper defaulted to posting_date for any doctype
other than Sales Order. Purchase Order also uses transaction_date, so
add it to the in-check to prevent the Unknown column SQL error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:40:38 +05:30
Dipen Gala
1631985726 fix: remove territory from all Purchase Partner reports
Purchase Order, Purchase Invoice, and Purchase Receipt have no
territory field. Removed it from the base query SELECT, the common
filters loop, the column definitions, and the JS filter inputs in
Purchase Partner Commission Summary and Transaction Summary reports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:38:33 +05:30
Dipen Gala
5284b8c7a8 feat: add Purchase Partner Target Variance Based On Item Group report
Mirrors the Sales Partner Target Variance Based On Item Group report
for the purchase flow. Calls the shared get_data_column helper with
"Purchase Partner" so it reads Target Details with parenttype=Purchase
Partner and matches against the purchase_partner field on PO/PI/PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:39:05 +05:30
Dipen Gala
a674d9216a fix: filter purchase_person dropdown to non-group enabled records only
Adds a set_query on purchase_person in the purchase_team child table
so the dropdown excludes group nodes (like "Purchase Team") and
disabled records, matching the same filter used for sales_person in
Sales Team.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:22:34 +05:30
Dipen Gala
9dfb60f826 fix: remove territory from Purchase Person reports
Purchase Order, Purchase Invoice, and Purchase Receipt do not have a
territory field (unlike their selling counterparts), causing an
Unknown column SQL error. Removed territory from columns, SELECT, and
filter conditions in both Commission Summary and Transaction Summary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:17:33 +05:30
Dipen Gala
1138effd7c feat: add Purchase Person reports mirroring Sales Person reports
Three new Script Reports in the Buying module:
- Purchase Person-wise Transaction Summary (mirrors Sales Person-wise)
- Purchase Person Commission Summary (mirrors Sales Person Commission)
- Purchase Person Target Variance Based On Item Group (mirrors Sales Person Target)

The shared `item_group_wise_sales_target_variance.get_actual_data` helper
gains a `purchase_person` branch that joins `Purchase Team` the same way
the existing `sales_person` branch joins `Sales Team`.

All three reports are added to the Buying workspace under a Purchase Person card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:12:54 +05:30
Dipen Gala
c8cb70dbd2 feat: add Purchase Team root node as default fixture on install
Mirrors the Sales Person/Sales Team fixture so fresh installs get a
root "Purchase Team" group node for the Purchase Person tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:07:04 +05:30
Dipen Gala
5249274bcd feat: add Purchase Person tree DocType and Purchase Team child table
Mirrors Sales Person/Sales Team functionality for the purchase flow.
Purchase Person is a tree DocType (Setup module) and Purchase Team is
a child table (Buying module). Both are added to Purchase Order,
Purchase Invoice, and Purchase Receipt. The BuyingController gains
calculate_contribution() and validate_purchase_team() methods, and
accounts_controller wires it into the calculate_totals flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 12:54:44 +05:30
Dipen Gala
56ed9e8e43 fix: address review comments on purchase partner commission PR
- Fix division-by-zero on PostgreSQL in purchase_partners_commission report
  by wrapping sum(amount_eligible_for_commission) with NULLIF(..., 0)
- Replace lazy `from frappe import throw` with `frappe.throw()` in
  buying_controller.calculate_commission to match selling controller pattern
- Fix indentation of purchase_partner() event handler in buying.js
- Mirror server-side validation in JS: block commission_rate < 0 as well
  as > 100, with consistent error message "must be between 0 and 100"
- Remove unused IntegrationTestCase import from test_purchase_partner.py
- Add Purchase Partner Type fixtures (same types as Sales Partner Type)
  installed via setup wizard so generic records exist out of the box

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 15:19:53 +05:30
Dipen Gala
0147312951 feat: add Purchase Partner and commission functionality
Mirrors the existing Sales Partner / Sales Commission feature for the
purchase side, as requested in issue #52298.

**New DocTypes:**
- Purchase Partner (Setup module) — master for purchase agents/brokers
  with commission_rate, territory, address & contacts, and targets
- Purchase Partner Type (Buying module) — classification for partners

**Commission fields added to:**
- Purchase Order, Purchase Invoice, Purchase Receipt — commission_section,
  purchase_partner (Link), commission_rate (fetch_from partner),
  amount_eligible_for_commission, total_commission
- Purchase Order Item, Purchase Invoice Item, Purchase Receipt Item —
  grant_commission (fetched from Item master, default 0)

**Commission calculation:**
- Python: BuyingController.calculate_commission() mirrors
  SellingController logic; triggered via accounts_controller on validate
- JS: BuyingController.calculate_purchase_commission() in buying.js;
  triggered from taxes_and_totals.js after totals recalculate
- Event handlers: purchase_partner / commission_rate / total_commission

**New Reports:**
- Purchase Partner Commission Summary (Buying) — per-document summary
- Purchase Partner Transaction Summary (Buying) — item-level breakdown
- Purchase Partners Commission (Accounts) — aggregated query report

**Workspace:** Purchase Partner card added to Buying workspace

**Tests:** test_purchase_partner.py covers commission calculation,
grant_commission exclusion, rate validation, and report execution

Fixes #52298

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:39:59 +05:30
57 changed files with 2823 additions and 5 deletions

View File

@@ -167,6 +167,14 @@
"terms_section_break",
"tc_name",
"terms",
"commission_section",
"purchase_partner",
"amount_eligible_for_commission",
"column_break_commission",
"commission_rate",
"total_commission",
"purchase_team_section",
"purchase_team",
"more_info_tab",
"status_section",
"status",
@@ -1683,6 +1691,66 @@
"fieldname": "automation_section",
"fieldtype": "Section Break",
"label": "Automation"
},
{
"collapsible": 1,
"collapsible_depends_on": "purchase_partner",
"fieldname": "commission_section",
"fieldtype": "Section Break",
"label": "Commission",
"print_hide": 1
},
{
"fieldname": "purchase_partner",
"fieldtype": "Link",
"label": "Purchase Partner",
"options": "Purchase Partner",
"print_hide": 1
},
{
"fieldname": "amount_eligible_for_commission",
"fieldtype": "Currency",
"label": "Amount Eligible for Commission",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_commission",
"fieldtype": "Column Break",
"print_hide": 1
},
{
"fetch_from": "purchase_partner.commission_rate",
"fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"label": "Commission Rate (%)",
"print_hide": 1
},
{
"fieldname": "total_commission",
"fieldtype": "Currency",
"label": "Total Commission",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "purchase_team",
"fieldname": "purchase_team_section",
"fieldtype": "Section Break",
"label": "Purchase Team",
"print_hide": 1
},
{
"allow_on_submit": 1,
"fieldname": "purchase_team",
"fieldtype": "Table",
"label": "Purchase Contributions and Incentives",
"options": "Purchase Team",
"print_hide": 1
}
],
"grid_page_length": 50,

View File

@@ -121,6 +121,7 @@
"dimension_col_break",
"cost_center",
"section_break_82",
"grant_commission",
"page_break"
],
"fields": [
@@ -1004,6 +1005,15 @@
"label": "Delivered by Supplier",
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fetch_from": "item_code.grant_commission",
"fieldname": "grant_commission",
"fieldtype": "Check",
"label": "Grant Commission",
"print_hide": 1,
"read_only": 1
}
],
"grid_page_length": 50,
@@ -1021,4 +1031,4 @@
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -0,0 +1,31 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-06-15 00:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-06-15 00:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Partners Commission",
"owner": "Administrator",
"prepared_report": 0,
"query": "SELECT\n purchase_partner as \"Purchase Partner:Link/Purchase Partner:220\",\n sum(base_net_total) as \"Invoiced Amount (Excl. Tax):Currency:220\",\n sum(amount_eligible_for_commission) as \"Amount Eligible for Commission:Currency:220\",\n sum(total_commission) as \"Total Commission:Currency:170\",\n sum(total_commission)*100 / NULLIF(sum(amount_eligible_for_commission), 0) as \"Average Commission Rate:Percent:220\"\nFROM\n `tabPurchase Invoice`\nWHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\nGROUP BY\n purchase_partner\nORDER BY\n sum(total_commission) DESC",
"ref_doctype": "Purchase Invoice",
"report_name": "Purchase Partners Commission",
"report_type": "Query Report",
"roles": [
{
"role": "Accounts Manager"
},
{
"role": "Accounts User"
}
],
"timeout": 0
}

View File

@@ -133,6 +133,14 @@
"terms_section_break",
"tc_name",
"terms",
"commission_section",
"purchase_partner",
"amount_eligible_for_commission",
"column_break_commission",
"commission_rate",
"total_commission",
"purchase_team_section",
"purchase_team",
"more_info_tab",
"tracking_section",
"status",
@@ -1291,6 +1299,66 @@
"fieldname": "auto_repeat_section",
"fieldtype": "Section Break",
"label": "Auto Repeat"
},
{
"collapsible": 1,
"collapsible_depends_on": "purchase_partner",
"fieldname": "commission_section",
"fieldtype": "Section Break",
"label": "Commission",
"print_hide": 1
},
{
"fieldname": "purchase_partner",
"fieldtype": "Link",
"label": "Purchase Partner",
"options": "Purchase Partner",
"print_hide": 1
},
{
"fieldname": "amount_eligible_for_commission",
"fieldtype": "Currency",
"label": "Amount Eligible for Commission",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_commission",
"fieldtype": "Column Break",
"print_hide": 1
},
{
"fetch_from": "purchase_partner.commission_rate",
"fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"label": "Commission Rate (%)",
"print_hide": 1
},
{
"fieldname": "total_commission",
"fieldtype": "Currency",
"label": "Total Commission",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "purchase_team",
"fieldname": "purchase_team_section",
"fieldtype": "Section Break",
"label": "Purchase Team",
"print_hide": 1
},
{
"allow_on_submit": 1,
"fieldname": "purchase_team",
"fieldtype": "Table",
"label": "Purchase Contributions and Incentives",
"options": "Purchase Team",
"print_hide": 1
}
],
"grid_page_length": 50,

View File

@@ -111,6 +111,7 @@
"production_plan",
"production_plan_item",
"production_plan_sub_assembly_item",
"grant_commission",
"page_break",
"column_break_pjyo",
"job_card"
@@ -934,6 +935,15 @@
"non_negative": 1,
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fetch_from": "item_code.grant_commission",
"fieldname": "grant_commission",
"fieldtype": "Check",
"label": "Grant Commission",
"print_hide": 1,
"read_only": 1
}
],
"grid_page_length": 50,
@@ -955,4 +965,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -0,0 +1,47 @@
{
"actions": [],
"autoname": "field:purchase_partner_type",
"creation": "2026-06-15 00:00:00.000000",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"purchase_partner_type"
],
"fields": [
{
"fieldname": "purchase_partner_type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Purchase Partner Type",
"reqd": 1,
"unique": 1
}
],
"links": [],
"modified": "2026-06-15 00:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Partner Type",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"translated_doctype": 1
}

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe.model.document import Document
class PurchasePartnerType(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
purchase_partner_type: DF.Data
# end: auto-generated types
pass

View File

@@ -0,0 +1,16 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestPurchasePartnerType(ERPNextTestSuite):
def test_purchase_partner_type_creation(self):
if not frappe.db.exists("Purchase Partner Type", "_Test Purchase Partner Type"):
ppt = frappe.new_doc("Purchase Partner Type")
ppt.purchase_partner_type = "_Test Purchase Partner Type"
ppt.insert(ignore_permissions=True)
self.assertTrue(frappe.db.exists("Purchase Partner Type", "_Test Purchase Partner Type"))
frappe.delete_doc("Purchase Partner Type", "_Test Purchase Partner Type", force=True)

View File

@@ -0,0 +1,83 @@
{
"actions": [],
"creation": "2026-06-17 00:00:00",
"doctype": "DocType",
"document_type": "Setup",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"purchase_person",
"contact_no",
"allocated_percentage",
"allocated_amount",
"commission_rate",
"incentives"
],
"fields": [
{
"allow_on_submit": 1,
"fieldname": "purchase_person",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Purchase Person",
"options": "Purchase Person",
"reqd": 1,
"search_index": 1
},
{
"allow_on_submit": 1,
"fieldname": "contact_no",
"fieldtype": "Data",
"hidden": 1,
"in_list_view": 1,
"label": "Contact No."
},
{
"allow_on_submit": 1,
"fieldname": "allocated_percentage",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Contribution (%)"
},
{
"allow_on_submit": 1,
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Contribution to Net Total",
"options": "Company:company:default_currency",
"read_only": 1
},
{
"fetch_from": "purchase_person.commission_rate",
"fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Commission Rate",
"read_only": 1
},
{
"allow_on_submit": 1,
"fieldname": "incentives",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Incentives",
"options": "Company:company:default_currency"
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-17 00:00:00",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Team",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -0,0 +1,27 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe.model.document import Document
class PurchaseTeam(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
allocated_amount: DF.Currency
allocated_percentage: DF.Float
commission_rate: DF.Data | None
contact_no: DF.Data | None
incentives: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
purchase_person: DF.Link
# end: auto-generated types
pass

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.query_reports["Purchase Partner Commission Summary"] = {
filters: [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "purchase_partner",
label: __("Purchase Partner"),
fieldtype: "Link",
options: "Purchase Partner",
},
{
fieldname: "doctype",
label: __("Document Type"),
fieldtype: "Select",
options: "Purchase Order\nPurchase Receipt\nPurchase Invoice",
default: "Purchase Order",
},
{
fieldname: "from_date",
label: __("From Date"),
fieldtype: "Date",
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
},
{
fieldname: "to_date",
label: __("To Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "supplier",
label: __("Supplier"),
fieldtype: "Link",
options: "Supplier",
},
],
};

View File

@@ -0,0 +1,27 @@
{
"add_total_row": 1,
"creation": "2026-06-15 00:00:00.000000",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2026-06-15 00:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Partner Commission Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Order",
"report_name": "Purchase Partner Commission Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Purchase Manager"
},
{
"role": "Purchase User"
}
]
}

View File

@@ -0,0 +1,161 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.query_builder import DocType, Field, Order
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.utils import QueryBuilder
from frappe.utils.data import comma_or
PURCHASE_TRANSACTION_DOCTYPES = ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]
def execute(filters=None):
if not filters:
filters = {}
return PurchasePartnerCommissionSummaryReport(filters).run()
class PurchasePartnerSummaryReport:
"""Base class for Purchase Partner Summary related Reports."""
dt: DocType
date_field: str
date_label: str
columns: list
data: list
query: QueryBuilder
filters: dict
def __init__(self, filters: dict):
self.filters = filters
self.columns = []
def run(self):
self.validate_filters()
self.prepare_columns()
self.get_data()
return self.columns, self.data
def validate_filters(self):
if not self.filters.get("doctype"):
frappe.throw(_("Please select the document type first."))
if self.filters.get("doctype") not in PURCHASE_TRANSACTION_DOCTYPES:
frappe.throw(_("DocType can be one of them {0}").format(comma_or(PURCHASE_TRANSACTION_DOCTYPES)))
if not self.filters.get("company"):
frappe.throw(_("Please select a company."))
if (
self.filters.get("from_date")
and self.filters.get("to_date")
and self.filters.get("from_date") > self.filters.get("to_date")
):
frappe.throw(_("From Date cannot be greater than To Date."))
self._set_date_field_and_label()
def _set_date_field_and_label(self):
self.date_field = (
"transaction_date" if self.filters.get("doctype") == "Purchase Order" else "posting_date"
)
self.date_label = _("Order Date") if self.date_field == "transaction_date" else _("Posting Date")
def prepare_columns(self):
raise NotImplementedError
def get_data(self):
self.build_report_query()
self.data = self.query.run(as_dict=1)
def build_report_query(self):
self._build_report_base_query()
self.extend_report_query()
self._apply_common_filters()
self.apply_filters()
def _build_report_base_query(self):
self.dt = DocType(self.filters.get("doctype"))
company_currency = frappe.get_cached_value("Company", self.filters.get("company"), "default_currency")
self.query = (
frappe.qb.from_(self.dt)
.select(
self.dt.name,
self.dt.supplier,
Field(self.date_field, "posting_date", table=self.dt),
self.dt.purchase_partner,
self.dt.commission_rate,
ConstantColumn(company_currency).as_("currency"),
)
.where(
(self.dt.docstatus == 1)
& (self.dt.purchase_partner.notnull())
& (self.dt.purchase_partner != "")
)
.orderby(self.dt.name, order=Order.desc)
.orderby(self.dt.purchase_partner)
)
def extend_report_query(self):
pass
def _apply_common_filters(self):
for field in ["company", "supplier", "purchase_partner"]:
if self.filters.get(field):
self.query = self.query.where(Field(field, table=self.dt) == self.filters.get(field))
if self.filters.get("from_date"):
self.query = self.query.where(
Field(self.date_field, table=self.dt) >= self.filters.get("from_date")
)
if self.filters.get("to_date"):
self.query = self.query.where(
Field(self.date_field, table=self.dt) <= self.filters.get("to_date")
)
def apply_filters(self):
pass
def make_column(
self,
label: str,
fieldname: str,
fieldtype: str,
width: int = 140,
options: str = "",
hidden: int = 0,
):
self.columns.append(
dict(
label=label,
fieldname=fieldname,
fieldtype=fieldtype,
options=options,
width=width,
hidden=hidden,
)
)
class PurchasePartnerCommissionSummaryReport(PurchasePartnerSummaryReport):
def prepare_columns(self):
self.make_column(_(self.filters.get("doctype")), "name", "Link", options=self.filters.get("doctype"))
self.make_column(_("Supplier"), "supplier", "Link", options="Supplier")
self.make_column(_("Currency"), "currency", "Data", 80, hidden=1)
self.make_column(self.date_label, "posting_date", "Date")
self.make_column(_("Amount"), "amount", "Currency", 120, "currency")
self.make_column(_("Purchase Partner"), "purchase_partner", "Link", options="Purchase Partner")
self.make_column(_("Commission Rate %"), "commission_rate", "Data", 100)
self.make_column(_("Total Commission"), "total_commission", "Currency", 120, "currency")
def extend_report_query(self):
self.query = self.query.select(
self.dt.base_net_total.as_("amount"),
self.dt.total_commission,
)

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Purchase Partner Target Variance Based On Item Group"] = {
filters: [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
},
{
fieldname: "fiscal_year",
label: __("Fiscal Year"),
fieldtype: "Link",
options: "Fiscal Year",
default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
},
{
fieldname: "doctype",
label: __("Document Type"),
fieldtype: "Select",
options: "Purchase Order\nPurchase Receipt\nPurchase Invoice",
default: "Purchase Order",
},
{
fieldname: "period",
label: __("Period"),
fieldtype: "Select",
options: [
{ value: "Monthly", label: __("Monthly") },
{ value: "Quarterly", label: __("Quarterly") },
{ value: "Half-Yearly", label: __("Half-Yearly") },
{ value: "Yearly", label: __("Yearly") },
],
default: "Monthly",
},
{
fieldname: "target_on",
label: __("Target On"),
fieldtype: "Select",
options: "Quantity\nAmount",
default: "Quantity",
},
],
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (column.fieldname.includes("variance")) {
if (data[column.fieldname] < 0) {
value = "<span style='color:red'>" + value + "</span>";
} else if (data[column.fieldname] > 0) {
value = "<span style='color:green'>" + value + "</span>";
}
}
return value;
},
};

View File

@@ -0,0 +1,33 @@
{
"add_total_row": 0,
"creation": "2026-06-17 00:00:00",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2026-06-17 00:00:00",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Partner Target Variance Based On Item Group",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Order",
"report_name": "Purchase Partner Target Variance Based On Item Group",
"report_type": "Script Report",
"roles": [
{
"role": "Purchase User"
},
{
"role": "Purchase Manager"
},
{
"role": "Accounts User"
},
{
"role": "Stock User"
}
]
}

View File

@@ -0,0 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from erpnext.selling.report.sales_partner_target_variance_based_on_item_group.item_group_wise_sales_target_variance import (
get_data_column,
)
def execute(filters=None):
return get_data_column(filters, "Purchase Partner")

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.query_reports["Purchase Partner Transaction Summary"] = {
filters: [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "purchase_partner",
label: __("Purchase Partner"),
fieldtype: "Link",
options: "Purchase Partner",
},
{
fieldname: "doctype",
label: __("Document Type"),
fieldtype: "Select",
options: "Purchase Order\nPurchase Receipt\nPurchase Invoice",
default: "Purchase Order",
},
{
fieldname: "from_date",
label: __("From Date"),
fieldtype: "Date",
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
},
{
fieldname: "to_date",
label: __("To Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "supplier",
label: __("Supplier"),
fieldtype: "Link",
options: "Supplier",
},
{
fieldname: "item_group",
label: __("Item Group"),
fieldtype: "Link",
options: "Item Group",
},
{
fieldname: "brand",
label: __("Brand"),
fieldtype: "Link",
options: "Brand",
},
{
fieldname: "show_return_entries",
label: __("Show Return Entries"),
fieldtype: "Check",
default: 0,
},
],
};

View File

@@ -0,0 +1,33 @@
{
"add_total_row": 1,
"creation": "2026-06-15 00:00:00.000000",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2026-06-15 00:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Partner Transaction Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Order",
"report_name": "Purchase Partner Transaction Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Purchase User"
},
{
"role": "Purchase Manager"
},
{
"role": "Accounts User"
},
{
"role": "Stock User"
}
]
}

View File

@@ -0,0 +1,71 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.query_builder import Case
from erpnext.buying.report.purchase_partner_commission_summary.purchase_partner_commission_summary import (
PurchasePartnerSummaryReport,
)
def execute(filters=None):
if not filters:
filters = {}
return PurchasePartnerTransactionSummaryReport(filters=filters).run()
class PurchasePartnerTransactionSummaryReport(PurchasePartnerSummaryReport):
def prepare_columns(self):
self.make_column(_(self.filters.get("doctype")), "name", "Link", options=self.filters.get("doctype"))
self.make_column(_("Supplier"), "supplier", "Link", options="Supplier")
self.make_column(_("Currency"), "currency", "Data", 80, hidden=1)
self.make_column(self.date_label, "posting_date", "Date")
self.make_column(_("Item Code"), "item_code", "Link", 100, "Item")
self.make_column(_("Item Group"), "item_group", "Link", 100, "Item Group")
self.make_column(_("Brand"), "brand", "Link", 100, "Brand")
self.make_column(_("Quantity"), "qty", "Float", 120)
self.make_column(_("Rate"), "rate", "Currency", 120, "currency")
self.make_column(_("Amount"), "amount", "Currency", 120, "currency")
self.make_column(_("Purchase Partner"), "purchase_partner", "Link", options="Purchase Partner")
self.make_column(_("Commission Rate %"), "commission_rate", "Data", 100)
self.make_column(_("Commission"), "commission", "Currency", 120, "currency")
def extend_report_query(self):
self.dt_item = frappe.qb.DocType(f"{self.filters['doctype']} Item")
self.query = (
self.query.join(self.dt_item)
.on(self.dt.name == self.dt_item.parent)
.select(
self.dt_item.base_net_rate.as_("rate"),
self.dt_item.qty,
self.dt_item.base_net_amount.as_("amount"),
Case()
.when(
self.dt_item.grant_commission.eq(1),
((self.dt_item.base_net_amount * self.dt.commission_rate) / 100),
)
.else_(0)
.as_("commission"),
self.dt_item.brand,
self.dt_item.item_group,
self.dt_item.item_code,
)
)
def apply_filters(self):
if not self.filters.get("show_return_entries"):
self.query = self.query.where(self.dt_item.qty > 0.0)
if self.filters.get("brand"):
self.query = self.query.where(self.dt_item.brand == self.filters.get("brand"))
if self.filters.get("item_group"):
lft, rgt = frappe.get_cached_value("Item Group", self.filters.get("item_group"), ["lft", "rgt"])
if item_groups := frappe.get_all(
"Item Group", filters=[["lft", ">=", lft], ["rgt", "<=", rgt]], pluck="name"
):
self.query = self.query.where(self.dt_item.item_group.isin(item_groups))

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Purchase Person Commission Summary"] = {
filters: [
{
fieldname: "purchase_person",
label: __("Purchase Person"),
fieldtype: "Link",
options: "Purchase Person",
},
{
fieldname: "doc_type",
label: __("Document Type"),
fieldtype: "Select",
options: "Purchase Order\nPurchase Receipt\nPurchase Invoice",
default: "Purchase Order",
},
{
fieldname: "from_date",
label: __("From Date"),
fieldtype: "Date",
default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
},
{
fieldname: "to_date",
label: __("To Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
},
{
fieldname: "supplier",
label: __("Supplier"),
fieldtype: "Link",
options: "Supplier",
},
],
};

View File

@@ -0,0 +1,26 @@
{
"add_total_row": 1,
"creation": "2026-06-17 00:00:00",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2026-06-17 00:00:00",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Person Commission Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Order",
"report_name": "Purchase Person Commission Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Purchase Manager"
},
{
"role": "Accounts User"
}
]
}

View File

@@ -0,0 +1,134 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe import _, msgprint, qb
from frappe.query_builder import Criterion
def execute(filters=None):
if not filters:
filters = {}
columns = get_columns(filters)
entries = get_entries(filters)
data = []
for d in entries:
data.append(
[
d.name,
d.supplier,
d.posting_date,
d.base_net_amount,
d.purchase_person,
d.allocated_percentage,
d.commission_rate,
d.allocated_amount,
d.incentives,
]
)
if data:
total_row = [""] * len(data[0])
data.append(total_row)
return columns, data
def get_columns(filters):
if not filters.get("doc_type"):
msgprint(_("Please select the document type first"), raise_exception=1)
return [
{
"label": _(filters["doc_type"]),
"options": filters["doc_type"],
"fieldname": filters["doc_type"],
"fieldtype": "Link",
"width": 140,
},
{
"label": _("Supplier"),
"options": "Supplier",
"fieldname": "supplier",
"fieldtype": "Link",
"width": 140,
},
{"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
{"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120},
{
"label": _("Purchase Person"),
"options": "Purchase Person",
"fieldname": "purchase_person",
"fieldtype": "Link",
"width": 140,
},
{
"label": _("Contribution %"),
"fieldname": "contribution_percentage",
"fieldtype": "Data",
"width": 110,
},
{
"label": _("Commission Rate %"),
"fieldname": "commission_rate",
"fieldtype": "Data",
"width": 100,
},
{
"label": _("Contribution Amount"),
"fieldname": "contribution_amount",
"fieldtype": "Currency",
"width": 120,
},
{"label": _("Incentives"), "fieldname": "incentives", "fieldtype": "Currency", "width": 120},
]
def get_entries(filters):
dt = qb.DocType(filters["doc_type"])
pt = qb.DocType("Purchase Team")
date_field = dt["transaction_date"] if filters["doc_type"] == "Purchase Order" else dt["posting_date"]
conditions = get_conditions(dt, pt, filters, date_field)
return (
qb.from_(dt)
.join(pt)
.on(pt.parent.eq(dt.name) & pt.parenttype.eq(filters["doc_type"]))
.select(
dt.name,
dt.supplier,
date_field.as_("posting_date"),
dt.base_net_total.as_("base_net_amount"),
pt.commission_rate,
pt.purchase_person,
pt.allocated_percentage,
pt.allocated_amount,
pt.incentives,
)
.where(Criterion.all(conditions))
.orderby(dt.name, pt.purchase_person)
.run(as_dict=True)
)
def get_conditions(dt, pt, filters, date_field):
conditions = [dt.docstatus.eq(1)]
from_dt = filters.get("from_date")
to_dt = filters.get("to_date")
if from_dt and to_dt:
conditions.append(date_field.between(from_dt, to_dt))
elif from_dt:
conditions.append(date_field.gte(from_dt))
elif to_dt:
conditions.append(date_field.lte(to_dt))
for field in ["company", "supplier"]:
if filters.get(field):
conditions.append(dt[field].eq(filters.get(field)))
if filters.get("purchase_person"):
conditions.append(pt["purchase_person"].eq(filters.get("purchase_person")))
return conditions

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Purchase Person Target Variance Based On Item Group"] = {
filters: [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
},
{
fieldname: "fiscal_year",
label: __("Fiscal Year"),
fieldtype: "Link",
options: "Fiscal Year",
default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
},
{
fieldname: "doctype",
label: __("Document Type"),
fieldtype: "Select",
options: "Purchase Order\nPurchase Receipt\nPurchase Invoice",
default: "Purchase Order",
},
{
fieldname: "period",
label: __("Period"),
fieldtype: "Select",
options: [
{ value: "Monthly", label: __("Monthly") },
{ value: "Quarterly", label: __("Quarterly") },
{ value: "Half-Yearly", label: __("Half-Yearly") },
{ value: "Yearly", label: __("Yearly") },
],
default: "Monthly",
},
{
fieldname: "target_on",
label: __("Target On"),
fieldtype: "Select",
options: "Quantity\nAmount",
default: "Quantity",
},
],
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (column.fieldname.includes("variance")) {
if (data[column.fieldname] < 0) {
value = "<span style='color:red'>" + value + "</span>";
} else if (data[column.fieldname] > 0) {
value = "<span style='color:green'>" + value + "</span>";
}
}
return value;
},
};

View File

@@ -0,0 +1,33 @@
{
"add_total_row": 0,
"creation": "2026-06-17 00:00:00",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2026-06-17 00:00:00",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Person Target Variance Based On Item Group",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Order",
"report_name": "Purchase Person Target Variance Based On Item Group",
"report_type": "Script Report",
"roles": [
{
"role": "Purchase User"
},
{
"role": "Purchase Manager"
},
{
"role": "Accounts User"
},
{
"role": "Stock User"
}
]
}

View File

@@ -0,0 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from erpnext.selling.report.sales_partner_target_variance_based_on_item_group.item_group_wise_sales_target_variance import (
get_data_column,
)
def execute(filters=None):
return get_data_column(filters, "Purchase Person")

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Purchase Person-wise Transaction Summary"] = {
filters: [
{
fieldname: "purchase_person",
label: __("Purchase Person"),
fieldtype: "Link",
options: "Purchase Person",
},
{
fieldname: "doc_type",
label: __("Document Type"),
fieldtype: "Select",
options: "Purchase Order\nPurchase Receipt\nPurchase Invoice",
default: "Purchase Order",
},
{
fieldname: "from_date",
label: __("From Date"),
fieldtype: "Date",
default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
},
{
fieldname: "to_date",
label: __("To Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "item_group",
label: __("Item Group"),
fieldtype: "Link",
options: "Item Group",
},
{
fieldname: "brand",
label: __("Brand"),
fieldtype: "Link",
options: "Brand",
},
{
fieldname: "supplier",
label: __("Supplier"),
fieldtype: "Link",
options: "Supplier",
},
{
fieldname: "show_return_entries",
label: __("Show Return Entries"),
fieldtype: "Check",
default: 0,
},
],
};

View File

@@ -0,0 +1,31 @@
{
"add_total_row": 1,
"creation": "2026-06-17 00:00:00",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2026-06-17 00:00:00",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Person-wise Transaction Summary",
"owner": "Administrator",
"ref_doctype": "Purchase Order",
"report_name": "Purchase Person-wise Transaction Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Purchase User"
},
{
"role": "Purchase Manager"
},
{
"role": "Accounts User"
},
{
"role": "Stock User"
}
]
}

View File

@@ -0,0 +1,267 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _, msgprint, qb
from frappe.query_builder import Case, Criterion
from erpnext import get_company_currency
def execute(filters=None):
if not filters:
filters = {}
validate_filters(filters)
columns = get_columns(filters)
entries = get_entries(filters)
item_details = get_item_details()
data = []
company_currency = get_company_currency(filters.get("company"))
for d in entries:
if d.stock_qty > 0 or filters.get("show_return_entries", 0):
data.append(
[
d.name,
d.supplier,
d.warehouse,
d.posting_date,
d.item_code,
item_details.get(d.item_code, {}).get("item_group"),
item_details.get(d.item_code, {}).get("brand"),
d.stock_qty,
d.base_net_amount,
d.purchase_person,
d.allocated_percentage,
(d.stock_qty * d.allocated_percentage / 100),
d.contribution_amt,
company_currency,
]
)
if data:
total_row = [""] * len(data[0])
data.append(total_row)
return columns, data
def validate_filters(filters):
ALLOWED_DOCTYPES = ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]
if not filters.get("doc_type"):
msgprint(_("Please select the document type first"), raise_exception=1)
if filters.get("doc_type") not in ALLOWED_DOCTYPES:
frappe.throw(_("{0}, {1} or {2} are the only allowed options.").format(*ALLOWED_DOCTYPES))
def get_columns(filters):
return [
{
"label": _(filters["doc_type"]),
"options": filters["doc_type"],
"fieldname": frappe.scrub(filters["doc_type"]),
"fieldtype": "Link",
"width": 140,
},
{
"label": _("Supplier"),
"options": "Supplier",
"fieldname": "supplier",
"fieldtype": "Link",
"width": 140,
},
{
"label": _("Warehouse"),
"options": "Warehouse",
"fieldname": "warehouse",
"fieldtype": "Link",
"width": 140,
},
{"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 140},
{
"label": _("Item Code"),
"options": "Item",
"fieldname": "item_code",
"fieldtype": "Link",
"width": 140,
},
{
"label": _("Item Group"),
"options": "Item Group",
"fieldname": "item_group",
"fieldtype": "Link",
"width": 140,
},
{
"label": _("Brand"),
"options": "Brand",
"fieldname": "brand",
"fieldtype": "Link",
"width": 140,
},
{"label": _("Total Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 140},
{
"label": _("Amount"),
"options": "currency",
"fieldname": "amount",
"fieldtype": "Currency",
"width": 140,
},
{
"label": _("Purchase Person"),
"options": "Purchase Person",
"fieldname": "purchase_person",
"fieldtype": "Link",
"width": 140,
},
{"label": _("Contribution %"), "fieldname": "contribution", "fieldtype": "Float", "width": 140},
{
"label": _("Contribution Qty"),
"fieldname": "contribution_qty",
"fieldtype": "Float",
"width": 140,
},
{
"label": _("Contribution Amount"),
"options": "currency",
"fieldname": "contribution_amt",
"fieldtype": "Currency",
"width": 140,
},
{
"label": _("Currency"),
"options": "Currency",
"fieldname": "currency",
"fieldtype": "Link",
"hidden": 1,
},
]
def get_entries(filters):
doc_type = filters["doc_type"]
date_field = "transaction_date" if doc_type == "Purchase Order" else "posting_date"
qty_field = "received_qty" if doc_type == "Purchase Order" else "qty"
dt = frappe.qb.DocType(doc_type)
dt_item = frappe.qb.DocType(f"{doc_type} Item")
pt = frappe.qb.DocType("Purchase Team")
calc_qty = dt_item[qty_field] * dt_item.conversion_factor
calc_net_amount = dt_item.base_net_rate * calc_qty
stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty")
base_net_amount_case = (
Case()
.when(dt.status == "Closed", calc_net_amount)
.else_(dt_item.base_net_amount)
.as_("base_net_amount")
)
contribution_amt_case = (
Case()
.when(dt.status == "Closed", (calc_net_amount * pt.allocated_percentage / 100))
.else_(dt_item.base_net_amount * pt.allocated_percentage / 100)
.as_("contribution_amt")
)
conditions = get_conditions(dt, pt, filters, date_field)
query = (
frappe.qb.from_(dt)
.join(dt_item)
.on(dt.name == dt_item.parent)
.join(pt)
.on(dt.name == pt.parent)
.select(
dt.name,
dt.supplier,
dt[date_field].as_("posting_date"),
dt_item.item_code,
pt.purchase_person,
pt.allocated_percentage,
dt_item.warehouse,
stock_qty_case,
base_net_amount_case,
contribution_amt_case,
)
.where(pt.parenttype == doc_type)
.where(dt.docstatus == 1)
.where(Criterion.all(conditions))
.orderby(pt.purchase_person)
.orderby(dt.name, order=frappe.qb.desc)
)
return query.run(as_dict=True)
def get_conditions(dt, pt, filters, date_field):
conditions = []
for field in ["company", "supplier"]:
if filters.get(field):
conditions.append(dt[field].eq(filters[field]))
if filters.get("purchase_person"):
lft, rgt = frappe.get_value("Purchase Person", filters.get("purchase_person"), ["lft", "rgt"])
purchase_person_tbl = frappe.qb.DocType("Purchase Person")
subquery = (
frappe.qb.from_(purchase_person_tbl)
.select(purchase_person_tbl.name)
.where(purchase_person_tbl.lft >= lft)
.where(purchase_person_tbl.rgt <= rgt)
)
conditions.append(pt.purchase_person.isin(subquery))
if filters.get("from_date"):
conditions.append(dt[date_field].gte(filters["from_date"]))
if filters.get("to_date"):
conditions.append(dt[date_field].lte(filters["to_date"]))
items = get_items(filters)
if items:
conditions.append(
frappe.qb.DocType(f"{filters['doc_type']} Item").item_code.isin([i[0] for i in items])
)
elif filters.get("item_group") or filters.get("brand"):
conditions.append(frappe.qb.terms.ValueWrapper(0).eq(1))
return conditions
def get_items(filters):
item = qb.DocType("Item")
item_query_conditions = []
if filters.get("item_group"):
item_group = qb.DocType("Item Group")
lft, rgt = frappe.db.get_all(
"Item Group", filters={"name": filters.get("item_group")}, fields=["lft", "rgt"], as_list=True
)[0]
item_group_query = (
qb.from_(item_group)
.select(item_group.name)
.where((item_group.lft >= lft) & (item_group.rgt <= rgt))
)
item_query_conditions.append(item.item_group.isin(item_group_query))
if filters.get("brand"):
item_query_conditions.append(item.brand == filters.get("brand"))
if not item_query_conditions:
return []
return qb.from_(item).select(item.name).where(Criterion.all(item_query_conditions)).run()
def get_item_details():
items = frappe.get_all("Item", fields=["name", "item_group", "brand"])
return {d.name: d for d in items}

View File

@@ -23,6 +23,7 @@
"is_query_report": 0,
"label": "Buying",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
@@ -86,6 +87,7 @@
"is_query_report": 0,
"label": "Items & Pricing",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
@@ -171,6 +173,7 @@
"is_query_report": 0,
"label": "Settings",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
@@ -212,6 +215,7 @@
"is_query_report": 0,
"label": "Supplier",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
@@ -264,6 +268,7 @@
"is_query_report": 0,
"label": "Supplier Scorecard",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
@@ -316,6 +321,7 @@
"is_query_report": 0,
"label": "Key Reports",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
@@ -385,11 +391,140 @@
"onboard": 1,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Purchase Partner",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Purchase Partner",
"link_count": 0,
"link_to": "Purchase Partner",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Purchase Partner Type",
"link_count": 0,
"link_to": "Purchase Partner Type",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Purchase Partner",
"hidden": 0,
"is_query_report": 1,
"label": "Purchase Partners Commission",
"link_count": 0,
"link_to": "Purchase Partners Commission",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Purchase Partner",
"hidden": 0,
"is_query_report": 1,
"label": "Purchase Partner Commission Summary",
"link_count": 0,
"link_to": "Purchase Partner Commission Summary",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Purchase Partner",
"hidden": 0,
"is_query_report": 1,
"label": "Purchase Partner Transaction Summary",
"link_count": 0,
"link_to": "Purchase Partner Transaction Summary",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Purchase Partner",
"hidden": 0,
"is_query_report": 1,
"label": "Purchase Partner Target Variance Based On Item Group",
"link_count": 0,
"link_to": "Purchase Partner Target Variance Based On Item Group",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Purchase Person",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Purchase Person",
"link_count": 0,
"link_to": "Purchase Person",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "Purchase Person",
"hidden": 0,
"is_query_report": 1,
"label": "Purchase Person-wise Transaction Summary",
"link_count": 0,
"link_to": "Purchase Person-wise Transaction Summary",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Purchase Person",
"hidden": 0,
"is_query_report": 1,
"label": "Purchase Person Commission Summary",
"link_count": 0,
"link_to": "Purchase Person Commission Summary",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Purchase Person",
"hidden": 0,
"is_query_report": 1,
"label": "Purchase Person Target Variance Based On Item Group",
"link_count": 0,
"link_to": "Purchase Person Target Variance Based On Item Group",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Other Reports",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
@@ -497,6 +632,7 @@
"is_query_report": 0,
"label": "Regional",
"link_count": 0,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
@@ -512,7 +648,7 @@
"type": "Link"
}
],
"modified": "2026-01-02 14:55:59.078773",
"modified": "2026-06-17 13:13:38.489837",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying",

View File

@@ -639,6 +639,14 @@ class AccountsController(TransactionBase):
self.calculate_commission()
self.calculate_contribution()
if self.doctype in (
"Purchase Order",
"Purchase Receipt",
"Purchase Invoice",
):
self.calculate_commission()
self.calculate_contribution()
def validate_date_with_fiscal_year(self):
if self.meta.get_field("fiscal_year"):
date_field = None

View File

@@ -384,6 +384,71 @@ class BuyingController(SubcontractingController):
item=row,
)
def calculate_commission(self):
if not self.meta.get_field("commission_rate"):
return
self.round_floats_in(self, ("amount_eligible_for_commission", "commission_rate"))
if not (0 <= self.commission_rate <= 100.0):
frappe.throw(
"{} {}".format(
_(self.meta.get_label("commission_rate")),
_("must be between 0 and 100"),
)
)
self.amount_eligible_for_commission = sum(
item.base_net_amount for item in self.items if item.grant_commission
)
self.total_commission = flt(
self.amount_eligible_for_commission * self.commission_rate / 100.0,
self.precision("total_commission"),
)
def calculate_contribution(self):
if not self.meta.get_field("purchase_team"):
return
total = 0.0
purchase_team = self.get("purchase_team")
self.validate_purchase_team(purchase_team)
for purchase_person in purchase_team:
self.round_floats_in(purchase_person)
purchase_person.allocated_amount = flt(
flt(self.amount_eligible_for_commission) * purchase_person.allocated_percentage / 100.0,
self.precision("allocated_amount", purchase_person),
)
if purchase_person.commission_rate:
purchase_person.incentives = flt(
purchase_person.allocated_amount * flt(purchase_person.commission_rate) / 100.0,
self.precision("incentives", purchase_person),
)
total += purchase_person.allocated_percentage
if purchase_team and total != 100.0:
frappe.throw(_("Total allocated percentage for purchase team should be 100"))
def validate_purchase_team(self, purchase_team):
purchase_persons = [d.purchase_person for d in purchase_team]
if not purchase_persons:
return
purchase_person_status = frappe.db.get_all(
"Purchase Person", filters={"name": ["in", purchase_persons]}, fields=["name", "enabled"]
)
for row in purchase_person_status:
if not row.enabled:
frappe.throw(_("Purchase Person <b>{0}</b> is disabled.").format(row.name))
def set_total_in_words(self):
from frappe.utils import money_in_words

View File

@@ -87,6 +87,15 @@ erpnext.buying = {
me.frm.set_query("supplier_address", erpnext.queries.address_query);
me.frm.set_query("billing_address", erpnext.queries.company_address_query);
me.frm.set_query("purchase_person", "purchase_team", function () {
return {
filters: {
is_group: 0,
enabled: 1,
},
};
});
erpnext.accounts.dimensions.setup_dimension_filters(me.frm, me.frm.doctype);
this.frm.set_query("item_code", "items", function () {
@@ -473,6 +482,92 @@ erpnext.buying = {
});
}
purchase_partner() {
this.calculate_purchase_commission();
}
commission_rate() {
if (
["Purchase Order", "Purchase Receipt", "Purchase Invoice"].includes(this.frm.doc.doctype)
) {
this.calculate_purchase_commission();
}
}
total_commission() {
if (
!["Purchase Order", "Purchase Receipt", "Purchase Invoice"].includes(this.frm.doc.doctype)
)
return;
frappe.model.round_floats_in(this.frm.doc, [
"amount_eligible_for_commission",
"total_commission",
]);
const { amount_eligible_for_commission } = this.frm.doc;
if (!amount_eligible_for_commission) return;
this.frm.set_value(
"commission_rate",
flt((this.frm.doc.total_commission * 100.0) / amount_eligible_for_commission)
);
}
purchase_team_add(doc, cdt, cdn) {
this.calculate_purchase_contribution();
}
purchase_team_remove() {
this.calculate_purchase_contribution();
}
calculate_purchase_contribution() {
if (!this.frm.fields_dict.purchase_team || this.frm.doc.docstatus === 1) return;
const purchaseTeam = this.frm.doc.purchase_team || [];
let total = 0.0;
purchaseTeam.forEach((row) => {
row.allocated_amount = flt(
(flt(this.frm.doc.amount_eligible_for_commission) * row.allocated_percentage) / 100.0
);
if (row.commission_rate) {
row.incentives = flt((row.allocated_amount * flt(row.commission_rate)) / 100.0);
}
total += flt(row.allocated_percentage);
});
if (purchaseTeam.length && total !== 100.0) {
frappe.msgprint(__("Total allocated percentage for purchase team should be 100"));
}
refresh_field("purchase_team");
}
calculate_purchase_commission() {
if (!this.frm.fields_dict.commission_rate || this.frm.doc.docstatus === 1) return;
if (this.frm.doc.commission_rate < 0 || this.frm.doc.commission_rate > 100) {
frappe.throw(
`${__(
frappe.meta.get_label(this.frm.doc.doctype, "commission_rate", this.frm.doc.name)
)} ${__("must be between 0 and 100")}`
);
}
this.frm.doc.amount_eligible_for_commission = (this.frm.doc.items || []).reduce(
(sum, item) => (item.grant_commission ? sum + item.base_net_amount : sum),
0
);
this.frm.doc.total_commission = flt(
(this.frm.doc.amount_eligible_for_commission * this.frm.doc.commission_rate) / 100.0,
precision("total_commission")
);
refresh_field(["amount_eligible_for_commission", "total_commission"]);
}
add_serial_batch_for_rejected_qty(doc, cdt, cdn) {
let item = locals[cdt][cdn];
let me = this;

View File

@@ -76,6 +76,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
this.calculate_contribution();
}
// Purchase partner commission
if (["Purchase Order", "Purchase Receipt", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
this.calculate_purchase_commission();
}
// Update paid amount on return/debit note creation
if (
this.frm.doc.doctype === "Purchase Invoice" &&

View File

@@ -55,7 +55,9 @@ def get_data(filters, period_list, partner_doctype):
if d.item_group:
sales_user_wise_item_groups[d.parent].append(d.item_group)
date_field = "transaction_date" if filters.get("doctype") == "Sales Order" else "posting_date"
date_field = (
"transaction_date" if filters.get("doctype") in ("Sales Order", "Purchase Order") else "posting_date"
)
actual_data = get_actual_data(filters, sales_users, date_field, sales_field)
@@ -243,6 +245,13 @@ def get_actual_data(filters, sales_users_or_territory_data, date_field, sales_fi
sales_field_col = sales_team[sales_field]
query = query.inner_join(sales_team).on(sales_team.parent == parent_doc.name)
elif sales_field == "purchase_person":
purchase_team = frappe.qb.DocType("Purchase Team")
stock_qty = child_doc.stock_qty * purchase_team.allocated_percentage / 100
net_amount = child_doc.base_net_amount * purchase_team.allocated_percentage / 100
sales_field_col = purchase_team[sales_field]
query = query.inner_join(purchase_team).on(purchase_team.parent == parent_doc.name)
else:
stock_qty = child_doc.stock_qty
net_amount = child_doc.base_net_amount

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.ui.form.on("Purchase Partner", {
refresh: function (frm) {
if (frm.doc.__islocal) {
hide_field(["address_html", "contact_html", "address_contacts"]);
frappe.contacts.clear_address_and_contact(frm);
} else {
unhide_field(["address_html", "contact_html", "address_contacts"]);
frappe.contacts.render_address_and_contact(frm);
}
},
setup: function (frm) {
frm.fields_dict["targets"].grid.get_field("distribution_id").get_query = function (doc, cdt, cdn) {
var row = locals[cdt][cdn];
return {
filters: {
fiscal_year: row.fiscal_year,
},
};
};
},
});

View File

@@ -0,0 +1,145 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:partner_name",
"creation": "2026-06-15 00:00:00",
"description": "A third party agent / broker / commission agent who facilitates purchases for a commission.",
"doctype": "DocType",
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"partner_name",
"partner_type",
"territory",
"column_break0",
"commission_rate",
"address_contacts",
"address_desc",
"address_html",
"column_break1",
"contact_desc",
"contact_html",
"partner_target_details_section_break",
"targets"
],
"fields": [
{
"fieldname": "partner_name",
"fieldtype": "Data",
"label": "Purchase Partner Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "partner_type",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Partner Type",
"options": "Purchase Partner Type"
},
{
"fieldname": "territory",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Territory",
"options": "Territory",
"reqd": 1
},
{
"fieldname": "column_break0",
"fieldtype": "Column Break",
"width": "50%"
},
{
"fieldname": "commission_rate",
"fieldtype": "Float",
"label": "Commission Rate",
"reqd": 1
},
{
"fieldname": "address_contacts",
"fieldtype": "Section Break",
"label": "Address & Contacts"
},
{
"depends_on": "eval:doc.__islocal",
"fieldname": "address_desc",
"fieldtype": "HTML",
"label": "Address Desc"
},
{
"fieldname": "address_html",
"fieldtype": "HTML",
"label": "Address HTML",
"read_only": 1
},
{
"fieldname": "column_break1",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.__islocal",
"fieldname": "contact_desc",
"fieldtype": "HTML",
"label": "Contact Desc"
},
{
"fieldname": "contact_html",
"fieldtype": "HTML",
"label": "Contact HTML",
"read_only": 1
},
{
"fieldname": "partner_target_details_section_break",
"fieldtype": "Section Break",
"label": "Purchase Partner Target"
},
{
"fieldname": "targets",
"fieldtype": "Table",
"label": "Targets",
"options": "Target Detail"
}
],
"icon": "fa fa-user",
"idx": 1,
"links": [],
"modified": "2026-06-15 00:00:00.000000",
"modified_by": "Administrator",
"module": "Setup",
"name": "Purchase Partner",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Purchase Manager"
},
{
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Purchase User"
},
{
"create": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Purchase Master Manager",
"share": 1,
"write": 1
}
],
"show_name_in_global_search": 1,
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}

View File

@@ -0,0 +1,28 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.contacts.address_and_contact import load_address_and_contact
from frappe.model.document import Document
class PurchasePartner(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.setup.doctype.target_detail.target_detail import TargetDetail
commission_rate: DF.Float
partner_name: DF.Data
partner_type: DF.Link | None
targets: DF.Table[TargetDetail]
territory: DF.Link
# end: auto-generated types
def onload(self):
load_address_and_contact(self)

View File

@@ -0,0 +1,165 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.tests.utils import ERPNextTestSuite
class TestPurchasePartner(ERPNextTestSuite):
def setUp(self):
self.partner = make_purchase_partner()
def tearDown(self):
frappe.delete_doc("Purchase Partner", self.partner.name, force=True)
def test_purchase_partner_creation(self):
self.assertEqual(self.partner.commission_rate, 10.0)
self.assertEqual(self.partner.territory, "_Test Territory")
def test_commission_calculated_on_purchase_order(self):
po = create_purchase_order(do_not_submit=True)
po.purchase_partner = self.partner.name
po.commission_rate = self.partner.commission_rate
# grant_commission defaults to 1 fetched from item, ensure it's set
for item in po.items:
item.grant_commission = 1
po.save()
self.assertEqual(po.commission_rate, 10.0)
expected_commission = po.base_net_total * 10.0 / 100.0
self.assertAlmostEqual(po.total_commission, expected_commission, places=2)
self.assertAlmostEqual(po.amount_eligible_for_commission, po.base_net_total, places=2)
def test_commission_zero_when_grant_commission_false(self):
po = create_purchase_order(do_not_submit=True)
po.purchase_partner = self.partner.name
po.commission_rate = 10.0
for item in po.items:
item.grant_commission = 0
po.save()
self.assertEqual(po.total_commission, 0)
self.assertEqual(po.amount_eligible_for_commission, 0)
def test_commission_rate_validation(self):
po = create_purchase_order(do_not_submit=True)
po.purchase_partner = self.partner.name
po.commission_rate = 110.0
with self.assertRaises(frappe.ValidationError):
po.save()
def test_commission_on_purchase_invoice(self):
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
pi = make_purchase_invoice(do_not_save=True)
pi.purchase_partner = self.partner.name
pi.commission_rate = self.partner.commission_rate
for item in pi.items:
item.grant_commission = 1
pi.save()
self.assertEqual(pi.commission_rate, 10.0)
expected_commission = pi.base_net_total * 10.0 / 100.0
self.assertAlmostEqual(pi.total_commission, expected_commission, places=2)
def test_purchase_partner_commission_summary_report(self):
from erpnext.buying.report.purchase_partner_commission_summary.purchase_partner_commission_summary import (
execute,
)
po = create_purchase_order(do_not_submit=True)
po.purchase_partner = self.partner.name
po.commission_rate = 10.0
for item in po.items:
item.grant_commission = 1
po.save()
po.submit()
columns, data = execute(
{
"company": "_Test Company",
"doctype": "Purchase Order",
"purchase_partner": self.partner.name,
}
)
self.assertTrue(len(columns) > 0)
self.assertTrue(any(row.get("purchase_partner") == self.partner.name for row in data))
po.cancel()
def test_purchase_team_contribution_on_purchase_order(self):
purchase_person = make_purchase_person()
po = create_purchase_order(do_not_submit=True)
for item in po.items:
item.grant_commission = 1
po.save()
po.append(
"purchase_team",
{
"purchase_person": purchase_person.purchase_person_name,
"allocated_percentage": 100.0,
},
)
po.save()
self.assertEqual(len(po.purchase_team), 1)
self.assertAlmostEqual(
po.purchase_team[0].allocated_amount, po.amount_eligible_for_commission, places=2
)
frappe.delete_doc("Purchase Person", purchase_person.name, force=True)
def test_purchase_team_total_percentage_validation(self):
purchase_person = make_purchase_person()
po = create_purchase_order(do_not_submit=True)
for item in po.items:
item.grant_commission = 1
po.save()
po.append(
"purchase_team",
{
"purchase_person": purchase_person.purchase_person_name,
"allocated_percentage": 60.0,
},
)
with self.assertRaises(frappe.ValidationError):
po.save()
frappe.delete_doc("Purchase Person", purchase_person.name, force=True)
def make_purchase_partner(**kwargs):
kwargs = frappe._dict(kwargs)
partner = frappe.new_doc("Purchase Partner")
partner.partner_name = kwargs.partner_name or "_Test Purchase Partner"
partner.territory = kwargs.territory or "_Test Territory"
partner.commission_rate = kwargs.commission_rate or 10.0
if not frappe.db.exists("Purchase Partner", partner.partner_name):
partner.insert(ignore_permissions=True)
else:
partner = frappe.get_doc("Purchase Partner", partner.partner_name)
return partner
def make_purchase_person(**kwargs):
kwargs = frappe._dict(kwargs)
name = kwargs.purchase_person_name or "_Test Purchase Person"
if frappe.db.exists("Purchase Person", name):
frappe.delete_doc("Purchase Person", name, force=True)
person = frappe.get_doc(
{
"doctype": "Purchase Person",
"purchase_person_name": name,
"commission_rate": kwargs.commission_rate or "10",
"enabled": 1,
}
)
person.insert(ignore_permissions=True)
return person

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.ui.form.on("Purchase Person", {
refresh: function (frm) {
if (frm.doc.__onload && frm.doc.__onload.dashboard_info) {
let info = frm.doc.__onload.dashboard_info;
frm.dashboard.add_indicator(
__("Total Contribution Amount Against Orders: {0}", [
format_currency(info.allocated_amount_against_order, info.currency),
]),
"blue"
);
frm.dashboard.add_indicator(
__("Total Contribution Amount Against Invoices: {0}", [
format_currency(info.allocated_amount_against_invoice, info.currency),
]),
"blue"
);
}
frm.trigger("set_root_readonly");
},
setup: function (frm) {
frm.fields_dict["targets"].grid.get_field("distribution_id").get_query = function (doc, cdt, cdn) {
var row = locals[cdt][cdn];
return {
filters: {
fiscal_year: row.fiscal_year,
},
};
};
frm.make_methods = {
"Purchase Order": () =>
frappe
.new_doc("Purchase Order")
.then(() => frm.add_child("purchase_team", { purchase_person: frm.doc.name })),
};
},
set_root_readonly: function (frm) {
if (!frm.doc.parent_purchase_person && !frm.doc.__islocal) {
frm.set_read_only();
frm.set_intro(__("This is a root purchase person and cannot be edited."));
} else {
frm.set_intro(null);
}
},
});
cur_frm.fields_dict["parent_purchase_person"].get_query = function (doc, cdt, cdn) {
return {
filters: [
["Purchase Person", "is_group", "=", 1],
["Purchase Person", "name", "!=", doc.purchase_person_name],
],
};
};
cur_frm.fields_dict.employee.get_query = function (doc, cdt, cdn) {
return { query: "erpnext.controllers.queries.employee_query" };
};

View File

@@ -0,0 +1,181 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:purchase_person_name",
"creation": "2026-06-17 00:00:00",
"description": "All Purchase Transactions can be tagged against multiple Purchase Persons so that you can set and monitor targets.",
"doctype": "DocType",
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"name_and_employee_id",
"purchase_person_name",
"parent_purchase_person",
"commission_rate",
"is_group",
"enabled",
"cb0",
"employee",
"department",
"lft",
"rgt",
"old_parent",
"target_details_section_break",
"targets"
],
"fields": [
{
"fieldname": "name_and_employee_id",
"fieldtype": "Section Break",
"label": "Name and Employee ID",
"options": "icon-user"
},
{
"fieldname": "purchase_person_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Purchase Person Name",
"reqd": 1,
"unique": 1
},
{
"description": "Select company name first.",
"fieldname": "parent_purchase_person",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Parent Purchase Person",
"options": "Purchase Person"
},
{
"fieldname": "commission_rate",
"fieldtype": "Data",
"label": "Commission Rate",
"print_hide": 1
},
{
"default": "0",
"fieldname": "is_group",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Is Group",
"reqd": 1
},
{
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "cb0",
"fieldtype": "Column Break"
},
{
"fieldname": "employee",
"fieldtype": "Link",
"label": "Employee",
"options": "Employee"
},
{
"fetch_from": "employee.department",
"fieldname": "department",
"fieldtype": "Link",
"label": "Department",
"options": "Department",
"read_only": 1
},
{
"fieldname": "lft",
"fieldtype": "Int",
"hidden": 1,
"label": "lft",
"no_copy": 1,
"print_hide": 1,
"read_only": 1,
"search_index": 1
},
{
"fieldname": "rgt",
"fieldtype": "Int",
"hidden": 1,
"label": "rgt",
"no_copy": 1,
"print_hide": 1,
"read_only": 1,
"search_index": 1
},
{
"fieldname": "old_parent",
"fieldtype": "Data",
"hidden": 1,
"label": "Old Parent",
"no_copy": 1,
"print_hide": 1
},
{
"fieldname": "target_details_section_break",
"fieldtype": "Section Break",
"label": "Target Details"
},
{
"fieldname": "targets",
"fieldtype": "Table",
"label": "Target Details",
"options": "Target Detail"
}
],
"icon": "fa fa-user",
"idx": 1,
"is_tree": 1,
"links": [],
"max_attachments": 0,
"modified": "2026-06-17 00:00:00",
"modified_by": "Administrator",
"module": "Setup",
"name": "Purchase Person",
"nsm_parent_field": "parent_purchase_person",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Purchase Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Purchase User",
"share": 1
}
],
"quick_entry": 0,
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -0,0 +1,148 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import defaultdict
from itertools import chain
import frappe
from frappe import _
from frappe.query_builder import Interval
from frappe.query_builder.functions import Count, CurDate, UnixTimestamp
from frappe.utils import flt
from frappe.utils.nestedset import NestedSet, get_root_of
from erpnext import get_default_currency
class PurchasePerson(NestedSet):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.setup.doctype.target_detail.target_detail import TargetDetail
commission_rate: DF.Data | None
department: DF.Link | None
employee: DF.Link | None
enabled: DF.Check
is_group: DF.Check
lft: DF.Int
old_parent: DF.Data | None
parent_purchase_person: DF.Link | None
purchase_person_name: DF.Data
rgt: DF.Int
targets: DF.Table[TargetDetail]
# end: auto-generated types
nsm_parent_field = "parent_purchase_person"
def validate(self):
if not self.enabled:
self.validate_purchase_person()
if not self.parent_purchase_person:
self.parent_purchase_person = get_root_of("Purchase Person")
for d in self.get("targets") or []:
if not flt(d.target_qty) and not flt(d.target_amount):
frappe.throw(_("Either target qty or target amount is mandatory."))
self.validate_employee_id()
def onload(self):
self.load_dashboard_info()
def load_dashboard_info(self):
company_default_currency = get_default_currency()
allocated_amount_against_order = flt(
frappe.db.get_value(
"Purchase Team",
{
"docstatus": 1,
"parenttype": "Purchase Order",
"purchase_person": self.purchase_person_name,
},
[{"SUM": "allocated_amount"}],
)
)
allocated_amount_against_invoice = flt(
frappe.db.get_value(
"Purchase Team",
{
"docstatus": 1,
"parenttype": "Purchase Invoice",
"purchase_person": self.purchase_person_name,
},
[{"SUM": "allocated_amount"}],
)
)
info = {}
info["allocated_amount_against_order"] = allocated_amount_against_order
info["allocated_amount_against_invoice"] = allocated_amount_against_invoice
info["currency"] = company_default_currency
self.set_onload("dashboard_info", info)
def on_update(self):
super().on_update()
self.validate_one_root()
def validate_purchase_person(self):
purchase_team = frappe.qb.DocType("Purchase Team")
query = (
frappe.qb.from_(purchase_team)
.select(purchase_team.purchase_person)
.where(purchase_team.purchase_person == self.name)
.groupby(purchase_team.purchase_person)
).run(as_dict=True)
if query:
frappe.throw(_("The Purchase Person {0} is linked with existing transactions.").format(self.name))
def validate_employee_id(self):
if self.employee:
purchase_person = frappe.db.get_value("Purchase Person", {"employee": self.employee})
if purchase_person and purchase_person != self.name:
frappe.throw(
_("Another Purchase Person {0} exists with the same Employee id").format(purchase_person)
)
def on_doctype_update():
frappe.db.add_index("Purchase Person", ["lft", "rgt"])
def get_timeline_data(doctype: str, name: str) -> dict[int, int]:
def _fetch_activity(doctype: str, date_field: str):
purchase_team = frappe.qb.DocType("Purchase Team")
transaction = frappe.qb.DocType(doctype)
return dict(
frappe.qb.from_(transaction)
.join(purchase_team)
.on(transaction.name == purchase_team.parent)
.select(UnixTimestamp(transaction[date_field]), Count("*"))
.where(purchase_team.purchase_person == name)
.where(transaction[date_field] > CurDate() - Interval(years=1))
.groupby(transaction[date_field])
.run()
)
purchase_order_activity = _fetch_activity("Purchase Order", "transaction_date")
purchase_invoice_activity = _fetch_activity("Purchase Invoice", "posting_date")
merged_activities = defaultdict(int)
for ts, count in chain(purchase_order_activity.items(), purchase_invoice_activity.items()):
merged_activities[ts] += count
return merged_activities

View File

@@ -0,0 +1,23 @@
frappe.treeview_settings["Purchase Person"] = {
fields: [
{
fieldtype: "Data",
fieldname: "purchase_person_name",
label: __("New Purchase Person Name"),
reqd: true,
},
{
fieldtype: "Link",
fieldname: "employee",
label: __("Employee"),
options: "Employee",
description: __("Please enter Employee Id of this purchase person"),
},
{
fieldtype: "Check",
fieldname: "is_group",
label: __("Group Node"),
description: __("Further nodes can be only created under 'Group' type nodes"),
},
],
};

View File

@@ -0,0 +1,72 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestPurchasePerson(ERPNextTestSuite):
def setUp(self):
frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person"})
frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person Group"})
def tearDown(self):
frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person"})
frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person Group"})
def test_create_purchase_person(self):
purchase_person = frappe.get_doc(
{
"doctype": "Purchase Person",
"purchase_person_name": "_Test Purchase Person",
"commission_rate": "10",
"enabled": 1,
}
)
purchase_person.insert(ignore_permissions=True)
self.assertEqual(purchase_person.purchase_person_name, "_Test Purchase Person")
self.assertTrue(purchase_person.enabled)
def test_create_purchase_person_group(self):
group = frappe.get_doc(
{
"doctype": "Purchase Person",
"purchase_person_name": "_Test Purchase Person Group",
"is_group": 1,
"enabled": 1,
}
)
group.insert(ignore_permissions=True)
self.assertTrue(group.is_group)
self.assertTrue(group.lft)
self.assertTrue(group.rgt)
def test_duplicate_employee_raises_error(self):
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
if not employee:
return
pp1 = frappe.get_doc(
{
"doctype": "Purchase Person",
"purchase_person_name": "_Test Purchase Person",
"employee": employee,
"enabled": 1,
}
)
pp1.insert(ignore_permissions=True)
pp2 = frappe.get_doc(
{
"doctype": "Purchase Person",
"purchase_person_name": "_Test Purchase Person 2",
"employee": employee,
"enabled": 1,
}
)
self.assertRaises(frappe.ValidationError, pp2.insert)
frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person 2"})

View File

@@ -0,0 +1,7 @@
Channel Partner
Distributor
Dealer
Agent
Retailer
Implementation Partner
Reseller

View File

@@ -248,6 +248,13 @@ def get_preset_records(country=None):
"is_group": 1,
"parent_sales_person": "",
},
# Purchase Person
{
"doctype": "Purchase Person",
"purchase_person_name": _("Purchase Team"),
"is_group": 1,
"parent_purchase_person": "",
},
# Mode of Payment
{
"doctype": "Mode of Payment",
@@ -328,6 +335,7 @@ def install(country=None):
("Industry Type", "industry", "industry_type.txt"),
("UTM Source", "name", "marketing_source.txt"),
("Sales Partner Type", "sales_partner_type", "sales_partner_type.txt"),
("Purchase Partner Type", "purchase_partner_type", "purchase_partner_type.txt"),
):
records += [{"doctype": doctype, title_field: title} for title in read_lines(filename)]

View File

@@ -126,6 +126,14 @@
"terms_tab",
"tc_name",
"terms",
"commission_section",
"purchase_partner",
"amount_eligible_for_commission",
"column_break_commission",
"commission_rate",
"total_commission",
"purchase_team_section",
"purchase_team",
"more_info_tab",
"status_section",
"status",
@@ -1286,6 +1294,66 @@
{
"fieldname": "column_break_ugyv",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"collapsible_depends_on": "purchase_partner",
"fieldname": "commission_section",
"fieldtype": "Section Break",
"label": "Commission",
"print_hide": 1
},
{
"fieldname": "purchase_partner",
"fieldtype": "Link",
"label": "Purchase Partner",
"options": "Purchase Partner",
"print_hide": 1
},
{
"fieldname": "amount_eligible_for_commission",
"fieldtype": "Currency",
"label": "Amount Eligible for Commission",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "column_break_commission",
"fieldtype": "Column Break",
"print_hide": 1
},
{
"fetch_from": "purchase_partner.commission_rate",
"fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"label": "Commission Rate (%)",
"print_hide": 1
},
{
"fieldname": "total_commission",
"fieldtype": "Currency",
"label": "Total Commission",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "purchase_team",
"fieldname": "purchase_team_section",
"fieldtype": "Section Break",
"label": "Purchase Team",
"print_hide": 1
},
{
"allow_on_submit": 1,
"fieldname": "purchase_team",
"fieldtype": "Table",
"label": "Purchase Contributions and Incentives",
"options": "Purchase Team",
"print_hide": 1
}
],
"grid_page_length": 50,

View File

@@ -126,6 +126,7 @@
"dimension_col_break",
"cost_center",
"section_break_80",
"grant_commission",
"page_break",
"sales_order",
"sales_order_item",
@@ -1117,6 +1118,15 @@
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fetch_from": "item_code.grant_commission",
"fieldname": "grant_commission",
"fieldtype": "Check",
"label": "Grant Commission",
"print_hide": 1,
"read_only": 1
}
],
"idx": 1,
@@ -1134,4 +1144,4 @@
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}