Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: incorrect available qty for backdated stock reco with batch #37858

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def validate_serial_nos_duplicate(self):
def throw_error_message(self, message, exception=frappe.ValidationError):
frappe.throw(_(message), exception, title=_("Error"))

def set_incoming_rate(self, row=None, save=False):
def set_incoming_rate(self, row=None, save=False, allow_negative_stock=False):
if self.type_of_transaction not in ["Inward", "Outward"] or self.voucher_type in [
"Installation Note",
"Job Card",
Expand All @@ -131,7 +131,9 @@ def set_incoming_rate(self, row=None, save=False):
return

if self.type_of_transaction == "Outward":
self.set_incoming_rate_for_outward_transaction(row, save)
self.set_incoming_rate_for_outward_transaction(
row, save, allow_negative_stock=allow_negative_stock
)
else:
self.set_incoming_rate_for_inward_transaction(row, save)

Expand All @@ -152,7 +154,9 @@ def calculate_total_qty(self, save=True):
def get_serial_nos(self):
return [d.serial_no for d in self.entries if d.serial_no]

def set_incoming_rate_for_outward_transaction(self, row=None, save=False):
def set_incoming_rate_for_outward_transaction(
self, row=None, save=False, allow_negative_stock=False
):
sle = self.get_sle_for_outward_transaction()

if self.has_serial_no:
Expand Down Expand Up @@ -181,7 +185,8 @@ def set_incoming_rate_for_outward_transaction(self, row=None, save=False):
if self.docstatus == 1:
available_qty += flt(d.qty)

self.validate_negative_batch(d.batch_no, available_qty)
if not allow_negative_stock:
self.validate_negative_batch(d.batch_no, available_qty)

d.stock_value_difference = flt(d.qty) * flt(d.incoming_rate)

Expand Down
137 changes: 98 additions & 39 deletions erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import frappe
from frappe import _, bold, msgprint
from frappe.query_builder.functions import CombineDatetime, Sum
from frappe.utils import cint, cstr, flt
from frappe.utils import add_to_date, cint, cstr, flt

import erpnext
from erpnext.accounts.utils import get_company_default
Expand Down Expand Up @@ -88,9 +88,12 @@ def on_cancel(self):
self.repost_future_sle_and_gle()
self.delete_auto_created_batches()

def set_current_serial_and_batch_bundle(self):
def set_current_serial_and_batch_bundle(self, voucher_detail_no=None, save=False) -> None:
"""Set Serial and Batch Bundle for each item"""
for item in self.items:
if voucher_detail_no and voucher_detail_no != item.name:
continue

item_details = frappe.get_cached_value(
"Item", item.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
)
Expand Down Expand Up @@ -148,6 +151,7 @@ def set_current_serial_and_batch_bundle(self):
"warehouse": item.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"ignore_voucher_nos": [self.name],
}
)
)
Expand All @@ -163,11 +167,36 @@ def set_current_serial_and_batch_bundle(self):
)

if not serial_and_batch_bundle.entries:
if voucher_detail_no:
return

continue

item.current_serial_and_batch_bundle = serial_and_batch_bundle.save().name
serial_and_batch_bundle.save()
item.current_serial_and_batch_bundle = serial_and_batch_bundle.name
item.current_qty = abs(serial_and_batch_bundle.total_qty)
item.current_valuation_rate = abs(serial_and_batch_bundle.avg_rate)
if save:
sle_creation = frappe.db.get_value(
"Serial and Batch Bundle", item.serial_and_batch_bundle, "creation"
)
creation = add_to_date(sle_creation, seconds=-1)
item.db_set(
{
"current_serial_and_batch_bundle": item.current_serial_and_batch_bundle,
"current_qty": item.current_qty,
"current_valuation_rate": item.current_valuation_rate,
"creation": creation,
}
)

serial_and_batch_bundle.db_set(
{
"creation": creation,
"voucher_no": self.name,
"voucher_detail_no": voucher_detail_no,
}
)

def set_new_serial_and_batch_bundle(self):
for item in self.items:
Expand Down Expand Up @@ -689,56 +718,84 @@ def cancel(self):
else:
self._cancel()

