fix: incorrect available qty for backdated stock reco with batch (#37858)
* fix: incorrect available qty for backdated stock reco with batch
* test: added test case
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index f96c184..f2bbf2b 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -121,7 +121,7 @@
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",
@@ -131,7 +131,9 @@
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)
@@ -152,7 +154,9 @@
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:
@@ -181,7 +185,8 @@
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)
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index 98b4ffd..323ad4f 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -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
@@ -88,9 +88,12 @@
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
)
@@ -148,6 +151,7 @@
"warehouse": item.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
+ "ignore_voucher_nos": [self.name],
}
)
)
@@ -163,11 +167,36 @@
)
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:
@@ -689,56 +718,84 @@
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
+ 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,
+ )
- val_rate = get_valuation_rate(
- item_code, row.warehouse, self.doctype, self.name, company=self.company, batch_no=batch_no
- )
+ 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_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)
+ 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()
- 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 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 recalculate_qty_for_serial_and_batch_bundle(self, row):
+ def has_negative_stock_allowed(self):
+ allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
+
+ 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(
@@ -751,10 +808,12 @@
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)
- d.db_set("qty", qty)
+ current_qty += qty
+
+ return abs(current_qty)
def get_batch_qty_for_stock_reco(
diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
index 34a7cba..1ec99bf 100644
--- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
@@ -742,13 +742,6 @@
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},
@@ -766,6 +759,68 @@
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
diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
index ca19bbb..d9cbf95 100644
--- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
+++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
@@ -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
},
@@ -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",
diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py
index eeef396..e59f2fe 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.py
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.py
@@ -249,6 +249,13 @@
"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"),
@@ -287,6 +294,7 @@
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,
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
index ca15afe..fb392f7 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
@@ -24,6 +24,7 @@
"stock_value_difference",
"valuation_rate",
"voucher_detail_no",
+ "serial_and_batch_bundle",
)
@@ -64,7 +65,11 @@
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
@@ -144,6 +149,12 @@
"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",
"label": _("Batchwise Valuation"),
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index b950f18..551701b 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -203,6 +203,11 @@
sle.allow_negative_stock = allow_negative_stock
sle.via_landed_cost_voucher = via_landed_cost_voucher
sle.submit()
+
+ # Added to handle the case when the stock ledger entry is created from the repostig
+ if args.get("creation_time") and args.get("voucher_type") == "Stock Reconciliation":
+ sle.db_set("creation", args.get("creation_time"))
+
return sle
@@ -689,9 +694,11 @@
if (
sle.voucher_type == "Stock Reconciliation"
- and (sle.batch_no or (sle.has_batch_no and sle.serial_and_batch_bundle))
+ and (
+ sle.batch_no or (sle.has_batch_no and sle.serial_and_batch_bundle and not sle.has_serial_no)
+ )
and sle.voucher_detail_no
- and sle.actual_qty < 0
+ and not self.args.get("sle_id")
):
self.reset_actual_qty_for_stock_reco(sle)
@@ -754,27 +761,22 @@
self.update_outgoing_rate_on_transaction(sle)
def reset_actual_qty_for_stock_reco(self, sle):
- if sle.serial_and_batch_bundle:
- current_qty = frappe.get_cached_value(
- "Serial and Batch Bundle", sle.serial_and_batch_bundle, "total_qty"
+ doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no)
+ doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0)
+
+ if sle.actual_qty < 0:
+ sle.actual_qty = (
+ flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty"))
+ * -1
)
- if current_qty is not None:
- current_qty = abs(current_qty)
- else:
- current_qty = frappe.get_cached_value(
- "Stock Reconciliation Item", sle.voucher_detail_no, "current_qty"
- )
-
- if current_qty:
- sle.actual_qty = current_qty * -1
- elif current_qty == 0:
- sle.is_cancelled = 1
+ if abs(sle.actual_qty) == 0.0:
+ sle.is_cancelled = 1
def calculate_valuation_for_serial_batch_bundle(self, sle):
doc = frappe.get_cached_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle)
- doc.set_incoming_rate(save=True)
+ doc.set_incoming_rate(save=True, allow_negative_stock=self.allow_negative_stock)
doc.calculate_qty_and_amount(save=True)
self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount)
@@ -1461,6 +1463,7 @@
currency=None,
company=None,
raise_error_if_no_rate=True,
+ batch_no=None,
serial_and_batch_bundle=None,
):
@@ -1469,6 +1472,25 @@
if not company:
company = frappe.get_cached_value("Warehouse", warehouse, "company")
+ if warehouse and batch_no and frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"):
+ table = frappe.qb.DocType("Stock Ledger Entry")
+ query = (
+ frappe.qb.from_(table)
+ .select(Sum(table.stock_value_difference) / Sum(table.actual_qty))
+ .where(
+ (table.item_code == item_code)
+ & (table.warehouse == warehouse)
+ & (table.batch_no == batch_no)
+ & (table.is_cancelled == 0)
+ & (table.voucher_no != voucher_no)
+ & (table.voucher_type != voucher_type)
+ )
+ )
+
+ last_valuation_rate = query.run()
+ if last_valuation_rate:
+ return flt(last_valuation_rate[0][0])
+
# Get moving average rate of a specific batch number
if warehouse and serial_and_batch_bundle:
batch_obj = BatchNoValuation(
@@ -1563,8 +1585,6 @@
next_stock_reco_detail = get_next_stock_reco(args)
if next_stock_reco_detail:
detail = next_stock_reco_detail[0]
- if detail.batch_no or (detail.serial_and_batch_bundle and detail.has_batch_no):
- regenerate_sle_for_batch_stock_reco(detail)
# add condition to update SLEs before this date & time
datetime_limit_condition = get_datetime_limit_condition(detail)
@@ -1593,16 +1613,6 @@
validate_negative_qty_in_future_sle(args, allow_negative_stock)
-def regenerate_sle_for_batch_stock_reco(detail):
- doc = frappe.get_cached_doc("Stock Reconciliation", detail.voucher_no)
- doc.recalculate_current_qty(detail.item_code, detail.batch_no)
-
- if not frappe.db.exists(
- "Repost Item Valuation", {"voucher_no": doc.name, "status": "Queued", "docstatus": "1"}
- ):
- doc.repost_future_sle_and_gle(force=True)
-
-
def get_stock_reco_qty_shift(args):
stock_reco_qty_shift = 0
if args.get("is_cancelled"):