From 7def475eb12bcbe4a546ceeee4982bb5229d0366 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jan 2024 11:57:58 +0530 Subject: [PATCH] fix: incorrect active serial nos (backport #39389) (#39589) fix: incorrect active serial nos (cherry picked from commit 64cb1153de2d09883234e4e80e5306de27db328f) Co-authored-by: Rohit Waghchaure --- .../js/utils/serial_no_batch_selector.js | 4 + .../serial_and_batch_bundle.py | 16 ++- .../stock_reconciliation.py | 88 ++++++++++-- .../test_stock_reconciliation.py | 68 +++++++++ erpnext/stock/stock_ledger.py | 129 ++++++++++++++---- 5 files changed, 263 insertions(+), 42 deletions(-) diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 44a4957b41..80ade7086c 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -71,6 +71,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { let warehouse = this.item?.type_of_transaction === "Outward" ? (this.item.warehouse || this.item.s_warehouse) : ""; + if (!warehouse && this.frm.doc.doctype === 'Stock Reconciliation') { + warehouse = this.get_warehouse(); + } + return { 'item_code': this.item.item_code, 'warehouse': ["=", warehouse] 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 63cc938c09..9cad8f62b8 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 @@ -250,6 +250,7 @@ class SerialandBatchBundle(Document): for d in self.entries: available_qty = 0 + if self.has_serial_no: d.incoming_rate = abs(sn_obj.serial_no_incoming_rate.get(d.serial_no, 0.0)) else: @@ -892,6 +893,13 @@ class SerialandBatchBundle(Document): elif batch_nos: self.set("entries", batch_nos) + def delete_serial_batch_entries(self): + SBBE = frappe.qb.DocType("Serial and Batch Entry") + + frappe.qb.from_(SBBE).delete().where(SBBE.parent == self.name).run() + + self.set("entries", []) + @frappe.whitelist() def download_blank_csv_template(content): @@ -1374,10 +1382,12 @@ def get_available_serial_nos(kwargs): elif kwargs.based_on == "Expiry": order_by = "amc_expiry_date asc" - filters = {"item_code": kwargs.item_code, "warehouse": ("is", "set")} + filters = {"item_code": kwargs.item_code} - if kwargs.warehouse: - filters["warehouse"] = kwargs.warehouse + if not kwargs.get("ignore_warehouse"): + filters["warehouse"] = ("is", "set") + if kwargs.warehouse: + filters["warehouse"] = kwargs.warehouse # Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos. ignore_serial_nos = get_reserved_serial_nos(kwargs) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 6819968394..788ae0d3ab 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -156,6 +156,7 @@ class StockReconciliation(StockController): "warehouse": item.warehouse, "posting_date": self.posting_date, "posting_time": self.posting_time, + "ignore_warehouse": 1, } ) ) @@ -780,7 +781,20 @@ class StockReconciliation(StockController): current_qty = 0.0 if row.current_serial_and_batch_bundle: - current_qty = self.get_qty_for_serial_and_batch_bundle(row) + current_qty = self.get_current_qty_for_serial_or_batch(row) + elif row.serial_no: + item_dict = get_stock_balance_for( + row.item_code, + row.warehouse, + self.posting_date, + self.posting_time, + voucher_no=self.name, + ) + + current_qty = item_dict.get("qty") + row.current_serial_no = item_dict.get("serial_nos") + row.current_valuation_rate = item_dict.get("rate") + val_rate = item_dict.get("rate") 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 @@ -788,15 +802,16 @@ class StockReconciliation(StockController): precesion = row.precision("current_qty") 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, - ) + if not row.serial_no: + 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 row.current_qty = current_qty @@ -842,11 +857,56 @@ class StockReconciliation(StockController): return allow_negative_stock - def get_qty_for_serial_and_batch_bundle(self, row): + def get_current_qty_for_serial_or_batch(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.0 + if doc.has_serial_no: + current_qty = self.get_current_qty_for_serial_nos(doc) + elif doc.has_batch_no: + current_qty = self.get_current_qty_for_batch_nos(doc) - current_qty = 0 + return abs(current_qty) + + def get_current_qty_for_serial_nos(self, doc): + serial_nos_details = get_available_serial_nos( + frappe._dict( + { + "item_code": doc.item_code, + "warehouse": doc.warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "voucher_no": self.name, + "ignore_warehouse": 1, + } + ) + ) + + if not serial_nos_details: + return 0.0 + + doc.delete_serial_batch_entries() + current_qty = 0.0 + for serial_no_row in serial_nos_details: + current_qty += 1 + doc.append( + "entries", + { + "serial_no": serial_no_row.serial_no, + "qty": -1, + "warehouse": doc.warehouse, + "batch_no": serial_no_row.batch_no, + }, + ) + + doc.set_incoming_rate(save=True) + doc.calculate_qty_and_amount(save=True) + doc.db_update_all() + + return current_qty + + def get_current_qty_for_batch_nos(self, doc): + current_qty = 0.0 + precision = doc.entries[0].precision("qty") for d in doc.entries: qty = ( get_batch_qty( @@ -864,7 +924,7 @@ class StockReconciliation(StockController): current_qty += qty - return abs(current_qty) + return 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 70e9fb2205..0bbfed40d8 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -925,6 +925,74 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(len(serial_batch_bundle), 0) + def test_backdated_purchase_receipt_with_stock_reco(self): + item_code = self.make_item( + properties={ + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TEST-SERIAL-.###", + } + ).name + + warehouse = "_Test Warehouse - _TC" + + # Step - 1: Create a Backdated Purchase Receipt + + pr1 = make_purchase_receipt( + item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -3) + ) + pr1.reload() + + serial_nos = sorted(get_serial_nos_from_bundle(pr1.items[0].serial_and_batch_bundle))[:5] + + # Step - 2: Create a Stock Reconciliation + sr1 = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + serial_no=serial_nos, + ) + + data = frappe.get_all( + "Stock Ledger Entry", + fields=["serial_no", "actual_qty", "stock_value_difference"], + filters={"voucher_no": sr1.name, "is_cancelled": 0}, + order_by="creation", + ) + + for d in data: + if d.actual_qty < 0: + self.assertEqual(d.actual_qty, -10.0) + self.assertAlmostEqual(d.stock_value_difference, -1000.0) + else: + self.assertEqual(d.actual_qty, 5.0) + self.assertAlmostEqual(d.stock_value_difference, 500.0) + + # Step - 3: Create a Purchase Receipt before the first Purchase Receipt + make_purchase_receipt( + item_code=item_code, warehouse=warehouse, qty=10, rate=200, posting_date=add_days(nowdate(), -5) + ) + + data = frappe.get_all( + "Stock Ledger Entry", + fields=["serial_no", "actual_qty", "stock_value_difference"], + filters={"voucher_no": sr1.name, "is_cancelled": 0}, + order_by="creation", + ) + + for d in data: + if d.actual_qty < 0: + self.assertEqual(d.actual_qty, -20.0) + self.assertAlmostEqual(d.stock_value_difference, -3000.0) + else: + self.assertEqual(d.actual_qty, 5.0) + self.assertAlmostEqual(d.stock_value_difference, 500.0) + + active_serial_no = frappe.get_all( + "Serial No", filters={"status": "Active", "item_code": item_code} + ) + self.assertEqual(len(active_serial_no), 5) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index a221e006af..0a6a686d8e 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -9,9 +9,18 @@ from typing import Optional, Set, Tuple import frappe from frappe import _, scrub from frappe.model.meta import get_field_precision -from frappe.query_builder import Case from frappe.query_builder.functions import CombineDatetime, Sum -from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, nowtime, parse_json +from frappe.utils import ( + cint, + cstr, + flt, + get_link_to_form, + getdate, + now, + nowdate, + nowtime, + parse_json, +) import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty @@ -712,11 +721,10 @@ class update_entries_after(object): if ( sle.voucher_type == "Stock Reconciliation" - and ( - sle.batch_no or (sle.has_batch_no and sle.serial_and_batch_bundle and not sle.has_serial_no) - ) + and (sle.batch_no or sle.serial_no or sle.serial_and_batch_bundle) and sle.voucher_detail_no and not self.args.get("sle_id") + and sle.is_cancelled == 0 ): self.reset_actual_qty_for_stock_reco(sle) @@ -737,6 +745,23 @@ class update_entries_after(object): if sle.serial_and_batch_bundle: self.calculate_valuation_for_serial_batch_bundle(sle) + elif sle.serial_no and not self.args.get("sle_id"): + # Only run in reposting + self.get_serialized_values(sle) + self.wh_data.qty_after_transaction += flt(sle.actual_qty) + if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: + self.wh_data.qty_after_transaction = sle.qty_after_transaction + + self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt( + self.wh_data.valuation_rate + ) + elif ( + sle.batch_no + and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True) + and not self.args.get("sle_id") + ): + # Only run in reposting + self.update_batched_values(sle) else: if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and not has_dimensions: # assert @@ -782,6 +807,45 @@ class update_entries_after(object): ): self.update_outgoing_rate_on_transaction(sle) + def get_serialized_values(self, sle): + incoming_rate = flt(sle.incoming_rate) + actual_qty = flt(sle.actual_qty) + serial_nos = cstr(sle.serial_no).split("\n") + + if incoming_rate < 0: + # wrong incoming rate + incoming_rate = self.wh_data.valuation_rate + + stock_value_change = 0 + if actual_qty > 0: + stock_value_change = actual_qty * incoming_rate + else: + # In case of delivery/stock issue, get average purchase rate + # of serial nos of current entry + if not sle.is_cancelled: + outgoing_value = self.get_incoming_value_for_serial_nos(sle, serial_nos) + stock_value_change = -1 * outgoing_value + else: + stock_value_change = actual_qty * sle.outgoing_rate + + new_stock_qty = self.wh_data.qty_after_transaction + actual_qty + + if new_stock_qty > 0: + new_stock_value = ( + self.wh_data.qty_after_transaction * self.wh_data.valuation_rate + ) + stock_value_change + if new_stock_value >= 0: + # calculate new valuation rate only if stock value is positive + # else it remains the same as that of previous entry + self.wh_data.valuation_rate = new_stock_value / new_stock_qty + + if not self.wh_data.valuation_rate and sle.voucher_detail_no: + allow_zero_rate = self.check_if_allow_zero_valuation_rate( + sle.voucher_type, sle.voucher_detail_no + ) + if not allow_zero_rate: + self.wh_data.valuation_rate = self.get_fallback_rate(sle) + def reset_actual_qty_for_stock_reco(self, sle): doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no) doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0) @@ -795,6 +859,36 @@ class update_entries_after(object): if abs(sle.actual_qty) == 0.0: sle.is_cancelled = 1 + if sle.serial_and_batch_bundle and frappe.get_cached_value( + "Item", sle.item_code, "has_serial_no" + ): + self.update_serial_no_status(sle) + + def update_serial_no_status(self, sle): + from erpnext.stock.serial_batch_bundle import get_serial_nos + + serial_nos = get_serial_nos(sle.serial_and_batch_bundle) + if not serial_nos: + return + + warehouse = None + status = "Inactive" + + if sle.actual_qty > 0: + warehouse = sle.warehouse + status = "Active" + + sn_table = frappe.qb.DocType("Serial No") + + query = ( + frappe.qb.update(sn_table) + .set(sn_table.warehouse, warehouse) + .set(sn_table.status, status) + .where(sn_table.name.isin(serial_nos)) + ) + + query.run() + def calculate_valuation_for_serial_batch_bundle(self, sle): doc = frappe.get_cached_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle) @@ -1171,11 +1265,12 @@ class update_entries_after(object): outgoing_rate = get_batch_incoming_rate( item_code=sle.item_code, warehouse=sle.warehouse, - serial_and_batch_bundle=sle.serial_and_batch_bundle, + batch_no=sle.batch_no, posting_date=sle.posting_date, posting_time=sle.posting_time, creation=sle.creation, ) + if outgoing_rate is None: # This can *only* happen if qty available for the batch is zero. # in such case fall back various other rates. @@ -1449,11 +1544,10 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): def get_batch_incoming_rate( - item_code, warehouse, serial_and_batch_bundle, posting_date, posting_time, creation=None + item_code, warehouse, batch_no, posting_date, posting_time, creation=None ): sle = frappe.qb.DocType("Stock Ledger Entry") - batch_ledger = frappe.qb.DocType("Serial and Batch Entry") timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime( posting_date, posting_time @@ -1464,28 +1558,13 @@ def get_batch_incoming_rate( == CombineDatetime(posting_date, posting_time) ) & (sle.creation < creation) - batches = frappe.get_all( - "Serial and Batch Entry", fields=["batch_no"], filters={"parent": serial_and_batch_bundle} - ) - batch_details = ( frappe.qb.from_(sle) - .inner_join(batch_ledger) - .on(sle.serial_and_batch_bundle == batch_ledger.parent) - .select( - Sum( - Case() - .when(sle.actual_qty > 0, batch_ledger.qty * batch_ledger.incoming_rate) - .else_(batch_ledger.qty * batch_ledger.outgoing_rate * -1) - ).as_("batch_value"), - Sum(Case().when(sle.actual_qty > 0, batch_ledger.qty).else_(batch_ledger.qty * -1)).as_( - "batch_qty" - ), - ) + .select(Sum(sle.stock_value_difference).as_("batch_value"), Sum(sle.actual_qty).as_("batch_qty")) .where( (sle.item_code == item_code) & (sle.warehouse == warehouse) - & (batch_ledger.batch_no.isin([row.batch_no for row in batches])) + & (sle.batch_no == batch_no) & (sle.is_cancelled == 0) ) .where(timestamp_condition)