def recalculate_current_qty(self, item_code, batch_no):
def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False):
from erpnext.stock.stock_ledger import get_valuation_rate

sl_entries = []

for row in self.items:
if (
not (row.item_code == item_code and row.batch_no == batch_no)
and not row.serial_and_batch_bundle
):
if voucher_detail_no != row.name:
continue

current_qty = 0.0
if row.current_serial_and_batch_bundle:
self.recalculate_qty_for_serial_and_batch_bundle(row)
continue

current_qty = get_batch_qty_for_stock_reco(
item_code, row.warehouse, batch_no, self.posting_date, self.posting_time, self.name
)
current_qty = self.get_qty_for_serial_and_batch_bundle(row)
elif row.batch_no:
current_qty = get_batch_qty_for_stock_reco(
row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name
)

precesion = row.precision("current_qty")
if flt(current_qty, precesion) == flt(row.current_qty, precesion):
continue

val_rate = get_valuation_rate(
item_code, row.warehouse, self.doctype, self.name, company=self.company, batch_no=batch_no
)
if flt(current_qty, precesion) != flt(row.current_qty, precesion):
val_rate = get_valuation_rate(
row.item_code,
row.warehouse,
self.doctype,
self.name,
company=self.company,
batch_no=row.batch_no,
serial_and_batch_bundle=row.current_serial_and_batch_bundle,
)

row.current_valuation_rate = val_rate
if not row.current_qty and current_qty:
sle = self.get_sle_for_items(row)
sle.actual_qty = current_qty * -1
sle.valuation_rate = val_rate
sl_entries.append(sle)
row.current_valuation_rate = val_rate
row.current_qty = current_qty
row.db_set(
{
"current_qty": row.current_qty,
"current_valuation_rate": row.current_valuation_rate,
"current_amount": flt(row.current_qty * row.current_valuation_rate),
}
)

row.current_qty = current_qty
row.db_set(
{
"current_qty": row.current_qty,
"current_valuation_rate": row.current_valuation_rate,
"current_amount": flt(row.current_qty * row.current_valuation_rate),
}
)
if (
add_new_sle
and not frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0},
"name",
)
and (not row.current_serial_and_batch_bundle and not row.batch_no)
):
self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True)
row.reload()

if row.current_qty > 0 and row.current_serial_and_batch_bundle:
new_sle = self.get_sle_for_items(row)
new_sle.actual_qty = row.current_qty * -1
new_sle.valuation_rate = row.current_valuation_rate
new_sle.creation_time = add_to_date(sle_creation, seconds=-1)
new_sle.serial_and_batch_bundle = row.current_serial_and_batch_bundle
new_sle.qty_after_transaction = 0.0
sl_entries.append(new_sle)

if sl_entries:
self.make_sl_entries(sl_entries, allow_negative_stock=True)
self.make_sl_entries(sl_entries, allow_negative_stock=self.has_negative_stock_allowed())
if not frappe.db.exists("Repost Item Valuation", {"voucher_no": self.name, "status": "Queued"}):
self.repost_future_sle_and_gle(force=True)

def has_negative_stock_allowed(self):
allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))

def recalculate_qty_for_serial_and_batch_bundle(self, row):
if all(d.serial_and_batch_bundle and flt(d.qty) == flt(d.current_qty) for d in self.items):
allow_negative_stock = True

return allow_negative_stock

def get_qty_for_serial_and_batch_bundle(self, row):
doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle)
precision = doc.entries[0].precision("qty")

