Merge pull request #26158 from marination/stock-reco-repost_qty
fix: Include Stock Reco logic in `update_qty_in_future_sle`
This commit is contained in:
commit
7b781b1498
@ -357,6 +357,7 @@ class StockReconciliation(StockController):
|
|||||||
if row.current_qty:
|
if row.current_qty:
|
||||||
data.actual_qty = -1 * row.current_qty
|
data.actual_qty = -1 * row.current_qty
|
||||||
data.qty_after_transaction = flt(row.current_qty)
|
data.qty_after_transaction = flt(row.current_qty)
|
||||||
|
data.previous_qty_after_transaction = flt(row.qty)
|
||||||
data.valuation_rate = flt(row.current_valuation_rate)
|
data.valuation_rate = flt(row.current_valuation_rate)
|
||||||
data.stock_value = data.qty_after_transaction * data.valuation_rate
|
data.stock_value = data.qty_after_transaction * data.valuation_rate
|
||||||
data.stock_value_difference = -1 * flt(row.amount_difference)
|
data.stock_value_difference = -1 * flt(row.amount_difference)
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
import frappe, unittest
|
import frappe, unittest
|
||||||
from frappe.utils import flt, nowdate, nowtime, random_string
|
from frappe.utils import flt, nowdate, nowtime, random_string, add_days
|
||||||
from erpnext.accounts.utils import get_stock_and_account_balance
|
from erpnext.accounts.utils import get_stock_and_account_balance
|
||||||
from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after
|
from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after
|
||||||
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import EmptyStockReconciliationItemsError, get_items
|
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import EmptyStockReconciliationItemsError, get_items
|
||||||
@ -14,6 +14,7 @@ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
|||||||
from erpnext.stock.doctype.item.test_item import create_item
|
from erpnext.stock.doctype.item.test_item import create_item
|
||||||
from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method
|
from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method
|
||||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||||
|
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||||
|
|
||||||
class TestStockReconciliation(unittest.TestCase):
|
class TestStockReconciliation(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -240,6 +241,117 @@ class TestStockReconciliation(unittest.TestCase):
|
|||||||
self.assertEqual(sr.get("items")[0].valuation_rate, 0)
|
self.assertEqual(sr.get("items")[0].valuation_rate, 0)
|
||||||
self.assertEqual(sr.get("items")[0].amount, 0)
|
self.assertEqual(sr.get("items")[0].amount, 0)
|
||||||
|
|
||||||
|
def test_backdated_stock_reco_qty_reposting(self):
|
||||||
|
"""
|
||||||
|
Test if a backdated stock reco recalculates future qty until next reco.
|
||||||
|
-------------------------------------------
|
||||||
|
Var | Doc | Qty | Balance
|
||||||
|
-------------------------------------------
|
||||||
|
SR5 | Reco | 0 | 8 (posting date: today-4) [backdated]
|
||||||
|
PR1 | PR | 10 | 18 (posting date: today-3)
|
||||||
|
PR2 | PR | 1 | 19 (posting date: today-2)
|
||||||
|
SR4 | Reco | 0 | 6 (posting date: today-1) [backdated]
|
||||||
|
PR3 | PR | 1 | 7 (posting date: today) # can't post future PR
|
||||||
|
"""
|
||||||
|
item_code = "Backdated-Reco-Item"
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
create_item(item_code)
|
||||||
|
|
||||||
|
pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100,
|
||||||
|
posting_date=add_days(nowdate(), -3))
|
||||||
|
pr2 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=1, rate=100,
|
||||||
|
posting_date=add_days(nowdate(), -2))
|
||||||
|
pr3 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=1, rate=100,
|
||||||
|
posting_date=nowdate())
|
||||||
|
|
||||||
|
pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
pr3_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
self.assertEqual(pr1_balance, 10)
|
||||||
|
self.assertEqual(pr3_balance, 12)
|
||||||
|
|
||||||
|
# post backdated stock reco in between
|
||||||
|
sr4 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=6, rate=100,
|
||||||
|
posting_date=add_days(nowdate(), -1))
|
||||||
|
pr3_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
self.assertEqual(pr3_balance, 7)
|
||||||
|
|
||||||
|
# post backdated stock reco at the start
|
||||||
|
sr5 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=8, rate=100,
|
||||||
|
posting_date=add_days(nowdate(), -4))
|
||||||
|
pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
pr2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
sr4_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
self.assertEqual(pr1_balance, 18)
|
||||||
|
self.assertEqual(pr2_balance, 19)
|
||||||
|
self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected
|
||||||
|
|
||||||
|
# cancel backdated stock reco and check future impact
|
||||||
|
sr5.cancel()
|
||||||
|
pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
pr2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
sr4_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
self.assertEqual(pr1_balance, 10)
|
||||||
|
self.assertEqual(pr2_balance, 11)
|
||||||
|
self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected
|
||||||
|
|
||||||
|
# teardown
|
||||||
|
sr4.cancel()
|
||||||
|
pr3.cancel()
|
||||||
|
pr2.cancel()
|
||||||
|
pr1.cancel()
|
||||||
|
|
||||||
|
def test_backdated_stock_reco_future_negative_stock(self):
|
||||||
|
"""
|
||||||
|
Test if a backdated stock reco causes future negative stock and is blocked.
|
||||||
|
-------------------------------------------
|
||||||
|
Var | Doc | Qty | Balance
|
||||||
|
-------------------------------------------
|
||||||
|
PR1 | PR | 10 | 10 (posting date: today-2)
|
||||||
|
SR3 | Reco | 0 | 1 (posting date: today-1) [backdated & blocked]
|
||||||
|
DN2 | DN | -2 | 8(-1) (posting date: today)
|
||||||
|
"""
|
||||||
|
from erpnext.stock.stock_ledger import NegativeStockError
|
||||||
|
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||||
|
|
||||||
|
item_code = "Backdated-Reco-Item"
|
||||||
|
warehouse = "_Test Warehouse - _TC"
|
||||||
|
create_item(item_code)
|
||||||
|
|
||||||
|
negative_stock_setting = frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
|
||||||
|
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 0)
|
||||||
|
|
||||||
|
pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100,
|
||||||
|
posting_date=add_days(nowdate(), -2))
|
||||||
|
dn2 = create_delivery_note(item_code=item_code, warehouse=warehouse, qty=2, rate=120,
|
||||||
|
posting_date=nowdate())
|
||||||
|
|
||||||
|
pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
dn2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": dn2.name, "is_cancelled": 0},
|
||||||
|
"qty_after_transaction")
|
||||||
|
self.assertEqual(pr1_balance, 10)
|
||||||
|
self.assertEqual(dn2_balance, 8)
|
||||||
|
|
||||||
|
# check if stock reco is blocked
|
||||||
|
sr3 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=1, rate=100,
|
||||||
|
posting_date=add_days(nowdate(), -1), do_not_submit=True)
|
||||||
|
self.assertRaises(NegativeStockError, sr3.submit)
|
||||||
|
|
||||||
|
# teardown
|
||||||
|
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", negative_stock_setting)
|
||||||
|
sr3.cancel()
|
||||||
|
dn2.cancel()
|
||||||
|
pr1.cancel()
|
||||||
|
|
||||||
def insert_existing_sle(warehouse):
|
def insert_existing_sle(warehouse):
|
||||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||||
|
|
||||||
|
@ -55,6 +55,11 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
|||||||
sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher)
|
sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher)
|
||||||
|
|
||||||
args = sle_doc.as_dict()
|
args = sle_doc.as_dict()
|
||||||
|
|
||||||
|
if sle.get("voucher_type") == "Stock Reconciliation":
|
||||||
|
# preserve previous_qty_after_transaction for qty reposting
|
||||||
|
args.previous_qty_after_transaction = sle.get("previous_qty_after_transaction")
|
||||||
|
|
||||||
update_bin(args, allow_negative_stock, via_landed_cost_voucher)
|
update_bin(args, allow_negative_stock, via_landed_cost_voucher)
|
||||||
|
|
||||||
def get_args_for_future_sle(row):
|
def get_args_for_future_sle(row):
|
||||||
@ -215,7 +220,7 @@ class update_entries_after(object):
|
|||||||
"""
|
"""
|
||||||
self.data.setdefault(args.warehouse, frappe._dict())
|
self.data.setdefault(args.warehouse, frappe._dict())
|
||||||
warehouse_dict = self.data[args.warehouse]
|
warehouse_dict = self.data[args.warehouse]
|
||||||
previous_sle = self.get_previous_sle_of_current_voucher(args)
|
previous_sle = get_previous_sle_of_current_voucher(args)
|
||||||
warehouse_dict.previous_sle = previous_sle
|
warehouse_dict.previous_sle = previous_sle
|
||||||
|
|
||||||
for key in ("qty_after_transaction", "valuation_rate", "stock_value"):
|
for key in ("qty_after_transaction", "valuation_rate", "stock_value"):
|
||||||
@ -227,29 +232,6 @@ class update_entries_after(object):
|
|||||||
"stock_value_difference": 0.0
|
"stock_value_difference": 0.0
|
||||||
})
|
})
|
||||||
|
|
||||||
def get_previous_sle_of_current_voucher(self, args):
|
|
||||||
"""get stock ledger entries filtered by specific posting datetime conditions"""
|
|
||||||
|
|
||||||
args['time_format'] = '%H:%i:%s'
|
|
||||||
if not args.get("posting_date"):
|
|
||||||
args["posting_date"] = "1900-01-01"
|
|
||||||
if not args.get("posting_time"):
|
|
||||||
args["posting_time"] = "00:00"
|
|
||||||
|
|
||||||
sle = frappe.db.sql("""
|
|
||||||
select *, timestamp(posting_date, posting_time) as "timestamp"
|
|
||||||
from `tabStock Ledger Entry`
|
|
||||||
where item_code = %(item_code)s
|
|
||||||
and warehouse = %(warehouse)s
|
|
||||||
and is_cancelled = 0
|
|
||||||
and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s))
|
|
||||||
order by timestamp(posting_date, posting_time) desc, creation desc
|
|
||||||
limit 1
|
|
||||||
for update""", args, as_dict=1)
|
|
||||||
|
|
||||||
return sle[0] if sle else frappe._dict()
|
|
||||||
|
|
||||||
|
|
||||||
def build(self):
|
def build(self):
|
||||||
from erpnext.controllers.stock_controller import future_sle_exists
|
from erpnext.controllers.stock_controller import future_sle_exists
|
||||||
|
|
||||||
@ -734,6 +716,35 @@ class update_entries_after(object):
|
|||||||
bin_doc.flags.via_stock_ledger_entry = True
|
bin_doc.flags.via_stock_ledger_entry = True
|
||||||
bin_doc.save(ignore_permissions=True)
|
bin_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_previous_sle_of_current_voucher(args, exclude_current_voucher=False):
|
||||||
|
"""get stock ledger entries filtered by specific posting datetime conditions"""
|
||||||
|
|
||||||
|
args['time_format'] = '%H:%i:%s'
|
||||||
|
if not args.get("posting_date"):
|
||||||
|
args["posting_date"] = "1900-01-01"
|
||||||
|
if not args.get("posting_time"):
|
||||||
|
args["posting_time"] = "00:00"
|
||||||
|
|
||||||
|
voucher_condition = ""
|
||||||
|
if exclude_current_voucher:
|
||||||
|
voucher_no = args.get("voucher_no")
|
||||||
|
voucher_condition = f"and voucher_no != '{voucher_no}'"
|
||||||
|
|
||||||
|
sle = frappe.db.sql("""
|
||||||
|
select *, timestamp(posting_date, posting_time) as "timestamp"
|
||||||
|
from `tabStock Ledger Entry`
|
||||||
|
where item_code = %(item_code)s
|
||||||
|
and warehouse = %(warehouse)s
|
||||||
|
and is_cancelled = 0
|
||||||
|
{voucher_condition}
|
||||||
|
and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s))
|
||||||
|
order by timestamp(posting_date, posting_time) desc, creation desc
|
||||||
|
limit 1
|
||||||
|
for update""".format(voucher_condition=voucher_condition), args, as_dict=1)
|
||||||
|
|
||||||
|
return sle[0] if sle else frappe._dict()
|
||||||
|
|
||||||
def get_previous_sle(args, for_update=False):
|
def get_previous_sle(args, for_update=False):
|
||||||
"""
|
"""
|
||||||
get the last sle on or before the current time-bucket,
|
get the last sle on or before the current time-bucket,
|
||||||
@ -862,9 +873,24 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no,
|
|||||||
return valuation_rate
|
return valuation_rate
|
||||||
|
|
||||||
def update_qty_in_future_sle(args, allow_negative_stock=None):
|
def update_qty_in_future_sle(args, allow_negative_stock=None):
|
||||||
|
"""Recalculate Qty after Transaction in future SLEs based on current SLE."""
|
||||||
|
datetime_limit_condition = ""
|
||||||
|
qty_shift = args.actual_qty
|
||||||
|
|
||||||
|
# find difference/shift in qty caused by stock reconciliation
|
||||||
|
if args.voucher_type == "Stock Reconciliation":
|
||||||
|
qty_shift = get_stock_reco_qty_shift(args)
|
||||||
|
|
||||||
|
# find the next nearest stock reco so that we only recalculate SLEs till that point
|
||||||
|
next_stock_reco_detail = get_next_stock_reco(args)
|
||||||
|
if next_stock_reco_detail:
|
||||||
|
detail = next_stock_reco_detail[0]
|
||||||
|
# add condition to update SLEs before this date & time
|
||||||
|
datetime_limit_condition = get_datetime_limit_condition(detail)
|
||||||
|
|
||||||
frappe.db.sql("""
|
frappe.db.sql("""
|
||||||
update `tabStock Ledger Entry`
|
update `tabStock Ledger Entry`
|
||||||
set qty_after_transaction = qty_after_transaction + {qty}
|
set qty_after_transaction = qty_after_transaction + {qty_shift}
|
||||||
where
|
where
|
||||||
item_code = %(item_code)s
|
item_code = %(item_code)s
|
||||||
and warehouse = %(warehouse)s
|
and warehouse = %(warehouse)s
|
||||||
@ -876,15 +902,70 @@ def update_qty_in_future_sle(args, allow_negative_stock=None):
|
|||||||
and creation > %(creation)s
|
and creation > %(creation)s
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""".format(qty=args.actual_qty), args)
|
{datetime_limit_condition}
|
||||||
|
""".format(qty_shift=qty_shift, datetime_limit_condition=datetime_limit_condition), args)
|
||||||
|
|
||||||
validate_negative_qty_in_future_sle(args, allow_negative_stock)
|
validate_negative_qty_in_future_sle(args, allow_negative_stock)
|
||||||
|
|
||||||
|
def get_stock_reco_qty_shift(args):
|
||||||
|
stock_reco_qty_shift = 0
|
||||||
|
if args.get("is_cancelled"):
|
||||||
|
if args.get("previous_qty_after_transaction"):
|
||||||
|
# get qty (balance) that was set at submission
|
||||||
|
last_balance = args.get("previous_qty_after_transaction")
|
||||||
|
stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance)
|
||||||
|
else:
|
||||||
|
stock_reco_qty_shift = flt(args.actual_qty)
|
||||||
|
else:
|
||||||
|
# reco is being submitted
|
||||||
|
last_balance = get_previous_sle_of_current_voucher(args,
|
||||||
|
exclude_current_voucher=True).get("qty_after_transaction")
|
||||||
|
|
||||||
|
if last_balance is not None:
|
||||||
|
stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance)
|
||||||
|
else:
|
||||||
|
stock_reco_qty_shift = args.qty_after_transaction
|
||||||
|
|
||||||
|
return stock_reco_qty_shift
|
||||||
|
|
||||||
|
def get_next_stock_reco(args):
|
||||||
|
"""Returns next nearest stock reconciliaton's details."""
|
||||||
|
|
||||||
|
return frappe.db.sql("""
|
||||||
|
select
|
||||||
|
name, posting_date, posting_time, creation, voucher_no
|
||||||
|
from
|
||||||
|
`tabStock Ledger Entry`
|
||||||
|
where
|
||||||
|
item_code = %(item_code)s
|
||||||
|
and warehouse = %(warehouse)s
|
||||||
|
and voucher_type = 'Stock Reconciliation'
|
||||||
|
and voucher_no != %(voucher_no)s
|
||||||
|
and is_cancelled = 0
|
||||||
|
and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s)
|
||||||
|
or (
|
||||||
|
timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s)
|
||||||
|
and creation > %(creation)s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
limit 1
|
||||||
|
""", args, as_dict=1)
|
||||||
|
|
||||||
|
def get_datetime_limit_condition(detail):
|
||||||
|
return f"""
|
||||||
|
and
|
||||||
|
(timestamp(posting_date, posting_time) < timestamp('{detail.posting_date}', '{detail.posting_time}')
|
||||||
|
or (
|
||||||
|
timestamp(posting_date, posting_time) = timestamp('{detail.posting_date}', '{detail.posting_time}')
|
||||||
|
and creation < '{detail.creation}'
|
||||||
|
)
|
||||||
|
)"""
|
||||||
|
|
||||||
def validate_negative_qty_in_future_sle(args, allow_negative_stock=None):
|
def validate_negative_qty_in_future_sle(args, allow_negative_stock=None):
|
||||||
allow_negative_stock = allow_negative_stock \
|
allow_negative_stock = allow_negative_stock \
|
||||||
or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
|
or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
|
||||||
|
|
||||||
if args.actual_qty < 0 and not allow_negative_stock:
|
if (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation") and not allow_negative_stock:
|
||||||
sle = get_future_sle_with_negative_qty(args)
|
sle = get_future_sle_with_negative_qty(args)
|
||||||
if sle:
|
if sle:
|
||||||
message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
|
message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
|
||||||
|
Loading…
Reference in New Issue
Block a user