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>
This commit is contained in:
Dipen Gala
2026-06-15 17:39:59 +05:30
parent 292bfa2a34
commit 0147312951
29 changed files with 1187 additions and 8 deletions

View File

@@ -167,6 +167,12 @@
"terms_section_break",
"tc_name",
"terms",
"commission_section",
"purchase_partner",
"amount_eligible_for_commission",
"column_break_commission",
"commission_rate",
"total_commission",
"more_info_tab",
"status_section",
"status",
@@ -1683,6 +1689,50 @@
"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
}
],
"grid_page_length": 50,
@@ -1755,4 +1805,4 @@
"timeline_field": "supplier",
"title_field": "supplier_name",
"track_changes": 1
}
}

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 / sum(amount_eligible_for_commission) 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,12 @@
"terms_section_break",
"tc_name",
"terms",
"commission_section",
"purchase_partner",
"amount_eligible_for_commission",
"column_break_commission",
"commission_rate",
"total_commission",
"more_info_tab",
"tracking_section",
"status",
@@ -1291,6 +1297,50 @@
"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
}
],
"grid_page_length": 50,
@@ -1354,4 +1404,4 @@
"timeline_field": "supplier",
"title_field": "supplier_name",
"track_changes": 1
}
}

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,52 @@
// 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",
},
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "Link",
options: "Territory",
},
],
};

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,165 @@
# 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,
self.dt.territory,
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", "territory", "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(_("Territory"), "territory", "Link", 100, "Territory")
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,70 @@
// 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: "territory",
label: __("Territory"),
fieldtype: "Link",
options: "Territory",
},
{
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,74 @@
# 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(_("Territory"), "territory", "Link", 100, "Territory")
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

@@ -385,6 +385,69 @@
"onboard": 1,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Purchase Partner",
"link_count": 0,
"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"
},
{
"hidden": 0,
"is_query_report": 0,
@@ -540,4 +603,4 @@
"shortcuts": [],
"title": "Buying",
"type": "Workspace"
}
}

View File

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

View File

@@ -384,6 +384,31 @@ 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):
from frappe import throw
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 set_total_in_words(self):
from frappe.utils import money_in_words

View File

@@ -473,7 +473,69 @@ erpnext.buying = {
});
}
add_serial_batch_for_rejected_qty(doc, cdt, cdn) {
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)
);
}
calculate_purchase_commission() {
if (!this.frm.fields_dict.commission_rate || this.frm.doc.docstatus === 1) return;
if (this.frm.doc.commission_rate > 100) {
this.frm.set_value("commission_rate", 100);
frappe.throw(
`${__(
frappe.meta.get_label(
this.frm.doc.doctype,
"commission_rate",
this.frm.doc.name
)
)} ${__("cannot be greater than 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;
let fields = ["has_batch_no", "has_serial_no"];

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

@@ -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,105 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.tests import IntegrationTestCase
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 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

View File

@@ -126,6 +126,12 @@
"terms_tab",
"tc_name",
"terms",
"commission_section",
"purchase_partner",
"amount_eligible_for_commission",
"column_break_commission",
"commission_rate",
"total_commission",
"more_info_tab",
"status_section",
"status",
@@ -1286,6 +1292,50 @@
{
"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
}
],
"grid_page_length": 50,
@@ -1363,4 +1413,4 @@
"timeline_field": "supplier",
"title_field": "supplier_name",
"track_changes": 1
}
}

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": []
}
}