diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 47bb39b1aac..96fdb2cc450 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -23,7 +23,7 @@ from erpnext.stock.get_item_details import ( get_conversion_factor, get_item_defaults, ) -from erpnext.stock.utils import get_incoming_rate +from erpnext.stock.utils import get_incoming_rate, is_serial_no_wise_valuation_disabled class QtyMismatchError(ValidationError): @@ -866,9 +866,11 @@ class BuyingController(SubcontractingController): ) if self.is_return: - outgoing_rate = get_rate_for_return( - self.doctype, self.name, d.item_code, self.return_against, item_row=d - ) + outgoing_rate = 0.0 + if not is_serial_no_wise_valuation_disabled(d.item_code): + outgoing_rate = get_rate_for_return( + self.doctype, self.name, d.item_code, self.return_against, item_row=d + ) sle.update( { diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 8af83828c17..6e62e536545 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -508,3 +508,4 @@ erpnext.patches.v16_0.set_secondary_item_valuation_type erpnext.patches.v16_0.add_transaction_roles_to_sms_settings erpnext.patches.v16_0.recalculate_holiday_list_totals erpnext.patches.v16_0.rename_component_cost_valuation_type +erpnext.patches.v16_0.enable_serial_no_wise_valuation diff --git a/erpnext/patches/v16_0/enable_serial_no_wise_valuation.py b/erpnext/patches/v16_0/enable_serial_no_wise_valuation.py new file mode 100644 index 00000000000..b292e9f7565 --- /dev/null +++ b/erpnext/patches/v16_0/enable_serial_no_wise_valuation.py @@ -0,0 +1,11 @@ +import frappe + + +def execute(): + item = frappe.qb.DocType("Item") + + ( + frappe.qb.update(item) + .set(item.use_serial_no_wise_valuation, 1) + .where((item.has_serial_no == 1) & (item.use_serial_no_wise_valuation == 0)) + ).run() diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 77d691837f1..a9fc98ca776 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -3,10 +3,10 @@ import json from collections import defaultdict import frappe -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import Locate, Sum from frappe.utils import flt, nowtime from pypika import Order -from pypika.functions import Coalesce +from pypika.functions import Coalesce, Concat from erpnext.deprecation_dumpster import deprecated @@ -64,36 +64,39 @@ class DeprecatedSerialNoValuation: incoming_values += self.serial_no_incoming_rate[serial_no] continue - table = frappe.qb.DocType("Stock Ledger Entry") - stock_ledgers = ( - frappe.qb.from_(table) - .select(table.incoming_rate, table.actual_qty, table.stock_value_difference) - .where( - ( - (table.serial_no == serial_no) - | (table.serial_no.like(serial_no + "\n%")) - | (table.serial_no.like("%\n" + serial_no)) - | (table.serial_no.like("%\n" + serial_no + "\n%")) - ) - & (table.item_code == self.sle.item_code) - & (table.company == self.sle.company) - & (table.warehouse == self.sle.warehouse) - & (table.serial_and_batch_bundle.isnull()) - & (table.actual_qty > 0) - & (table.is_cancelled == 0) - & table.posting_datetime - <= posting_datetime - ) - .orderby(table.posting_datetime, order=Order.desc) - .limit(1) - ).run(as_dict=1) - - for sle in stock_ledgers: + for sle in self.get_last_inward_sle_for_serial_no(serial_no, posting_datetime): self.serial_no_incoming_rate[serial_no] += flt(sle.incoming_rate) incoming_values += self.serial_no_incoming_rate[serial_no] return incoming_values + def get_last_inward_sle_for_serial_no(self, serial_no, posting_datetime): + table = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(table) + .select(table.incoming_rate, table.actual_qty, table.stock_value_difference) + .where( + (table.item_code == self.sle.item_code) + & (table.company == self.sle.company) + & (table.warehouse == self.sle.warehouse) + & (table.posting_datetime <= posting_datetime) + & (table.is_cancelled == 0) + & (table.actual_qty > 0) + & (table.serial_and_batch_bundle.isnull()) + & (Locate(serial_no, table.serial_no) > 0) + & (Locate(f"\n{serial_no}\n", Concat("\n", table.serial_no, "\n")) > 0) + ) + .orderby(table.posting_datetime, order=Order.desc) + .orderby(table.creation, order=Order.desc) + .limit(1) + ) + + if frappe.db.db_type == "mariadb": + query = query.force_index("item_code_warehouse_posting_datetime_creation_index") + + return query.run(as_dict=1) + class DeprecatedBatchNoValuation: @deprecated( diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 3f2ae45cd91..15dae4a1ced 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -95,6 +95,7 @@ "column_break_37", "has_serial_no", "serial_no_series", + "use_serial_no_wise_valuation", "variants_section", "variant_of", "variant_based_on", @@ -387,7 +388,8 @@ "fieldname": "valuation_method", "fieldtype": "Select", "label": "Valuation Method", - "options": "\nFIFO\nMoving Average\nLIFO" + "options": "\nFIFO\nMoving Average\nLIFO", + "description": "Serialized items are valued at Moving Average once stock transactions exist and Serial No Wise Valuation is disabled." }, { "depends_on": "is_stock_item", @@ -529,6 +531,15 @@ "label": "Serial Number Series", "show_description_on_click": 1 }, + { + "default": "1", + "depends_on": "eval:doc.is_stock_item && doc.has_serial_no", + "description": "Value every outward movement at each Serial No's own incoming rate. If unchecked, the item is valued at Moving Average once stock transactions exist. Cannot be enabled once Serial Nos exist for this item.", + "fieldname": "use_serial_no_wise_valuation", + "fieldtype": "Check", + "label": "Use Serial No Wise Valuation", + "show_description_on_click": 1 + }, { "collapsible": 1, "collapsible_depends_on": "attributes", @@ -1095,7 +1106,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-09-04 10:08:30.115003", + "modified": "2026-09-16 12:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 35e9ee0040d..06272aa034d 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -145,6 +145,7 @@ class Item(Document): taxes: DF.Table[ItemTax] total_projected_qty: DF.Float uoms: DF.Table[UOMConversionDetail] + use_serial_no_wise_valuation: DF.Check valuation_method: DF.Literal["", "FIFO", "Moving Average", "LIFO"] valuation_rate: DF.Currency variant_based_on: DF.Literal["Item Attribute", "Manufacturer"] @@ -227,14 +228,19 @@ class Item(Document): self.validate_auto_reorder_enabled_in_stock_settings() self.cant_change() self.validate_serialized_change_with_bundle() + self.validate_serial_no_wise_valuation() + self.set_valuation_method_for_serial_no_wise_valuation() self.validate_item_tax_net_rate_range() if not self.is_new(): self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group") def on_update(self): + from erpnext.stock.utils import clear_valuation_method_cache + self.update_variants() self.update_item_price() + clear_valuation_method_cache() def validate_description(self): """Clean HTML description if set""" @@ -1130,6 +1136,43 @@ class Item(Document): frappe.throw(msg, title=_("Linked with submitted documents")) + def validate_serial_no_wise_valuation(self): + if self.is_new() or not self._doc_before_save: + return + + if not self.use_serial_no_wise_valuation or self._doc_before_save.use_serial_no_wise_valuation: + return + + if frappe.db.exists("Serial No", {"item_code": self.name}): + frappe.throw( + _( + "Serial No Wise Valuation cannot be enabled for Item {0} because Serial Nos already exist for it. Valuation for those Serial Nos was not tracked, so enabling it now would value outward entries incorrectly." + ).format(frappe.bold(self.name)), + title=_("Serial Nos Exist"), + ) + + def set_valuation_method_for_serial_no_wise_valuation(self): + if not self.has_serial_no or self.use_serial_no_wise_valuation: + return + + if not frappe.db.exists("Stock Ledger Entry", {"item_code": self.name, "is_cancelled": 0}): + return + + if ( + not self.is_new() + and self._doc_before_save + and self.has_value_changed("valuation_method") + and self.valuation_method in ("FIFO", "LIFO") + ): + frappe.throw( + _( + "Valuation Method for Item {0} must be Moving Average because Serial No Wise Valuation is disabled. Enable Serial No Wise Valuation to use FIFO or LIFO." + ).format(frappe.bold(self.name)), + title=_("Invalid Valuation Method"), + ) + + self.valuation_method = "Moving Average" + def validate_serialized_change_with_bundle(self): """Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first.""" 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 fc19b46c133..a3359aa34b3 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 @@ -410,8 +410,9 @@ class SerialandBatchBundle(Document): def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None): if valuation_details := self.get_valuation_rate_for_return_entry(return_against): - from erpnext.stock.utils import get_valuation_method + from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled + skip_rate_update = is_serial_no_wise_valuation_disabled(self.item_code) valuation_method = get_valuation_method(self.item_code, self.company) # An outward return must go out at the batch's current average rate for a @@ -440,6 +441,9 @@ class SerialandBatchBundle(Document): if valuation_details: self.validate_returned_serial_batch_no(return_against, row, valuation_details) + if skip_rate_update: + continue + if row.serial_no: valuation_rate = valuation_details["serial_nos"].get(row.serial_no) else: @@ -696,7 +700,10 @@ class SerialandBatchBundle(Document): ) def set_incoming_rate_for_outward_transaction(self, row=None, save=False, allow_negative_stock=False): - from erpnext.stock.utils import get_valuation_method + from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled + + if is_serial_no_wise_valuation_disabled(self.item_code): + return sle = self.get_sle_for_outward_transaction() diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index af0b03706de..2ee0d7e1ec5 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1632,6 +1632,216 @@ class TestSerialandBatchBundle(ERPNextTestSuite): ) self.assertRaises(NegativeStockError, backdated.submit) + def make_serial_item_for_valuation(self, item_code, use_serial_no_wise_valuation): + return make_item( + item_code, + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": item_code + "-.####", + "valuation_method": "FIFO" if use_serial_no_wise_valuation else "Moving Average", + "use_serial_no_wise_valuation": use_serial_no_wise_valuation, + }, + ) + + def receive_serial_stock(self, item_code, qty, rate, warehouse): + entry = make_stock_entry( + item_code=item_code, target=warehouse, qty=qty, basic_rate=rate, use_serial_batch_fields=1 + ) + + return get_serial_nos_from_bundle(entry.items[0].serial_and_batch_bundle) + + def issue_serial_no(self, item_code, serial_no, warehouse): + return make_stock_entry( + item_code=item_code, + source=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + ) + + def get_stock_value_difference(self, voucher_no): + return frappe.db.get_value( + "Stock Ledger Entry", {"voucher_no": voucher_no, "is_cancelled": 0}, "stock_value_difference" + ) + + def test_serial_no_wise_valuation_uses_serial_rate_when_enabled(self): + warehouse = "_Test Warehouse - _TC" + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation On", 1) + + self.receive_serial_stock(item.name, 2, 100, warehouse) + newer_serial_nos = self.receive_serial_stock(item.name, 2, 200, warehouse) + + issue = self.issue_serial_no(item.name, newer_serial_nos[-1], warehouse) + + self.assertEqual(flt(self.get_stock_value_difference(issue.name)), -200.0) + + def test_serial_no_wise_valuation_uses_item_valuation_method_when_disabled(self): + warehouse = "_Test Warehouse - _TC" + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Off", 0) + + self.receive_serial_stock(item.name, 2, 100, warehouse) + newer_serial_nos = self.receive_serial_stock(item.name, 2, 200, warehouse) + + issue = self.issue_serial_no(item.name, newer_serial_nos[-1], warehouse) + + self.assertEqual(flt(self.get_stock_value_difference(issue.name)), -150.0) + + def test_outward_bundle_rate_not_set_when_serial_no_wise_valuation_disabled(self): + warehouse = "_Test Warehouse - _TC" + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No Rate", 0) + + serial_nos = self.receive_serial_stock(item.name, 2, 100, warehouse) + issue = self.issue_serial_no(item.name, serial_nos[-1], warehouse) + + rates = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": issue.items[0].serial_and_batch_bundle}, + pluck="incoming_rate", + ) + + self.assertTrue(rates) + for rate in rates: + self.assertEqual(flt(rate), 0.0) + + def test_cannot_enable_serial_no_wise_valuation_when_serial_nos_exist(self): + warehouse = "_Test Warehouse - _TC" + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Toggle", 1) + self.receive_serial_stock(item.name, 1, 100, warehouse) + + item.reload() + item.use_serial_no_wise_valuation = 0 + item.save() + + item.reload() + item.use_serial_no_wise_valuation = 1 + self.assertRaises(frappe.ValidationError, item.save) + + def make_purchase_return_for_serial_no(self, item_code, serial_no, receipt): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + entry = make_return_doc("Purchase Receipt", receipt.name) + entry.items[0].qty = -1 + entry.items[0].received_qty = -1 + for row in entry.items: + row.serial_and_batch_bundle = None + row.use_serial_batch_fields = 1 + row.serial_no = serial_no + + entry.save() + entry.submit() + + return entry + + def test_purchase_return_uses_item_valuation_method_when_disabled(self): + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + warehouse = "_Test Warehouse - _TC" + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Pur Return Off", 0) + + make_purchase_receipt(item_code=item.name, qty=2, rate=100, warehouse=warehouse) + costlier_receipt = make_purchase_receipt(item_code=item.name, qty=2, rate=200, warehouse=warehouse) + serial_nos = get_serial_nos_from_bundle(costlier_receipt.items[0].serial_and_batch_bundle) + + entry = self.make_purchase_return_for_serial_no(item.name, serial_nos[-1], costlier_receipt) + + self.assertEqual(flt(self.get_stock_value_difference(entry.name)), -150.0) + + def test_purchase_return_uses_serial_rate_when_enabled(self): + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + warehouse = "_Test Warehouse - _TC" + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Pur Return On", 1) + + make_purchase_receipt(item_code=item.name, qty=2, rate=100, warehouse=warehouse) + costlier_receipt = make_purchase_receipt(item_code=item.name, qty=2, rate=200, warehouse=warehouse) + serial_nos = get_serial_nos_from_bundle(costlier_receipt.items[0].serial_and_batch_bundle) + + entry = self.make_purchase_return_for_serial_no(item.name, serial_nos[-1], costlier_receipt) + + self.assertEqual(flt(self.get_stock_value_difference(entry.name)), -200.0) + + def test_valuation_method_forced_to_moving_average_when_disabled(self): + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Forced MA", 1) + self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC") + + item.reload() + item.valuation_method = "FIFO" + item.use_serial_no_wise_valuation = 0 + item.save() + + item.reload() + self.assertEqual(item.valuation_method, "Moving Average") + + def test_valuation_method_kept_when_disabled_without_stock_transactions(self): + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No MA Yet", 1) + + item.reload() + item.valuation_method = "FIFO" + item.use_serial_no_wise_valuation = 0 + item.save() + + item.reload() + self.assertEqual(item.valuation_method, "FIFO") + + def test_fifo_allowed_when_disabled_without_stock_transactions(self): + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation FIFO Ok", 0) + + item.reload() + item.valuation_method = "FIFO" + item.save() + + item.reload() + self.assertEqual(item.valuation_method, "FIFO") + + def test_cannot_set_fifo_when_serial_no_wise_valuation_disabled(self): + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No FIFO", 0) + self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC") + + item.reload() + self.assertEqual(item.valuation_method, "Moving Average") + + item.valuation_method = "FIFO" + self.assertRaises(frappe.ValidationError, item.save) + + def test_valuation_helpers_not_stale_after_disabling_in_same_request(self): + from collections import defaultdict + + from erpnext.stock.utils import get_valuation_method, is_serial_no_wise_valuation_disabled + + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Cache", 1) + self.receive_serial_stock(item.name, 1, 100, "_Test Warehouse - _TC") + + previous_cache = getattr(frappe.local, "request_cache", None) + self.addCleanup(setattr, frappe.local, "request_cache", previous_cache) + frappe.local.request_cache = defaultdict(dict) + + self.assertEqual(get_valuation_method(item.name), "FIFO") + self.assertFalse(is_serial_no_wise_valuation_disabled(item.name)) + + item.reload() + item.use_serial_no_wise_valuation = 0 + item.save() + + self.assertEqual(get_valuation_method(item.name), "Moving Average") + self.assertTrue(is_serial_no_wise_valuation_disabled(item.name)) + + def test_valuation_method_untouched_when_serial_no_wise_valuation_enabled(self): + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation Keeps FIFO", 1) + + item.reload() + self.assertEqual(item.valuation_method, "FIFO") + + def test_enable_serial_no_wise_valuation_allowed_without_serial_nos(self): + item = self.make_serial_item_for_valuation("_Test Serial Wise Valuation No Serials", 0) + + item.reload() + item.use_serial_no_wise_valuation = 1 + item.save() + + item.reload() + self.assertEqual(item.use_serial_no_wise_valuation, 1) + def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 003ab715146..910b44b7276 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -43,6 +43,7 @@ from erpnext.stock.utils import ( get_serial_nos_data, get_stock_balance, get_valuation_method, + is_serial_no_wise_valuation_disabled, ) from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero @@ -519,6 +520,7 @@ class update_entries_after: self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company") self.set_precision() self.valuation_method = get_valuation_method(self.item_code, self.company) + self.skip_serial_batch_valuation = is_serial_no_wise_valuation_disabled(self.item_code) self.repost_affected_transaction = args.get("repost_affected_transaction") or set() self.new_items_found = False @@ -929,9 +931,9 @@ class update_entries_after: if sle.get(dimension.get("fieldname")): has_dimensions = True - if sle.serial_and_batch_bundle: + if sle.serial_and_batch_bundle and not self.skip_serial_batch_valuation: self.calculate_valuation_for_serial_batch_bundle(sle) - elif sle.serial_no and not self.args.get("sle_id"): + elif sle.serial_no and not self.skip_serial_batch_valuation 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) @@ -943,6 +945,7 @@ class update_entries_after: ) elif ( sle.batch_no + and not self.skip_serial_batch_valuation and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True) and not self.args.get("sle_id") ): @@ -1286,11 +1289,9 @@ class update_entries_after: get_rate_for_return, # don't move this import to top ) - if ( - self.valuation_method == "Moving Average" - and not sle.get("serial_no") - and not sle.get("batch_no") - and not sle.get("serial_and_batch_bundle") + if self.valuation_method == "Moving Average" and ( + self.skip_serial_batch_valuation + or not (sle.get("serial_no") or sle.get("batch_no") or sle.get("serial_and_batch_bundle")) ): rate = self.get_moving_average_rate_for_return(sle) @@ -1303,6 +1304,9 @@ class update_entries_after: sle=sle, ) + elif self.skip_serial_batch_valuation and flt(sle.actual_qty) < 0: + rate = 0.0 + else: rate = get_rate_for_return( sle.voucher_type, @@ -1641,6 +1645,9 @@ class update_entries_after: self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction def is_return_purchase_entry(self, sle): + if self.skip_serial_batch_valuation: + return False + if sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"]: return frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_return") diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 0e95ba06ab1..c10b041be61 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -246,7 +246,7 @@ def _create_bin(item_code, warehouse): @frappe.whitelist() -def get_incoming_rate(args, raise_error_if_no_rate=True, fallbacks: bool = True): +def get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fallbacks: bool = True): """Get Incoming Rate based on valuation method""" from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate @@ -259,15 +259,26 @@ def get_incoming_rate(args, raise_error_if_no_rate=True, fallbacks: bool = True) in_rate = None item_details = frappe.get_cached_value( - "Item", args.get("item_code"), ["has_serial_no", "has_batch_no"], as_dict=1 + "Item", + args.get("item_code"), + ["has_serial_no", "has_batch_no", "use_serial_no_wise_valuation"], + as_dict=1, ) use_moving_avg_for_batch = frappe.get_single_value("Stock Settings", "do_not_use_batchwise_valuation") + skip_serial_batch_valuation = bool( + item_details and item_details.has_serial_no and not item_details.use_serial_no_wise_valuation + ) if isinstance(args, dict): args = frappe._dict(args) - if item_details and item_details.has_serial_no and args.get("serial_and_batch_bundle"): + if ( + item_details + and item_details.has_serial_no + and args.get("serial_and_batch_bundle") + and not skip_serial_batch_valuation + ): args.actual_qty = args.qty sn_obj = SerialNoValuation( sle=args, @@ -282,6 +293,7 @@ def get_incoming_rate(args, raise_error_if_no_rate=True, fallbacks: bool = True) and item_details.has_batch_no and args.get("serial_and_batch_bundle") and not use_moving_avg_for_batch + and not skip_serial_batch_valuation ): args.actual_qty = args.qty batch_obj = BatchNoValuation( @@ -292,14 +304,23 @@ def get_incoming_rate(args, raise_error_if_no_rate=True, fallbacks: bool = True) return batch_obj.get_incoming_rate() - elif (args.get("serial_no") or "").strip() and not args.get("serial_and_batch_bundle"): + elif ( + (args.get("serial_no") or "").strip() + and not args.get("serial_and_batch_bundle") + and not skip_serial_batch_valuation + ): args.actual_qty = args.qty args.serial_nos = get_serial_nos_data(args.get("serial_no")) sn_obj = SerialNoValuation(sle=args, warehouse=args.get("warehouse"), item_code=args.get("item_code")) return sn_obj.get_incoming_rate() - elif args.get("batch_no") and not args.get("serial_and_batch_bundle") and not use_moving_avg_for_batch: + elif ( + args.get("batch_no") + and not args.get("serial_and_batch_bundle") + and not use_moving_avg_for_batch + and not skip_serial_batch_valuation + ): args.actual_qty = args.qty args.batch_nos = frappe._dict({args.batch_no: args}) @@ -355,6 +376,14 @@ def get_avg_purchase_rate(serial_nos): ) +def is_serial_no_wise_valuation_disabled(item_code) -> bool: + item_details = frappe.get_cached_value( + "Item", item_code, ["has_serial_no", "use_serial_no_wise_valuation"], as_dict=1 + ) + + return bool(item_details and item_details.has_serial_no and not item_details.use_serial_no_wise_valuation) + + @frappe.request_cache def get_valuation_method(item_code, company=None): """get valuation method from item or default""" @@ -368,6 +397,14 @@ def get_valuation_method(item_code, company=None): return val_method +def clear_valuation_method_cache(): + cache = getattr(frappe.local, "request_cache", None) + if not cache: + return + + cache.pop(getattr(get_valuation_method, "__wrapped__", get_valuation_method), None) + + def get_fifo_rate(previous_stock_queue, qty): """get FIFO (average) Rate from Queue""" return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO")