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