Skip to content

Commit

Permalink
fix: stock analytics report shows incorrect data there's no stock mov…
Browse files Browse the repository at this point in the history
…ement in a period (backport #30945) (#30980)

* test: basic test for stock analytics report

(cherry picked from commit d81422f)

* fix: consider previous balance is missing

Also remove `total`, total of total is a meaningless value.

(cherry picked from commit 6ab0046)

* fix: batch_no doesn't maintain qty_after_transaction

(cherry picked from commit 287b255)

* fix: only carry-forward balances till today's period

Showing data in future doesn't make sense. Only carry-forward till last
bucket that contains today's day.

(cherry picked from commit 198b91f)

Co-authored-by: Ankush Menat <me@ankush.dev>
  • Loading branch information
mergify[bot] and ankush committed May 12, 2022
1 parent d697515 commit 295ffb3
Show file tree
Hide file tree
Showing 2 changed files with 131 additions and 8 deletions.
56 changes: 49 additions & 7 deletions erpnext/stock/report/stock_analytics/stock_analytics.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import datetime
from typing import List

import frappe
from frappe import _, scrub
Expand Down Expand Up @@ -148,18 +149,26 @@ def get_periodic_data(entry, filters):
- Warehouse A : bal_qty/value
- Warehouse B : bal_qty/value
"""

expected_ranges = get_period_date_ranges(filters)
expected_periods = []
for _start_date, end_date in expected_ranges:
expected_periods.append(get_period(end_date, filters))

periodic_data = {}
for d in entry:
period = get_period(d.posting_date, filters)
bal_qty = 0

fill_intermediate_periods(periodic_data, d.item_code, period, expected_periods)

# if period against item does not exist yet, instantiate it
# insert existing balance dict against period, and add/subtract to it
if periodic_data.get(d.item_code) and not periodic_data.get(d.item_code).get(period):
previous_balance = periodic_data[d.item_code]["balance"].copy()
periodic_data[d.item_code][period] = previous_balance

if d.voucher_type == "Stock Reconciliation":
if d.voucher_type == "Stock Reconciliation" and not d.batch_no:
if periodic_data.get(d.item_code) and periodic_data.get(d.item_code).get("balance").get(
d.warehouse
):
Expand All @@ -186,6 +195,36 @@ def get_periodic_data(entry, filters):
return periodic_data


def fill_intermediate_periods(
periodic_data, item_code: str, current_period: str, all_periods: List[str]
) -> None:
"""There might be intermediate periods where no stock ledger entry exists, copy previous previous data.
Previous data is ONLY copied if period falls in report range and before period being processed currently.
args:
current_period: process till this period (exclusive)
all_periods: all periods expected in report via filters
periodic_data: report's periodic data
item_code: item_code being processed
"""

previous_period_data = None
for period in all_periods:
if period == current_period:
return

if (
periodic_data.get(item_code)
and not periodic_data.get(item_code).get(period)
and previous_period_data
):
# This period should exist since it's in report range, assign previous period data
periodic_data[item_code][period] = previous_period_data.copy()

previous_period_data = periodic_data.get(item_code, {}).get(period)


def get_data(filters):
data = []
items = get_items(filters)
Expand All @@ -194,6 +233,8 @@ def get_data(filters):
periodic_data = get_periodic_data(sle, filters)
ranges = get_period_date_ranges(filters)

today = getdate()

for dummy, item_data in item_details.items():
row = {
"name": item_data.name,
Expand All @@ -202,14 +243,15 @@ def get_data(filters):
"uom": item_data.stock_uom,
"brand": item_data.brand,
}
total = 0
for dummy, end_date in ranges:
previous_period_value = 0.0
for start_date, end_date in ranges:
period = get_period(end_date, filters)
period_data = periodic_data.get(item_data.name, {}).get(period)
amount = sum(period_data.values()) if period_data else 0
row[scrub(period)] = amount
total += amount
row["total"] = total
if period_data:
row[scrub(period)] = previous_period_value = sum(period_data.values())
else:
row[scrub(period)] = previous_period_value if today >= start_date else None

data.append(row)

return data
Expand Down
83 changes: 82 additions & 1 deletion erpnext/stock/report/stock_analytics/test_stock_analytics.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,59 @@
import datetime

import frappe
from frappe import _dict
from frappe.tests.utils import FrappeTestCase
from frappe.utils.data import add_to_date, get_datetime, getdate, nowdate

from erpnext.accounts.utils import get_fiscal_year
from erpnext.stock.report.stock_analytics.stock_analytics import get_period_date_ranges
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.stock.report.stock_analytics.stock_analytics import execute, get_period_date_ranges


def stock_analytics(filters):
col, data, *_ = execute(filters)
return col, data


class TestStockAnalyticsReport(FrappeTestCase):
def setUp(self) -> None:
self.item = make_item().name
self.warehouse = "_Test Warehouse - _TC"

def assert_single_item_report(self, movement, expected_buckets):
self.generate_stock(movement)
filters = _dict(
range="Monthly",
from_date=movement[0][1].replace(day=1),
to_date=movement[-1][1].replace(day=28),
value_quantity="Quantity",
company="_Test Company",
item_code=self.item,
)

cols, data = stock_analytics(filters)

self.assertEqual(len(data), 1)
row = frappe._dict(data[0])
self.assertEqual(row.name, self.item)
self.compare_analytics_row(row, cols, expected_buckets)

def generate_stock(self, movement):
for qty, posting_date in movement:
args = {"item": self.item, "qty": abs(qty), "posting_date": posting_date}
args["to_warehouse" if qty > 0 else "from_warehouse"] = self.warehouse
make_stock_entry(**args)

def compare_analytics_row(self, report_row, columns, expected_buckets):
# last (N) cols will be monthly data
no_of_buckets = len(expected_buckets)
month_cols = [col["fieldname"] for col in columns[-no_of_buckets:]]

actual_buckets = [report_row.get(col) for col in month_cols]

self.assertEqual(actual_buckets, expected_buckets)

def test_get_period_date_ranges(self):

filters = _dict(range="Monthly", from_date="2020-12-28", to_date="2021-02-06")
Expand All @@ -33,3 +79,38 @@ def test_get_period_date_ranges_yearly(self):
]

self.assertEqual(ranges, expected_ranges)

def test_basic_report_functionality(self):
"""Stock analytics report generates balance "as of" periods based on
user defined ranges. Check that this behaviour is correct."""

# create stock movement in 3 months at 15th of month
today = getdate()
movement = [
(10, add_to_date(today, months=0).replace(day=15)),
(-5, add_to_date(today, months=1).replace(day=15)),
(10, add_to_date(today, months=2).replace(day=15)),
]
self.assert_single_item_report(movement, [10, 5, 15])

def test_empty_month_in_between(self):
today = getdate()
movement = [
(100, add_to_date(today, months=0).replace(day=15)),
(-50, add_to_date(today, months=1).replace(day=15)),
# Skip a month
(20, add_to_date(today, months=3).replace(day=15)),
]
self.assert_single_item_report(movement, [100, 50, 50, 70])

def test_multi_month_missings(self):
today = getdate()
movement = [
(100, add_to_date(today, months=0).replace(day=15)),
(-50, add_to_date(today, months=1).replace(day=15)),
# Skip a month
(20, add_to_date(today, months=3).replace(day=15)),
# Skip another month
(-10, add_to_date(today, months=5).replace(day=15)),
]
self.assert_single_item_report(movement, [100, 50, 50, 70, 70, 60])

0 comments on commit 295ffb3

Please sign in to comment.