diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 0bc8cfc4ab2..a04124f93a2 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -173,6 +173,8 @@ "column_break_commission", "commission_rate", "total_commission", + "purchase_team_section", + "purchase_team", "more_info_tab", "status_section", "status", @@ -1733,6 +1735,22 @@ "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "purchase_team", + "fieldname": "purchase_team_section", + "fieldtype": "Section Break", + "label": "Purchase Team", + "print_hide": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "purchase_team", + "fieldtype": "Table", + "label": "Purchase Contributions and Incentives", + "options": "Purchase Team", + "print_hide": 1 } ], "grid_page_length": 50, @@ -1805,4 +1823,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/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index b83e17c2c4f..4b0bd1ff952 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -139,6 +139,8 @@ "column_break_commission", "commission_rate", "total_commission", + "purchase_team_section", + "purchase_team", "more_info_tab", "tracking_section", "status", @@ -1341,6 +1343,22 @@ "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "purchase_team", + "fieldname": "purchase_team_section", + "fieldtype": "Section Break", + "label": "Purchase Team", + "print_hide": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "purchase_team", + "fieldtype": "Table", + "label": "Purchase Contributions and Incentives", + "options": "Purchase Team", + "print_hide": 1 } ], "grid_page_length": 50, @@ -1404,4 +1422,4 @@ "timeline_field": "supplier", "title_field": "supplier_name", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/buying/doctype/purchase_team/__init__.py b/erpnext/buying/doctype/purchase_team/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/buying/doctype/purchase_team/purchase_team.json b/erpnext/buying/doctype/purchase_team/purchase_team.json new file mode 100644 index 00000000000..050c9516d61 --- /dev/null +++ b/erpnext/buying/doctype/purchase_team/purchase_team.json @@ -0,0 +1,83 @@ +{ + "actions": [], + "creation": "2026-06-17 00:00:00", + "doctype": "DocType", + "document_type": "Setup", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "purchase_person", + "contact_no", + "allocated_percentage", + "allocated_amount", + "commission_rate", + "incentives" + ], + "fields": [ + { + "allow_on_submit": 1, + "fieldname": "purchase_person", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Purchase Person", + "options": "Purchase Person", + "reqd": 1, + "search_index": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "contact_no", + "fieldtype": "Data", + "hidden": 1, + "in_list_view": 1, + "label": "Contact No." + }, + { + "allow_on_submit": 1, + "fieldname": "allocated_percentage", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Contribution (%)" + }, + { + "allow_on_submit": 1, + "fieldname": "allocated_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Contribution to Net Total", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fetch_from": "purchase_person.commission_rate", + "fetch_if_empty": 1, + "fieldname": "commission_rate", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Commission Rate", + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "incentives", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Incentives", + "options": "Company:company:default_currency" + } + ], + "idx": 1, + "istable": 1, + "links": [], + "modified": "2026-06-17 00:00:00", + "modified_by": "Administrator", + "module": "Buying", + "name": "Purchase Team", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/erpnext/buying/doctype/purchase_team/purchase_team.py b/erpnext/buying/doctype/purchase_team/purchase_team.py new file mode 100644 index 00000000000..9cdbca728f6 --- /dev/null +++ b/erpnext/buying/doctype/purchase_team/purchase_team.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from frappe.model.document import Document + + +class PurchaseTeam(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + allocated_amount: DF.Currency + allocated_percentage: DF.Float + commission_rate: DF.Data | None + contact_no: DF.Data | None + incentives: DF.Currency + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + purchase_person: DF.Link + # end: auto-generated types + + pass diff --git a/erpnext/buying/workspace/buying/buying.json b/erpnext/buying/workspace/buying/buying.json index 87860975e94..6d37ae3cbe8 100644 --- a/erpnext/buying/workspace/buying/buying.json +++ b/erpnext/buying/workspace/buying/buying.json @@ -415,6 +415,25 @@ "onboard": 0, "type": "Link" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Person", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Person", + "link_count": 0, + "link_to": "Purchase Person", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "Purchase Partner", "hidden": 0, @@ -603,4 +622,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 efec0c8c7c3..54a189ec852 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -645,6 +645,7 @@ class AccountsController(TransactionBase): "Purchase Invoice", ): self.calculate_commission() + self.calculate_contribution() def validate_date_with_fiscal_year(self): if self.meta.get_field("fiscal_year"): diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index ea35a04b95d..4189253559f 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -407,6 +407,48 @@ class BuyingController(SubcontractingController): self.precision("total_commission"), ) + def calculate_contribution(self): + if not self.meta.get_field("purchase_team"): + return + + total = 0.0 + purchase_team = self.get("purchase_team") + + self.validate_purchase_team(purchase_team) + + for purchase_person in purchase_team: + self.round_floats_in(purchase_person) + + purchase_person.allocated_amount = flt( + flt(self.amount_eligible_for_commission) * purchase_person.allocated_percentage / 100.0, + self.precision("allocated_amount", purchase_person), + ) + + if purchase_person.commission_rate: + purchase_person.incentives = flt( + purchase_person.allocated_amount * flt(purchase_person.commission_rate) / 100.0, + self.precision("incentives", purchase_person), + ) + + total += purchase_person.allocated_percentage + + if purchase_team and total != 100.0: + frappe.throw(_("Total allocated percentage for purchase team should be 100")) + + def validate_purchase_team(self, purchase_team): + purchase_persons = [d.purchase_person for d in purchase_team] + + if not purchase_persons: + return + + purchase_person_status = frappe.db.get_all( + "Purchase Person", filters={"name": ["in", purchase_persons]}, fields=["name", "enabled"] + ) + + for row in purchase_person_status: + if not row.enabled: + frappe.throw(_("Purchase Person {0} is disabled.").format(row.name)) + 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 bb214d8473f..317f3ad5410 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -477,64 +477,89 @@ erpnext.buying = { this.calculate_purchase_commission(); } - commission_rate() { - if ( - ["Purchase Order", "Purchase Receipt", "Purchase Invoice"].includes( - this.frm.doc.doctype - ) - ) { - 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 + 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 < 0 || this.frm.doc.commission_rate > 100) { - frappe.throw( - `${__( - frappe.meta.get_label( - this.frm.doc.doctype, - "commission_rate", - this.frm.doc.name - ) - )} ${__("must be between 0 and 100")}` + 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) ); } - this.frm.doc.amount_eligible_for_commission = (this.frm.doc.items || []).reduce( - (sum, item) => (item.grant_commission ? sum + item.base_net_amount : sum), - 0 - ); + purchase_team_add(doc, cdt, cdn) { + this.calculate_purchase_contribution(); + } - this.frm.doc.total_commission = flt( - (this.frm.doc.amount_eligible_for_commission * this.frm.doc.commission_rate) / 100.0, - precision("total_commission") - ); + purchase_team_remove() { + this.calculate_purchase_contribution(); + } - refresh_field(["amount_eligible_for_commission", "total_commission"]); - } + calculate_purchase_contribution() { + if (!this.frm.fields_dict.purchase_team || this.frm.doc.docstatus === 1) return; - add_serial_batch_for_rejected_qty(doc, cdt, cdn) { + const purchaseTeam = this.frm.doc.purchase_team || []; + let total = 0.0; + + purchaseTeam.forEach((row) => { + row.allocated_amount = flt( + (flt(this.frm.doc.amount_eligible_for_commission) * row.allocated_percentage) / 100.0 + ); + + if (row.commission_rate) { + row.incentives = flt((row.allocated_amount * flt(row.commission_rate)) / 100.0); + } + + total += flt(row.allocated_percentage); + }); + + if (purchaseTeam.length && total !== 100.0) { + frappe.msgprint(__("Total allocated percentage for purchase team should be 100")); + } + + refresh_field("purchase_team"); + } + + calculate_purchase_commission() { + if (!this.frm.fields_dict.commission_rate || this.frm.doc.docstatus === 1) return; + + if (this.frm.doc.commission_rate < 0 || this.frm.doc.commission_rate > 100) { + frappe.throw( + `${__( + frappe.meta.get_label(this.frm.doc.doctype, "commission_rate", this.frm.doc.name) + )} ${__("must be between 0 and 100")}` + ); + } + + this.frm.doc.amount_eligible_for_commission = (this.frm.doc.items || []).reduce( + (sum, item) => (item.grant_commission ? sum + item.base_net_amount : sum), + 0 + ); + + this.frm.doc.total_commission = flt( + (this.frm.doc.amount_eligible_for_commission * this.frm.doc.commission_rate) / 100.0, + precision("total_commission") + ); + + refresh_field(["amount_eligible_for_commission", "total_commission"]); + } + + add_serial_batch_for_rejected_qty(doc, cdt, cdn) { let item = locals[cdt][cdn]; let me = this; let fields = ["has_batch_no", "has_serial_no"]; diff --git a/erpnext/setup/doctype/purchase_partner/test_purchase_partner.py b/erpnext/setup/doctype/purchase_partner/test_purchase_partner.py index 5168ca536f5..4694c17ebb4 100644 --- a/erpnext/setup/doctype/purchase_partner/test_purchase_partner.py +++ b/erpnext/setup/doctype/purchase_partner/test_purchase_partner.py @@ -90,6 +90,50 @@ class TestPurchasePartner(ERPNextTestSuite): po.cancel() + def test_purchase_team_contribution_on_purchase_order(self): + purchase_person = make_purchase_person() + + po = create_purchase_order(do_not_submit=True) + for item in po.items: + item.grant_commission = 1 + po.save() + + po.append( + "purchase_team", + { + "purchase_person": purchase_person.purchase_person_name, + "allocated_percentage": 100.0, + }, + ) + po.save() + + self.assertEqual(len(po.purchase_team), 1) + self.assertAlmostEqual( + po.purchase_team[0].allocated_amount, po.amount_eligible_for_commission, places=2 + ) + + frappe.delete_doc("Purchase Person", purchase_person.name, force=True) + + def test_purchase_team_total_percentage_validation(self): + purchase_person = make_purchase_person() + + po = create_purchase_order(do_not_submit=True) + for item in po.items: + item.grant_commission = 1 + po.save() + + po.append( + "purchase_team", + { + "purchase_person": purchase_person.purchase_person_name, + "allocated_percentage": 60.0, + }, + ) + with self.assertRaises(frappe.ValidationError): + po.save() + + frappe.delete_doc("Purchase Person", purchase_person.name, force=True) + def make_purchase_partner(**kwargs): kwargs = frappe._dict(kwargs) @@ -102,3 +146,20 @@ def make_purchase_partner(**kwargs): else: partner = frappe.get_doc("Purchase Partner", partner.partner_name) return partner + + +def make_purchase_person(**kwargs): + kwargs = frappe._dict(kwargs) + name = kwargs.purchase_person_name or "_Test Purchase Person" + if frappe.db.exists("Purchase Person", name): + frappe.delete_doc("Purchase Person", name, force=True) + person = frappe.get_doc( + { + "doctype": "Purchase Person", + "purchase_person_name": name, + "commission_rate": kwargs.commission_rate or "10", + "enabled": 1, + } + ) + person.insert(ignore_permissions=True) + return person diff --git a/erpnext/setup/doctype/purchase_person/__init__.py b/erpnext/setup/doctype/purchase_person/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/setup/doctype/purchase_person/purchase_person.js b/erpnext/setup/doctype/purchase_person/purchase_person.js new file mode 100644 index 00000000000..5230512e288 --- /dev/null +++ b/erpnext/setup/doctype/purchase_person/purchase_person.js @@ -0,0 +1,64 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +frappe.ui.form.on("Purchase Person", { + refresh: function (frm) { + if (frm.doc.__onload && frm.doc.__onload.dashboard_info) { + let info = frm.doc.__onload.dashboard_info; + frm.dashboard.add_indicator( + __("Total Contribution Amount Against Orders: {0}", [ + format_currency(info.allocated_amount_against_order, info.currency), + ]), + "blue" + ); + + frm.dashboard.add_indicator( + __("Total Contribution Amount Against Invoices: {0}", [ + format_currency(info.allocated_amount_against_invoice, info.currency), + ]), + "blue" + ); + } + frm.trigger("set_root_readonly"); + }, + + setup: function (frm) { + frm.fields_dict["targets"].grid.get_field("distribution_id").get_query = function (doc, cdt, cdn) { + var row = locals[cdt][cdn]; + return { + filters: { + fiscal_year: row.fiscal_year, + }, + }; + }; + + frm.make_methods = { + "Purchase Order": () => + frappe + .new_doc("Purchase Order") + .then(() => frm.add_child("purchase_team", { purchase_person: frm.doc.name })), + }; + }, + + set_root_readonly: function (frm) { + if (!frm.doc.parent_purchase_person && !frm.doc.__islocal) { + frm.set_read_only(); + frm.set_intro(__("This is a root purchase person and cannot be edited.")); + } else { + frm.set_intro(null); + } + }, +}); + +cur_frm.fields_dict["parent_purchase_person"].get_query = function (doc, cdt, cdn) { + return { + filters: [ + ["Purchase Person", "is_group", "=", 1], + ["Purchase Person", "name", "!=", doc.purchase_person_name], + ], + }; +}; + +cur_frm.fields_dict.employee.get_query = function (doc, cdt, cdn) { + return { query: "erpnext.controllers.queries.employee_query" }; +}; diff --git a/erpnext/setup/doctype/purchase_person/purchase_person.json b/erpnext/setup/doctype/purchase_person/purchase_person.json new file mode 100644 index 00000000000..dd57dcf49bc --- /dev/null +++ b/erpnext/setup/doctype/purchase_person/purchase_person.json @@ -0,0 +1,181 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:purchase_person_name", + "creation": "2026-06-17 00:00:00", + "description": "All Purchase Transactions can be tagged against multiple Purchase Persons so that you can set and monitor targets.", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "name_and_employee_id", + "purchase_person_name", + "parent_purchase_person", + "commission_rate", + "is_group", + "enabled", + "cb0", + "employee", + "department", + "lft", + "rgt", + "old_parent", + "target_details_section_break", + "targets" + ], + "fields": [ + { + "fieldname": "name_and_employee_id", + "fieldtype": "Section Break", + "label": "Name and Employee ID", + "options": "icon-user" + }, + { + "fieldname": "purchase_person_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Purchase Person Name", + "reqd": 1, + "unique": 1 + }, + { + "description": "Select company name first.", + "fieldname": "parent_purchase_person", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_list_view": 1, + "label": "Parent Purchase Person", + "options": "Purchase Person" + }, + { + "fieldname": "commission_rate", + "fieldtype": "Data", + "label": "Commission Rate", + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "is_group", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Group", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "cb0", + "fieldtype": "Column Break" + }, + { + "fieldname": "employee", + "fieldtype": "Link", + "label": "Employee", + "options": "Employee" + }, + { + "fetch_from": "employee.department", + "fieldname": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department", + "read_only": 1 + }, + { + "fieldname": "lft", + "fieldtype": "Int", + "hidden": 1, + "label": "lft", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "rgt", + "fieldtype": "Int", + "hidden": 1, + "label": "rgt", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "old_parent", + "fieldtype": "Data", + "hidden": 1, + "label": "Old Parent", + "no_copy": 1, + "print_hide": 1 + }, + { + "fieldname": "target_details_section_break", + "fieldtype": "Section Break", + "label": "Target Details" + }, + { + "fieldname": "targets", + "fieldtype": "Table", + "label": "Target Details", + "options": "Target Detail" + } + ], + "icon": "fa fa-user", + "idx": 1, + "is_tree": 1, + "links": [], + "max_attachments": 0, + "modified": "2026-06-17 00:00:00", + "modified_by": "Administrator", + "module": "Setup", + "name": "Purchase Person", + "nsm_parent_field": "parent_purchase_person", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Purchase Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Purchase User", + "share": 1 + } + ], + "quick_entry": 0, + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/erpnext/setup/doctype/purchase_person/purchase_person.py b/erpnext/setup/doctype/purchase_person/purchase_person.py new file mode 100644 index 00000000000..2a1e1cbbfa0 --- /dev/null +++ b/erpnext/setup/doctype/purchase_person/purchase_person.py @@ -0,0 +1,148 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + + +from collections import defaultdict +from itertools import chain + +import frappe +from frappe import _ +from frappe.query_builder import Interval +from frappe.query_builder.functions import Count, CurDate, UnixTimestamp +from frappe.utils import flt +from frappe.utils.nestedset import NestedSet, get_root_of + +from erpnext import get_default_currency + + +class PurchasePerson(NestedSet): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from erpnext.setup.doctype.target_detail.target_detail import TargetDetail + + commission_rate: DF.Data | None + department: DF.Link | None + employee: DF.Link | None + enabled: DF.Check + is_group: DF.Check + lft: DF.Int + old_parent: DF.Data | None + parent_purchase_person: DF.Link | None + purchase_person_name: DF.Data + rgt: DF.Int + targets: DF.Table[TargetDetail] + # end: auto-generated types + + nsm_parent_field = "parent_purchase_person" + + def validate(self): + if not self.enabled: + self.validate_purchase_person() + + if not self.parent_purchase_person: + self.parent_purchase_person = get_root_of("Purchase Person") + + for d in self.get("targets") or []: + if not flt(d.target_qty) and not flt(d.target_amount): + frappe.throw(_("Either target qty or target amount is mandatory.")) + self.validate_employee_id() + + def onload(self): + self.load_dashboard_info() + + def load_dashboard_info(self): + company_default_currency = get_default_currency() + + allocated_amount_against_order = flt( + frappe.db.get_value( + "Purchase Team", + { + "docstatus": 1, + "parenttype": "Purchase Order", + "purchase_person": self.purchase_person_name, + }, + [{"SUM": "allocated_amount"}], + ) + ) + + allocated_amount_against_invoice = flt( + frappe.db.get_value( + "Purchase Team", + { + "docstatus": 1, + "parenttype": "Purchase Invoice", + "purchase_person": self.purchase_person_name, + }, + [{"SUM": "allocated_amount"}], + ) + ) + + info = {} + info["allocated_amount_against_order"] = allocated_amount_against_order + info["allocated_amount_against_invoice"] = allocated_amount_against_invoice + info["currency"] = company_default_currency + + self.set_onload("dashboard_info", info) + + def on_update(self): + super().on_update() + self.validate_one_root() + + def validate_purchase_person(self): + purchase_team = frappe.qb.DocType("Purchase Team") + + query = ( + frappe.qb.from_(purchase_team) + .select(purchase_team.purchase_person) + .where(purchase_team.purchase_person == self.name) + .groupby(purchase_team.purchase_person) + ).run(as_dict=True) + + if query: + frappe.throw(_("The Purchase Person {0} is linked with existing transactions.").format(self.name)) + + def validate_employee_id(self): + if self.employee: + purchase_person = frappe.db.get_value("Purchase Person", {"employee": self.employee}) + + if purchase_person and purchase_person != self.name: + frappe.throw( + _("Another Purchase Person {0} exists with the same Employee id").format(purchase_person) + ) + + +def on_doctype_update(): + frappe.db.add_index("Purchase Person", ["lft", "rgt"]) + + +def get_timeline_data(doctype: str, name: str) -> dict[int, int]: + def _fetch_activity(doctype: str, date_field: str): + purchase_team = frappe.qb.DocType("Purchase Team") + transaction = frappe.qb.DocType(doctype) + + return dict( + frappe.qb.from_(transaction) + .join(purchase_team) + .on(transaction.name == purchase_team.parent) + .select(UnixTimestamp(transaction[date_field]), Count("*")) + .where(purchase_team.purchase_person == name) + .where(transaction[date_field] > CurDate() - Interval(years=1)) + .groupby(transaction[date_field]) + .run() + ) + + purchase_order_activity = _fetch_activity("Purchase Order", "transaction_date") + purchase_invoice_activity = _fetch_activity("Purchase Invoice", "posting_date") + + merged_activities = defaultdict(int) + + for ts, count in chain(purchase_order_activity.items(), purchase_invoice_activity.items()): + merged_activities[ts] += count + + return merged_activities diff --git a/erpnext/setup/doctype/purchase_person/purchase_person_tree.js b/erpnext/setup/doctype/purchase_person/purchase_person_tree.js new file mode 100644 index 00000000000..f214dce6801 --- /dev/null +++ b/erpnext/setup/doctype/purchase_person/purchase_person_tree.js @@ -0,0 +1,23 @@ +frappe.treeview_settings["Purchase Person"] = { + fields: [ + { + fieldtype: "Data", + fieldname: "purchase_person_name", + label: __("New Purchase Person Name"), + reqd: true, + }, + { + fieldtype: "Link", + fieldname: "employee", + label: __("Employee"), + options: "Employee", + description: __("Please enter Employee Id of this purchase person"), + }, + { + fieldtype: "Check", + fieldname: "is_group", + label: __("Group Node"), + description: __("Further nodes can be only created under 'Group' type nodes"), + }, + ], +}; diff --git a/erpnext/setup/doctype/purchase_person/test_purchase_person.py b/erpnext/setup/doctype/purchase_person/test_purchase_person.py new file mode 100644 index 00000000000..e1308a9658e --- /dev/null +++ b/erpnext/setup/doctype/purchase_person/test_purchase_person.py @@ -0,0 +1,72 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe + +from erpnext.tests.utils import ERPNextTestSuite + + +class TestPurchasePerson(ERPNextTestSuite): + def setUp(self): + frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person"}) + frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person Group"}) + + def tearDown(self): + frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person"}) + frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person Group"}) + + def test_create_purchase_person(self): + purchase_person = frappe.get_doc( + { + "doctype": "Purchase Person", + "purchase_person_name": "_Test Purchase Person", + "commission_rate": "10", + "enabled": 1, + } + ) + purchase_person.insert(ignore_permissions=True) + + self.assertEqual(purchase_person.purchase_person_name, "_Test Purchase Person") + self.assertTrue(purchase_person.enabled) + + def test_create_purchase_person_group(self): + group = frappe.get_doc( + { + "doctype": "Purchase Person", + "purchase_person_name": "_Test Purchase Person Group", + "is_group": 1, + "enabled": 1, + } + ) + group.insert(ignore_permissions=True) + + self.assertTrue(group.is_group) + self.assertTrue(group.lft) + self.assertTrue(group.rgt) + + def test_duplicate_employee_raises_error(self): + employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") + if not employee: + return + + pp1 = frappe.get_doc( + { + "doctype": "Purchase Person", + "purchase_person_name": "_Test Purchase Person", + "employee": employee, + "enabled": 1, + } + ) + pp1.insert(ignore_permissions=True) + + pp2 = frappe.get_doc( + { + "doctype": "Purchase Person", + "purchase_person_name": "_Test Purchase Person 2", + "employee": employee, + "enabled": 1, + } + ) + self.assertRaises(frappe.ValidationError, pp2.insert) + + frappe.db.delete("Purchase Person", {"purchase_person_name": "_Test Purchase Person 2"}) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index 27ec40168d3..418a92901a1 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -132,6 +132,8 @@ "column_break_commission", "commission_rate", "total_commission", + "purchase_team_section", + "purchase_team", "more_info_tab", "status_section", "status", @@ -1336,6 +1338,22 @@ "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "purchase_team", + "fieldname": "purchase_team_section", + "fieldtype": "Section Break", + "label": "Purchase Team", + "print_hide": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "purchase_team", + "fieldtype": "Table", + "label": "Purchase Contributions and Incentives", + "options": "Purchase Team", + "print_hide": 1 } ], "grid_page_length": 50, @@ -1413,4 +1431,4 @@ "timeline_field": "supplier", "title_field": "supplier_name", "track_changes": 1 -} \ No newline at end of file +}