test: add Supplier Scorecard coverage for scoring engine and variables

Cover previously untested Supplier Scorecard logic:
- standings overlap/gap validation, total-score fallback, standing flag
  propagation to Supplier, period date generation, idempotent scorecard
  generation
- period scoring engine: criteria clamping, formula evaluation, weighted
  score, weight validation
- data-driven variables: in-period cost of shipments, on-time vs delayed
  shipment classification
This commit is contained in:
Nabin Hait
2026-06-21 11:31:55 +05:30
parent bda7a8ced2
commit d6c926a416
3 changed files with 189 additions and 1 deletions

View File

@@ -3,7 +3,12 @@
import frappe
from frappe.utils import add_days, getdate, nowdate
from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import (
get_scorecard_date,
make_all_scorecards,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -18,6 +23,56 @@ class TestSupplierScorecard(ERPNextTestSuite):
d.weight = 0
self.assertRaises(frappe.ValidationError, my_doc.insert)
def test_overlapping_standings_are_rejected(self):
doc = make_supplier_scorecard()
# "Poor" (30-50) stretched to 60 now overlaps "Average" (50-80)
doc.standings[1].max_grade = 60
self.assertRaises(frappe.ValidationError, doc.validate_standings)
def test_standings_must_cover_full_range(self):
doc = make_supplier_scorecard()
# "Excellent" capped at 90 leaves the 90-100 band uncovered
doc.standings[3].max_grade = 90
self.assertRaises(frappe.ValidationError, doc.validate_standings)
def test_total_score_defaults_to_100_without_periods(self):
doc = make_supplier_scorecard()
doc.name = "_Test Scorecard Without Periods"
doc.calculate_total_score()
self.assertEqual(doc.supplier_score, 100)
def test_update_standing_propagates_blocking_flags_to_supplier(self):
supplier = create_test_supplier("_Test Supplier SC Standing")
doc = make_supplier_scorecard()
doc.supplier = supplier
doc.supplier_score = 20 # falls in the "Very Poor" (0-30) band
doc.update_standing()
self.assertEqual(doc.status, "Very Poor")
self.assertEqual(doc.prevent_pos, 1)
self.assertEqual(doc.prevent_rfqs, 1)
self.assertEqual(frappe.db.get_value("Supplier", supplier, "prevent_pos"), 1)
self.assertEqual(frappe.db.get_value("Supplier", supplier, "prevent_rfqs"), 1)
def test_scorecard_period_end_dates(self):
start = getdate("2024-01-01")
self.assertEqual(get_scorecard_date("Per Week", start), getdate("2024-01-08"))
self.assertEqual(get_scorecard_date("Per Month", start), getdate("2024-01-31"))
self.assertEqual(get_scorecard_date("Per Year", start), getdate("2024-12-31"))
def test_make_all_scorecards_is_idempotent(self):
supplier = create_test_supplier("_Test Supplier SC Idempotent")
frappe.db.set_value("Supplier", supplier, "creation", add_days(nowdate(), -75))
doc = make_supplier_scorecard()
doc.supplier = supplier
doc.name = supplier
doc.insert() # on_update generates the period scorecards
created = frappe.db.count("Supplier Scorecard Period", {"scorecard": doc.name, "docstatus": 1})
self.assertGreater(created, 0)
self.assertEqual(make_all_scorecards(doc.name), 0)
def make_supplier_scorecard():
my_doc = frappe.get_doc(valid_scorecard[0])
@@ -32,6 +87,18 @@ def make_supplier_scorecard():
return my_doc
def create_test_supplier(supplier_name):
if not frappe.db.exists("Supplier", supplier_name):
frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": supplier_name,
"supplier_group": "_Test Supplier Group",
}
).insert()
return supplier_name
valid_scorecard = [
{
"standings": [

View File

@@ -1,8 +1,64 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestSupplierScorecardPeriod(ERPNextTestSuite):
pass
def test_criteria_score_is_clamped_to_bounds(self):
period = make_period(
criteria=[
{"criteria_name": "Over", "formula": "200", "max_score": 100, "weight": 50},
{"criteria_name": "Negative", "formula": "-50", "max_score": 100, "weight": 50},
]
)
period.calculate_criteria()
self.assertEqual(period.criteria[0].score, 100) # capped at max_score
self.assertEqual(period.criteria[1].score, 0) # floored at zero
def test_invalid_criteria_formula_raises(self):
period = make_period(
criteria=[{"criteria_name": "Bad", "formula": "{missing} +", "max_score": 100, "weight": 100}]
)
self.assertRaises(frappe.ValidationError, period.calculate_criteria)
def test_eval_statement_substitutes_variable_values(self):
period = make_period(
variables=[
{"variable_label": "A", "param_name": "a", "path": "get_total_workdays", "value": 5},
{"variable_label": "B", "param_name": "b", "path": "get_total_workdays", "value": 0},
]
)
# present value -> formatted; missing/zero value -> "0.0"
self.assertEqual(period.get_eval_statement("{a} + {b}"), "5.00 + 0.0")
def test_period_score_is_weighted_sum_of_criteria(self):
period = make_period(
criteria=[
{"criteria_name": "C1", "formula": "80", "max_score": 100, "weight": 25},
{"criteria_name": "C2", "formula": "40", "max_score": 100, "weight": 75},
]
)
period.calculate_criteria()
period.calculate_score()
# 80 * 0.25 + 40 * 0.75 = 50
self.assertEqual(period.total_score, 50)
def test_criteria_weights_must_total_100(self):
period = make_period(
criteria=[{"criteria_name": "C1", "formula": "100", "max_score": 100, "weight": 60}]
)
self.assertRaises(frappe.ValidationError, period.validate_criteria_weights)
def make_period(variables=None, criteria=None):
period = frappe.new_doc("Supplier Scorecard Period")
for variable in variables or []:
period.append("variables", variable)
for criterion in criteria or []:
period.append("criteria", criterion)
return period

View File

@@ -3,9 +3,15 @@
import frappe
from frappe.utils import add_days, nowdate
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt as make_pr_from_po
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_variable import (
VariablePathNotFound,
get_on_time_shipments,
get_total_cost_of_shipments,
get_total_days_late,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -27,6 +33,65 @@ class TestSupplierScorecardVariable(ERPNextTestSuite):
for d in test_bad_variables:
self.assertRaises(VariablePathNotFound, frappe.get_doc(d).insert)
def test_total_cost_of_shipments_counts_only_in_period(self):
supplier = create_scorecard_supplier()
create_scorecard_po(supplier, nowdate(), qty=10, rate=100) # in period -> 1000
create_scorecard_po(supplier, add_days(nowdate(), 60), qty=5, rate=100) # outside period
scorecard = scorecard_for(supplier)
self.assertEqual(get_total_cost_of_shipments(scorecard), 1000)
def test_on_time_and_delayed_shipments(self):
supplier = create_scorecard_supplier()
on_time_po = create_scorecard_po(supplier, add_days(nowdate(), 5), qty=10, rate=100)
late_po = create_scorecard_po(
supplier,
add_days(nowdate(), -5),
transaction_date=add_days(nowdate(), -10),
qty=10,
rate=100,
)
for po in (on_time_po, late_po):
receipt = make_pr_from_po(po.name)
receipt.insert()
receipt.submit()
scorecard = scorecard_for(supplier)
self.assertEqual(get_on_time_shipments(scorecard), 1)
self.assertEqual(get_total_days_late(scorecard), 50) # 5 days late * 10 qty
def create_scorecard_supplier(supplier_name="_Test Supplier Scorecard"):
if not frappe.db.exists("Supplier", supplier_name):
frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": supplier_name,
"supplier_group": "_Test Supplier Group",
}
).insert()
return supplier_name
def create_scorecard_po(supplier, schedule_date, transaction_date=None, qty=10, rate=100):
po = create_purchase_order(
supplier=supplier, transaction_date=transaction_date, qty=qty, rate=rate, do_not_save=True
)
po.schedule_date = schedule_date
po.items[0].schedule_date = schedule_date
po.set_missing_values()
po.insert()
po.submit()
return po
def scorecard_for(supplier):
return frappe._dict(
supplier=supplier,
start_date=add_days(nowdate(), -30),
end_date=add_days(nowdate(), 30),
)
test_existing_variables = [
{