fix(selling): validate targets and count achievements once

(cherry picked from commit eec3bcf900)
This commit is contained in:
Krishna Shirsath
2026-10-27 12:14:24 +00:00
committed by Mergify
parent 7550e0570f
commit 6ed19302f7
9 changed files with 185 additions and 33 deletions
@@ -169,43 +169,32 @@ def prepare_data(
item_group_parent_child_map = get_item_group_parent_child_map()
for d in sales_users_data:
key = (d.parent, d.item_group)
key = (d.parent, d.item_group or None)
dist_data = get_periodwise_distribution_data(d.distribution_id, period_list, filters.get("period"))
details = rows.setdefault(key, {"total_target": 0, "total_achieved": 0})
for period in period_list:
target_key = f"target_{period.key}"
target = (d.get(target_qty_amt_field) * dist_data.get(period.key)) / 100
details[target_key] = details.get(target_key, 0) + target
details["total_target"] += target
if key not in rows:
rows.setdefault(key, {"total_target": 0, "total_achieved": 0, "total_variance": 0})
details = rows[key]
for (parent, item_group), details in rows.items():
for period in period_list:
p_key = period.key
if p_key not in details:
details[p_key] = 0
target_key = f"target_{p_key}"
variance_key = f"variance_{p_key}"
details[target_key] = (d.get(target_qty_amt_field) * dist_data.get(p_key)) / 100
details[variance_key] = 0
details["total_target"] += details[target_key]
for r in actual_data:
if (
r.get(sales_field) == d.parent
and period.from_date <= r.get(date_field)
and r.get(date_field) <= period.to_date
and (
not d.item_group
or not sales_user_wise_item_groups.get(d.parent)
or r.item_group == d.item_group
or r.item_group in item_group_parent_child_map.get(d.item_group, [])
)
):
details[p_key] += r.get(qty_or_amount_field, 0)
details[variance_key] = details.get(p_key) - details.get(target_key)
else:
details[variance_key] = details.get(p_key) - details.get(target_key)
details["total_achieved"] += details.get(p_key)
details["total_variance"] = details.get("total_achieved") - details.get("total_target")
details[p_key] = sum(
r.get(qty_or_amount_field, 0)
for r in actual_data
if r.get(sales_field) == parent
and period.from_date <= r.get(date_field) <= period.to_date
and (
not item_group
or r.item_group == item_group
or r.item_group in item_group_parent_child_map.get(item_group, [])
)
)
details[f"variance_{p_key}"] = details[p_key] - details[f"target_{p_key}"]
details["total_achieved"] += details[p_key]
details["total_variance"] = details["total_achieved"] - details["total_target"]
return rows
@@ -62,3 +62,10 @@ class TestSalesPartnerTargetVarianceBasedOnItemGroup(FrappeTestCase):
)
check_company_filter(self, "Sales Partner", "partner_name", execute)
def test_duplicate_target_inputs_count_actuals_once(self):
from erpnext.selling.report.territory_target_variance_based_on_item_group.test_territory_target_variance_based_on_item_group import (
check_duplicate_targets,
)
check_duplicate_targets(self, "Sales Partner", "partner_name", execute)
@@ -120,6 +120,13 @@ class TestSalesPersonTargetVarianceBasedOnItemGroup(FrappeTestCase):
check_company_filter(self, "Sales Person", "sales_person_name", execute)
def test_duplicate_target_inputs_count_actuals_once(self):
from erpnext.selling.report.territory_target_variance_based_on_item_group.test_territory_target_variance_based_on_item_group import (
check_duplicate_targets,
)
check_duplicate_targets(self, "Sales Person", "sales_person_name", execute)
def create_target_distribution(fiscal_year):
if frappe.db.exists("Monthly Distribution", "Target Report Distribution"):
@@ -157,6 +157,13 @@ class TestTerritoryTargetVarianceBasedOnItemGroup(FrappeTestCase):
check_company_filter(self, "Territory", "territory_name", execute)
def test_duplicate_target_inputs_count_actuals_once(self):
from erpnext.selling.report.territory_target_variance_based_on_item_group.test_territory_target_variance_based_on_item_group import (
check_duplicate_targets,
)
check_duplicate_targets(self, "Territory", "territory_name", execute)
def create_territory_with_target(name, fiscal_year, distribution_id, target_qty=50):
doc = frappe.new_doc("Territory")
@@ -211,3 +218,81 @@ def check_company_filter(test, doctype, name_field, execute_report):
test.assertEqual(flt(achieved(), 2), flt(order.base_net_total, 2))
filters.company = "_Test Company"
test.assertEqual(flt(achieved(), 2), 0)
def check_duplicate_targets(test, doctype, name_field, execute_report):
from frappe.utils import add_months, getdate
from erpnext.selling.report.sales_person_target_variance_based_on_item_group.test_sales_person_target_variance_based_on_item_group import (
create_sales_target_doc,
)
distribution = create_target_distribution(test.fiscal_year)
month = getdate(nowdate()).strftime("%B")
for percentage in distribution.percentages:
percentage.percentage_allocation = 100 if percentage.month == month else 0
distribution.save()
other_distribution = frappe.copy_doc(distribution)
other_distribution.distribution_id = frappe.generate_hash(length=10)
other_month = getdate(add_months(nowdate(), 1)).strftime("%B")
for percentage in other_distribution.percentages:
percentage.percentage_allocation = 100 if percentage.month == other_month else 0
other_distribution.insert()
holder = create_sales_target_doc(
doctype, name_field, frappe.generate_hash(length=10), test.fiscal_year, distribution.name
)
holder.targets[0].target_amount = 1000
holder.save()
holder.append(
"targets", dict(holder.targets[0].as_dict(), name=None, distribution_id=other_distribution.name)
)
order = make_sales_order(rate=600, qty=1, do_not_submit=True)
field = frappe.scrub(doctype)
if doctype == "Sales Person":
order.set("sales_team", [{"sales_person": holder.name, "allocated_percentage": 100}])
else:
order.set(field, holder.name)
if doctype == "Sales Partner":
order.commission_rate = 5
order.submit()
filters = frappe._dict(
company=order.company,
fiscal_year=test.fiscal_year,
doctype="Sales Order",
period="Yearly",
target_on="Amount",
)
from erpnext.accounts.report.financial_statements import get_period_list
from erpnext.selling.report.sales_partner_target_variance_based_on_item_group.item_group_wise_sales_target_variance import (
get_actual_data,
prepare_data,
)
def report_row():
periods = get_period_list(
test.fiscal_year, test.fiscal_year, "", "", "Fiscal Year", filters.period, company=order.company
)
rows = prepare_data(
filters,
holder.targets,
{holder.name: []},
get_actual_data(filters, [holder.name], "transaction_date", field),
"transaction_date",
periods,
field,
)
return rows[(holder.name, None)]
# Feed historical duplicate inputs to the report without writing invalid records.
row = report_row()
test.assertEqual(
[flt(row[k], 2) for k in ("total_target", "total_achieved", "total_variance")], [2000, 600, -1400]
)
filters.period = "Monthly"
row = report_row()
test.assertEqual(
[flt(row[k], 2) for k in ("total_target", "total_achieved", "total_variance")], [2000, 600, -1400]
)
test.assertEqual(
sorted(flt(v, 2) for k, v in row.items() if k.startswith("target_")), [0] * 10 + [1000, 1000]
)
@@ -7,6 +7,8 @@ from frappe.contacts.address_and_contact import load_address_and_contact
from frappe.utils import cstr, filter_strip_join
from frappe.website.website_generator import WebsiteGenerator
from erpnext.setup.doctype.target_detail.target_detail import validate_duplicate_targets
class SalesPartner(WebsiteGenerator):
# begin: auto-generated types
@@ -47,6 +49,7 @@ class SalesPartner(WebsiteGenerator):
self.name = self.partner_name
def validate(self):
validate_duplicate_targets(self.get("targets"))
if not self.route:
self.route = "partners/" + self.scrub(self.partner_name)
super().validate()
@@ -14,6 +14,7 @@ from frappe.utils.data import get_url_to_list
from frappe.utils.nestedset import NestedSet, get_root_of
from erpnext import get_default_currency
from erpnext.setup.doctype.target_detail.target_detail import validate_duplicate_targets
class SalesPerson(NestedSet):
@@ -43,6 +44,7 @@ class SalesPerson(NestedSet):
nsm_parent_field = "parent_sales_person"
def validate(self):
validate_duplicate_targets(self.get("targets"))
if not self.enabled:
self.validate_sales_person()
@@ -2,6 +2,8 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.model.document import Document
@@ -25,3 +27,14 @@ class TargetDetail(Document):
# end: auto-generated types
pass
def validate_duplicate_targets(targets):
seen = set()
for target in targets or []:
key = (target.fiscal_year, target.item_group or None)
if key in seen:
frappe.throw(
_("Row {0}: A target already exists for this fiscal year and item group.").format(target.idx)
)
seen.add(key)
@@ -0,0 +1,43 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import nowdate
from erpnext.accounts.utils import get_fiscal_year
from erpnext.selling.report.sales_person_target_variance_based_on_item_group.test_sales_person_target_variance_based_on_item_group import (
create_sales_target_doc,
create_target_distribution,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestTargetDetail(ERPNextTestSuite):
def test_duplicate_target_rows_are_rejected(self):
fiscal_year = get_fiscal_year(nowdate())[0]
distribution = create_target_distribution(fiscal_year)
for doctype, name_field in (
("Territory", "territory_name"),
("Sales Person", "sales_person_name"),
("Sales Partner", "partner_name"),
):
holder = create_sales_target_doc(
doctype, name_field, frappe.generate_hash(length=10), fiscal_year, distribution.name
)
for item_group in (None, "_Test Item Group A"):
with self.subTest(doctype=doctype, item_group=item_group):
holder.reload()
holder.targets[0].item_group = item_group
holder.append(
"targets", dict(holder.targets[0].as_dict(), name=None, item_group=item_group or "")
)
with self.assertRaisesRegex(frappe.ValidationError, "target already exists"):
holder.save()
# Overall and specific item groups are separate targets.
holder.reload()
holder.targets[0].item_group = None
holder.append(
"targets", dict(holder.targets[0].as_dict(), name=None, item_group="_Test Item Group A")
)
holder.save()
@@ -7,6 +7,8 @@ from frappe import _
from frappe.utils import flt
from frappe.utils.nestedset import NestedSet, get_root_of
from erpnext.setup.doctype.target_detail.target_detail import validate_duplicate_targets
class Territory(NestedSet):
# begin: auto-generated types
@@ -32,6 +34,7 @@ class Territory(NestedSet):
nsm_parent_field = "parent_territory"
def validate(self):
validate_duplicate_targets(self.get("targets"))
if not self.parent_territory:
self.parent_territory = get_root_of("Territory")