current_qty = 0
for d in doc.entries:
qty = (
get_batch_qty(
Expand All @@ -751,10 +808,12 @@ def recalculate_qty_for_serial_and_batch_bundle(self, row):
or 0
) * -1

if flt(d.qty, precision) == flt(qty, precision):
continue
if flt(d.qty, precision) != flt(qty, precision):
d.db_set("qty", qty)

current_qty += qty

d.db_set("qty", qty)
return abs(current_qty)


def get_batch_qty_for_stock_reco(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -742,13 +742,6 @@ def test_backdated_stock_reco_entry(self):

se2.cancel()

self.assertTrue(frappe.db.exists("Repost Item Valuation", {"voucher_no": stock_reco.name}))

self.assertEqual(
frappe.db.get_value("Repost Item Valuation", {"voucher_no": stock_reco.name}, "status"),
"Completed",
)

sle = frappe.get_all(
"Stock Ledger Entry",
filters={"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0},
Expand All @@ -766,6 +759,68 @@ def test_backdated_stock_reco_entry(self):

self.assertEqual(flt(sle[0].actual_qty), flt(-100.0))

def test_backdated_stock_reco_entry_with_batch(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry

item_code = self.make_item(
"Test New Batch Item ABCVSD",
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "BNS9.####",
"create_new_batch": 1,
},
).name

warehouse = "_Test Warehouse - _TC"

# Stock Reco for 100, Balace Qty 100
stock_reco = create_stock_reconciliation(
item_code=item_code,
posting_date=nowdate(),
posting_time="11:00:00",
warehouse=warehouse,
qty=100,
rate=100,
)

sles = frappe.get_all(
"Stock Ledger Entry",
fields=["actual_qty"],
filters={"voucher_no": stock_reco.name, "is_cancelled": 0},
)

self.assertEqual(len(sles), 1)

stock_reco.reload()
batch_no = get_batch_from_bundle(stock_reco.items[0].serial_and_batch_bundle)

# Stock Reco for 100, Balace Qty 100
stock_reco1 = create_stock_reconciliation(
item_code=item_code,
posting_date=add_days(nowdate(), -1),
posting_time="11:00:00",
batch_no=batch_no,
warehouse=warehouse,
qty=60,
rate=100,
)

sles = frappe.get_all(
"Stock Ledger Entry",
fields=["actual_qty"],
filters={"voucher_no": stock_reco.name, "is_cancelled": 0},
)

stock_reco1.reload()
new_batch_no = get_batch_from_bundle(stock_reco1.items[0].serial_and_batch_bundle)

self.assertEqual(len(sles), 2)

for row in sles:
if row.actual_qty < 0:
self.assertEqual(row.actual_qty, -60)

def test_update_stock_reconciliation_while_reposting(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
"fieldname": "current_serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Current Serial / Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"read_only": 1
},
Expand All @@ -216,7 +217,7 @@
],
"istable": 1,
"links": [],
"modified": "2023-07-26 12:54:34.011915",
"modified": "2023-11-02 15:47:07.929550",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation Item",
Expand Down
8 changes: 8 additions & 0 deletions erpnext/stock/report/stock_ledger/stock_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@ def get_columns(filters):
"options": "Serial No",
"width": 100,
},
{
"label": _("Serial and Batch Bundle"),
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"options": "Serial and Batch Bundle",
"width": 100,
},
{"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 100},
{
"label": _("Project"),
Expand Down Expand Up @@ -287,6 +294,7 @@ def get_stock_ledger_entries(filters, items):
sle.voucher_type,
sle.qty_after_transaction,
sle.stock_value_difference,
sle.serial_and_batch_bundle,
sle.voucher_no,
sle.stock_value,
sle.batch_no,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"stock_value_difference",
"valuation_rate",
"voucher_detail_no",
"serial_and_batch_bundle",
)


Expand Down Expand Up @@ -64,7 +65,11 @@ def add_invariant_check_fields(sles):

balance_qty += sle.actual_qty
balance_stock_value += sle.stock_value_difference
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
if (
sle.voucher_type == "Stock Reconciliation"
and not sle.batch_no
and not sle.serial_and_batch_bundle
):
balance_qty = frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "qty")
if balance_qty is None:
balance_qty = sle.qty_after_transaction
Expand Down Expand Up @@ -143,6 +148,12 @@ def get_columns():
"label": _("Batch"),
"options": "Batch",
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": _("Serial and Batch Bundle"),
"options": "Serial and Batch Bundle",
},
{
"fieldname": "use_batchwise_valuation",
"fieldtype": "Check",
Expand Down
Loading