Merge branch 'develop' into fix/letterhead-footer-print-formats

This commit is contained in:
Khushi Rawat
2026-07-03 02:27:58 +05:30
committed by GitHub
175 changed files with 15525 additions and 985 deletions

View File

@@ -21,9 +21,6 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None):
)
target_doc.quotation_to = "Customer"
target_doc.run_method("set_missing_values")
target_doc.run_method("set_other_charges")
target_doc.run_method("calculate_taxes_and_totals")
price_list, currency = frappe.db.get_value(
"Customer", {"name": source_name}, ["default_price_list", "default_currency"]
@@ -33,6 +30,10 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None):
if currency:
target_doc.currency = currency
target_doc.run_method("set_missing_values")
target_doc.run_method("set_other_charges")
target_doc.run_method("calculate_taxes_and_totals")
return target_doc

View File

@@ -5,7 +5,7 @@
import json
import frappe
from frappe.utils import flt
from frappe.utils import flt, nowdate
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
@@ -14,12 +14,53 @@ from erpnext.selling.doctype.customer.customer import (
get_customer_outstanding,
)
from erpnext.selling.doctype.customer.mapper import (
make_quotation,
parse_full_name,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestCustomer(ERPNextTestSuite):
def test_quotation_from_customer_uses_actual_exchange_rate(self):
company = "_Test Company"
company_currency = frappe.get_cached_value("Company", company, "default_currency")
foreign_currency = "USD" if company_currency != "USD" else "EUR"
frappe.defaults.set_user_default("company", company)
self.addCleanup(frappe.defaults.clear_user_default, "company")
# Seed a deterministic rate so the test does not depend on the live exchange-rate API.
rate = 83.0
exchange = frappe.get_doc(
{
"doctype": "Currency Exchange",
"date": nowdate(),
"from_currency": foreign_currency,
"to_currency": company_currency,
"exchange_rate": rate,
"for_selling": 1,
"for_buying": 1,
}
).insert(ignore_if_duplicate=True)
self.addCleanup(frappe.delete_doc, "Currency Exchange", exchange.name, force=1)
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "_Test Customer FX Quotation",
"customer_type": "Company",
"default_currency": foreign_currency,
}
).insert()
self.addCleanup(frappe.delete_doc, "Customer", customer.name, force=1)
quotation = make_quotation(customer.name)
self.assertEqual(quotation.currency, foreign_currency)
self.assertNotEqual(flt(quotation.conversion_rate), 1.0)
self.assertNotEqual(flt(quotation.conversion_rate), 0.0)
self.assertEqual(flt(quotation.conversion_rate), rate)
def test_get_customer_name_dedupes_with_numeric_suffix(self):
# When a customer name already exists, get_customer_name appends "- <max suffix + 1>". The
# Postgres branch extracts the suffix with regexp_replace/NULLIF/CAST (pypika's Substring cannot

View File

@@ -228,7 +228,7 @@ def _make_customer(source_name, ignore_permissions=False):
def create_customer_from_lead(lead_name, ignore_permissions=False):
from erpnext.crm.doctype.lead.lead import _make_customer
from erpnext.crm.doctype.lead.mapper import _make_customer
customer = _make_customer(lead_name, ignore_permissions=ignore_permissions)
customer.flags.ignore_permissions = ignore_permissions

View File

@@ -0,0 +1,69 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.selling.report.customer_wise_item_price.customer_wise_item_price import execute
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
PRICE_LIST = "Standard Selling"
class TestCustomerWiseItemPrice(ERPNextTestSuite):
"""The report lists sales items with the selling rate from the customer's price
list and the available stock (summed across warehouses)."""
def setUp(self):
self.item = make_item(properties={"is_stock_item": 1, "is_sales_item": 1}).name
self.customer = self.create_customer()
frappe.get_doc(
{
"doctype": "Item Price",
"item_code": self.item,
"price_list": PRICE_LIST,
"selling": 1,
"price_list_rate": 250,
}
).insert()
make_stock_entry(item_code=self.item, to_warehouse="Stores - _TC", qty=10, rate=100)
def create_customer(self):
name = "_Test CWIP Customer"
if not frappe.db.exists("Customer", name):
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": name,
"customer_group": "_Test Customer Group",
"territory": "_Test Territory",
"default_price_list": PRICE_LIST,
}
).insert()
return name
def run_report(self, **extra):
filters = frappe._dict({"customer": self.customer})
filters.update(extra)
return execute(filters)[1]
def test_customer_filter_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({}))
def test_selling_rate_and_available_stock_for_item(self):
rows = self.run_report(item=self.item)
row = next((r for r in rows if r["item_code"] == self.item), None)
self.assertIsNotNone(row, "Sales item missing from report")
self.assertEqual(row["item_name"], frappe.db.get_value("Item", self.item, "item_name"))
self.assertEqual(row["selling_rate"], 250) # from the customer's price list
self.assertEqual(row["available_stock"], 10) # stocked into Stores - _TC
self.assertEqual(row["price_list"], PRICE_LIST)
def test_item_filter_scopes_to_single_item(self):
other = make_item(properties={"is_stock_item": 1, "is_sales_item": 1}).name
item_codes = {r["item_code"] for r in self.run_report(item=self.item)}
self.assertIn(self.item, item_codes)
self.assertNotIn(other, item_codes)

View File

@@ -0,0 +1,123 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import (
create_dn_against_so,
make_sales_order,
)
from erpnext.selling.report.item_wise_sales_history.item_wise_sales_history import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseSalesHistory(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)
def so_row(self, so_name, **extra):
data = self.run_report(**extra)[1]
return next(row for row in data if row["sales_order"] == so_name)
def test_sales_order_line_shown_with_values(self):
so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01")
row = self.so_row(so.name)
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["quantity"], 10)
self.assertEqual(row["rate"], 100)
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["customer"], "_Test Customer")
def test_draft_sales_order_excluded(self):
so = make_sales_order(transaction_date="2026-06-01", do_not_submit=True)
names = {row["sales_order"] for row in self.run_report()[1]}
self.assertNotIn(so.name, names)
def test_date_range_filters_on_transaction_date(self):
so = make_sales_order(transaction_date="2026-06-01")
in_range = {
row["sales_order"] for row in self.run_report(from_date="2026-05-01", to_date="2026-07-01")[1]
}
self.assertIn(so.name, in_range)
out_of_range = {
row["sales_order"] for row in self.run_report(from_date="2026-01-01", to_date="2026-03-01")[1]
}
self.assertNotIn(so.name, out_of_range)
def test_item_code_filter(self):
so = make_sales_order(
transaction_date="2026-06-01",
item_list=[
{"item_code": "_Test Item", "qty": 5, "rate": 100, "warehouse": "_Test Warehouse - _TC"},
{"item_code": "_Test Item 2", "qty": 3, "rate": 200, "warehouse": "_Test Warehouse - _TC"},
],
)
item_codes = {row["item_code"] for row in self.run_report(item_code="_Test Item 2")[1]}
self.assertEqual(item_codes, {"_Test Item 2"})
# the filtered-out line of the same order must not leak in
self.assertTrue(
all(row["sales_order"] == so.name for row in self.run_report(item_code="_Test Item 2")[1])
)
def test_customer_filter(self):
make_sales_order(customer="_Test Customer 1", transaction_date="2026-06-01")
make_sales_order(customer="_Test Customer 2", transaction_date="2026-06-01")
customers = {row["customer"] for row in self.run_report(customer="_Test Customer 1")[1]}
self.assertEqual(customers, {"_Test Customer 1"})
def test_delivered_quantity_reflects_delivery(self):
so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01")
create_dn_against_so(so.name, delivered_qty=4)
self.assertEqual(self.so_row(so.name)["delivered_quantity"], 4)
def test_billed_amount_reflects_invoice(self):
so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01")
si = make_sales_invoice(so.name)
si.insert()
si.submit()
self.assertEqual(self.so_row(so.name)["billed_amount"], 1000)
def test_amounts_reported_in_company_currency(self):
# a USD order must report rate/amount converted to the company's currency (base_* fields)
so = make_sales_order(
do_not_save=True,
currency="USD",
qty=10,
rate=100,
transaction_date="2026-06-01",
)
so.conversion_rate = 80
so.insert()
so.submit()
row = self.so_row(so.name)
self.assertEqual(row["rate"], 8000) # 100 USD * 80
self.assertEqual(row["amount"], 80000) # 10 * 100 USD * 80
def test_chart_aggregates_amount_per_item(self):
make_sales_order(item_code="_Test Item", qty=2, rate=100, transaction_date="2026-06-01")
make_sales_order(item_code="_Test Item", qty=3, rate=100, transaction_date="2026-06-01")
chart = self.run_report(item_code="_Test Item")[3]
labels = chart["data"]["labels"]
values = chart["data"]["datasets"][0]["values"]
self.assertIn("_Test Item", labels)
# 2*100 + 3*100 aggregated for the item
self.assertEqual(values[labels.index("_Test Item")], 500)

View File

@@ -0,0 +1,88 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.report.quotation_trends.quotation_trends import execute
from erpnext.tests.utils import ERPNextTestSuite
FISCAL_YEAR = "_Test Fiscal Year 2026"
TXN_DATE = "2026-06-01"
class TestQuotationTrends(ERPNextTestSuite):
"""The trends report buckets submitted Quotation quantities/amounts by period
(Yearly/Monthly) for the chosen `based_on` dimension (Item, Customer, ...)."""
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": FISCAL_YEAR,
"based_on": "Item",
"period": "Yearly",
}
)
filters.update(extra)
result = execute(filters)
columns, data = result[0], result[1]
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
return labels, data
def _cell(self, data, key_label, key_value, col_label, labels):
"""Value at column `col_label` for the row whose `key_label` column equals
`key_value`, or 0 when that row doesn't exist yet."""
key_idx = labels.index(key_label)
col_idx = labels.index(col_label)
for row in data:
if row[key_idx] == key_value:
return row[col_idx] or 0
return 0
def test_yearly_item_amount_and_total(self):
# Yearly period => a single "<FY> (Qty)"/"(Amt)" bucket plus Total(Qty)/Total(Amt).
labels, before = self.run_report()
qty_col = f"{FISCAL_YEAR} (Qty)"
amt_col = f"{FISCAL_YEAR} (Amt)"
before_qty = self._cell(before, "Item", "_Test Item", qty_col, labels)
before_amt = self._cell(before, "Item", "_Test Item", amt_col, labels)
before_tot_qty = self._cell(before, "Item", "_Test Item", "Total(Qty)", labels)
before_tot_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
make_quotation(item="_Test Item", qty=4, rate=200, transaction_date=TXN_DATE)
labels, after = self.run_report()
self.assertEqual(self._cell(after, "Item", "_Test Item", qty_col, labels) - before_qty, 4)
self.assertEqual(self._cell(after, "Item", "_Test Item", amt_col, labels) - before_amt, 800)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Qty)", labels) - before_tot_qty, 4)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot_amt, 800)
def test_monthly_lands_in_june_bucket(self):
# Monthly period => one bucket per month; a 2026-06-01 quotation hits "Jun (Qty)"/"(Amt)".
labels, before = self.run_report(period="Monthly")
before_jun_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels)
before_jun_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels)
before_may_qty = self._cell(before, "Item", "_Test Item", "May (Qty)", labels)
make_quotation(item="_Test Item", qty=3, rate=100, transaction_date=TXN_DATE)
labels, after = self.run_report(period="Monthly")
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_jun_qty, 3)
# the amount path is a separate SUM(base_net_amount) case, so assert it too
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_jun_amt, 300)
# nothing was quoted in May, so that bucket is unchanged
self.assertEqual(self._cell(after, "Item", "_Test Item", "May (Qty)", labels) - before_may_qty, 0)
def test_based_on_customer_groups_amount_by_party(self):
# based_on Customer keys rows on the "Party" column (the customer id)
labels, before = self.run_report(based_on="Customer")
amt_col = f"{FISCAL_YEAR} (Amt)"
before_amt = self._cell(before, "Party", "_Test Customer", amt_col, labels)
make_quotation(
party_name="_Test Customer", item="_Test Item", qty=2, rate=150, transaction_date=TXN_DATE
)
labels, after = self.run_report(based_on="Customer")
self.assertEqual(self._cell(after, "Party", "_Test Customer", amt_col, labels) - before_amt, 300)

View File

@@ -0,0 +1,85 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.selling.report.sales_person_commission_summary.sales_person_commission_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesPersonCommissionSummary(ERPNextTestSuite):
"""The report joins a sales document (Sales Invoice/Order/Delivery Note) with its
Sales Team rows, listing each sales person's contribution and commission."""
def setUp(self):
# reuse the bootstrap sales persons (under the "Sales Team" group)
self.sales_person = "_Test Sales Person"
def make_invoice_with_commission(self, percentage=100, commission_rate=5, incentives=50):
si = create_sales_invoice(rate=1000, qty=1, do_not_save=True, posting_date="2026-06-01")
si.append(
"sales_team",
{
"sales_person": self.sales_person,
"allocated_percentage": percentage,
"commission_rate": commission_rate,
"incentives": incentives,
},
)
si.insert()
si.submit()
si.reload() # reflect any values recomputed on submit
return si
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"doc_type": "Sales Invoice",
"sales_person": self.sales_person,
# scope to this test's posting date so the query isn't unbounded over
# every invoice for the shared sales person
"from_date": "2026-06-01",
"to_date": "2026-06-01",
}
)
filters.update(extra)
return execute(filters)[1]
def test_doc_type_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"}))
def test_commission_row_matches_sales_team_entry(self):
si = self.make_invoice_with_commission(percentage=100, commission_rate=5, incentives=50)
team = si.sales_team[0]
rows = self.run_report()
row = next((r for r in rows if r[0] == si.name), None)
self.assertIsNotNone(row, "Invoice with commission missing from report")
# row: name, customer, territory, posting_date, base_net_amount, sales_person,
# allocated_percentage, commission_rate, allocated_amount, incentives
self.assertEqual(row[1], si.customer)
self.assertEqual(row[4], si.base_net_total)
self.assertEqual(row[5], self.sales_person)
self.assertEqual(row[6], team.allocated_percentage)
self.assertEqual(row[7], team.commission_rate)
self.assertEqual(row[8], team.allocated_amount)
self.assertEqual(row[9], team.incentives)
def test_appends_total_row(self):
self.make_invoice_with_commission()
rows = self.run_report()
# the report appends a blank total row after one or more real data rows
self.assertGreaterEqual(len(rows), 2)
self.assertTrue(any(r[0] for r in rows[:-1]), "expected real data rows before the total row")
self.assertEqual(rows[-1], [""] * len(rows[0]))
def test_sales_person_filter_scopes_rows(self):
si = self.make_invoice_with_commission()
filtered = self.run_report(sales_person="_Test Sales Person 1")
self.assertNotIn(si.name, {r[0] for r in filtered if r[0]})

View File

@@ -183,8 +183,22 @@ def get_entries(filters):
.as_("contribution_amt")
)
# Only pass valid document-field filters to get_query; report-specific keys such as
# doc_type / sales_person / item_group are handled separately below.
doc_filters = {"docstatus": 1}
for field in ["company", "customer", "territory"]:
if filters.get(field):
doc_filters[field] = filters.get(field)
if filters.get("from_date") and filters.get("to_date"):
doc_filters[date_field] = ["between", [filters.get("from_date"), filters.get("to_date")]]
elif filters.get("from_date"):
doc_filters[date_field] = [">=", filters.get("from_date")]
elif filters.get("to_date"):
doc_filters[date_field] = ["<=", filters.get("to_date")]
query = (
frappe.get_query(dt, filters=filters, ignore_permissions=False)
frappe.get_query(dt, filters=doc_filters, ignore_permissions=False)
.join(dt_item)
.on(dt.name == dt_item.parent)
.join(st)
@@ -203,48 +217,29 @@ def get_entries(filters):
contribution_amt_case,
)
.where(st.parenttype == doc_type)
.where(dt.docstatus == 1)
)
if filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
sp = frappe.qb.DocType("Sales Person")
query = query.where(
st.sales_person.isin(frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt)))
)
# only resolve items when an item_group/brand filter is set; otherwise get_items
# would return every item in the system and add a huge IN() clause on each run
if filters.get("item_group") or filters.get("brand"):
items = get_items(filters)
if not items:
# the item_group/brand filter matched nothing -> no rows
return []
query = query.where(dt_item.item_code.isin([d[0] for d in items]))
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
return query.run(as_dict=True)
def get_conditions(filters, date_field):
conditions = [""]
values = []
for field in ["company", "customer", "territory"]:
if filters.get(field):
conditions.append(f"dt.{field}=%s")
values.append(filters[field])
if filters.get("sales_person"):
lft, rgt = frappe.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
conditions.append(
f"exists(select name from `tabSales Person` where lft >= {lft} and rgt <= {rgt} and name=st.sales_person)"
)
if filters.get("from_date"):
conditions.append(f"dt.{date_field}>=%s")
values.append(filters["from_date"])
if filters.get("to_date"):
conditions.append(f"dt.{date_field}<=%s")
values.append(filters["to_date"])
items = get_items(filters)
if items:
conditions.append("dt_item.item_code in (%s)" % ", ".join(["%s"] * len(items)))
values += items
else:
# return empty result, if no items are fetched after filtering on 'item group' and 'brand'
conditions.append("dt_item.item_code = Null")
return " and ".join(conditions), values
def get_items(filters):
item = qb.DocType("Item")

View File

@@ -0,0 +1,69 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.selling.report.sales_person_wise_transaction_summary.sales_person_wise_transaction_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesPersonWiseTransactionSummary(ERPNextTestSuite):
"""Item-level summary joining a sales document with its Sales Team rows, showing
each sales person's contributed qty and amount per item line."""
def setUp(self):
self.sales_person = "_Test Sales Person"
def make_invoice_with_commission(self, qty=5, rate=200, percentage=100):
si = create_sales_invoice(
item="_Test Item", qty=qty, rate=rate, do_not_save=True, posting_date="2026-06-01"
)
si.append("sales_team", {"sales_person": self.sales_person, "allocated_percentage": percentage})
si.insert()
si.submit()
return si
def run_report(self, **extra):
filters = frappe._dict(
{"company": "_Test Company", "doc_type": "Sales Invoice", "sales_person": self.sales_person}
)
filters.update(extra)
return execute(filters)[1]
def test_doc_type_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"}))
def test_invalid_doc_type_throws(self):
self.assertRaises(
frappe.ValidationError,
execute,
frappe._dict({"company": "_Test Company", "doc_type": "Purchase Invoice"}),
)
def test_item_line_contribution(self):
si = self.make_invoice_with_commission(qty=5, rate=200, percentage=100)
item = si.items[0]
rows = self.run_report()
row = next((r for r in rows if r[0] == si.name and r[5] == "_Test Item"), None)
self.assertIsNotNone(row, "Invoice item line missing from report")
# row: name, customer, territory, warehouse, posting_date, item_code, item_group,
# brand, stock_qty, base_net_amount, sales_person, allocated_percentage,
# contributed_qty, contribution_amt, currency
self.assertEqual(row[1], si.customer)
self.assertEqual(row[8], item.stock_qty)
self.assertEqual(row[9], item.base_net_amount)
self.assertEqual(row[10], self.sales_person)
self.assertEqual(row[11], 100)
self.assertEqual(row[12], item.stock_qty * 100 / 100) # contributed qty
self.assertEqual(row[13], item.base_net_amount * 100 / 100) # contribution amount
def test_appends_total_row(self):
self.make_invoice_with_commission()
rows = self.run_report()
self.assertTrue(rows)
self.assertEqual(rows[-1], [""] * len(rows[0]))

View File

@@ -0,0 +1,68 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt, nowdate
from erpnext.accounts.utils import get_fiscal_year
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_person_target_variance_based_on_item_group.test_sales_person_target_variance_based_on_item_group import (
create_target_distribution,
)
from erpnext.selling.report.territory_target_variance_based_on_item_group.territory_target_variance_based_on_item_group import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestTerritoryTargetVarianceBasedOnItemGroup(ERPNextTestSuite):
def setUp(self):
self.fiscal_year = get_fiscal_year(nowdate())[0]
def test_achieved_target_and_variance(self):
distribution = create_target_distribution(self.fiscal_year)
territory = create_territory_with_target(
"_Test Target Territory", self.fiscal_year, distribution.name, target_qty=50
)
# a Sales Order in that territory contributes to the achieved quantity
so = make_sales_order(rate=1000, qty=20, do_not_submit=True)
so.territory = territory.name
so.submit()
result = execute(
frappe._dict(
{
"fiscal_year": self.fiscal_year,
"doctype": "Sales Order",
"period": "Yearly",
"target_on": "Quantity",
}
)
)[1]
# no item_group is set on the target, so the report emits exactly one row per
# territory -- assert all three figures against that single row
rows = [frappe._dict(r) for r in result if r.get("territory") == territory.name]
self.assertEqual(len(rows), 1, "expected exactly one row for the target territory")
row = rows[0]
self.assertEqual(flt(row.total_target, 2), 50)
self.assertEqual(flt(row.total_achieved, 2), 20)
self.assertEqual(flt(row.total_variance, 2), -30)
def create_territory_with_target(name, fiscal_year, distribution_id, target_qty=50):
doc = frappe.new_doc("Territory")
doc.territory_name = name
doc.parent_territory = "All Territories"
doc.is_group = 0
doc.append(
"targets",
{
"fiscal_year": fiscal_year,
"target_qty": target_qty,
"target_amount": 30000,
"distribution_id": distribution_id,
},
)
return doc.insert()

View File

@@ -0,0 +1,62 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.report.territory_wise_sales.territory_wise_sales import execute
from erpnext.tests.utils import ERPNextTestSuite
TERRITORY = "_Test Territory"
class TestTerritoryWiseSales(ERPNextTestSuite):
"""The report walks the Opportunity -> Quotation -> Sales Order -> Sales Invoice
funnel and totals each stage's amount per territory.
These tests cover the Opportunity and Quotation stages; the Sales Order and
Sales Invoice (order_amount / billing_amount) stages are not yet exercised."""
def make_opportunity(self, amount=5000):
return frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": "Customer",
"party_name": "_Test Customer",
"territory": TERRITORY,
"company": "_Test Company",
"currency": "INR",
"opportunity_amount": amount,
"transaction_date": "2026-06-01",
}
).insert()
def make_quotation_for(self, opportunity, qty, rate):
qo = make_quotation(item="_Test Item", qty=qty, rate=rate, do_not_save=True)
qo.opportunity = opportunity.name
qo.insert()
qo.submit()
return qo
def amount_for(self, territory, field):
for row in execute(frappe._dict({"company": "_Test Company"}))[1]:
if row["territory"] == territory:
return row[field]
return 0
def test_opportunity_amount_grouped_by_territory(self):
before = self.amount_for(TERRITORY, "opportunity_amount")
opp = self.make_opportunity(5000)
self.assertEqual(opp.territory, TERRITORY)
after = self.amount_for(TERRITORY, "opportunity_amount")
self.assertEqual(after - before, 5000)
def test_quotation_amount_flows_from_opportunity(self):
before = self.amount_for(TERRITORY, "quotation_amount")
opp = self.make_opportunity()
quotation = self.make_quotation_for(opp, qty=2, rate=500)
after = self.amount_for(TERRITORY, "quotation_amount")
self.assertEqual(after - before, quotation.base_grand_total)

View File

@@ -622,9 +622,10 @@
"type": "Link"
}
],
"modified": "2026-02-19 13:01:26.893303",
"modified": "2026-06-14 13:44:07.820564",
"modified_by": "Administrator",
"module": "Selling",
"module_onboarding": "Selling Onboarding",
"name": "Selling",
"number_cards": [
{
@@ -648,6 +649,762 @@
"roles": [],
"sequence_id": 6.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Selling",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Selling",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "receipt-text",
"indent": 0,
"keep_closed": 0,
"label": "Quotation",
"link_to": "Quotation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sell",
"indent": 0,
"keep_closed": 0,
"label": "Sales Order",
"link_to": "Sales Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "receipt",
"indent": 0,
"keep_closed": 0,
"label": "Sales Invoice",
"link_to": "Sales Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "computer",
"indent": 1,
"keep_closed": 1,
"label": "POS",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "POS",
"link_to": "point-of-sale",
"link_type": "Page",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "POS Profile",
"link_to": "POS Profile",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "POS Invoice",
"link_to": "POS Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "POS Opening Entry",
"link_to": "POS Opening Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "POS Closing Entry",
"link_to": "POS Closing Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "POS Invoice Merge Log",
"link_to": "POS Invoice Merge Log",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "POS Settings",
"link_to": "POS Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Loyalty Program",
"link_to": "Loyalty Program",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Loyalty Point Entry",
"link_to": "Loyalty Point Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "stock",
"indent": 1,
"keep_closed": 1,
"label": "Items & Pricing",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item Group",
"link_to": "Item Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Price List",
"link_to": "Price List",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item Price",
"link_to": "Item Price",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Pricing Rule",
"link_to": "Pricing Rule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Promotional Scheme",
"link_to": "Promotional Scheme",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Coupon Code",
"link_to": "Coupon Code",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Blanket Order",
"link_to": "Blanket Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Customer",
"link_to": "Customer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Group",
"link_to": "Customer Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Address",
"link_to": "Address",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Contact",
"link_to": "Contact",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Territory",
"link_to": "Territory",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Campaign",
"link_to": "Campaign",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Person",
"link_to": "Sales Person",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Partner",
"link_to": "Sales Partner",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Monthly Distribution",
"link_to": "Monthly Distribution",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Terms Template",
"link_to": "Terms and Conditions",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Tax Template",
"link_to": "Sales Taxes and Charges Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Product Bundle",
"link_to": "Product Bundle",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "UTM Source",
"link_to": "UTM Source",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Shipping Rule",
"link_to": "Shipping Rule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Register",
"link_to": "Sales Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item-wise Sales Register",
"link_to": "Item-wise Sales Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Analytics",
"link_to": "Sales Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Addresses And Contacts",
"link_to": "Address And Contacts",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Inactive Customers",
"link_to": "Inactive Customers",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Invoice Trends",
"link_to": "Sales Invoice Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Credit Balance",
"link_to": "Customer Credit Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customers Without Any Sales Transactions",
"link_to": "Customers Without Any Sales Transactions",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Partners Commission",
"link_to": "Sales Partners Commission",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Available Stock for Packing Items",
"link_to": "Available Stock for Packing Items",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Territory Target Variance Based On Item Group",
"link_to": "Territory Target Variance Based On Item Group",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Person Target Variance Based On Item Group",
"link_to": "Sales Person Target Variance Based On Item Group",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Partner Target Variance Based On Item Group",
"link_to": "Sales Partner Target Variance based on Item Group",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Pending SO Items For Purchase Request",
"link_to": "Pending SO Items For Purchase Request",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Funnel",
"link_to": "sales-funnel",
"link_type": "Page",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Order Analysis",
"link_to": "Sales Order Analysis",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Acquisition and Loyalty",
"link_to": "Customer Acquisition and Loyalty",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Quotation Trends",
"link_to": "Quotation Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Order Trends",
"link_to": "Sales Order Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item-wise Sales History",
"link_to": "Item-wise Sales History",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Person-wise Transaction Summary",
"link_to": "Sales Person-wise Transaction Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Selling Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Selling",
"type": "Workspace"
}