From 8b7780d49495bdcf0a97499a668079250496820c Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 1 Jul 2026 22:54:38 +0530 Subject: [PATCH 1/2] test: add coverage for Exponential Smoothing Forecasting report --- .../test_exponential_smoothing_forecasting.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 erpnext/manufacturing/report/exponential_smoothing_forecasting/test_exponential_smoothing_forecasting.py diff --git a/erpnext/manufacturing/report/exponential_smoothing_forecasting/test_exponential_smoothing_forecasting.py b/erpnext/manufacturing/report/exponential_smoothing_forecasting/test_exponential_smoothing_forecasting.py new file mode 100644 index 00000000000..3f8ffd74af9 --- /dev/null +++ b/erpnext/manufacturing/report/exponential_smoothing_forecasting/test_exponential_smoothing_forecasting.py @@ -0,0 +1,91 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.utils import flt + +from erpnext.manufacturing.report.exponential_smoothing_forecasting.exponential_smoothing_forecasting import ( + execute, +) +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.tests.utils import ERPNextTestSuite + +TEST_ITEM = "_Test Item" +FROM_DATE = "2026-06-01" +TO_DATE = "2026-08-31" + + +class TestExponentialSmoothingForecasting(ERPNextTestSuite): + """Drive real submitted Sales Orders and assert the report buckets the ordered + quantities into the correct historical periods and produces a forecast.""" + + def test_monthly_qty_forecast_from_sales_orders(self): + # Historical demand: distinct calendar months strictly before FROM_DATE. + # Monthly period keys are derived from the period's last day (e.g. "mar_2026"). + history = {"mar_2026": 7, "apr_2026": 4, "may_2026": 9} + self.create_sales_orders( + { + "2026-03-15": history["mar_2026"], + "2026-04-15": history["apr_2026"], + "2026-05-15": history["may_2026"], + } + ) + + columns, row = self.run_report() + fields = {col["fieldname"] for col in columns} + + # For Monthly periodicity only future periods are exposed as columns, each as a + # forecast_ field. Historical demand lives in the row data (keyed by month) but is + # not surfaced as its own column. + self.assertIn("forecast_jun_2026", fields, "expected future forecast column") + self.assertNotIn("jun_2026", fields, "future period must not expose raw demand column") + self.assertNotIn("mar_2026", fields, "historical month is not a Monthly report column") + + # Historical buckets must exactly reflect the ordered quantities. + for key, qty in history.items(): + self.assertEqual(flt(row.get(key)), flt(qty), f"bucket {key} mismatch") + + # A forecast is produced for the first future period. The first non-zero + # historical period seeds the forecast at the average of non-zero months, + # so the future forecast must be positive. + expected_avg = sum(history.values()) / len(history) + self.assertGreater(flt(row.get("forecast_jun_2026")), 0.0) + self.assertLessEqual(flt(row.get("forecast_jun_2026")), max(history.values())) + self.assertAlmostEqual(flt(row.get("avg")), expected_avg, places=6) + + def test_ignores_documents_outside_range_and_other_docstatus(self): + self.create_sales_orders({"2026-05-10": 6}) + # A draft SO and a future-dated SO must not contribute to historical demand. + make_sales_order(item_code=TEST_ITEM, qty=100, transaction_date="2026-05-20", do_not_submit=True) + make_sales_order(item_code=TEST_ITEM, qty=100, transaction_date=FROM_DATE) + + _columns, row = self.run_report() + self.assertEqual(flt(row.get("may_2026")), 6.0) + + def create_sales_orders(self, date_to_qty): + for transaction_date, qty in date_to_qty.items(): + make_sales_order(item_code=TEST_ITEM, qty=qty, transaction_date=transaction_date) + + def run_report(self, **extra): + filters = frappe._dict( + { + "company": "_Test Company", + "based_on_document": "Sales Order", + "based_on_field": "Qty", + "no_of_years": 3, + "periodicity": "Monthly", + "from_date": FROM_DATE, + "to_date": TO_DATE, + "smoothing_constant": 0.5, + "item_code": TEST_ITEM, + } + ) + filters.update(extra) + + columns, data = execute(filters)[:2] + item_row = next( + (r for r in data if r.get("item_code") == TEST_ITEM), + None, + ) + self.assertIsNotNone(item_row, f"{TEST_ITEM} row missing from report output") + return columns, item_row From cc9d94efe884a54dfa81d674a1dff7655b35b658 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 2 Jul 2026 13:57:07 +0530 Subject: [PATCH 2/2] test: use unique item and assert exact forecast in Exponential Smoothing test --- .../test_exponential_smoothing_forecasting.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/erpnext/manufacturing/report/exponential_smoothing_forecasting/test_exponential_smoothing_forecasting.py b/erpnext/manufacturing/report/exponential_smoothing_forecasting/test_exponential_smoothing_forecasting.py index 3f8ffd74af9..37d6c7da7ab 100644 --- a/erpnext/manufacturing/report/exponential_smoothing_forecasting/test_exponential_smoothing_forecasting.py +++ b/erpnext/manufacturing/report/exponential_smoothing_forecasting/test_exponential_smoothing_forecasting.py @@ -8,17 +8,24 @@ from erpnext.manufacturing.report.exponential_smoothing_forecasting.exponential_ execute, ) from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.item.test_item import make_item from erpnext.tests.utils import ERPNextTestSuite -TEST_ITEM = "_Test Item" FROM_DATE = "2026-06-01" TO_DATE = "2026-08-31" +SMOOTHING_CONSTANT = 0.5 class TestExponentialSmoothingForecasting(ERPNextTestSuite): """Drive real submitted Sales Orders and assert the report buckets the ordered quantities into the correct historical periods and produces a forecast.""" + def setUp(self): + # The forecast query has no lower date bound, so it would pick up any committed + # Sales Order for the item. A uniquely-named item keeps the buckets scoped to + # just this test's orders. + self.item = make_item(properties={"is_stock_item": 1}).name + def test_monthly_qty_forecast_from_sales_orders(self): # Historical demand: distinct calendar months strictly before FROM_DATE. # Monthly period keys are derived from the period's last day (e.g. "mar_2026"). @@ -45,26 +52,29 @@ class TestExponentialSmoothingForecasting(ERPNextTestSuite): for key, qty in history.items(): self.assertEqual(flt(row.get(key)), flt(qty), f"bucket {key} mismatch") - # A forecast is produced for the first future period. The first non-zero - # historical period seeds the forecast at the average of non-zero months, - # so the future forecast must be positive. + # The forecast seeds at the average of the non-zero historical months and then + # smooths through them in order: F = F + a*(actual - F). Asserting the exact + # analytical value pins the smoothing formula (Jun 2026 works out to ~7.2083). expected_avg = sum(history.values()) / len(history) - self.assertGreater(flt(row.get("forecast_jun_2026")), 0.0) - self.assertLessEqual(flt(row.get("forecast_jun_2026")), max(history.values())) self.assertAlmostEqual(flt(row.get("avg")), expected_avg, places=6) + forecast = expected_avg + for month in ("mar_2026", "apr_2026", "may_2026"): + forecast = forecast + SMOOTHING_CONSTANT * (history[month] - forecast) + self.assertAlmostEqual(flt(row.get("forecast_jun_2026")), forecast, places=6) + def test_ignores_documents_outside_range_and_other_docstatus(self): self.create_sales_orders({"2026-05-10": 6}) # A draft SO and a future-dated SO must not contribute to historical demand. - make_sales_order(item_code=TEST_ITEM, qty=100, transaction_date="2026-05-20", do_not_submit=True) - make_sales_order(item_code=TEST_ITEM, qty=100, transaction_date=FROM_DATE) + make_sales_order(item_code=self.item, qty=100, transaction_date="2026-05-20", do_not_submit=True) + make_sales_order(item_code=self.item, qty=100, transaction_date=FROM_DATE) _columns, row = self.run_report() self.assertEqual(flt(row.get("may_2026")), 6.0) def create_sales_orders(self, date_to_qty): for transaction_date, qty in date_to_qty.items(): - make_sales_order(item_code=TEST_ITEM, qty=qty, transaction_date=transaction_date) + make_sales_order(item_code=self.item, qty=qty, transaction_date=transaction_date) def run_report(self, **extra): filters = frappe._dict( @@ -76,16 +86,16 @@ class TestExponentialSmoothingForecasting(ERPNextTestSuite): "periodicity": "Monthly", "from_date": FROM_DATE, "to_date": TO_DATE, - "smoothing_constant": 0.5, - "item_code": TEST_ITEM, + "smoothing_constant": SMOOTHING_CONSTANT, + "item_code": self.item, } ) filters.update(extra) columns, data = execute(filters)[:2] item_row = next( - (r for r in data if r.get("item_code") == TEST_ITEM), + (r for r in data if r.get("item_code") == self.item), None, ) - self.assertIsNotNone(item_row, f"{TEST_ITEM} row missing from report output") + self.assertIsNotNone(item_row, f"{self.item} row missing from report output") return columns, item_row