diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index f5ad73eae0f..0bc8cfc4ab2 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -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 -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index 5269fec916c..653b12e3c33 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -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": [] -} +} \ No newline at end of file diff --git a/erpnext/accounts/report/purchase_partners_commission/purchase_partners_commission.json b/erpnext/accounts/report/purchase_partners_commission/purchase_partners_commission.json new file mode 100644 index 00000000000..f6d84aedb50 --- /dev/null +++ b/erpnext/accounts/report/purchase_partners_commission/purchase_partners_commission.json @@ -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 +} diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 46d7d2293b2..b83e17c2c4f 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -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 -} +} \ No newline at end of file diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index b0c75c49d9e..bd9a68a7a24 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -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 -} +} \ No newline at end of file diff --git a/erpnext/buying/doctype/purchase_partner_type/__init__.py b/erpnext/buying/doctype/purchase_partner_type/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/buying/doctype/purchase_partner_type/purchase_partner_type.json b/erpnext/buying/doctype/purchase_partner_type/purchase_partner_type.json new file mode 100644 index 00000000000..976a18d29fb --- /dev/null +++ b/erpnext/buying/doctype/purchase_partner_type/purchase_partner_type.json @@ -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 +} diff --git a/erpnext/buying/doctype/purchase_partner_type/purchase_partner_type.py b/erpnext/buying/doctype/purchase_partner_type/purchase_partner_type.py new file mode 100644 index 00000000000..4a4ebec79aa --- /dev/null +++ b/erpnext/buying/doctype/purchase_partner_type/purchase_partner_type.py @@ -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 diff --git a/erpnext/buying/doctype/purchase_partner_type/test_purchase_partner_type.py b/erpnext/buying/doctype/purchase_partner_type/test_purchase_partner_type.py new file mode 100644 index 00000000000..cb6da169c94 --- /dev/null +++ b/erpnext/buying/doctype/purchase_partner_type/test_purchase_partner_type.py @@ -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) diff --git a/erpnext/buying/report/purchase_partner_commission_summary/__init__.py b/erpnext/buying/report/purchase_partner_commission_summary/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.js b/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.js new file mode 100644 index 00000000000..b142dd84d8c --- /dev/null +++ b/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.js @@ -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", + }, + ], +}; diff --git a/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.json b/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.json new file mode 100644 index 00000000000..c301cf5c97e --- /dev/null +++ b/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.json @@ -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" + } + ] +} diff --git a/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.py b/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.py new file mode 100644 index 00000000000..fe62135c7d0 --- /dev/null +++ b/erpnext/buying/report/purchase_partner_commission_summary/purchase_partner_commission_summary.py @@ -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, + ) diff --git a/erpnext/buying/report/purchase_partner_transaction_summary/__init__.py b/erpnext/buying/report/purchase_partner_transaction_summary/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.js b/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.js new file mode 100644 index 00000000000..53260873823 --- /dev/null +++ b/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.js @@ -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, + }, + ], +}; diff --git a/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.json b/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.json new file mode 100644 index 00000000000..e1e80741653 --- /dev/null +++ b/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.json @@ -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" + } + ] +} diff --git a/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.py b/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.py new file mode 100644 index 00000000000..58b92c54130 --- /dev/null +++ b/erpnext/buying/report/purchase_partner_transaction_summary/purchase_partner_transaction_summary.py @@ -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)) diff --git a/erpnext/buying/workspace/buying/buying.json b/erpnext/buying/workspace/buying/buying.json index 6bc20901467..87860975e94 100644 --- a/erpnext/buying/workspace/buying/buying.json +++ b/erpnext/buying/workspace/buying/buying.json @@ -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" -} +} \ No newline at end of file diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 4dcb6b00b49..efec0c8c7c3 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -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 diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 1b5574b764f..52f46691784 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -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 diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 5c556287c6c..093910a21ea 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -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"]; diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index dc4f1e3bd61..3d4e7357700 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -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" && diff --git a/erpnext/setup/doctype/purchase_partner/__init__.py b/erpnext/setup/doctype/purchase_partner/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/setup/doctype/purchase_partner/purchase_partner.js b/erpnext/setup/doctype/purchase_partner/purchase_partner.js new file mode 100644 index 00000000000..33d419d6d31 --- /dev/null +++ b/erpnext/setup/doctype/purchase_partner/purchase_partner.js @@ -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, + }, + }; + }; + }, +}); diff --git a/erpnext/setup/doctype/purchase_partner/purchase_partner.json b/erpnext/setup/doctype/purchase_partner/purchase_partner.json new file mode 100644 index 00000000000..99460fc8b30 --- /dev/null +++ b/erpnext/setup/doctype/purchase_partner/purchase_partner.json @@ -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": [] +} diff --git a/erpnext/setup/doctype/purchase_partner/purchase_partner.py b/erpnext/setup/doctype/purchase_partner/purchase_partner.py new file mode 100644 index 00000000000..0d4ad202439 --- /dev/null +++ b/erpnext/setup/doctype/purchase_partner/purchase_partner.py @@ -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) diff --git a/erpnext/setup/doctype/purchase_partner/test_purchase_partner.py b/erpnext/setup/doctype/purchase_partner/test_purchase_partner.py new file mode 100644 index 00000000000..5d32b4a75ab --- /dev/null +++ b/erpnext/setup/doctype/purchase_partner/test_purchase_partner.py @@ -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 diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index ecb44ee544f..27ec40168d3 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -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 -} +} \ No newline at end of file diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 6409e05724b..83ef5128341 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -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": [] -} +} \ No newline at end of file