mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-17 00:18:39 +00:00
feat: add Purchase Person tree DocType and Purchase Team child table
Mirrors Sales Person/Sales Team functionality for the purchase flow. Purchase Person is a tree DocType (Setup module) and Purchase Team is a child table (Buying module). Both are added to Purchase Order, Purchase Invoice, and Purchase Receipt. The BuyingController gains calculate_contribution() and validate_purchase_team() methods, and accounts_controller wires it into the calculate_totals flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
0
erpnext/buying/doctype/purchase_team/__init__.py
Normal file
0
erpnext/buying/doctype/purchase_team/__init__.py
Normal file
83
erpnext/buying/doctype/purchase_team/purchase_team.json
Normal file
83
erpnext/buying/doctype/purchase_team/purchase_team.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"actions": [],
|
||||
"creation": "2026-06-17 00:00:00",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"purchase_person",
|
||||
"contact_no",
|
||||
"allocated_percentage",
|
||||
"allocated_amount",
|
||||
"commission_rate",
|
||||
"incentives"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "purchase_person",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Purchase Person",
|
||||
"options": "Purchase Person",
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "contact_no",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Contact No."
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "allocated_percentage",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Contribution (%)"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "allocated_amount",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Contribution to Net Total",
|
||||
"options": "Company:company:default_currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "purchase_person.commission_rate",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "commission_rate",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Commission Rate",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "incentives",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Incentives",
|
||||
"options": "Company:company:default_currency"
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-17 00:00:00",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Team",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
27
erpnext/buying/doctype/purchase_team/purchase_team.py
Normal file
27
erpnext/buying/doctype/purchase_team/purchase_team.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class PurchaseTeam(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
allocated_amount: DF.Currency
|
||||
allocated_percentage: DF.Float
|
||||
commission_rate: DF.Data | None
|
||||
contact_no: DF.Data | None
|
||||
incentives: DF.Currency
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
purchase_person: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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 <b>{0}</b> is disabled.").format(row.name))
|
||||
|
||||
def set_total_in_words(self):
|
||||
from frappe.utils import money_in_words
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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
|
||||
|
||||
0
erpnext/setup/doctype/purchase_person/__init__.py
Normal file
0
erpnext/setup/doctype/purchase_person/__init__.py
Normal file
64
erpnext/setup/doctype/purchase_person/purchase_person.js
Normal file
64
erpnext/setup/doctype/purchase_person/purchase_person.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
frappe.ui.form.on("Purchase Person", {
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.__onload && frm.doc.__onload.dashboard_info) {
|
||||
let info = frm.doc.__onload.dashboard_info;
|
||||
frm.dashboard.add_indicator(
|
||||
__("Total Contribution Amount Against Orders: {0}", [
|
||||
format_currency(info.allocated_amount_against_order, info.currency),
|
||||
]),
|
||||
"blue"
|
||||
);
|
||||
|
||||
frm.dashboard.add_indicator(
|
||||
__("Total Contribution Amount Against Invoices: {0}", [
|
||||
format_currency(info.allocated_amount_against_invoice, info.currency),
|
||||
]),
|
||||
"blue"
|
||||
);
|
||||
}
|
||||
frm.trigger("set_root_readonly");
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.fields_dict["targets"].grid.get_field("distribution_id").get_query = function (doc, cdt, cdn) {
|
||||
var row = locals[cdt][cdn];
|
||||
return {
|
||||
filters: {
|
||||
fiscal_year: row.fiscal_year,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
frm.make_methods = {
|
||||
"Purchase Order": () =>
|
||||
frappe
|
||||
.new_doc("Purchase Order")
|
||||
.then(() => frm.add_child("purchase_team", { purchase_person: frm.doc.name })),
|
||||
};
|
||||
},
|
||||
|
||||
set_root_readonly: function (frm) {
|
||||
if (!frm.doc.parent_purchase_person && !frm.doc.__islocal) {
|
||||
frm.set_read_only();
|
||||
frm.set_intro(__("This is a root purchase person and cannot be edited."));
|
||||
} else {
|
||||
frm.set_intro(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
cur_frm.fields_dict["parent_purchase_person"].get_query = function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: [
|
||||
["Purchase Person", "is_group", "=", 1],
|
||||
["Purchase Person", "name", "!=", doc.purchase_person_name],
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
cur_frm.fields_dict.employee.get_query = function (doc, cdt, cdn) {
|
||||
return { query: "erpnext.controllers.queries.employee_query" };
|
||||
};
|
||||
181
erpnext/setup/doctype/purchase_person/purchase_person.json
Normal file
181
erpnext/setup/doctype/purchase_person/purchase_person.json
Normal file
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:purchase_person_name",
|
||||
"creation": "2026-06-17 00:00:00",
|
||||
"description": "All Purchase Transactions can be tagged against multiple Purchase Persons so that you can set and monitor targets.",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"name_and_employee_id",
|
||||
"purchase_person_name",
|
||||
"parent_purchase_person",
|
||||
"commission_rate",
|
||||
"is_group",
|
||||
"enabled",
|
||||
"cb0",
|
||||
"employee",
|
||||
"department",
|
||||
"lft",
|
||||
"rgt",
|
||||
"old_parent",
|
||||
"target_details_section_break",
|
||||
"targets"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "name_and_employee_id",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Name and Employee ID",
|
||||
"options": "icon-user"
|
||||
},
|
||||
{
|
||||
"fieldname": "purchase_person_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Purchase Person Name",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"description": "Select company name first.",
|
||||
"fieldname": "parent_purchase_person",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Parent Purchase Person",
|
||||
"options": "Purchase Person"
|
||||
},
|
||||
{
|
||||
"fieldname": "commission_rate",
|
||||
"fieldtype": "Data",
|
||||
"label": "Commission Rate",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_group",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Is Group",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "cb0",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "employee",
|
||||
"fieldtype": "Link",
|
||||
"label": "Employee",
|
||||
"options": "Employee"
|
||||
},
|
||||
{
|
||||
"fetch_from": "employee.department",
|
||||
"fieldname": "department",
|
||||
"fieldtype": "Link",
|
||||
"label": "Department",
|
||||
"options": "Department",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "lft",
|
||||
"fieldtype": "Int",
|
||||
"hidden": 1,
|
||||
"label": "lft",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "rgt",
|
||||
"fieldtype": "Int",
|
||||
"hidden": 1,
|
||||
"label": "rgt",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "old_parent",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Old Parent",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "target_details_section_break",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Target Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "targets",
|
||||
"fieldtype": "Table",
|
||||
"label": "Target Details",
|
||||
"options": "Target Detail"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-user",
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"max_attachments": 0,
|
||||
"modified": "2026-06-17 00:00:00",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "Purchase Person",
|
||||
"nsm_parent_field": "parent_purchase_person",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Purchase Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Purchase User",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 0,
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
148
erpnext/setup/doctype/purchase_person/purchase_person.py
Normal file
148
erpnext/setup/doctype/purchase_person/purchase_person.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Interval
|
||||
from frappe.query_builder.functions import Count, CurDate, UnixTimestamp
|
||||
from frappe.utils import flt
|
||||
from frappe.utils.nestedset import NestedSet, get_root_of
|
||||
|
||||
from erpnext import get_default_currency
|
||||
|
||||
|
||||
class PurchasePerson(NestedSet):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from erpnext.setup.doctype.target_detail.target_detail import TargetDetail
|
||||
|
||||
commission_rate: DF.Data | None
|
||||
department: DF.Link | None
|
||||
employee: DF.Link | None
|
||||
enabled: DF.Check
|
||||
is_group: DF.Check
|
||||
lft: DF.Int
|
||||
old_parent: DF.Data | None
|
||||
parent_purchase_person: DF.Link | None
|
||||
purchase_person_name: DF.Data
|
||||
rgt: DF.Int
|
||||
targets: DF.Table[TargetDetail]
|
||||
# end: auto-generated types
|
||||
|
||||
nsm_parent_field = "parent_purchase_person"
|
||||
|
||||
def validate(self):
|
||||
if not self.enabled:
|
||||
self.validate_purchase_person()
|
||||
|
||||
if not self.parent_purchase_person:
|
||||
self.parent_purchase_person = get_root_of("Purchase Person")
|
||||
|
||||
for d in self.get("targets") or []:
|
||||
if not flt(d.target_qty) and not flt(d.target_amount):
|
||||
frappe.throw(_("Either target qty or target amount is mandatory."))
|
||||
self.validate_employee_id()
|
||||
|
||||
def onload(self):
|
||||
self.load_dashboard_info()
|
||||
|
||||
def load_dashboard_info(self):
|
||||
company_default_currency = get_default_currency()
|
||||
|
||||
allocated_amount_against_order = flt(
|
||||
frappe.db.get_value(
|
||||
"Purchase Team",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"parenttype": "Purchase Order",
|
||||
"purchase_person": self.purchase_person_name,
|
||||
},
|
||||
[{"SUM": "allocated_amount"}],
|
||||
)
|
||||
)
|
||||
|
||||
allocated_amount_against_invoice = flt(
|
||||
frappe.db.get_value(
|
||||
"Purchase Team",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"parenttype": "Purchase Invoice",
|
||||
"purchase_person": self.purchase_person_name,
|
||||
},
|
||||
[{"SUM": "allocated_amount"}],
|
||||
)
|
||||
)
|
||||
|
||||
info = {}
|
||||
info["allocated_amount_against_order"] = allocated_amount_against_order
|
||||
info["allocated_amount_against_invoice"] = allocated_amount_against_invoice
|
||||
info["currency"] = company_default_currency
|
||||
|
||||
self.set_onload("dashboard_info", info)
|
||||
|
||||
def on_update(self):
|
||||
super().on_update()
|
||||
self.validate_one_root()
|
||||
|
||||
def validate_purchase_person(self):
|
||||
purchase_team = frappe.qb.DocType("Purchase Team")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(purchase_team)
|
||||
.select(purchase_team.purchase_person)
|
||||
.where(purchase_team.purchase_person == self.name)
|
||||
.groupby(purchase_team.purchase_person)
|
||||
).run(as_dict=True)
|
||||
|
||||
if query:
|
||||
frappe.throw(_("The Purchase Person {0} is linked with existing transactions.").format(self.name))
|
||||
|
||||
def validate_employee_id(self):
|
||||
if self.employee:
|
||||
purchase_person = frappe.db.get_value("Purchase Person", {"employee": self.employee})
|
||||
|
||||
if purchase_person and purchase_person != self.name:
|
||||
frappe.throw(
|
||||
_("Another Purchase Person {0} exists with the same Employee id").format(purchase_person)
|
||||
)
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index("Purchase Person", ["lft", "rgt"])
|
||||
|
||||
|
||||
def get_timeline_data(doctype: str, name: str) -> dict[int, int]:
|
||||
def _fetch_activity(doctype: str, date_field: str):
|
||||
purchase_team = frappe.qb.DocType("Purchase Team")
|
||||
transaction = frappe.qb.DocType(doctype)
|
||||
|
||||
return dict(
|
||||
frappe.qb.from_(transaction)
|
||||
.join(purchase_team)
|
||||
.on(transaction.name == purchase_team.parent)
|
||||
.select(UnixTimestamp(transaction[date_field]), Count("*"))
|
||||
.where(purchase_team.purchase_person == name)
|
||||
.where(transaction[date_field] > CurDate() - Interval(years=1))
|
||||
.groupby(transaction[date_field])
|
||||
.run()
|
||||
)
|
||||
|
||||
purchase_order_activity = _fetch_activity("Purchase Order", "transaction_date")
|
||||
purchase_invoice_activity = _fetch_activity("Purchase Invoice", "posting_date")
|
||||
|
||||
merged_activities = defaultdict(int)
|
||||
|
||||
for ts, count in chain(purchase_order_activity.items(), purchase_invoice_activity.items()):
|
||||
merged_activities[ts] += count
|
||||
|
||||
return merged_activities
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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"})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user