feat: use serial no wise valuation switch on item (#59082)

* perf: speed up legacy serial no valuation lookup

* feat: use serial no wise valuation switch on item

* fix: clear stale valuation method cache on item update

* fix: force moving average only when stock transactions exist

* fix: correct serial no wise valuation field description

* fix: add type hints to whitelisted get_incoming_rate args
This commit is contained in:
rohitwaghchaure
2026-09-16 12:24:18 +05:30
committed by GitHub
parent 378c756d52
commit f09ce0583f
10 changed files with 379 additions and 47 deletions

View File

@@ -23,7 +23,7 @@ from erpnext.stock.get_item_details import (
get_conversion_factor, get_conversion_factor,
get_item_defaults, 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): class QtyMismatchError(ValidationError):
@@ -866,9 +866,11 @@ class BuyingController(SubcontractingController):
) )
if self.is_return: if self.is_return:
outgoing_rate = get_rate_for_return( outgoing_rate = 0.0
self.doctype, self.name, d.item_code, self.return_against, item_row=d 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( sle.update(
{ {

View File

@@ -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.add_transaction_roles_to_sms_settings
erpnext.patches.v16_0.recalculate_holiday_list_totals erpnext.patches.v16_0.recalculate_holiday_list_totals
erpnext.patches.v16_0.rename_component_cost_valuation_type erpnext.patches.v16_0.rename_component_cost_valuation_type
erpnext.patches.v16_0.enable_serial_no_wise_valuation

View File

@@ -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()

View File

@@ -3,10 +3,10 @@ import json
from collections import defaultdict from collections import defaultdict
import frappe import frappe
from frappe.query_builder.functions import Sum from frappe.query_builder.functions import Locate, Sum
from frappe.utils import flt, nowtime from frappe.utils import flt, nowtime
from pypika import Order from pypika import Order
from pypika.functions import Coalesce from pypika.functions import Coalesce, Concat
from erpnext.deprecation_dumpster import deprecated from erpnext.deprecation_dumpster import deprecated
@@ -64,36 +64,39 @@ class DeprecatedSerialNoValuation:
incoming_values += self.serial_no_incoming_rate[serial_no] incoming_values += self.serial_no_incoming_rate[serial_no]
continue continue
table = frappe.qb.DocType("Stock Ledger Entry") for sle in self.get_last_inward_sle_for_serial_no(serial_no, posting_datetime):
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:
self.serial_no_incoming_rate[serial_no] += flt(sle.incoming_rate) self.serial_no_incoming_rate[serial_no] += flt(sle.incoming_rate)
incoming_values += self.serial_no_incoming_rate[serial_no] incoming_values += self.serial_no_incoming_rate[serial_no]
return incoming_values 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: class DeprecatedBatchNoValuation:
@deprecated( @deprecated(

View File

@@ -95,6 +95,7 @@
"column_break_37", "column_break_37",
"has_serial_no", "has_serial_no",
"serial_no_series", "serial_no_series",
"use_serial_no_wise_valuation",
"variants_section", "variants_section",
"variant_of", "variant_of",
"variant_based_on", "variant_based_on",
@@ -387,7 +388,8 @@
"fieldname": "valuation_method", "fieldname": "valuation_method",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Valuation Method", "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", "depends_on": "is_stock_item",
@@ -529,6 +531,15 @@
"label": "Serial Number Series", "label": "Serial Number Series",
"show_description_on_click": 1 "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": 1,
"collapsible_depends_on": "attributes", "collapsible_depends_on": "attributes",
@@ -1095,7 +1106,7 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2026-09-04 10:08:30.115003", "modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Item", "name": "Item",

View File

@@ -145,6 +145,7 @@ class Item(Document):
taxes: DF.Table[ItemTax] taxes: DF.Table[ItemTax]
total_projected_qty: DF.Float total_projected_qty: DF.Float
uoms: DF.Table[UOMConversionDetail] uoms: DF.Table[UOMConversionDetail]
use_serial_no_wise_valuation: DF.Check
valuation_method: DF.Literal["", "FIFO", "Moving Average", "LIFO"] valuation_method: DF.Literal["", "FIFO", "Moving Average", "LIFO"]
valuation_rate: DF.Currency valuation_rate: DF.Currency
variant_based_on: DF.Literal["Item Attribute", "Manufacturer"] variant_based_on: DF.Literal["Item Attribute", "Manufacturer"]
@@ -227,14 +228,19 @@ class Item(Document):
self.validate_auto_reorder_enabled_in_stock_settings() self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change() self.cant_change()
self.validate_serialized_change_with_bundle() 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() self.validate_item_tax_net_rate_range()
if not self.is_new(): if not self.is_new():
self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group") self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group")
def on_update(self): def on_update(self):
from erpnext.stock.utils import clear_valuation_method_cache
self.update_variants() self.update_variants()
self.update_item_price() self.update_item_price()
clear_valuation_method_cache()
def validate_description(self): def validate_description(self):
"""Clean HTML description if set""" """Clean HTML description if set"""
@@ -1130,6 +1136,43 @@ class Item(Document):
frappe.throw(msg, title=_("Linked with submitted documents")) 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): def validate_serialized_change_with_bundle(self):
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists """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.""" for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""

View File

@@ -410,8 +410,9 @@ class SerialandBatchBundle(Document):
def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None): 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): 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) 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 # 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: if valuation_details:
self.validate_returned_serial_batch_no(return_against, row, valuation_details) self.validate_returned_serial_batch_no(return_against, row, valuation_details)
if skip_rate_update:
continue
if row.serial_no: if row.serial_no:
valuation_rate = valuation_details["serial_nos"].get(row.serial_no) valuation_rate = valuation_details["serial_nos"].get(row.serial_no)
else: else:
@@ -696,7 +700,10 @@ class SerialandBatchBundle(Document):
) )
def set_incoming_rate_for_outward_transaction(self, row=None, save=False, allow_negative_stock=False): 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() sle = self.get_sle_for_outward_transaction()

