Compare commits

..

23 Commits

Author SHA1 Message Date
ruthra kumar
7b6dd42aa1 Merge pull request #56158 from frappe/mergify/bp/version-15-hotfix/pr-55265
fix: update reference doctype mapping and field visibility in bank guarantee (backport #55265)
2026-06-19 17:54:27 +05:30
nareshkannasln
e556cbbe6a fix: update reference doctype mapping and field visibility in bank guarantee
(cherry picked from commit b1de654dfd)

# Conflicts:
#	erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json
2026-06-19 16:57:18 +05:30
Mihir Kandoi
ad9c16073e Merge pull request #56035 from aerele/fix/dashboard-party-dynamic-links 2026-06-19 11:09:39 +05:30
Smit Vora
ce2560e2fe Merge pull request #56122 from frappe/mergify/bp/version-15-hotfix/pr-56104
fix: base_tax_amount as none when payment entry created using API (backport #56104)
2026-06-19 09:25:41 +05:30
vorasmit
37dffa7273 fix: tax.base_tax_amount as none when payment entry created using API
(cherry picked from commit b9b402f2ec)
2026-06-19 03:17:39 +00:00
Mihir Kandoi
3ef6475249 Merge pull request #56114 from frappe/mergify/bp/version-15-hotfix/pr-56055
fix: disable is_debit_note while creating credit note (backport #56055)
2026-06-18 22:53:32 +05:30
Mihir Kandoi
6deff470d8 Merge pull request #56065 from barredterra/item-update-attribute-value
fix(stock): propagate renamed attribute values to variant items
2026-06-18 22:35:17 +05:30
Mihir Kandoi
03cda066a5 Merge pull request #56116 from frappe/mergify/bp/version-15-hotfix/pr-56098
fix: apply docstatus filter to exclude cancelled Work Orders in Seria… (backport #56098)
2026-06-18 22:30:44 +05:30
pandiyan
90fd057fb3 fix: apply docstatus filter to exclude cancelled Work Orders in Serial No
(cherry picked from commit 3ba8f690a4)
2026-06-18 16:56:24 +00:00
pandiyan
e4370ab332 fix: disable is_debit_note while creating credit note
(cherry picked from commit 279c8dea06)
2026-06-18 16:54:01 +00:00
mergify[bot]
ce8fce78f1 fix: Add likely missing escapes (backport #55574) (#55580)
* fix: Add likely missing escaps (#55574)

(cherry picked from commit b72cde73ba)

# Conflicts:
#	erpnext/accounts/doctype/budget/budget.py
#	erpnext/controllers/website_list_for_contact.py

* chore: conflicts

---------

Co-authored-by: Ankush Menat <ankush@frappe.io>
2026-06-18 11:32:54 +00:00
rohitwaghchaure
f3334eb2d3 Merge pull request #56080 from rohitwaghchaure/allow-negative-stock-for-batch-v15
feat: allow negative stock at batch level (backport to v15)
2026-06-18 14:31:21 +05:30
Rohit Waghchaure
5c4f19ebdc feat: add batch-level option to allow negative stock for batch 2026-06-18 14:00:25 +05:30
rohitwaghchaure
98caefea88 refactor: posting datetime for SABB (backport #49710 + #50248) (#56072)
* refactor: posting datetime for SABB (backport #49710 + #50248)

Backport of #49710 "refactor: posting datetime for SABB" to
version-15-hotfix, including the regression fix #50248
"fix: missed refactoring code".

Replaces Serial and Batch Bundle's `posting_date`/`posting_time` with a
single `posting_datetime` field and updates all readers/writers; adds a
patch to populate `posting_datetime` from the linked Stock Ledger Entry
and drop the now-unused single-column indexes.

Adapted to version-15-hotfix where the surrounding stock code had already
moved to `posting_datetime` (SLE) and diverged from develop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: fix conflicts

* chore: fixed formatting

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 05:51:13 +00:00
barredterra
c7acd88742 fix(stock): update variant attributes on value rename 2026-06-17 17:55:27 +02:00
barredterra
60f5de7ab8 test(stock): add cleanup for item attribute value changes in tests 2026-06-17 17:34:42 +02:00
barredterra
27d574dad5 fix(stock): propagate renamed attribute values to variant items 2026-06-17 17:04:58 +02:00
Mihir Kandoi
5c4220ee77 Merge pull request #56052 from aerele/backport-56002
fix(stock): update transfer status for mixed transfer flows
2026-06-17 17:42:07 +05:30
pandiyan
c13567228e test(stock): validate completed status for mixed transfer methods 2026-06-17 17:22:53 +05:30
pandiyan
3f9a88a5e2 fix(stock): update transfer status for mixed transfer flows 2026-06-17 17:22:48 +05:30
Mihir Kandoi
8b2204ce69 Merge pull request #56044 from mihir-kandoi/codex/fix-stock-ageing-reco-ageing-v15
fix: preserve stock ageing on non-serial reconciliation
2026-06-17 16:19:17 +05:30
Mihir Kandoi
19913127a7 fix: preserve stock ageing on non-serial reconciliation 2026-06-17 15:58:47 +05:30
S Sakthivel Murugan
690adf1051 fix: add dynamic links for customer and supplier dashboards 2026-06-17 12:51:35 +05:30
43 changed files with 602 additions and 276 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.112.0"
__version__ = "15.108.3"
def get_default_company(user=None):

View File

@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.set_query("reference_doctype", function () {
return {
filters: {
name: ["in", ["Sales Order", "Purchase Order"]],
},
};
});
frm.set_query("bank_account", function () {
return {
filters: {

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "ACC-BG-.YYYY.-.#####",
"creation": "2016-12-17 10:43:35.731631",
"doctype": "DocType",
@@ -50,8 +51,7 @@
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Reference Document Type",
"options": "DocType",
"read_only": 1
"options": "DocType"
},
{
"fieldname": "reference_docname",
@@ -60,14 +60,14 @@
"options": "reference_doctype"
},
{
"depends_on": "eval: doc.bg_type == \"Receiving\"",
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
},
{
"depends_on": "eval: doc.bg_type == \"Providing\"",
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
"fieldname": "supplier",
"fieldtype": "Link",
"label": "Supplier",
@@ -217,11 +217,11 @@
],
"is_submittable": 1,
"links": [],
"modified": "2025-09-26 00:38:17.584694",
"modified": "2026-05-25 18:12:10.768835",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Guarantee",
"naming_rule": "Expression (old style)",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -425,11 +425,11 @@ def get_ordered_amount(args):
def get_other_condition(args, for_doc):
condition = "expense_account = '%s'" % (args.expense_account)
condition = f"expense_account = {frappe.db.escape(args.expense_account)}"
budget_against_field = args.get("budget_against_field")
if budget_against_field and args.get(budget_against_field):
condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'"
condition += f" and child.{budget_against_field} = {frappe.db.escape(args.get(budget_against_field))}"
if args.get("fiscal_year"):
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
@@ -437,8 +437,7 @@ def get_other_condition(args, for_doc):
"Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"]
)
condition += f""" and parent.{date_field}
between '{start_date}' and '{end_date}' """
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
return condition

View File

@@ -1184,7 +1184,11 @@ class JournalEntry(AccountsController):
self.validate_total_debit_and_credit()
def get_values(self):
cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else ""
cond = (
f" and outstanding_amount <= {flt(self.write_off_amount)}"
if flt(self.write_off_amount) > 0
else ""
)
if self.write_off_based_on == "Accounts Receivable":
return frappe.db.sql(

View File

@@ -1197,9 +1197,9 @@ class PaymentEntry(AccountsController):
continue
if tax.add_deduct_tax == "Add":
included_taxes += tax.base_tax_amount
included_taxes += flt(tax.base_tax_amount)
else:
included_taxes -= tax.base_tax_amount
included_taxes -= flt(tax.base_tax_amount)
return included_taxes

View File

@@ -1118,6 +1118,27 @@ class TestPaymentEntry(FrappeTestCase):
self.assertEqual(gl_entries, expected_gl_entries)
def test_payment_entry_with_inclusive_tax(self):
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
payment_entry = create_payment_entry(paid_amount=1180)
payment_entry.append(
"taxes",
{
"account_head": "_Test Account Service Tax - _TC",
"charge_type": "On Paid Amount",
"rate": 18,
"included_in_paid_amount": 1,
"add_deduct_tax": "Add",
"description": "Service Tax",
},
)
payment_entry.save()
payment_entry.submit()
# 1180 incl 18% => 1000 base + 180 tax
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
def test_payment_entry_against_onhold_purchase_invoice(self):
pi = make_purchase_invoice()

View File

@@ -31,7 +31,8 @@ class BulkTransactionLog(Document):
log_detail = qb.DocType("Bulk Transaction Log Detail")
has_records = frappe.db.sql(
f"select exists (select * from `tabBulk Transaction Log Detail` where date = '{self.name}');"
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
(self.name,),
)[0][0]
if not has_records:
raise frappe.DoesNotExistError

View File

@@ -5,6 +5,7 @@ def get_data():
return {
"fieldname": "supplier",
"non_standard_fieldnames": {"Payment Entry": "party", "Bank Account": "party"},
"dynamic_links": {"party": ["Supplier", "party_type"]},
"transactions": [
{"label": _("Procurement"), "items": ["Request for Quotation", "Supplier Quotation"]},
{"label": _("Orders"), "items": ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]},

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _
from frappe.query_builder import Case
from frappe.utils import cstr, flt
from erpnext.utilities.product import get_item_codes_by_attributes
@@ -129,6 +130,53 @@ def validate_is_incremental(numeric_attribute, attribute, value, item):
)
def get_attribute_value_renames(item_attribute):
"""Return old to new attribute value mappings for renamed Item Attribute Value rows."""
if item_attribute.numeric_values:
return {}
db_value = item_attribute.get_doc_before_save()
if not db_value:
return {}
old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values}
renames = {}
for row in item_attribute.item_attribute_values:
if row.name in old_values and old_values[row.name] != row.attribute_value:
renames[old_values[row.name]] = row.attribute_value
return renames
def update_variant_attribute_values(item_attribute):
"""Propagate renamed Item Attribute Values to Item Variant Attribute on variant items."""
value_map = get_attribute_value_renames(item_attribute)
if not value_map:
return
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
item_table = frappe.qb.DocType("Item")
attribute_value = item_variant_table.attribute_value
attribute_value_case = Case()
for old_value, new_value in value_map.items():
attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value)
(
frappe.qb.update(item_variant_table)
.join(item_table)
.on(item_table.name == item_variant_table.parent)
.set(attribute_value, attribute_value_case.else_(attribute_value))
.where(item_table.variant_of.isnotnull())
.where(item_table.variant_of != "")
.where(item_variant_table.attribute == item_attribute.name)
.where(attribute_value.isin(list(value_map)))
).run()
frappe.flags.attribute_values = None
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
allow_rename_attribute_value = frappe.db.get_single_value(
"Item Variant Settings", "allow_rename_attribute_value"

View File

@@ -11,7 +11,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime
import erpnext
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle
from erpnext.stock.utils import get_incoming_rate, get_valuation_method, getdate
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method, getdate
class StockOverReturnError(frappe.ValidationError):
@@ -380,6 +380,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
doc.pricing_rules = []
doc.return_against = source.name
doc.set_warehouse = ""
if doctype == "Sales Invoice":
doc.is_debit_note = 0
if doctype == "Sales Invoice" or doctype == "POS Invoice":
doc.is_pos = source.is_pos
@@ -1180,8 +1182,7 @@ def make_serial_batch_bundle_for_return(data, child_doc, parent_doc, warehouse_f
"batches": data.get("batches"),
"serial_nos_valuation": data.get("serial_nos_valuation"),
"batches_valuation": data.get("batches_valuation"),
"posting_date": parent_doc.posting_date,
"posting_time": parent_doc.posting_time,
"posting_datetime": get_combine_datetime(parent_doc.posting_date, parent_doc.posting_time),
"voucher_type": parent_doc.doctype,
"voucher_no": parent_doc.name,
"voucher_detail_no": child_doc.name,

View File

@@ -12,7 +12,7 @@ from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, i
from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.item.item import set_item_default
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
from erpnext.stock.utils import get_incoming_rate, get_valuation_method
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method
class SellingController(StockController):
@@ -1084,8 +1084,7 @@ def get_serial_and_batch_bundle(child, parent, delivery_note_child=None):
"voucher_type": parent.doctype,
"voucher_no": parent.name if parent.docstatus < 2 else None,
"voucher_detail_no": delivery_note_child.name if delivery_note_child else child.name,
"posting_date": parent.posting_date,
"posting_time": parent.posting_time,
"posting_datetime": get_combine_datetime(parent.posting_date, parent.posting_time),
"qty": child.qty,
"type_of_transaction": "Outward" if child.qty > 0 and parent.docstatus < 2 else "Inward",
"company": parent.company,

View File

@@ -394,9 +394,9 @@ class StatusUpdater(Document):
for args in self.status_updater:
# condition to include current record (if submit or no if cancel)
if self.docstatus == 1:
args["cond"] = " or parent='%s'" % self.name.replace('"', '"')
args["cond"] = " or parent=%s" % frappe.db.escape(self.name)
else:
args["cond"] = " and parent!='%s'" % self.name.replace('"', '"')
args["cond"] = " and parent!=%s" % frappe.db.escape(self.name)
self._update_children(args, update_modified)
@@ -426,9 +426,10 @@ class StatusUpdater(Document):
args["second_source_condition"] = frappe.db.sql(
""" select ifnull((select sum({second_source_field})
from `tab{second_source_dt}`
where `{second_join_field}`='{detail_id}'
where `{second_join_field}`=%(detail_id)s
and (`tab{second_source_dt}`.docstatus=1)
{second_source_extra_cond}), 0) """.format(**args)
{second_source_extra_cond}), 0) """.format(**args),
{"detail_id": args["detail_id"]},
)[0][0]
if args["detail_id"]:
@@ -439,9 +440,10 @@ class StatusUpdater(Document):
frappe.db.sql(
"""
(select ifnull(sum({source_field}), 0)
from `tab{source_dt}` where `{join_field}`='{detail_id}'
from `tab{source_dt}` where `{join_field}`=%(detail_id)s
and (docstatus=1 {cond}) {extra_cond})
""".format(**args)
""".format(**args),
{"detail_id": args["detail_id"]},
)[0][0]
or 0.0
)
@@ -452,7 +454,8 @@ class StatusUpdater(Document):
frappe.db.sql(
"""update `tab{target_dt}`
set {target_field} = {source_dt_value} {update_modified}
where name='{detail_id}'""".format(**args)
where name=%(detail_id)s""".format(**args),
{"detail_id": args["detail_id"]},
)
def _update_percent_field_in_targets(self, args, update_modified=True):

View File

@@ -26,6 +26,7 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
get_evaluated_inventory_dimension,
)
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
combine_datetime,
get_type_of_transaction,
)
from erpnext.stock.stock_ledger import get_items_to_be_repost
@@ -282,8 +283,7 @@ class StockController(AccountsController):
):
bundle_details = {
"item_code": row.get("rm_item_code") or row.item_code,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": combine_datetime(self.posting_date, self.posting_time),
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,

View File

@@ -13,6 +13,7 @@ from frappe.utils import cint, flt, get_link_to_form
from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
combine_datetime,
get_auto_batch_nos,
get_available_serial_nos,
get_voucher_wise_serial_batch_from_bundle,
@@ -570,8 +571,7 @@ class SubcontractingController(StockController):
"qty": qty,
"serial_nos": serial_nos,
"batches": batches,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": combine_datetime(self.posting_date, self.posting_time),
"voucher_type": "Subcontracting Receipt",
"do_not_submit": True,
"type_of_transaction": "Outward" if qty > 0 else "Inward",

View File

@@ -7,7 +7,7 @@ import json
import frappe
from frappe import _
from frappe.modules.utils import get_module_app
from frappe.utils import flt, has_common
from frappe.utils import cint, flt, has_common
from frappe.utils.user import is_website_user

View File

@@ -436,3 +436,4 @@ erpnext.patches.v16_0.depends_on_inv_dimensions
erpnext.patches.v16_0.clear_procedures_from_receivable_report
erpnext.patches.v16_0.migrate_address_contact_custom_fields
erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes

View File

@@ -0,0 +1,31 @@
import click
import frappe
def execute():
frappe.db.sql(
"""
UPDATE `tabSerial and Batch Bundle`
JOIN `tabStock Ledger Entry`
ON `tabSerial and Batch Bundle`.`name` = `tabStock Ledger Entry`.`serial_and_batch_bundle`
SET `tabSerial and Batch Bundle`.`posting_datetime` = `tabStock Ledger Entry`.`posting_datetime`
WHERE `tabStock Ledger Entry`.`is_cancelled` = 0
"""
)
drop_indexes()
def drop_indexes():
table = "tabSerial and Batch Bundle"
index_list = ["voucher_no_index", "item_code_index", "warehouse_index", "company_index"]
for index in index_list:
if not frappe.db.has_index(table, index):
continue
try:
frappe.db.sql_ddl(f"ALTER TABLE `{table}` DROP INDEX `{index}`")
click.echo(f"✓ dropped {index} index from {table}")
except Exception:
frappe.log_error("Failed to drop index")

View File

@@ -11,7 +11,10 @@ def get_data():
"Bank Account": "party",
"Subscription": "party",
},
"dynamic_links": {"party_name": ["Customer", "quotation_to"]},
"dynamic_links": {
"party_name": ["Customer", "quotation_to"],
"party": ["Customer", "party_type"],
},
"transactions": [
{"label": _("Pre Sales"), "items": ["Opportunity", "Quotation"]},
{"label": _("Orders"), "items": ["Sales Order", "Delivery Note", "Sales Invoice"]},

View File

@@ -14,6 +14,9 @@ def execute(filters=None):
days_since_last_order = filters.get("days_since_last_order")
doctype = filters.get("doctype")
if doctype not in ("Sales Order", "Sales Invoice"):
frappe.throw(_("Invalid value {0} for 'Doctype'").format(doctype))
if cint(days_since_last_order) <= 0:
frappe.throw(_("'Days Since Last Order' must be greater than or equal to zero"))

View File

@@ -427,14 +427,16 @@ class Analytics:
break
def get_groups(self):
if self.filters.tree_type == "Territory":
parent = "parent_territory"
if self.filters.tree_type == "Customer Group":
parent = "parent_customer_group"
if self.filters.tree_type == "Item Group":
parent = "parent_item_group"
if self.filters.tree_type == "Supplier Group":
parent = "parent_supplier_group"
parent_field_map = {
"Territory": "parent_territory",
"Customer Group": "parent_customer_group",
"Item Group": "parent_item_group",
"Supplier Group": "parent_supplier_group",
}
if self.filters.tree_type not in parent_field_map:
frappe.throw(_("Invalid Tree Type {0}").format(self.filters.tree_type))
parent = parent_field_map[self.filters.tree_type]
self.depth_map = frappe._dict()
@@ -453,6 +455,9 @@ class Analytics:
def get_teams(self):
self.depth_map = frappe._dict()
if not frappe.db.exists("DocType", self.filters.doc_type):
frappe.throw(_("Invalid Document Type {0}").format(self.filters.doc_type))
self.group_entries = frappe.db.sql(
f""" select * from (select "Order Types" as name, 0 as lft,
2 as rgt, '' as parent union select distinct order_type as name, 1 as lft, 1 as rgt, "Order Types" as parent

View File

@@ -120,7 +120,9 @@ class AuthorizationControl(TransactionBase):
if val == 1:
add_cond += " and system_user = {}".format(frappe.db.escape(session["user"]))
elif val == 2:
add_cond += " and system_role IN %s" % ("('" + "','".join(frappe.get_roles()) + "')")
add_cond += " and system_role IN (%s)" % ", ".join(
frappe.db.escape(r) for r in frappe.get_roles()
)
else:
add_cond += " and ifnull(system_user,'') = '' and ifnull(system_role,'') = ''"
@@ -203,8 +205,8 @@ class AuthorizationControl(TransactionBase):
and docstatus != 2
""".format(
"%s",
"'" + "','".join(frappe.get_roles()) + "'",
"'" + "','".join(final_based_on) + "'",
", ".join(frappe.db.escape(r) for r in frappe.get_roles()),
", ".join(frappe.db.escape(b) for b in final_based_on),
"%s",
),
(doctype_name, company),

View File

@@ -36,6 +36,11 @@ class DeprecatedSerialNoValuation:
# get rate from serial nos within same company
incoming_values = 0.0
posting_datetime = self.sle.posting_datetime
if not posting_datetime and self.sle.posting_date:
posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
for serial_no in serial_nos:
sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1)
if (
@@ -64,10 +69,7 @@ class DeprecatedSerialNoValuation:
& (table.serial_and_batch_bundle.isnull())
& (table.actual_qty > 0)
& (table.is_cancelled == 0)
& (
table.posting_datetime
<= get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
)
& (table.posting_datetime <= posting_datetime)
)
.orderby(table.posting_datetime, order=Order.desc)
.limit(1)
@@ -98,11 +100,8 @@ class DeprecatedBatchNoValuation:
sle = frappe.qb.DocType("Stock Ledger Entry")
timestamp_condition = None
if self.sle.posting_date:
if self.sle.posting_time is None:
self.sle.posting_time = nowtime()
posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
if self.sle.posting_datetime:
posting_datetime = self.sle.posting_datetime
if not self.sle.creation:
posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1)
@@ -202,7 +201,11 @@ class DeprecatedBatchNoValuation:
sle = frappe.qb.DocType("Stock Ledger Entry")
batch = frappe.qb.DocType("Batch")
posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
posting_datetime = self.sle.posting_datetime
if not posting_datetime and self.sle.posting_date:
posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
if not self.sle.creation:
posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1)
@@ -266,7 +269,10 @@ class DeprecatedBatchNoValuation:
sle = frappe.qb.DocType("Stock Ledger Entry")
posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
posting_datetime = self.sle.posting_datetime
if not posting_datetime and self.sle.posting_date:
posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
if not self.sle.creation:
posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1)
@@ -308,19 +314,22 @@ class DeprecatedBatchNoValuation:
@deprecated
def set_balance_value_from_bundle(self) -> None:
from erpnext.stock.utils import get_combine_datetime
bundle = frappe.qb.DocType("Serial and Batch Bundle")
bundle_child = frappe.qb.DocType("Serial and Batch Entry")
batch = frappe.qb.DocType("Batch")
timestamp_condition = CombineDatetime(bundle.posting_date, bundle.posting_time) < CombineDatetime(
self.sle.posting_date, self.sle.posting_time
)
posting_datetime = self.sle.posting_datetime
if not posting_datetime and self.sle.posting_date:
posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
timestamp_condition = bundle.posting_datetime < posting_datetime
if self.sle.creation:
timestamp_condition |= (
CombineDatetime(bundle.posting_date, bundle.posting_time)
== CombineDatetime(self.sle.posting_date, self.sle.posting_time)
) & (bundle.creation < self.sle.creation)
timestamp_condition |= (bundle.posting_datetime == posting_datetime) & (
bundle.creation < self.sle.creation
)
query = (
frappe.qb.from_(bundle)

View File

@@ -11,6 +11,7 @@
"disabled",
"column_break_24",
"use_batchwise_valuation",
"allow_negative_stock_for_batch",
"sb_batch",
"batch_id",
"item",
@@ -202,6 +203,14 @@
"label": "Use Batch-wise Valuation",
"read_only": 1,
"set_only_once": 1
},
{
"default": "0",
"description": "If enabled, the system will allow negative stock entries for this batch, overriding the 'Allow negative stock for Batch' setting in Stock Settings. This may lead to incorrect valuation rates, so it is recommended to avoid using this option.",
"fieldname": "allow_negative_stock_for_batch",
"fieldtype": "Check",
"label": "Allow Negative Stock for Batch",
"no_copy": 1
}
],
"icon": "fa fa-archive",
@@ -209,7 +218,7 @@
"image_field": "image",
"links": [],
"max_attachments": 5,
"modified": "2026-06-16 16:01:26.556324",
"modified": "2026-06-17 12:17:28.339975",
"modified_by": "Administrator",
"module": "Stock",
"name": "Batch",

View File

@@ -2,6 +2,7 @@
# License: GNU General Public License v3. See license.txt
import datetime
from collections import OrderedDict, defaultdict
import frappe
@@ -10,7 +11,7 @@ from frappe.model.document import Document
from frappe.model.naming import make_autoname, revert_series_if_last
from frappe.query_builder.functions import CurDate, Sum
from frappe.utils import cint, flt, get_link_to_form
from frappe.utils.data import add_days
from frappe.utils.data import DateTimeLikeObject, add_days
class UnableToSelectBatchError(frappe.ValidationError):
@@ -94,6 +95,7 @@ class Batch(Document):
if TYPE_CHECKING:
from frappe.types import DF
allow_negative_stock_for_batch: DF.Check
batch_id: DF.Data
batch_qty: DF.Float
description: DF.SmallText | None
@@ -232,17 +234,18 @@ class Batch(Document):
@frappe.whitelist()
def get_batch_qty(
batch_no=None,
warehouse=None,
item_code=None,
creation=None,
posting_date=None,
posting_time=None,
ignore_voucher_nos=None,
for_stock_levels=False,
consider_negative_batches=False,
do_not_check_future_batches=False,
ignore_reserved_stock=False,
batch_no: str | None = None,
warehouse: str | None = None,
item_code: str | None = None,
creation: DateTimeLikeObject | None = None,
posting_datetime: DateTimeLikeObject | None = None,
posting_date: DateTimeLikeObject | None = None,
posting_time: datetime.timedelta | None = None,
ignore_voucher_nos: list | None = None,
for_stock_levels: bool = False,
consider_negative_batches: bool = False,
do_not_check_future_batches: bool = False,
ignore_reserved_stock: bool = False,
):
"""Returns batch actual qty if warehouse is passed,
or returns dict of qty by warehouse if warehouse is None
@@ -255,6 +258,7 @@ def get_batch_qty(
:param for_stock_levels: True consider expired batches"""
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
combine_datetime,
get_auto_batch_nos,
)
@@ -264,8 +268,6 @@ def get_batch_qty(
"item_code": item_code,
"warehouse": warehouse,
"creation": creation,
"posting_date": posting_date,
"posting_time": posting_time,
"batch_no": batch_no,
"based_on": frappe.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"),
"ignore_voucher_nos": ignore_voucher_nos,
@@ -276,6 +278,10 @@ def get_batch_qty(
}
)
kwargs["posting_datetime"] = posting_datetime
if not kwargs.get("posting_datetime") and posting_date:
kwargs["posting_datetime"] = combine_datetime(posting_date, posting_time)
batches = get_auto_batch_nos(kwargs)
if not (batch_no and warehouse):
@@ -357,6 +363,7 @@ def make_batch_bundle(
):
from frappe.utils import nowtime, today
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import combine_datetime
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
return (
@@ -364,8 +371,7 @@ def make_batch_bundle(
{
"item_code": item_code,
"warehouse": warehouse,
"posting_date": today(),
"posting_time": nowtime(),
"posting_datetime": combine_datetime(today(), nowtime()),
"voucher_type": "Stock Entry",
"qty": qty,
"type_of_transaction": type_of_transaction,
@@ -476,9 +482,13 @@ def get_pos_reserved_batch_qty(filters):
def get_available_batches(kwargs):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
combine_datetime,
get_auto_batch_nos,
)
if kwargs.get("posting_date"):
kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
batchwise_qty = OrderedDict()
batches = get_auto_batch_nos(kwargs)

View File

@@ -360,6 +360,89 @@ class TestItem(FrappeTestCase):
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
frappe.db.rollback()
def test_rename_attribute_value_updates_variants(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
variant.save()
attribute = frappe.get_doc("Item Attribute", "Test Size")
for row in attribute.item_attribute_values:
if row.attribute_value == "Large":
row.attribute_value = "Larger"
break
def restore_test_size_large():
doc = frappe.get_doc("Item Attribute", "Test Size")
for row in doc.item_attribute_values:
if row.attribute_value == "Larger":
row.attribute_value = "Large"
break
frappe.flags.attribute_values = None
doc.save()
self.addCleanup(restore_test_size_large)
frappe.flags.attribute_values = None
attribute.save()
self.assertEqual(
frappe.db.get_value(
"Item Variant Attribute",
{"parent": variant.name, "attribute": "Test Size"},
"attribute_value",
),
"Larger",
)
def test_swapped_attribute_value_renames_update_variants(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
frappe.delete_doc_if_exists("Item", "_Test Variant Item-S", force=1)
large_variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
large_variant.save()
small_variant = create_variant("_Test Variant Item", {"Test Size": "Small"})
small_variant.save()
attribute = frappe.get_doc("Item Attribute", "Test Size")
original_values = {row.name: row.attribute_value for row in attribute.item_attribute_values}
def restore_test_size_values():
doc = frappe.get_doc("Item Attribute", "Test Size")
for row in doc.item_attribute_values:
row.attribute_value = original_values[row.name]
frappe.flags.attribute_values = None
doc.save()
self.addCleanup(restore_test_size_values)
for row in attribute.item_attribute_values:
if row.attribute_value == "Large":
row.attribute_value = "Small"
elif row.attribute_value == "Small":
row.attribute_value = "Large"
frappe.flags.attribute_values = None
attribute.save()
self.assertEqual(
frappe.db.get_value(
"Item Variant Attribute",
{"parent": large_variant.name, "attribute": "Test Size"},
"attribute_value",
),
"Small",
)
self.assertEqual(
frappe.db.get_value(
"Item Variant Attribute",
{"parent": small_variant.name, "attribute": "Test Size"},
"attribute_value",
),
"Large",
)
def test_make_item_variant(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)

View File

@@ -9,6 +9,7 @@ from frappe.utils import flt
from erpnext.controllers.item_variant import (
InvalidItemAttributeValueError,
update_variant_attribute_values,
validate_is_incremental,
validate_item_attribute_value,
)
@@ -47,6 +48,7 @@ class ItemAttribute(Document):
self.validate_duplication()
def on_update(self):
update_variant_attribute_values(self)
self.validate_exising_items()
self.set_enabled_disabled_in_items()

View File

@@ -209,7 +209,7 @@ class MaterialRequest(BuyingController):
def check_modified_date(self):
mod_db = frappe.db.sql("""select modified from `tabMaterial Request` where name = %s""", self.name)
date_diff = frappe.db.sql(f"""select TIMEDIFF('{mod_db[0][0]}', '{cstr(self.modified)}')""")
date_diff = frappe.db.sql("""select TIMEDIFF(%s, %s)""", (mod_db[0][0], cstr(self.modified)))
if date_diff and date_diff[0][0]:
frappe.throw(_("{0} {1} has been modified. Please refresh.").format(_(self.doctype), self.name))

View File

@@ -995,6 +995,52 @@ class TestMaterialRequest(FrappeTestCase):
se.save()
se.submit()
def test_mr_status_for_mixed_direct_and_transit_transfer(self):
material_request = make_material_request(
material_request_type="Material Transfer",
item_code="_Test Item Home Desktop 100",
qty=5,
)
in_transit_wh = get_in_transit_warehouse(material_request.company)
# Make stock available
self._insert_stock_entry(20.0, 20.0)
# Direct Transfer for 3 Qty
direct_transfer = make_stock_entry(material_request.name)
direct_transfer.items[0].update(
{
"qty": 3,
"transfer_qty": 3,
"s_warehouse": "_Test Warehouse 1 - _TC",
}
)
direct_transfer.save()
direct_transfer.submit()
# In Transit Transfer for remaining 2 Qty
transit_transfer = make_in_transit_stock_entry(material_request.name, in_transit_wh)
transit_transfer.items[0].update(
{
"qty": 2,
"s_warehouse": "_Test Warehouse 1 - _TC",
}
)
transit_transfer.save()
transit_transfer.submit()
# Complete End Transit
end_transit = make_stock_in_entry(transit_transfer.name)
end_transit.save()
end_transit.submit()
material_request.reload()
self.assertEqual(material_request.per_ordered, 100)
self.assertEqual(material_request.status, "Transferred")
self.assertEqual(material_request.transfer_status, "Completed")
def get_in_transit_warehouse(company):
if not frappe.db.exists("Warehouse Type", "Transit"):

View File

@@ -29,8 +29,7 @@
"voucher_no",
"voucher_detail_no",
"column_break_aouy",
"posting_date",
"posting_time",
"posting_datetime",
"returned_against",
"section_break_wzou",
"is_cancelled",
@@ -49,8 +48,7 @@
"in_list_view": 1,
"label": "Company",
"options": "Company",
"reqd": 1,
"search_index": 1
"reqd": 1
},
{
"fetch_from": "item_code.item_group",
@@ -79,8 +77,7 @@
"in_standard_filter": 1,
"label": "Item Code",
"options": "Item",
"reqd": 1,
"search_index": 1
"reqd": 1
},
{
"fetch_from": "item_code.item_name",
@@ -117,8 +114,7 @@
"in_standard_filter": 1,
"label": "Voucher No",
"no_copy": 1,
"options": "voucher_type",
"search_index": 1
"options": "voucher_type"
},
{
"default": "0",
@@ -188,8 +184,7 @@
"in_standard_filter": 1,
"label": "Warehouse",
"mandatory_depends_on": "eval:doc.type_of_transaction != \"Maintenance\"",
"options": "Warehouse",
"search_index": 1
"options": "Warehouse"
},
{
"fieldname": "type_of_transaction",
@@ -211,18 +206,6 @@
"fieldname": "section_break_wzou",
"fieldtype": "Section Break"
},
{
"fieldname": "posting_date",
"fieldtype": "Date",
"label": "Posting Date",
"no_copy": 1
},
{
"fieldname": "posting_time",
"fieldtype": "Time",
"label": "Posting Time",
"no_copy": 1
},
{
"fieldname": "voucher_detail_no",
"fieldtype": "Data",
@@ -251,12 +234,17 @@
"label": "Naming Series",
"options": "\nSABB-.########",
"set_only_once": 1
},
{
"fieldname": "posting_datetime",
"fieldtype": "Datetime",
"label": "Posting Datetime"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-09-15 14:37:26.441742",
"modified": "2025-09-24 16:24:48.154853",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial and Batch Bundle",

View File

@@ -74,8 +74,7 @@ class SerialandBatchBundle(Document):
item_group: DF.Link | None
item_name: DF.Data | None
naming_series: DF.Literal["", "SABB-.########"]
posting_date: DF.Date | None
posting_time: DF.Time | None
posting_datetime: DF.Datetime | None
returned_against: DF.Data | None
total_amount: DF.Float
total_qty: DF.Float
@@ -280,8 +279,7 @@ class SerialandBatchBundle(Document):
kwargs.update(
{
"voucher_no": self.voucher_no,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": self.posting_datetime,
}
)
@@ -332,8 +330,7 @@ class SerialandBatchBundle(Document):
kwargs = frappe._dict(
{
"item_code": self.item_code,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": self.posting_datetime,
"serial_nos": serial_nos,
"check_serial_nos": True,
}
@@ -669,8 +666,7 @@ class SerialandBatchBundle(Document):
def get_sle_for_outward_transaction(self):
sle = frappe._dict(
{
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": self.posting_datetime,
"item_code": self.item_code,
"warehouse": self.warehouse,
"serial_and_batch_bundle": self.name,
@@ -829,11 +825,10 @@ class SerialandBatchBundle(Document):
if not self.voucher_detail_no or self.voucher_detail_no != row.name:
values_to_set["voucher_detail_no"] = row.name
if parent.get("posting_date") and (not self.posting_date or self.posting_date != parent.posting_date):
values_to_set["posting_date"] = parent.posting_date or today()
if parent.get("posting_time") and (not self.posting_time or self.posting_time != parent.posting_time):
values_to_set["posting_time"] = parent.posting_time
if parent.get("posting_date") and parent.get("posting_time"):
posting_datetime = combine_datetime(parent.posting_date, parent.posting_time)
if not self.posting_datetime or self.posting_datetime != posting_datetime:
values_to_set["posting_datetime"] = posting_datetime
if row.get("doctype") == "Packed Item" and row.get("parent_detail_docname"):
values_to_set["voucher_detail_no"] = row.get("parent_detail_docname")
@@ -911,9 +906,7 @@ class SerialandBatchBundle(Document):
parent = frappe.qb.DocType("Serial and Batch Bundle")
child = frappe.qb.DocType("Serial and Batch Entry")
timestamp_condition = CombineDatetime(parent.posting_date, parent.posting_time) > CombineDatetime(
self.posting_date, self.posting_time
)
timestamp_condition = parent.posting_datetime > self.posting_datetime
future_entries = (
frappe.qb.from_(parent)
@@ -1515,7 +1508,7 @@ class SerialandBatchBundle(Document):
def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None):
from erpnext.stock.stock_ledger import NegativeStockError
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
if allow_negative_stock_for_batch(batch_no):
return
date_msg = ""
@@ -1526,7 +1519,7 @@ class SerialandBatchBundle(Document):
"""
The Batch {0} of an item {1} has negative stock in the warehouse {2}{3}.
Please add a stock quantity of {4} to proceed with this entry.
If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in Stock Settings to proceed.
If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in the batch {0} or Stock Settings to proceed.
However, enabling this setting may lead to negative stock in the system.
So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."""
).format(
@@ -2083,6 +2076,8 @@ def create_serial_batch_no_ledgers(
if parent_doc.get("doctype") == "Stock Entry":
warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse
posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time"))
doc = frappe.get_doc(
{
"doctype": "Serial and Batch Bundle",
@@ -2091,8 +2086,7 @@ def create_serial_batch_no_ledgers(
"warehouse": warehouse,
"is_rejected": child_row.is_rejected,
"type_of_transaction": type_of_transaction,
"posting_date": parent_doc.get("posting_date"),
"posting_time": parent_doc.get("posting_time"),
"posting_datetime": posting_datetime,
"company": parent_doc.get("company"),
}
)
@@ -2128,6 +2122,25 @@ def create_serial_batch_no_ledgers(
return doc
def combine_datetime(date, time=None):
from erpnext.stock.utils import get_combine_datetime
return get_combine_datetime(date, time)
def allow_negative_stock_for_batch(batch_no):
"""Return whether negative stock is allowed for the given batch.
The batch-level setting takes priority: if `allow_negative_stock_for_batch`
is enabled on the Batch, negative stock is allowed regardless of Stock Settings.
Otherwise, fall back to the `allow_negative_stock_for_batch` Stock Setting.
"""
if batch_no and frappe.db.get_value("Batch", batch_no, "allow_negative_stock_for_batch"):
return True
return bool(frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"))
def get_batch(item_code):
from erpnext.stock.doctype.batch.batch import make_batch
@@ -2180,8 +2193,8 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh
)
doc.voucher_detail_no = child_row.name
doc.posting_date = parent_doc.posting_date
doc.posting_time = parent_doc.posting_time
doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time"))
doc.warehouse = warehouse or doc.warehouse
doc.set("entries", [])
@@ -2269,6 +2282,9 @@ def get_available_serial_nos(kwargs):
elif kwargs.based_on == "Expiry":
order_by = "amc_expiry_date"
if not kwargs.get("posting_datetime") and kwargs.get("posting_date"):
kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
filters = {"item_code": kwargs.item_code}
# ignore_warehouse is used for backdated stock transactions
@@ -2285,10 +2301,8 @@ def get_available_serial_nos(kwargs):
if kwargs.get("ignore_serial_nos"):
ignore_serial_nos.extend(kwargs.get("ignore_serial_nos"))
if kwargs.get("posting_date"):
if kwargs.get("posting_time") is None:
kwargs.posting_time = nowtime()
ignore_serial_nos = list(set(ignore_serial_nos))
if kwargs.get("posting_datetime"):
time_based_serial_nos = get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos)
if not time_based_serial_nos:
@@ -2692,6 +2706,9 @@ def get_reserved_batches_for_sre(kwargs) -> dict:
def get_auto_batch_nos(kwargs):
if not kwargs.get("posting_datetime") and kwargs.get("posting_date"):
kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
available_batches = get_available_batches(kwargs)
qty = flt(kwargs.qty)
@@ -2721,7 +2738,7 @@ def get_auto_batch_nos(kwargs):
if kwargs.based_on == "Expiry":
available_batches = sorted(available_batches, key=lambda x: x.expiry_date or getdate("9999-12-31"))
if not kwargs.get("do_not_check_future_batches") and available_batches and kwargs.get("posting_date"):
if not kwargs.get("do_not_check_future_batches") and available_batches and kwargs.get("posting_datetime"):
filter_zero_near_batches(available_batches, kwargs)
if not kwargs.consider_negative_batches:
@@ -2737,8 +2754,7 @@ def get_auto_batch_nos(kwargs):
def filter_zero_near_batches(available_batches, kwargs):
kwargs.batch_no = [d.batch_no for d in available_batches]
del kwargs["posting_date"]
del kwargs["posting_time"]
del kwargs["posting_datetime"]
kwargs.do_not_check_future_batches = 1
available_batches_in_future = get_auto_batch_nos(kwargs)
@@ -2804,8 +2820,6 @@ def update_available_batches(available_batches, *reserved_batches) -> None:
def get_available_batches(kwargs):
from erpnext.stock.utils import get_combine_datetime
stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry")
batch_ledger = frappe.qb.DocType("Serial and Batch Entry")
batch_table = frappe.qb.DocType("Batch")
@@ -2833,23 +2847,15 @@ def get_available_batches(kwargs):
if not kwargs.get("for_stock_levels"):
query = query.where((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull()))
if kwargs.get("posting_date"):
if kwargs.get("posting_time") is None:
kwargs.posting_time = nowtime()
timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime(
kwargs.posting_date, kwargs.posting_time
)
if kwargs.get("posting_datetime"):
timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime
if kwargs.get("creation"):
timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime(
kwargs.posting_date, kwargs.posting_time
)
timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime
timestamp_condition |= (
stock_ledger_entry.posting_datetime
== get_combine_datetime(kwargs.posting_date, kwargs.posting_time)
) & (stock_ledger_entry.creation < kwargs.creation)
timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & (
stock_ledger_entry.creation < kwargs.creation
)
query = query.where(timestamp_condition)
@@ -3035,15 +3041,14 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]:
serial_batch_table.incoming_rate,
bundle_table.voucher_detail_no,
bundle_table.voucher_no,
bundle_table.posting_date,
bundle_table.posting_time,
bundle_table.posting_datetime,
)
.where(
(bundle_table.docstatus == 1)
& (bundle_table.is_cancelled == 0)
& (bundle_table.type_of_transaction.isin(["Inward", "Outward"]))
)
.orderby(bundle_table.posting_date, bundle_table.posting_time)
.orderby(bundle_table.posting_datetime)
)
for key, val in kwargs.items():
@@ -3061,7 +3066,7 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]:
query = query.where(bundle_table[key].isin(val))
else:
query = query.where(bundle_table[key] == val)
elif key in ["posting_date", "posting_time"]:
elif key in ["posting_datetime"]:
query = query.where(bundle_table[key] >= val)
else:
if isinstance(val, list):
@@ -3073,8 +3078,6 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]:
def get_stock_ledgers_for_serial_nos(kwargs):
from erpnext.stock.utils import get_combine_datetime
stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry")
query = (
@@ -3090,23 +3093,15 @@ def get_stock_ledgers_for_serial_nos(kwargs):
.orderby(stock_ledger_entry.creation)
)
if kwargs.get("posting_date"):
if kwargs.get("posting_time") is None:
kwargs.posting_time = nowtime()
timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime(
kwargs.posting_date, kwargs.posting_time
)
if kwargs.get("posting_datetime"):
timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime
if kwargs.get("creation"):
timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime(
kwargs.posting_date, kwargs.posting_time
)
timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime
timestamp_condition |= (
stock_ledger_entry.posting_datetime
== get_combine_datetime(kwargs.posting_date, kwargs.posting_time)
) & (stock_ledger_entry.creation < kwargs.creation)
timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & (
stock_ledger_entry.creation < kwargs.creation
)
query = query.where(timestamp_condition)
@@ -3129,8 +3124,6 @@ def get_stock_ledgers_for_serial_nos(kwargs):
def get_stock_ledgers_batches(kwargs):
from erpnext.stock.utils import get_combine_datetime
stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry")
batch_table = frappe.qb.DocType("Batch")
@@ -3164,23 +3157,15 @@ def get_stock_ledgers_batches(kwargs):
if not kwargs.get("for_stock_levels"):
query = query.where((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull()))
if kwargs.get("posting_date"):
if kwargs.get("posting_time") is None:
kwargs.posting_time = nowtime()
timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime(
kwargs.posting_date, kwargs.posting_time
)
if kwargs.get("posting_datetime"):
timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime
if kwargs.get("creation"):
timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime(
kwargs.posting_date, kwargs.posting_time
)
timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime
timestamp_condition |= (
stock_ledger_entry.posting_datetime
== get_combine_datetime(kwargs.posting_date, kwargs.posting_time)
) & (stock_ledger_entry.creation < kwargs.creation)
timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & (
stock_ledger_entry.creation < kwargs.creation
)
query = query.where(timestamp_condition)
@@ -3268,3 +3253,7 @@ def get_stock_reco_details(voucher_detail_no):
],
as_dict=True,
)
def on_doctype_update():
frappe.db.add_index("Serial and Batch Bundle", ["item_code", "warehouse", "posting_datetime", "creation"])

View File

@@ -10,6 +10,7 @@ from frappe.utils import flt, nowtime, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
add_serial_batch_ledgers,
combine_datetime,
make_batch_nos,
make_serial_nos,
)
@@ -1271,14 +1272,17 @@ def make_serial_batch_bundle(kwargs):
if kwargs.get("type_of_transaction"):
type_of_transaction = kwargs.get("type_of_transaction")
posting_datetime = None
if kwargs.get("posting_date"):
posting_datetime = combine_datetime(kwargs.posting_date, kwargs.posting_time or nowtime())
sb = SerialBatchCreation(
{
"item_code": kwargs.item_code,
"warehouse": kwargs.warehouse,
"voucher_type": kwargs.voucher_type,
"voucher_no": kwargs.voucher_no,
"posting_date": kwargs.posting_date,
"posting_time": kwargs.posting_time,
"posting_datetime": posting_datetime,
"qty": kwargs.qty,
"avg_rate": kwargs.rate,
"batches": kwargs.batches,

View File

@@ -1,26 +1,30 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
cur_frm.add_fetch("customer", "customer_name", "customer_name");
cur_frm.add_fetch("supplier", "supplier_name", "supplier_name");
cur_frm.add_fetch("item_code", "item_name", "item_name");
cur_frm.add_fetch("item_code", "description", "description");
cur_frm.add_fetch("item_code", "item_group", "item_group");
cur_frm.add_fetch("item_code", "brand", "brand");
cur_frm.cscript.onload = function () {
cur_frm.set_query("item_code", function () {
return erpnext.queries.item({ is_stock_item: 1, has_serial_no: 1 });
});
};
frappe.ui.form.on("Serial No", "refresh", function (frm) {
frm.toggle_enable("item_code", frm.doc.__islocal);
});
frappe.ui.form.on("Serial No", {
setup(frm) {
frm.add_fetch("customer", "customer_name", "customer_name");
frm.add_fetch("supplier", "supplier_name", "supplier_name");
frm.add_fetch("item_code", "item_name", "item_name");
frm.add_fetch("item_code", "description", "description");
frm.add_fetch("item_code", "item_group", "item_group");
frm.add_fetch("item_code", "brand", "brand");
frm.set_query("item_code", function () {
return erpnext.queries.item({ is_stock_item: 1, has_serial_no: 1 });
});
frm.set_query("work_order", () => {
return {
filters: {
docstatus: 1,
},
};
});
},
refresh(frm) {
frm.toggle_enable("item_code", frm.doc.__islocal);
frm.trigger("view_ledgers");
},

View File

@@ -52,7 +52,7 @@ from erpnext.stock.serial_batch_bundle import (
get_serial_or_batch_items,
)
from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate
from erpnext.stock.utils import get_bin, get_incoming_rate
from erpnext.stock.utils import get_bin, get_combine_datetime, get_incoming_rate
class FinishedGoodError(frappe.ValidationError):
@@ -1527,8 +1527,7 @@ class StockEntry(StockController):
{
"item_code": row.item_code,
"warehouse": row.s_warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": get_combine_datetime(self.posting_date, self.posting_time),
"voucher_type": self.doctype,
"voucher_detail_no": row.name,
"qty": row.transfer_qty * -1,
@@ -4058,8 +4057,7 @@ def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=N
"item_code": child.item_code,
"warehouse": child.warehouse,
"type_of_transaction": type_of_transaction,
"posting_date": parent_doc.posting_date,
"posting_time": parent_doc.posting_time,
"posting_datetime": get_combine_datetime(parent_doc.posting_date, parent_doc.posting_time),
}
)
@@ -4116,13 +4114,19 @@ def get_batchwise_serial_nos(item_code, row):
def get_transferred_qty(material_request):
sed = DocType("Stock Entry Detail")
from pypika import Case
se = frappe.qb.DocType("Stock Entry")
sed = frappe.qb.DocType("Stock Entry Detail")
completed_qty = Case().when(se.add_to_transit == 1, sed.transferred_qty).else_(sed.transfer_qty)
query = (
frappe.qb.from_(sed)
.inner_join(se)
.on(se.name == sed.parent)
.select(
Sum(sed.transfer_qty).as_("transfer_qty"),
Sum(sed.transferred_qty).as_("transferred_qty"),
Sum(completed_qty).as_("transferred_qty"),
)
.where((sed.material_request == material_request) & (sed.docstatus == 1))
).run(as_dict=True)

View File

@@ -8,6 +8,7 @@ import frappe
from frappe.utils import cint, flt
import erpnext
from erpnext.stock.utils import get_combine_datetime
if TYPE_CHECKING:
from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry
@@ -140,6 +141,10 @@ def make_stock_entry(**args):
elif args.batches:
batches = args.batches
posting_datetime = None
if args.posting_date and args.posting_time:
posting_datetime = get_combine_datetime(args.posting_date, args.posting_time)
bundle_id = (
SerialBatchCreation(
{
@@ -151,8 +156,7 @@ def make_stock_entry(**args):
"serial_nos": args.serial_no,
"type_of_transaction": "Outward" if args.source else "Inward",
"company": s.company,
"posting_date": s.posting_date,
"posting_time": s.posting_time,
"posting_datetime": posting_datetime,
"rate": args.rate or args.basic_rate,
"do_not_submit": True,
}

View File

@@ -13,6 +13,7 @@ from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
combine_datetime,
get_available_serial_nos,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -136,8 +137,7 @@ class StockReconciliation(StockController):
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": combine_datetime(self.posting_date, self.posting_time),
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
@@ -243,8 +243,7 @@ class StockReconciliation(StockController):
"doctype": "Serial and Batch Bundle",
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": combine_datetime(self.posting_date, self.posting_time),
"voucher_type": self.doctype,
"type_of_transaction": "Outward",
}
@@ -262,8 +261,7 @@ class StockReconciliation(StockController):
{
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"posting_datetime": combine_datetime(self.posting_date, self.posting_time),
"ignore_warehouse": 1,
}
)

View File

@@ -19,7 +19,7 @@ def get_data(filters):
"Serial and Batch Bundle",
fields=[
"`tabSerial and Batch Bundle`.`voucher_type`",
"`tabSerial and Batch Bundle`.`posting_date`",
"`tabSerial and Batch Bundle`.`posting_datetime` as posting_date",
"`tabSerial and Batch Bundle`.`name`",
"`tabSerial and Batch Bundle`.`company`",
"`tabSerial and Batch Bundle`.`voucher_no`",
@@ -33,7 +33,7 @@ def get_data(filters):
"`tabSerial and Batch Entry`.`qty`",
],
filters=filter_conditions,
order_by="posting_date",
order_by="posting_datetime",
)
@@ -54,7 +54,7 @@ def get_filter_conditions(filters):
filter_conditions.append(
[
"Serial and Batch Bundle",
"posting_date",
"posting_datetime",
"between",
[filters.get("from_date"), filters.get("to_date")],
]

View File

@@ -358,7 +358,7 @@ class FIFOSlots:
if row.voucher_type != "Stock Reconciliation":
return
if not row.batch_no or row.serial_no or row.serial_and_batch_bundle:
if row.has_serial_no and (not row.batch_no or row.serial_no or row.serial_and_batch_bundle):
if row.voucher_detail_no in self.stock_reco_voucher_wise_count:
# Legacy reconciliation with a single SLE has qty_after_transaction and
# stock_value_difference without an outward entry, so reset the queue first.
@@ -1083,6 +1083,7 @@ class FIFOSlots:
(doctype.voucher_type == "Stock Reconciliation")
& (doctype.docstatus < 2)
& (doctype.is_cancelled == 0)
& (item.has_serial_no == 1)
)
.groupby(doctype.voucher_detail_no)
)

View File

@@ -195,6 +195,67 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(queue[0][0], 20.0)
self.assertEqual(queue[1][0], 20.0)
def test_non_serial_stock_reco_decrease_preserves_ageing(self):
"""
Non-serial stock reconciliation should adjust FIFO by the balance delta.
Decreasing stock consumes old slots; increasing stock adds only the new qty.
"""
def make_sle(
posting_date,
voucher_type,
voucher_no,
actual_qty,
qty_after,
voucher_detail_no=None,
stock_value_difference=None,
):
stock_value_difference = actual_qty if stock_value_difference is None else stock_value_difference
return frappe._dict(
name="Flask Item",
item_name="Flask Item",
description="Flask Item",
item_group=None,
brand=None,
stock_uom="Nos",
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=1,
warehouse="WH 1",
posting_date=posting_date,
voucher_type=voucher_type,
voucher_no=voucher_no,
voucher_detail_no=voucher_detail_no,
has_serial_no=False,
has_batch_no=False,
serial_no=None,
batch_no=None,
serial_and_batch_bundle=None,
)
filters = frappe._dict(company="_Test Company", to_date="2026-02-15", ranges=["30", "60", "90"])
sle = [
make_sle("2025-11-30", "Stock Entry", "001", 100, 100),
make_sle("2025-12-31", "Stock Reconciliation", "002", 0, 60, "SRI-DECREASE", -40),
make_sle("2026-01-31", "Stock Reconciliation", "003", 0, 90, "SRI-INCREASE", 30),
]
fifo_slots = FIFOSlots(filters, sle)
def prepare_stock_reco_voucher_wise_count():
fifo_slots.stock_reco_voucher_wise_count = frappe._dict({"SRI-DECREASE": 100, "SRI-INCREASE": 60})
fifo_slots.prepare_stock_reco_voucher_wise_count = prepare_stock_reco_voucher_wise_count
slots = fifo_slots.generate()
queue = slots["Flask Item"]["fifo_queue"]
report_data = format_report_data(filters, slots, filters.to_date)
self.assertEqual(queue, [[60.0, "2025-11-30", 60.0], [30.0, "2026-01-31", 30.0]])
self.assertEqual(report_data[0][7:15], [30.0, 30.0, 0.0, 0.0, 60.0, 60.0, 0.0, 0.0])
def test_sequential_stock_reco_same_warehouse(self):
"""
Test back to back stock recos (same warehouse).

View File

@@ -5,7 +5,7 @@ from frappe import _, bold
from frappe.model.naming import NamingSeries, make_autoname, parse_naming_series
from frappe.query_builder import Case
from frappe.query_builder.functions import CombineDatetime, Max, Sum, Timestamp
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, now, nowtime, today
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now, nowtime, today
from pypika import Order
from pypika.terms import ExistsCriterion
@@ -140,8 +140,7 @@ class SerialBatchBundle:
{
"item_code": self.item_code,
"warehouse": self.warehouse,
"posting_date": self.sle.posting_date,
"posting_time": self.sle.posting_time,
"posting_datetime": self.sle.posting_datetime,
"voucher_type": self.sle.voucher_type,
"voucher_no": self.sle.voucher_no,
"voucher_detail_no": self.sle.voucher_detail_no,
@@ -484,7 +483,7 @@ class SerialBatchBundle:
if status == "Delivered":
warranty_period = frappe.get_cached_value("Item", sle.item_code, "warranty_period")
if warranty_period:
warranty_expiry_date = add_days(sle.posting_date, cint(warranty_period))
warranty_expiry_date = add_days(getdate(sle.posting_datetime), cint(warranty_period))
query = query.set(sn_table.warranty_expiry_date, warranty_expiry_date)
query = query.set(sn_table.warranty_period, warranty_period)
else:
@@ -509,7 +508,7 @@ class SerialBatchBundle:
sle_doctype.voucher_no,
sle_doctype.is_cancelled,
sle_doctype.item_code,
sle_doctype.posting_date,
sle_doctype.posting_datetime,
sle_doctype.company,
)
.where(
@@ -663,7 +662,7 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
.on(bundle.name == bundle_child.parent)
.select(
bundle_child.serial_no,
Max(CombineDatetime(bundle.posting_date, bundle.posting_time)).as_("max_posting_dt"),
Max(bundle.posting_datetime).as_("max_posting_dt"),
)
.where(
(bundle.is_cancelled == 0)
@@ -681,13 +680,8 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
if self.sle.voucher_no:
latest_posting = latest_posting.where(bundle.voucher_no != self.sle.voucher_no)
if self.sle.posting_date:
if self.sle.posting_time is None:
self.sle.posting_time = nowtime()
timestamp_condition = CombineDatetime(
bundle.posting_date, bundle.posting_time
) <= CombineDatetime(self.sle.posting_date, self.sle.posting_time)
if self.sle.posting_datetime:
timestamp_condition = bundle.posting_datetime <= self.sle.posting_datetime
latest_posting = latest_posting.where(timestamp_condition)
@@ -704,10 +698,7 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
.join(latest_posting)
.on(
(latest_posting.serial_no == bundle_child.serial_no)
& (
latest_posting.max_posting_dt
== CombineDatetime(bundle.posting_date, bundle.posting_time)
)
& (latest_posting.max_posting_dt == bundle.posting_datetime)
)
.select(
bundle_child.serial_no,
@@ -839,19 +830,13 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
child = frappe.qb.DocType("Serial and Batch Entry")
timestamp_condition = ""
if self.sle.posting_date:
if self.sle.posting_time is None:
self.sle.posting_time = nowtime()
timestamp_condition = CombineDatetime(parent.posting_date, parent.posting_time) < CombineDatetime(
self.sle.posting_date, self.sle.posting_time
)
if self.sle.posting_datetime:
timestamp_condition = parent.posting_datetime < self.sle.posting_datetime
if self.sle.creation:
timestamp_condition |= (
CombineDatetime(parent.posting_date, parent.posting_time)
== CombineDatetime(self.sle.posting_date, self.sle.posting_time)
) & (parent.creation < self.sle.creation)
timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & (
parent.creation < self.sle.creation
)
query = (
frappe.qb.from_(parent)
@@ -1074,9 +1059,9 @@ class SerialBatchCreation:
self.__dict__.update(item_details)
def set_other_details(self):
if not self.get("posting_date"):
self.posting_date = today()
self.__dict__["posting_date"] = self.posting_date
if not self.get("posting_datetime"):
self.posting_datetime = now()
self.__dict__["posting_datetime"] = self.posting_datetime
if not self.get("actual_qty"):
qty = self.get("qty") or self.get("total_qty")
@@ -1101,8 +1086,7 @@ class SerialBatchCreation:
new_package.docstatus = 0
new_package.warehouse = self.warehouse
new_package.voucher_no = ""
new_package.posting_date = self.posting_date if hasattr(self, "posting_date") else today()
new_package.posting_time = self.posting_time if hasattr(self, "posting_time") else nowtime()
new_package.posting_datetime = self.posting_datetime if hasattr(self, "posting_datetime") else now()
new_package.type_of_transaction = self.type_of_transaction
new_package.returned_against = self.get("returned_against")
@@ -1242,9 +1226,8 @@ class SerialBatchCreation:
elif self.has_serial_no and not self.get("serial_nos"):
self.serial_nos = get_serial_nos_for_outward(kwargs)
elif not self.has_serial_no and self.has_batch_no and not self.get("batches"):
if self.get("posting_date"):
kwargs["posting_date"] = self.get("posting_date")
kwargs["posting_time"] = self.get("posting_time")
if self.get("posting_datetime"):
kwargs["posting_datetime"] = self.get("posting_datetime")
self.batches = get_available_batches(kwargs)

View File

@@ -283,7 +283,7 @@ def set_stock_balance_as_per_serial_no(
if not posting_time:
posting_time = nowtime()
condition = " and item.name='%s'" % item_code.replace("'", "'") if item_code else ""
condition = " and item.name=%s" % frappe.db.escape(item_code, percent=False) if item_code else ""
bin = frappe.db.sql(
"""select bin.item_code, bin.warehouse, bin.actual_qty, item.stock_uom

View File

@@ -1975,9 +1975,6 @@ def get_valuation_rate(
# Get moving average rate of a specific batch number
if warehouse and serial_and_batch_bundle:
sabb = frappe.db.get_value(
"Serial and Batch Bundle", serial_and_batch_bundle, ["posting_date", "posting_time"], as_dict=True
)
batch_obj = BatchNoValuation(
sle=frappe._dict(
{
@@ -1985,8 +1982,9 @@ def get_valuation_rate(
"warehouse": warehouse,
"actual_qty": -1,
"serial_and_batch_bundle": serial_and_batch_bundle,
"posting_date": sabb.posting_date,
"posting_time": sabb.posting_time,
"posting_datetime": frappe.get_value(
"Serial and Batch Bundle", serial_and_batch_bundle, "posting_datetime"
),
}
)
)

View File

@@ -2,12 +2,14 @@
# License: GNU General Public License v3. See license.txt
import datetime
import json
import frappe
from frappe import _
from frappe.query_builder.functions import CombineDatetime, IfNull, Sum
from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime
from frappe.utils.data import DateTimeLikeObject
import erpnext
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
@@ -94,13 +96,13 @@ def get_stock_value_on(
@frappe.whitelist()
def get_stock_balance(
item_code,
warehouse,
posting_date=None,
posting_time=None,
with_valuation_rate=False,
with_serial_no=False,
inventory_dimensions_dict=None,
item_code: str,
warehouse: str | None,
posting_date: DateTimeLikeObject | None = None,
posting_time: DateTimeLikeObject | datetime.timedelta | None = None,
with_valuation_rate: bool = False,
with_serial_no: bool = False,
inventory_dimensions_dict: dict | None = None,
):
"""Returns stock balance quantity at given warehouse on given posting date or current date.
@@ -146,8 +148,7 @@ def get_stock_balance(
{
"item_code": item_code,
"warehouse": warehouse,
"posting_date": posting_date,
"posting_time": posting_time,
"posting_datetime": get_combine_datetime(posting_date, posting_time),
"ignore_warehouse": 1,
}
)
@@ -247,13 +248,16 @@ 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
if isinstance(args, str):
args = json.loads(args)
if not args.get("posting_datetime") and args.get("posting_date"):
args["posting_datetime"] = get_combine_datetime(args.get("posting_date"), args.get("posting_time"))
in_rate = None
item_details = frappe.get_cached_value(