From 11fc3e5495fefee663bc8aa46c9793fb5c12ba5d Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Tue, 21 Apr 2026 08:42:22 +0530 Subject: [PATCH] refactor: `Sales Partner Commission Summary` and `Sales Partner Transaction Summary` report (#54268) --- .../sales_partner_commission_summary.py | 248 ++++++----- .../test_sales_partner_commission_summary.py | 395 ++++++++++++++++++ .../sales_partner_transaction_summary.js | 16 +- .../sales_partner_transaction_summary.py | 174 +++----- .../test_sales_partner_transaction_summary.py | 183 ++++++++ 5 files changed, 794 insertions(+), 222 deletions(-) create mode 100644 erpnext/selling/report/sales_partner_commission_summary/test_sales_partner_commission_summary.py create mode 100644 erpnext/selling/report/sales_partner_transaction_summary/test_sales_partner_transaction_summary.py diff --git a/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py b/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py index 5e07eb5d8a8..5b98c4bf386 100644 --- a/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py +++ b/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py @@ -1,122 +1,176 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import frappe -from frappe import _, msgprint +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 + +SALES_TRANSACTION_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note", "POS Invoice"] def execute(filters=None): if not filters: filters = {} - columns = get_columns(filters) - data = get_entries(filters) - - return columns, data + return SalesPartnerCommissionSummaryReport(filters).run() -def get_columns(filters): - if not filters.get("doctype"): - msgprint(_("Please select the document type first"), raise_exception=1) +class SalesPartnerSummaryReport: + """ + Base class to generate Sales Partner Summary related Reports. + """ - columns = [ - { - "label": _(filters["doctype"]), - "options": filters["doctype"], - "fieldname": "name", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Customer"), - "options": "Customer", - "fieldname": "customer", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Currency"), - "fieldname": "currency", - "fieldtype": "Data", - "width": 80, - }, - { - "label": _("Territory"), - "options": "Territory", - "fieldname": "territory", - "fieldtype": "Link", - "width": 100, - }, - {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, - { - "label": _("Amount"), - "fieldname": "amount", - "fieldtype": "Currency", - "options": "currency", - "width": 120, - }, - { - "label": _("Sales Partner"), - "options": "Sales Partner", - "fieldname": "sales_partner", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Commission Rate %"), - "fieldname": "commission_rate", - "fieldtype": "Data", - "width": 100, - }, - { - "label": _("Total Commission"), - "fieldname": "total_commission", - "fieldtype": "Currency", - "options": "currency", - "width": 120, - }, - ] + dt: DocType + date_field: str + date_label: str + columns: list + data: list + query: QueryBuilder + filters: dict - return columns + def __init__(self, filters: dict): + self.filters = filters + self.columns = [] + def run(self): + self.validate_filters() + self.prepare_columns() + self.get_data() -def get_entries(filters): - date_field = "transaction_date" if filters.get("doctype") == "Sales Order" else "posting_date" - company_currency = frappe.db.get_value("Company", filters.get("company"), "default_currency") - conditions = get_conditions(filters, date_field) - entries = frappe.db.sql( + 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 SALES_TRANSACTION_DOCTYPES: + frappe.throw(_("DocType can be one of them {0}").format(comma_or(SALES_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") == "Sales Order" else "posting_date" + ) + self.date_label = _("Order Date") if self.date_field == "transaction_date" else _("Posting Date") + + def prepare_columns(self): """ - SELECT - name, customer, territory, {} as posting_date, base_net_total as amount, - sales_partner, commission_rate, total_commission, '{}' as currency - FROM - `tab{}` - WHERE - {} and docstatus = 1 and sales_partner is not null - and sales_partner != '' order by name desc, sales_partner - """.format(date_field, company_currency, filters.get("doctype"), conditions), - filters, - as_dict=1, - ) + Extend this method to add columns on the report. Use `make_column` to add more columns. + """ + raise NotImplementedError - return entries + 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.customer, + self.dt.territory, + Field(self.date_field, "posting_date", table=self.dt), + self.dt.sales_partner, + self.dt.commission_rate, + ConstantColumn(company_currency).as_("currency"), + ) + .where( + (self.dt.docstatus == 1) & (self.dt.sales_partner.notnull()) & (self.dt.sales_partner != "") + ) + .orderby(self.dt.name, order=Order.desc) + .orderby(self.dt.sales_partner) + ) + + def extend_report_query(self): + """ + Extend this method to select more columns on the query. + """ + pass + + def _apply_common_filters(self): + for field in ["company", "customer", "territory", "sales_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): + """ + Extend this method to add more conditions on the query. + """ + 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, + ) + ) -def get_conditions(filters, date_field): - conditions = "1=1" +class SalesPartnerCommissionSummaryReport(SalesPartnerSummaryReport): + def prepare_columns(self): + self.make_column(_(self.filters.get("doctype")), "name", "Link", options=self.filters.get("doctype")) - for field in ["company", "customer", "territory"]: - if filters.get(field): - conditions += f" and {field} = %({field})s" + self.make_column(_("Customer"), "customer", "Link", options="Customer") - if filters.get("sales_partner"): - conditions += " and sales_partner = %(sales_partner)s" + self.make_column(_("Currency"), "currency", "Data", 80, hidden=1) - if filters.get("from_date"): - conditions += f" and {date_field} >= %(from_date)s" + self.make_column(_("Territory"), "territory", "Link", 100, "Territory") - if filters.get("to_date"): - conditions += f" and {date_field} <= %(to_date)s" + self.make_column(self.date_label, "posting_date", "Date") - return conditions + self.make_column(_("Amount"), "amount", "Currency", 120, "currency") + + self.make_column(_("Sales Partner"), "sales_partner", "Link", options="Sales 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/selling/report/sales_partner_commission_summary/test_sales_partner_commission_summary.py b/erpnext/selling/report/sales_partner_commission_summary/test_sales_partner_commission_summary.py new file mode 100644 index 00000000000..9a46bcb85db --- /dev/null +++ b/erpnext/selling/report/sales_partner_commission_summary/test_sales_partner_commission_summary.py @@ -0,0 +1,395 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.desk.query_report import run +from frappe.utils.data import comma_or + +from erpnext.selling.report.sales_partner_commission_summary.sales_partner_commission_summary import ( + SALES_TRANSACTION_DOCTYPES, +) +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry +from erpnext.tests.utils import ERPNextTestSuite + + +class SalesPartnerSummaryReportTestMixin(ERPNextTestSuite): + def assert_doctype_filters(self): + self.filters["doctype"] = "Purchase Invoice" + + with self.assertRaisesRegex( + frappe.ValidationError, + _("DocType can be one of them {0}").format(comma_or(SALES_TRANSACTION_DOCTYPES)), + ): + run(self.report_name, self.filters) + + def assert_posting_date_label(self): + data = run(self.report_name, self.filters) + + posting_date_column = next( + (column for column in data.get("columns") if column.fieldname == "posting_date"), None + ) + + self.assertNotEqual(posting_date_column.get("label"), "Posting Date") + self.assertEqual(posting_date_column.get("label"), "Order Date") + + self.filters["doctype"] = "Sales Invoice" + + data = run(self.report_name, self.filters) + + posting_date_column = next( + (column for column in data.get("columns") if column.fieldname == "posting_date"), None + ) + + self.assertEqual(posting_date_column.get("label"), "Posting Date") + self.assertNotEqual(posting_date_column.get("label"), "Order Date") + + def create_transactions(self, doctype): + from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import ( + POSInvoiceTestMixin, + create_pos_invoice, + ) + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + make_transaction_funcs = { + "Sales Order": make_sales_order, + "Sales Invoice": create_sales_invoice, + "Delivery Note": create_delivery_note, + "POS Invoice": create_pos_invoice, + } + self.date_field = "transaction_date" if doctype == "Sales Order" else "posting_date" + + self.make_transaction_func = make_transaction_funcs[doctype] + + make_stock_entry( + item_code="_Test Item 2", + qty=10, + company="_Test Company", + to_warehouse="_Test Warehouse - _TC", + purpose="Material Receipt", + posting_date="2026-01-01", + ) + + if doctype == "POS Invoice": + POSInvoiceTestMixin.setUp(self) + + from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry + + pos_opening_entry = create_opening_entry(self.pos_profile, self.test_user.name, get_obj=1) + + self.transaction_doc_with_7pc_commision() + self.transaction_doc_with_5pc_commission() + self.transaction_doc_with_no_sales_partner() + self.transaction_doc_date_out_of_range_of_filters() + self.transaction_doc_with_revoked_commission() + self.transaction_doc_not_submitted() + self.transaction_doc_cancelled() + + if doctype == "Sales Order": + return + + self.transaction_doc_returned() + + if doctype == "POS Invoice": + pos_opening_entry.cancel() + + def transaction_doc_with_7pc_commision(self): + args = {"rate": 100, "qty": 10, self.date_field: "2026-01-14", "do_not_save": 1} + self.seven_pc_doc = self.make_transaction_func(**args) + self.seven_pc_doc.sales_partner = "_Test Sales Partner India - 1" + if self.seven_pc_doc.doctype == "POS Invoice": + self.seven_pc_doc.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1}) + + self.seven_pc_doc.save() + self.seven_pc_doc.submit() + + def transaction_doc_with_5pc_commission(self): + args = {"rate": 20, "qty": 6, self.date_field: "2026-01-15", "do_not_save": 1} + self.five_pc_doc = self.make_transaction_func(**args) + self.five_pc_doc.sales_partner = "_Test Sales Partner India - 2" + + self.five_pc_doc.append( + "items", + { + "item_code": "_Test Item 2", + "qty": 4, + "rate": 30, + }, + ) + + if self.five_pc_doc.doctype == "POS Invoice": + self.five_pc_doc.append("payments", {"mode_of_payment": "Cash", "amount": 500, "default": 1}) + + self.five_pc_doc.save() + self.five_pc_doc.submit() + + def transaction_doc_with_no_sales_partner(self): + args = { + "item_code": "_Test Item", + "rate": 50, + "qty": 10, + self.date_field: "2026-01-19", + "do_not_save": 1, + } + self.no_sp_doc = self.make_transaction_func(**args) + if self.no_sp_doc.doctype == "POS Invoice": + self.no_sp_doc.append("payments", {"mode_of_payment": "Cash", "amount": 500, "default": 1}) + + self.no_sp_doc.save() + self.no_sp_doc.submit() + + def transaction_doc_date_out_of_range_of_filters(self): + args = { + "item_code": "_Test Item", + "rate": 60, + "qty": 10, + self.date_field: "2026-02-04", + "do_not_save": 1, + } + self.date_out_of_range_doc = self.make_transaction_func(**args) + self.date_out_of_range_doc.sales_partner = "_Test Sales Partner India - 1" + if self.date_out_of_range_doc.doctype == "POS Invoice": + self.date_out_of_range_doc.append( + "payments", {"mode_of_payment": "Cash", "amount": 600, "default": 1} + ) + + self.date_out_of_range_doc.save() + self.date_out_of_range_doc.submit() + + def transaction_doc_with_revoked_commission(self): + try: + frappe.db.set_value("Item", "_Test Item", "grant_commission", 0) + args = { + "item_code": "_Test Item", + "rate": 80, + "qty": 10, + self.date_field: "2026-01-26", + "do_not_save": 1, + } + self.revoked_comm_doc = self.make_transaction_func(**args) + self.revoked_comm_doc.sales_partner = "_Test Sales Partner India - 1" + + if self.revoked_comm_doc.doctype == "POS Invoice": + self.revoked_comm_doc.append( + "payments", {"mode_of_payment": "Cash", "amount": 800, "default": 1} + ) + + self.revoked_comm_doc.save() + self.revoked_comm_doc.submit() + finally: + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + + def transaction_doc_not_submitted(self): + args = { + "item_code": "_Test Item", + "rate": 80, + "qty": 10, + self.date_field: "2026-01-26", + "do_not_save": 1, + } + self.doc_not_submitted = self.make_transaction_func(**args) + self.doc_not_submitted.set(self.date_field, "2026-01-26") + self.doc_not_submitted.sales_partner = "_Test Sales Partner India - 1" + if self.doc_not_submitted.doctype == "POS Invoice": + self.doc_not_submitted.append( + "payments", {"mode_of_payment": "Cash", "amount": 800, "default": 1} + ) + + self.doc_not_submitted.save() + + def transaction_doc_cancelled(self): + args = { + "item_code": "_Test Item", + "rate": 80, + "qty": 10, + self.date_field: "2026-01-26", + "do_not_save": 1, + } + self.cancelled_doc = self.make_transaction_func(**args) + self.cancelled_doc.sales_partner = "_Test Sales Partner India - 1" + if self.cancelled_doc.doctype == "POS Invoice": + self.cancelled_doc.append("payments", {"mode_of_payment": "Cash", "amount": 800, "default": 1}) + + self.cancelled_doc.save() + self.cancelled_doc.submit() + self.cancelled_doc.cancel() + + def transaction_doc_returned(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + args = { + "item_code": "_Test Item", + "rate": 90, + "qty": 10, + self.date_field: "2026-01-18", + "do_not_save": 1, + } + self.to_be_returned_doc = self.make_transaction_func(**args) + self.to_be_returned_doc.sales_partner = "_Test Sales Partner India - 2" + if self.to_be_returned_doc.doctype == "POS Invoice": + self.to_be_returned_doc.append( + "payments", {"mode_of_payment": "Cash", "amount": 900, "default": 1} + ) + + self.to_be_returned_doc.save() + self.to_be_returned_doc.submit() + + self.returned_doc = make_return_doc(self.to_be_returned_doc.doctype, self.to_be_returned_doc.name) + self.returned_doc.posting_date = "2026-01-19" + if self.returned_doc.doctype == "POS Invoice": + self.returned_doc.payments = [] + self.returned_doc.append("payments", {"mode_of_payment": "Cash", "amount": -900, "default": 1}) + + self.returned_doc.save() + self.returned_doc.submit() + + +class TestSalesPartnerCommissionSummary(SalesPartnerSummaryReportTestMixin): + def setUp(self): + self.filters = { + "company": "_Test Company", + "doctype": "Sales Order", + "from_date": "2026-01-01", + "to_date": "2026-01-31", + } + self.report_name = "Sales Partner Commission Summary" + + def test_doctype_filters(self): + self.assert_doctype_filters() + + def test_posting_date_column_label(self): + self.assert_posting_date_label() + + def test_sales_order_sp_commission_summary(self): + self.filters["doctype"] = "Sales Order" + self.create_transactions(self.filters["doctype"]) + + self.assert_sales_partner_commission_summary_report() + + def test_sales_invoice_sp_commission_summary(self): + self.filters["doctype"] = "Sales Invoice" + self.create_transactions(self.filters["doctype"]) + + self.assert_sales_partner_commission_summary_report() + + def test_delivery_note_sp_commission_summary(self): + self.filters["doctype"] = "Delivery Note" + self.create_transactions(self.filters["doctype"]) + + self.assert_sales_partner_commission_summary_report() + + def test_pos_invoice_sp_commission_summary(self): + self.filters["doctype"] = "POS Invoice" + self.create_transactions(self.filters["doctype"]) + + self.assert_sales_partner_commission_summary_report() + + def assert_sales_partner_commission_summary_report(self): + report_data = run(self.report_name, self.filters) + + self.report_result = report_data.get("result") + self.report_result_without_total_row = self.report_result[:-1] + + self.assertIsNotNone(self.report_result_without_total_row) + + self.assert_7pc_commission() + self.assert_5pc_commission_with_multiple_items() + self.assert_doc_with_no_sp() + self.assert_doc_with_posting_date_out_of_range() + self.assert_doc_with_revoked_commission() + self.assert_doc_not_submitted() + self.assert_doc_cancelled() + self.assert_total_commission() + + if self.filters["doctype"] != "Sales Order": + self.assert_returned_doc() + + def assert_7pc_commission(self): + doc_name = self.seven_pc_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNotNone(row) + self.assertEqual(row["amount"], 1000) + self.assertEqual(row["commission_rate"], 7) + self.assertEqual(row["total_commission"], 70) + + def assert_5pc_commission_with_multiple_items(self): + doc_name = self.five_pc_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNotNone(row) + self.assertEqual(row["amount"], 240) + self.assertEqual(row["commission_rate"], 5) + self.assertEqual(row["total_commission"], 12) + + def assert_doc_with_no_sp(self): + doc_name = self.no_sp_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNone(row) + + def assert_doc_with_posting_date_out_of_range(self): + doc_name = self.date_out_of_range_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNone(row) + + def assert_doc_with_revoked_commission(self): + doc_name = self.revoked_comm_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNotNone(row) + self.assertEqual(row["amount"], 800) + self.assertEqual(row["commission_rate"], 7) + self.assertEqual(row["total_commission"], 0) + + def assert_doc_not_submitted(self): + doc_name = self.doc_not_submitted.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNone(row) + + def assert_doc_cancelled(self): + doc_name = self.cancelled_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNone(row) + + def assert_total_commission(self): + total_row = self.report_result[-1] + + # Total Amount + self.assertEqual(total_row[-4], 2040) + + # Total Commission + self.assertEqual(total_row[-1], 82) + + def assert_returned_doc(self): + doc_name = self.to_be_returned_doc.name + returned_doc_name = self.returned_doc.name + + outward_row = next( + (row for row in self.report_result_without_total_row if row.get("name") == doc_name), None + ) + inward_row = next( + (row for row in self.report_result_without_total_row if row.get("name") == returned_doc_name), + None, + ) + + self.assertIsNotNone(outward_row) + self.assertIsNotNone(inward_row) + + self.assertEqual(outward_row["amount"], 900) + self.assertEqual(outward_row["total_commission"], 45) + + self.assertEqual(inward_row["amount"], -900) + self.assertEqual(inward_row["total_commission"], -45) diff --git a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js index f6f7c3f3cf3..e4e2199606a 100644 --- a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js +++ b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js @@ -3,6 +3,14 @@ frappe.query_reports["Sales Partner Transaction Summary"] = { filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1, + }, { fieldname: "sales_partner", label: __("Sales Partner"), @@ -28,14 +36,6 @@ frappe.query_reports["Sales Partner Transaction Summary"] = { 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"), diff --git a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py index 216adde18fd..f322b89f897 100644 --- a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py +++ b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py @@ -3,144 +3,84 @@ import frappe -from frappe import _, msgprint +from frappe import _ +from frappe.query_builder import Case + +from erpnext.selling.report.sales_partner_commission_summary.sales_partner_commission_summary import ( + SalesPartnerSummaryReport, +) def execute(filters=None): if not filters: filters = {} - columns = get_columns(filters) - data = get_entries(filters) - - return columns, data + return SalesPartnerTransactionSummaryReport(filters=filters).run() -def get_columns(filters): - if not filters.get("doctype"): - msgprint(_("Please select the document type first"), raise_exception=1) +class SalesPartnerTransactionSummaryReport(SalesPartnerSummaryReport): + def prepare_columns(self): + self.make_column(_(self.filters.get("doctype")), "name", "Link", options=self.filters.get("doctype")) - columns = [ - { - "label": _(filters["doctype"]), - "options": filters["doctype"], - "fieldname": "name", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Customer"), - "options": "Customer", - "fieldname": "customer", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Territory"), - "options": "Territory", - "fieldname": "territory", - "fieldtype": "Link", - "width": 100, - }, - {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, - { - "label": _("Item Code"), - "fieldname": "item_code", - "fieldtype": "Link", - "options": "Item", - "width": 100, - }, - { - "label": _("Item Group"), - "fieldname": "item_group", - "fieldtype": "Link", - "options": "Item Group", - "width": 100, - }, - { - "label": _("Brand"), - "fieldname": "brand", - "fieldtype": "Link", - "options": "Brand", - "width": 100, - }, - {"label": _("Quantity"), "fieldname": "qty", "fieldtype": "Float", "width": 120}, - {"label": _("Rate"), "fieldname": "rate", "fieldtype": "Currency", "width": 120}, - {"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120}, - { - "label": _("Sales Partner"), - "options": "Sales Partner", - "fieldname": "sales_partner", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Commission Rate %"), - "fieldname": "commission_rate", - "fieldtype": "Data", - "width": 100, - }, - {"label": _("Commission"), "fieldname": "commission", "fieldtype": "Currency", "width": 120}, - { - "label": _("Currency"), - "fieldname": "currency", - "fieldtype": "Link", - "options": "Currency", - "width": 120, - }, - ] + self.make_column(_("Customer"), "customer", "Link", options="Customer") - return columns + self.make_column(_("Currency"), "currency", "Data", 80, hidden=1) + self.make_column(_("Territory"), "territory", "Link", 100, "Territory") -def get_entries(filters): - date_field = "transaction_date" if filters.get("doctype") == "Sales Order" else "posting_date" + self.make_column(self.date_label, "posting_date", "Date") - conditions = get_conditions(filters, date_field) - entries = frappe.db.sql( - """ - SELECT - dt.name, dt.customer, dt.territory, dt.{date_field} as posting_date, dt.currency, - dt_item.base_net_rate as rate, dt_item.qty, dt_item.base_net_amount as amount, - ((dt_item.base_net_amount * dt.commission_rate) / 100) as commission, - dt_item.brand, dt.sales_partner, dt.commission_rate, dt_item.item_group, dt_item.item_code - FROM - `tab{doctype}` dt, `tab{doctype} Item` dt_item - WHERE - {cond} and dt.name = dt_item.parent and dt.docstatus = 1 - and dt.sales_partner is not null and dt.sales_partner != '' - order by dt.name desc, dt.sales_partner - """.format(date_field=date_field, doctype=filters.get("doctype"), cond=conditions), - filters, - as_dict=1, - ) + self.make_column(_("Item Code"), "item_code", "Link", 100, "Item") - return entries + self.make_column(_("Item Group"), "item_group", "Link", 100, "Item Group") + self.make_column(_("Brand"), "brand", "Link", 100, "Brand") -def get_conditions(filters, date_field): - conditions = "1=1" + self.make_column(_("Quantity"), "qty", "Float", 120) - for field in ["company", "customer", "territory", "sales_partner"]: - if filters.get(field): - conditions += f" and dt.{field} = %({field})s" + self.make_column(_("Rate"), "rate", "Currency", 120, "currency") - if filters.get("from_date"): - conditions += f" and dt.{date_field} >= %(from_date)s" + self.make_column(_("Amount"), "amount", "Currency", 120, "currency") - if filters.get("to_date"): - conditions += f" and dt.{date_field} <= %(to_date)s" + self.make_column(_("Sales Partner"), "sales_partner", "Link", options="Sales Partner") - if not filters.get("show_return_entries"): - conditions += " and dt_item.qty > 0.0" + self.make_column(_("Commission Rate %"), "commission_rate", "Data", 100) - if filters.get("brand"): - conditions += " and dt_item.brand = %(brand)s" + self.make_column(_("Commission"), "commission", "Currency", 120, "currency") - if filters.get("item_group"): - lft, rgt = frappe.get_cached_value("Item Group", filters.get("item_group"), ["lft", "rgt"]) + def extend_report_query(self): + self.dt_item = frappe.qb.DocType(f"{self.filters['doctype']} Item") - conditions += f""" and dt_item.item_group in (select name from - `tabItem Group` where lft >= {lft} and rgt <= {rgt})""" + 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, + ) + ) - return conditions + 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/selling/report/sales_partner_transaction_summary/test_sales_partner_transaction_summary.py b/erpnext/selling/report/sales_partner_transaction_summary/test_sales_partner_transaction_summary.py new file mode 100644 index 00000000000..45b4efee867 --- /dev/null +++ b/erpnext/selling/report/sales_partner_transaction_summary/test_sales_partner_transaction_summary.py @@ -0,0 +1,183 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from frappe.desk.query_report import run + +from erpnext.selling.report.sales_partner_commission_summary.test_sales_partner_commission_summary import ( + SalesPartnerSummaryReportTestMixin, +) + + +class TestSalesPartnerTransactionSummary(SalesPartnerSummaryReportTestMixin): + def setUp(self): + self.filters = { + "company": "_Test Company", + "doctype": "Sales Order", + "from_date": "2026-01-01", + "to_date": "2026-01-31", + "show_return_entries": 1, + } + self.report_name = "Sales Partner Transaction Summary" + + def test_doctype_filters(self): + self.assert_doctype_filters() + + def test_posting_date_column_label(self): + self.assert_posting_date_label() + + def test_sales_order_sp_transaction_summary(self): + self.filters["doctype"] = "Sales Order" + self.create_transactions(self.filters["doctype"]) + + self.assert_sales_partner_transaction_summary_report() + + def test_sales_invoice_sp_transaction_summary(self): + self.filters["doctype"] = "Sales Invoice" + self.create_transactions(self.filters["doctype"]) + + self.assert_sales_partner_transaction_summary_report() + + def test_delivery_note_sp_transaction_summary(self): + self.filters["doctype"] = "Delivery Note" + self.create_transactions(self.filters["doctype"]) + + self.assert_sales_partner_transaction_summary_report() + + def test_pos_invoice_sp_transaction_summary(self): + self.filters["doctype"] = "POS Invoice" + self.create_transactions(self.filters["doctype"]) + + self.assert_sales_partner_transaction_summary_report() + + def assert_sales_partner_transaction_summary_report(self): + report_data = run(self.report_name, self.filters) + + self.report_result = report_data.get("result") + self.report_result_without_total_row = self.report_result[:-1] + + self.assertIsNotNone(self.report_result_without_total_row) + + self.assert_7pc_commission() + self.assert_5pc_commission_with_multiple_items() + self.assert_doc_with_no_sp() + self.assert_doc_with_posting_date_out_of_range() + self.assert_doc_with_revoked_commission() + self.assert_doc_not_submitted() + self.assert_doc_cancelled() + self.assert_commission() + + if self.filters["doctype"] != "Sales Order": + self.assert_returned_doc() + + def assert_7pc_commission(self): + doc_name = self.seven_pc_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNotNone(row) + + self.assertEqual(row["customer"], "_Test Customer") + self.assertEqual(row["item_code"], "_Test Item") + self.assertEqual(row["item_group"], "_Test Item Group") + self.assertEqual(row["amount"], 1000) + self.assertEqual(row["commission_rate"], 7) + self.assertEqual(row["commission"], 70) + + def assert_5pc_commission_with_multiple_items(self): + doc_name = self.five_pc_doc.name + + row1 = next( + ( + row + for row in self.report_result_without_total_row + if row.get("name") == doc_name and row.get("item_code") == "_Test Item" + ), + None, + ) + self.assertIsNotNone(row1) + + row2 = next( + ( + row + for row in self.report_result_without_total_row + if row.get("name") == doc_name and row.get("item_code") == "_Test Item 2" + ), + None, + ) + self.assertIsNotNone(row2) + + self.assertEqual(row1["amount"], 120) + self.assertEqual(row1["commission_rate"], 5) + self.assertEqual(row1["commission"], 6) + + self.assertEqual(row2["amount"], 120) + self.assertEqual(row2["commission_rate"], 5) + self.assertEqual(row2["commission"], 6) + + def assert_doc_with_no_sp(self): + doc_name = self.no_sp_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNone(row) + + def assert_doc_with_posting_date_out_of_range(self): + doc_name = self.date_out_of_range_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNone(row) + + def assert_doc_with_revoked_commission(self): + doc_name = self.revoked_comm_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNotNone(row) + self.assertEqual(row["amount"], 800) + self.assertEqual(row["commission_rate"], 7) + self.assertEqual(row["commission"], 0) + + def assert_doc_not_submitted(self): + doc_name = self.doc_not_submitted.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNone(row) + + def assert_doc_cancelled(self): + doc_name = self.cancelled_doc.name + + row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None) + + self.assertIsNone(row) + + def assert_commission(self): + total_row = self.report_result[-1] + + # Total Amount + self.assertEqual(total_row[-4], 2040) + + # Total Commission + self.assertEqual(total_row[-1], 82) + + def assert_returned_doc(self): + doc_name = self.to_be_returned_doc.name + returned_doc_name = self.returned_doc.name + + outward_row = next( + (row for row in self.report_result_without_total_row if row.get("name") == doc_name), None + ) + inward_row = next( + (row for row in self.report_result_without_total_row if row.get("name") == returned_doc_name), + None, + ) + + self.assertIsNotNone(outward_row) + self.assertIsNotNone(inward_row) + + self.assertEqual(outward_row["amount"], 900) + self.assertEqual(outward_row["commission"], 45) + + self.assertEqual(inward_row["amount"], -900) + self.assertEqual(inward_row["commission"], -45)