View File

@@ -1632,6 +1632,216 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
) )
self.assertRaises(NegativeStockError, backdated.submit) 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): def get_batch_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_batch_nos from erpnext.stock.serial_batch_bundle import get_batch_nos

View File

@@ -43,6 +43,7 @@ from erpnext.stock.utils import (
get_serial_nos_data, get_serial_nos_data,
get_stock_balance, get_stock_balance,
get_valuation_method, get_valuation_method,
is_serial_no_wise_valuation_disabled,
) )
from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero 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.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company")
self.set_precision() self.set_precision()
self.valuation_method = get_valuation_method(self.item_code, self.company) 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.repost_affected_transaction = args.get("repost_affected_transaction") or set()
self.new_items_found = False self.new_items_found = False
@@ -929,9 +931,9 @@ class update_entries_after:
if sle.get(dimension.get("fieldname")): if sle.get(dimension.get("fieldname")):
has_dimensions = True 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) 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 # Only run in reposting
self.get_serialized_values(sle) self.get_serialized_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.qty_after_transaction += flt(sle.actual_qty)
@@ -943,6 +945,7 @@ class update_entries_after:
) )
elif ( elif (
sle.batch_no 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 frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True)
and not self.args.get("sle_id") 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 get_rate_for_return, # don't move this import to top
) )
if ( if self.valuation_method == "Moving Average" and (
self.valuation_method == "Moving Average" self.skip_serial_batch_valuation
and not sle.get("serial_no") or not (sle.get("serial_no") or sle.get("batch_no") or sle.get("serial_and_batch_bundle"))
and not sle.get("batch_no")
and not sle.get("serial_and_batch_bundle")
): ):
rate = self.get_moving_average_rate_for_return(sle) rate = self.get_moving_average_rate_for_return(sle)
@@ -1303,6 +1304,9 @@ class update_entries_after:
sle=sle, sle=sle,
) )
elif self.skip_serial_batch_valuation and flt(sle.actual_qty) < 0:
rate = 0.0
else: else:
rate = get_rate_for_return( rate = get_rate_for_return(
sle.voucher_type, 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 self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction
def is_return_purchase_entry(self, sle): def is_return_purchase_entry(self, sle):
if self.skip_serial_batch_valuation:
return False
if sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"]: if sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"]:
return frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_return") return frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_return")

View File

@@ -246,7 +246,7 @@ def _create_bin(item_code, warehouse):
@frappe.whitelist() @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""" """Get Incoming Rate based on valuation method"""
from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate 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 in_rate = None
item_details = frappe.get_cached_value( 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") 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): if isinstance(args, dict):
args = frappe._dict(args) 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 args.actual_qty = args.qty
sn_obj = SerialNoValuation( sn_obj = SerialNoValuation(
sle=args, 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 item_details.has_batch_no
and args.get("serial_and_batch_bundle") and args.get("serial_and_batch_bundle")
and not use_moving_avg_for_batch and not use_moving_avg_for_batch
and not skip_serial_batch_valuation
): ):
args.actual_qty = args.qty args.actual_qty = args.qty
batch_obj = BatchNoValuation( 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() 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.actual_qty = args.qty
args.serial_nos = get_serial_nos_data(args.get("serial_no")) 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")) sn_obj = SerialNoValuation(sle=args, warehouse=args.get("warehouse"), item_code=args.get("item_code"))
return sn_obj.get_incoming_rate() 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.actual_qty = args.qty
args.batch_nos = frappe._dict({args.batch_no: args}) 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 @frappe.request_cache
def get_valuation_method(item_code, company=None): def get_valuation_method(item_code, company=None):
"""get valuation method from item or default""" """get valuation method from item or default"""
@@ -368,6 +397,14 @@ def get_valuation_method(item_code, company=None):
return val_method 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): def get_fifo_rate(previous_stock_queue, qty):
"""get FIFO (average) Rate from Queue""" """get FIFO (average) Rate from Queue"""
return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO") return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO")