feat(selling): group Sales Order Analysis by item (backport #59236) (#59246)

Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
This commit is contained in:
mergify[bot]
2026-09-22 08:35:00 +00:00
committed by GitHub
parent 0f1cefa918
commit 814d7f0c4e
3 changed files with 380 additions and 163 deletions

View File

@@ -89,6 +89,30 @@ frappe.query_reports["Sales Order Analysis"] = {
label: __("Group by Sales Order"),
fieldtype: "Check",
default: 0,
on_change: (report) => {
if (report.get_filter_value("group_by_so") && report.get_filter_value("group_by_item")) {
report.set_filter_value("group_by_item", 0);
return;
}
if (!report._no_refresh) {
report.refresh(true);
}
},
},
{
fieldname: "group_by_item",
label: __("Group by Item"),
fieldtype: "Check",
default: 0,
on_change: (report) => {
if (report.get_filter_value("group_by_so") && report.get_filter_value("group_by_item")) {
report.set_filter_value("group_by_so", 0);
return;
}
if (!report._no_refresh) {
report.refresh(true);
}
},
},
],

View File

@@ -8,19 +8,24 @@ import frappe
from frappe import _, qb
from frappe.query_builder import CustomFunction
from frappe.query_builder.functions import Max
from frappe.utils import date_diff, flt, getdate
from frappe.utils import date_diff, flt
import erpnext
def execute(filters=None):
if not filters:
return [], [], None, []
filters = frappe._dict(filters)
filters.company = filters.get("company") or erpnext.get_default_company()
validate_filters(filters)
columns = get_columns(filters)
conditions = get_conditions(filters)
data = get_data(conditions, filters)
so_elapsed_time = get_so_elapsed_time(data)
so_elapsed_time = {} if filters.get("group_by_item") else get_so_elapsed_time(data)
if not data:
return [], [], None, []
@@ -31,6 +36,9 @@ def execute(filters=None):
def validate_filters(filters):
if not filters.get("company"):
frappe.throw(_("{0} is mandatory").format(_("Company")))
from_date, to_date = filters.get("from_date"), filters.get("to_date")
if not from_date and to_date:
@@ -38,14 +46,16 @@ def validate_filters(filters):
elif date_diff(to_date, from_date) < 0:
frappe.throw(_("To Date cannot be before From Date."))
if filters.get("group_by_so") and filters.get("group_by_item"):
frappe.throw(_("Group the report by Sales Order or by Item, not both."))
def get_conditions(filters):
conditions = ""
if filters.get("from_date") and filters.get("to_date"):
conditions += " and so.transaction_date between %(from_date)s and %(to_date)s"
if filters.get("company"):
conditions += " and so.company = %(company)s"
conditions += " and so.company = %(company)s"
if filters.get("sales_order"):
conditions += " and so.name in %(sales_order)s"
@@ -66,7 +76,7 @@ def get_data(conditions, filters):
so.transaction_date as date,
soi.delivery_date as delivery_date,
so.name as sales_order,
so.status, so.customer, soi.item_code,
so.status, so.customer, soi.item_code, soi.uom,
DATEDIFF(CURRENT_DATE, soi.delivery_date) as delay_days,
IF(so.status in ('Completed','To Bill'), 0, (SELECT delay_days)) as delay,
soi.qty, soi.delivered_qty,
@@ -140,71 +150,86 @@ def get_so_elapsed_time(data):
return so_elapsed_time
AGGREGATED_FIELDS = (
"qty",
"delivered_qty",
"pending_qty",
"billed_qty",
"qty_to_bill",
"amount",
"delivered_qty_amount",
"billed_amount",
"pending_amount",
)
def prepare_data(data, so_elapsed_time, filters):
completed, pending = 0, 0
if filters.get("group_by_so"):
sales_order_map = {}
for row in data:
# sum data for chart
completed += row["billed_amount"]
pending += row["pending_amount"]
# prepare data for report view
row["qty_to_bill"] = flt(row["qty"]) - flt(row["billed_qty"])
row["delay"] = 0 if row["delay"] and row["delay"] < 0 else row["delay"]
row["time_taken_to_deliver"] = (
so_elapsed_time.get((row.sales_order, row.item_code))
if row["status"] in ("To Bill", "Completed")
else 0
)
if filters.get("group_by_so"):
so_name = row["sales_order"]
if so_name not in sales_order_map:
# create an entry
row_copy = copy.deepcopy(row)
sales_order_map[so_name] = row_copy
else:
# update existing entry
so_row = sales_order_map[so_name]
so_row["required_date"] = max(getdate(so_row["delivery_date"]), getdate(row["delivery_date"]))
so_row["delay"] = (
min(so_row["delay"], row["delay"])
if row["delay"] and so_row["delay"]
else so_row["delay"]
)
# sum numeric columns
fields = [
"qty",
"delivered_qty",
"pending_qty",
"billed_qty",
"qty_to_bill",
"amount",
"delivered_qty_amount",
"billed_amount",
"pending_amount",
]
for field in fields:
so_row[field] = flt(row[field]) + flt(so_row[field])
chart_data = prepare_chart_data(pending, completed)
if filters.get("group_by_so"):
data = []
for so in sales_order_map:
data.append(sales_order_map[so])
return data, chart_data
data = group_by_sales_order(data)
elif filters.get("group_by_item"):
data = group_by_item(data)
return data, chart_data
def group_by_sales_order(data):
sales_order_map = {}
for row in data:
group = sales_order_map.get(row["sales_order"])
if not group:
sales_order_map[row["sales_order"]] = copy.deepcopy(row)
continue
group["delay"] = (
min(group["delay"], row["delay"]) if row["delay"] and group["delay"] else group["delay"]
)
add_aggregated_fields(group, row)
return list(sales_order_map.values())
def group_by_item(data):
"""Group on company and UOM as well as the item.
Quantities are in the line UOM and amounts are in the company currency, so neither sums
across a second UOM of the same item or a second company.
"""
item_map = {}
for row in data:
key = (row["company"], row["item_code"], row["uom"])
group = item_map.get(key)
if not group:
item_map[key] = copy.deepcopy(row)
continue
add_aggregated_fields(group, row)
return sorted(item_map.values(), key=lambda row: (row["company"], row["item_code"], row["uom"]))
def add_aggregated_fields(group, row):
for field in AGGREGATED_FIELDS:
group[field] = flt(group[field]) + flt(row[field])
def prepare_chart_data(pending, completed):
labels = [_("Amount to Bill"), _("Billed Amount")]
@@ -216,7 +241,34 @@ def prepare_chart_data(pending, completed):
def get_columns(filters):
columns = [
if filters.get("group_by_item"):
return get_grouped_by_item_columns()
columns = get_sales_order_columns()
if not filters.get("group_by_so"):
columns += get_item_columns()
columns += get_quantity_columns() + get_amount_columns() + get_delivery_columns()
if not filters.get("group_by_so"):
columns.append(get_warehouse_column())
columns.append(get_company_column())
return columns
def get_grouped_by_item_columns():
columns = [get_item_code_column(), get_uom_column()]
columns += get_quantity_columns() + get_amount_columns()
columns.append(get_company_column())
return columns
def get_sales_order_columns():
return [
{"label": _("Date"), "fieldname": "date", "fieldtype": "Date", "width": 90},
{
"label": _("Sales Order"),
@@ -235,117 +287,139 @@ def get_columns(filters):
},
]
if not filters.get("group_by_so"):
columns.append(
{
"label": _("Item Code"),
"fieldname": "item_code",
"fieldtype": "Link",
"options": "Item",
"width": 100,
}
)
columns.append(
{"label": _("Description"), "fieldname": "description", "fieldtype": "Small Text", "width": 100}
)
columns.extend(
[
{
"label": _("Qty"),
"fieldname": "qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Delivered Qty"),
"fieldname": "delivered_qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Qty to Deliver"),
"fieldname": "pending_qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Billed Qty"),
"fieldname": "billed_qty",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
{
"label": _("Qty to Bill"),
"fieldname": "qty_to_bill",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
{
"label": _("Amount"),
"fieldname": "amount",
"fieldtype": "Currency",
"width": 110,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Billed Amount"),
"fieldname": "billed_amount",
"fieldtype": "Currency",
"width": 110,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Pending Amount"),
"fieldname": "pending_amount",
"fieldtype": "Currency",
"width": 130,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Amount Delivered"),
"fieldname": "delivered_qty_amount",
"fieldtype": "Currency",
"width": 100,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{"label": _("Delivery Date"), "fieldname": "delivery_date", "fieldtype": "Date", "width": 120},
{"label": _("Delay (in Days)"), "fieldname": "delay", "fieldtype": "Data", "width": 100},
{
"label": _("Time Taken to Deliver"),
"fieldname": "time_taken_to_deliver",
"fieldtype": "Duration",
"width": 100,
},
]
)
if not filters.get("group_by_so"):
columns.append(
{
"label": _("Warehouse"),
"fieldname": "warehouse",
"fieldtype": "Link",
"options": "Warehouse",
"width": 100,
}
)
columns.append(
def get_item_columns():
return [
get_item_code_column(),
{"label": _("Description"), "fieldname": "description", "fieldtype": "Small Text", "width": 100},
]
def get_item_code_column():
return {
"label": _("Item Code"),
"fieldname": "item_code",
"fieldtype": "Link",
"options": "Item",
"width": 100,
}
def get_uom_column():
return {
"label": _("UOM"),
"fieldname": "uom",
"fieldtype": "Link",
"options": "UOM",
"width": 100,
}
def get_quantity_columns():
return [
{
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"width": 100,
}
)
"label": _("Qty"),
"fieldname": "qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Delivered Qty"),
"fieldname": "delivered_qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Qty to Deliver"),
"fieldname": "pending_qty",
"fieldtype": "Float",
"width": 120,
"convertible": "qty",
},
{
"label": _("Billed Qty"),
"fieldname": "billed_qty",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
{
"label": _("Qty to Bill"),
"fieldname": "qty_to_bill",
"fieldtype": "Float",
"width": 80,
"convertible": "qty",
},
]
return columns
def get_amount_columns():
return [
{
"label": _("Amount"),
"fieldname": "amount",
"fieldtype": "Currency",
"width": 110,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Billed Amount"),
"fieldname": "billed_amount",
"fieldtype": "Currency",
"width": 110,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Pending Amount"),
"fieldname": "pending_amount",
"fieldtype": "Currency",
"width": 130,
"options": "Company:company:default_currency",
"convertible": "rate",
},
{
"label": _("Amount Delivered"),
"fieldname": "delivered_qty_amount",
"fieldtype": "Currency",
"width": 100,
"options": "Company:company:default_currency",
"convertible": "rate",
},
]
def get_delivery_columns():
return [
{"label": _("Delivery Date"), "fieldname": "delivery_date", "fieldtype": "Date", "width": 120},
{"label": _("Delay (in Days)"), "fieldname": "delay", "fieldtype": "Data", "width": 100},
{
"label": _("Time Taken to Deliver"),
"fieldname": "time_taken_to_deliver",
"fieldtype": "Duration",
"width": 100,
},
]
def get_warehouse_column():
return {
"label": _("Warehouse"),
"fieldname": "warehouse",
"fieldtype": "Link",
"options": "Warehouse",
"width": 100,
}
def get_company_column():
return {
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"width": 100,
}

View File

@@ -1,20 +1,27 @@
from unittest.mock import patch
import frappe
from frappe.utils import add_days
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_order_analysis.sales_order_analysis import execute
from erpnext.selling.report.sales_order_analysis.sales_order_analysis import (
AGGREGATED_FIELDS,
execute,
group_by_item,
)
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesOrderAnalysis(ERPNextTestSuite):
def create_sales_order(self, transaction_date, do_not_save=False, do_not_submit=False):
def create_sales_order(self, transaction_date, do_not_save=False, do_not_submit=False, qty=10, uom=None):
item = create_item(item_code="_Test Excavator", is_stock_item=0)
so = make_sales_order(
transaction_date=transaction_date,
item=item.item_code,
qty=10,
qty=qty,
uom=uom,
rate=100000,
do_not_save=True,
)
@@ -28,6 +35,17 @@ class TestSalesOrderAnalysis(ERPNextTestSuite):
so.submit()
return item, so
def make_item_row(self, company, qty):
row = frappe._dict(dict.fromkeys(AGGREGATED_FIELDS, 0))
row.update({"company": company, "item_code": "_Test Excavator", "uom": "Nos", "qty": qty})
return row
def add_uom(self, item_code, uom, conversion_factor):
item = frappe.get_doc("Item", item_code)
if not any(row.uom == uom for row in item.uoms):
item.append("uoms", {"uom": uom, "conversion_factor": conversion_factor})
item.save()
def create_sales_invoice(self, so, do_not_save=False, do_not_submit=False):
sinv = make_sales_invoice(so.name)
sinv.posting_date = so.transaction_date
@@ -255,3 +273,104 @@ class TestSalesOrderAnalysis(ERPNextTestSuite):
for key, val in expected_value.items():
with self.subTest(key=key, val=val):
self.assertEqual(data[0][key], val)
def test_08_group_by_item_across_sales_orders(self):
transaction_date = "2021-06-01"
item, so1 = self.create_sales_order(transaction_date)
self.create_sales_order(transaction_date, qty=4)
dn = self.create_delivery_note(so1, do_not_save=True)
dn.items[0].qty = 3
dn.save().submit()
columns, data, message, chart = execute(
{
"company": "_Test Company",
"from_date": "2021-06-01",
"to_date": "2021-06-30",
"group_by_item": 1,
}
)
expected_value = {
"item_code": item.item_code,
"uom": "Nos",
"qty": 14,
"delivered_qty": 3,
"pending_qty": 11,
}
self.assertEqual(len(data), 1)
for key, val in expected_value.items():
with self.subTest(key=key, val=val):
self.assertEqual(data[0][key], val)
fieldnames = [column["fieldname"] for column in columns]
self.assertIn("uom", fieldnames)
self.assertNotIn("sales_order", fieldnames)
def test_09_group_by_item_keeps_each_uom_apart(self):
transaction_date = "2021-06-01"
item, so = self.create_sales_order(transaction_date)
self.add_uom(item.item_code, "Box", 10)
self.create_sales_order(transaction_date, qty=2, uom="Box")
columns, data, message, chart = execute(
{
"company": "_Test Company",
"from_date": "2021-06-01",
"to_date": "2021-06-30",
"group_by_item": 1,
}
)
self.assertEqual(len(data), 2)
self.assertEqual([(row["uom"], row["qty"]) for row in data], [("Box", 2), ("Nos", 10)])
def test_10_group_by_filters_cannot_be_combined(self):
self.assertRaises(
frappe.ValidationError,
execute,
{
"company": "_Test Company",
"from_date": "2021-06-01",
"to_date": "2021-06-30",
"group_by_so": 1,
"group_by_item": 1,
},
)
def test_11_group_by_item_keeps_each_company_apart(self):
rows = [
self.make_item_row("_Test Company", 10),
self.make_item_row("_Test Company 1", 4),
self.make_item_row("_Test Company", 6),
]
grouped = group_by_item(rows)
self.assertEqual(
[(row["company"], row["qty"]) for row in grouped],
[("_Test Company", 16), ("_Test Company 1", 4)],
)
def test_12_company_falls_back_to_the_default(self):
transaction_date = "2021-06-01"
item, so = self.create_sales_order(transaction_date)
filters = {"from_date": "2021-06-01", "to_date": "2021-06-30"}
with patch("erpnext.get_default_company", return_value="_Test Company"):
columns, data, message, chart = execute(filters)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["sales_order"], so.name)
with patch("erpnext.get_default_company", return_value="_Test Company 1"):
columns, data, message, chart = execute(filters)
self.assertNotIn(so.name, [row["sales_order"] for row in data])
def test_13_company_is_mandatory_without_a_default(self):
with patch("erpnext.get_default_company", return_value=None):
self.assertRaises(
frappe.ValidationError,
execute,
{"from_date": "2021-06-01", "to_date": "2021-06-30"},
)