Compare commits

...

27 Commits

Author SHA1 Message Date
Mihir Kandoi
081fbe9e3f Merge pull request #56586 from mihir-kandoi/alias-no-copy
fix: party aliases should be no copy
2026-06-27 16:24:40 +05:30
Mihir Kandoi
057af21cd8 fix: party aliases should be no copy 2026-06-27 16:13:43 +05:30
rohitwaghchaure
c7ef42ef98 fix: sync Stock Reconciliation difference amount with GL after reposting (#56574)
* fix: sync Stock Reconciliation difference amount with GL after reposting

* fix: placement of recalculate differece amount function
2026-06-27 10:28:45 +00:00
Diptanil Saha
79ad11e21b chore(crm_settings): remove unused delete_custom_fields import (#56558) 2026-06-27 14:38:59 +05:30
Diptanil Saha
485e9041de chore: removing controllers from pre-commit eslint hooks exclude list (#56575)
* chore: removed `controllers` from exclude list on `.pre-commit-config.yaml`

* chore: fix `transactions.js` eslint issues

* chore: fix `taxes_and_totals.js` eslint issue

* chore: fix `accounts.js` eslint issue
2026-06-27 00:42:34 +05:30
rohitwaghchaure
5e60e4faa7 fix: do not allow closing the accounting period for future dates (#56551) 2026-06-26 17:28:20 +00:00
Nabin Hait
3f053e599c Merge pull request #56536 from frappe/chore/test-bom-search
test: BOM Search report coverage
2026-06-26 22:01:25 +05:30
Nabin Hait
b0232f41ea Merge pull request #56545 from frappe/chore/test-incorrect-stock-value-report
test: Incorrect Stock Value Report report coverage
2026-06-26 22:00:43 +05:30
Nabin Hait
1d517375d9 Merge pull request #56553 from frappe/chore/refactor-ar-ap-report-tests
test: reuse bootstrap master data in Accounts Receivable/Payable report tests
2026-06-26 22:00:15 +05:30
Nabin Hait
1d9d982719 Merge pull request #56554 from frappe/chore/refactor-cash-flow-report-tests
test: reuse bootstrap master data in Cash Flow report tests
2026-06-26 21:59:47 +05:30
Nabin Hait
66a711c849 Merge pull request #56555 from frappe/chore/refactor-general-ledger-report-tests
test: reuse bootstrap master data in General Ledger report tests
2026-06-26 21:58:39 +05:30
Nabin Hait
a2f201e1d7 Merge pull request #56556 from frappe/chore/refactor-stock-balance-report-tests
test: reuse bootstrap master data in Stock Balance report tests
2026-06-26 21:58:05 +05:30
Nabin Hait
b989bef967 Merge pull request #56557 from frappe/chore/refactor-stock-ledger-projected-report-tests
test: reuse bootstrap master data in Stock Ledger & Stock Projected Qty report tests
2026-06-26 21:57:30 +05:30
Nabin Hait
c1b91b0f5f Merge pull request #56541 from frappe/chore/test-item-where-used
test: Item Where Used report coverage
2026-06-26 21:56:33 +05:30
rohitwaghchaure
31f89b72b4 fix: ignored posting time 00:00:00 in RIV (#56571) 2026-06-26 13:49:16 +00:00
Nabin Hait
0458446a06 test: cover search_sub_assemblies filter in BOM Search report 2026-06-26 14:57:26 +05:30
Nabin Hait
a001a15312 test: reuse BootStrapTestData master data in Stock Ledger & Stock Projected Qty report tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:09:20 +05:30
Nabin Hait
7f01d6b24e test: reuse BootStrapTestData master data in Stock Balance report tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:09:13 +05:30
Nabin Hait
f474c10f89 test: reuse BootStrapTestData master data in General Ledger report tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:09:06 +05:30
Nabin Hait
a797c31b57 test: reuse BootStrapTestData master data in Cash Flow report tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:09:00 +05:30
Nabin Hait
1a820abe3c test: reuse BootStrapTestData master data in Accounts Receivable/Payable report tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:08:53 +05:30
Nabin Hait
b11a2c3e9f test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:19 +05:30
Nabin Hait
2aff857561 test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:27:00 +05:30
Nabin Hait
f2d64d1a2a test: reuse BootStrapTestData master data to reduce runtime
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:26:14 +05:30
Nabin Hait
de45ab7fc9 test: add coverage for Incorrect Stock Value Report report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:40:32 +05:30
Nabin Hait
5ad8ea3f17 test: add coverage for Item Where Used report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:39:59 +05:30
Nabin Hait
c69077cd3a test: add coverage for BOM Search report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:32:58 +05:30
23 changed files with 547 additions and 164 deletions

View File

@@ -48,7 +48,6 @@ repos:
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
erpnext/public/js/controllers/.*|
erpnext/templates/pages/order.js|
erpnext/templates/includes/.*
)$

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
class OverlapError(frappe.ValidationError):
@@ -36,8 +37,20 @@ class AccountingPeriod(Document):
# end: auto-generated types
def validate(self):
self.validate_dates()
self.validate_overlap()
def validate_dates(self):
if getdate(self.start_date) > getdate(self.end_date):
frappe.throw(_("Start Date cannot be after End Date"))
if getdate(self.end_date) > getdate(nowdate()):
frappe.throw(
_(
"Accounting Period cannot be created for a future date. End Date {0} is after today."
).format(frappe.bold(frappe.format(self.end_date, "Date")))
)
def before_insert(self):
self.bootstrap_doctypes_for_closing()

View File

@@ -2,7 +2,7 @@
# See license.txt
import frappe
from frappe.utils import add_months, nowdate
from frappe.utils import nowdate
from erpnext.accounts.doctype.accounting_period.accounting_period import (
ClosedAccountingPeriod,
@@ -93,7 +93,7 @@ def create_accounting_period(**args):
accounting_period = frappe.new_doc("Accounting Period")
accounting_period.start_date = args.start_date or nowdate()
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
accounting_period.end_date = args.end_date or nowdate()
accounting_period.company = args.company or "_Test Company"
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})

View File

@@ -135,38 +135,9 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms
payment_term1 = frappe.get_doc(
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"}
).insert()
payment_term2 = frappe.get_doc(
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"}
).insert()
template = frappe.get_doc(
{
"doctype": "Payment Terms Template",
"template_name": "_Test 50-50",
"terms": [
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term": payment_term1.name,
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 15,
},
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term": payment_term2.name,
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 30,
},
],
}
)
template.insert()
template = frappe.get_doc("Payment Terms Template", "_Test Payment Term Template")
first_term = frappe.get_doc("Payment Term", template.terms[0].payment_term)
expected_payment_term = first_term.description or first_term.name
filters = {
"company": self.company,
@@ -193,12 +164,10 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
row = report[1][0]
self.assertEqual(len(report[1]), 2)
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
self.assertEqual([pi.name, expected_payment_term], [row.voucher_no, row.payment_term])
def test_project_filter(self):
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
).insert()
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
pi = self.create_purchase_invoice(do_not_submit=True)
pi.project = project.name
@@ -227,9 +196,7 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
"range": "30, 60, 90, 120",
}
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company}
).insert()
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
pi = self.create_purchase_invoice(do_not_submit=True)
pi.project = project.name

View File

@@ -1422,10 +1422,10 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
# must be applied explicitly. The report should only show permitted customers.
original_customer = self.customer
second_customer = "_Test AR Perm Customer"
second_customer = "_Test Customer 1"
# create_customer overrides self.customer, so build the restricted invoice first
self.create_customer(customer_name=second_customer)
self.customer = second_customer
self.create_sales_invoice(no_payment_schedule=True)
self.customer = original_customer

View File

@@ -59,16 +59,9 @@ class TestCashFlow(ERPNextTestSuite):
def test_cash_purchase_of_asset_is_investing_outflow(self):
"""Buying a fixed asset for cash is an investing outflow that reduces net change in cash."""
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
create_account(
account_name="_Test Cash Flow Asset",
company=self.company,
parent_account="Fixed Assets - _TC",
account_type="Fixed Asset",
)
asset_account = "_Test Cash Flow Asset - _TC"
asset_account = "Office Equipment - _TC"
before = self.net_change_in_cash()
# debit the fixed asset, credit cash -> cash goes out

View File

@@ -64,16 +64,12 @@ class TestGeneralLedger(ERPNextTestSuite):
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
def test_opening_total_and_closing_balances(self):
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
self.clear_old_entries()
account = create_account(
account_name="_Test GL Account", company=self.company, parent_account="Current Assets - _TC"
)
offset = create_account(
account_name="_Test GL Offset", company=self.company, parent_account="Current Assets - _TC"
)
# reuse bootstrap non-party accounts; clear_old_entries() leaves them clean of GL
account = "_Test Account Cost for Goods Sold - _TC"
offset = "_Test Bank - _TC"
make_journal_entry(account, offset, 1000, posting_date=add_days(today(), -60), submit=True) # opening
make_journal_entry(account, offset, 200, posting_date=today(), submit=True) # in period
@@ -87,19 +83,13 @@ class TestGeneralLedger(ERPNextTestSuite):
self.assertEqual(labelled["'Closing (Opening + Total)'"]["debit"], 1200)
def test_categorize_by_account_subtotals(self):
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
self.clear_old_entries()
account_a = create_account(
account_name="_Test GL Account A", company=self.company, parent_account="Current Assets - _TC"
)
account_b = create_account(
account_name="_Test GL Account B", company=self.company, parent_account="Current Assets - _TC"
)
offset = create_account(
account_name="_Test GL Offset", company=self.company, parent_account="Current Assets - _TC"
)
# reuse bootstrap non-party accounts; clear_old_entries() leaves them clean of GL
account_a = "_Test Account Cost for Goods Sold - _TC"
account_b = "_Test Bank - _TC"
offset = "_Test Cash - _TC"
make_journal_entry(account_a, offset, 300, posting_date=today(), submit=True)
make_journal_entry(account_b, offset, 400, posting_date=today(), submit=True)

View File

@@ -547,6 +547,7 @@
"fieldtype": "Data",
"in_global_search": 1,
"label": "Alias",
"no_copy": 1,
"unique": 1
}
],
@@ -561,7 +562,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-06-22 12:23:09.241125",
"modified": "2026-06-27 16:12:33.190257",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.model.document import Document

View File

@@ -23,15 +23,12 @@ erpnext.accounts.taxes = {
onload: function (frm) {
if (frm.get_field("taxes")) {
frm.set_query("account_head", "taxes", function (doc) {
let account_type = ["Tax", "Chargeable"];
if (frm.cscript.tax_table == "Sales Taxes and Charges") {
var account_type = ["Tax", "Chargeable", "Expense Account"];
account_type.push("Expense Account");
} else {
var account_type = [
"Tax",
"Chargeable",
"Income Account",
"Expenses Included In Valuation",
];
account_type.push("Income Account", "Expenses Included In Valuation");
}
return {

View File

@@ -952,14 +952,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
if (["Sales Invoice", "POS Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total;
let total_amount_to_pay;
if (this.frm.doc.party_account_currency == this.frm.doc.currency) {
var total_amount_to_pay = flt(
total_amount_to_pay = flt(
grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount,
precision("grand_total")
);
} else {
var total_amount_to_pay = flt(
total_amount_to_pay = flt(
flt(base_grand_total, precision("base_grand_total")) -
this.frm.doc.total_advance -
this.frm.doc.base_write_off_amount,
@@ -1004,14 +1005,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
async set_total_amount_to_default_mop() {
let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total;
let total_amount_to_pay;
if (this.frm.doc.party_account_currency == this.frm.doc.currency) {
var total_amount_to_pay = flt(
total_amount_to_pay = flt(
grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount,
precision("grand_total")
);
} else {
var total_amount_to_pay = flt(
total_amount_to_pay = flt(
flt(base_grand_total, precision("base_grand_total")) -
this.frm.doc.total_advance -
this.frm.doc.base_write_off_amount,

View File

@@ -1291,13 +1291,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
var set_party_account = function (set_pricing) {
if (["Sales Invoice", "Purchase Invoice"].includes(me.frm.doc.doctype)) {
if (me.frm.doc.doctype == "Sales Invoice") {
var party_type = "Customer";
var party_account_field = "debit_to";
} else {
var party_type = "Supplier";
var party_account_field = "credit_to";
}
let party_type = me.frm.doc.doctype == "Sales Invoice" ? "Customer" : "Supplier";
let party_account_field = me.frm.doc.doctype == "Sales Invoice" ? "debit_to" : "credit_to";
var party = me.frm.doc[frappe.model.scrub(party_type)];
if (
@@ -2071,7 +2066,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
if (this.frm.doc.operations && this.frm.doc.operations.length > 0) {
var item_grid = this.frm.fields_dict["operations"].grid;
let item_grid = this.frm.fields_dict["operations"].grid;
$.each(["base_operating_cost", "base_hour_rate"], function (i, fname) {
if (frappe.meta.get_docfield(item_grid.doctype, fname))
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);
@@ -2079,7 +2074,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
if (this.frm.doc.secondary_items && this.frm.doc.secondary_items.length > 0) {
var item_grid = this.frm.fields_dict["secondary_items"].grid;
let item_grid = this.frm.fields_dict["secondary_items"].grid;
$.each(["base_rate", "base_amount"], function (i, fname) {
if (frappe.meta.get_docfield(item_grid.doctype, fname))
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);
@@ -2470,7 +2465,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
row_to_modify[key] = pr_row[key];
}
if (this.frm.doc.hasOwnProperty("is_pos") && this.frm.doc.is_pos) {
if (Object.prototype.hasOwnProperty.call(this.frm.doc, "is_pos") && this.frm.doc.is_pos) {
let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "cost_center");
if (r.message.cost_center) {
row_to_modify["cost_center"] = r.message.cost_center;
@@ -2735,7 +2730,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
$.each(me.frm.doc.items || [], function (i, item) {
if (
item.name &&
r.message.hasOwnProperty(item.name) &&
Object.prototype.hasOwnProperty.call(r.message, item.name) &&
r.message[item.name].item_tax_template
) {
item.item_tax_template = r.message[item.name].item_tax_template;

View File

@@ -681,6 +681,7 @@
"fieldtype": "Data",
"in_global_search": 1,
"label": "Alias",
"no_copy": 1,
"unique": 1
}
],
@@ -695,7 +696,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-06-22 12:23:19.196991",
"modified": "2026-06-27 16:12:10.457900",
"modified_by": "Administrator",
"module": "Selling",
"name": "Customer",

View File

@@ -94,7 +94,7 @@ class RepostItemValuation(Document):
self.validate_recreate_stock_ledgers()
def set_default_posting_time(self):
if not self.posting_time:
if self.posting_time is None:
self.posting_time = nowtime()
if not self.posting_date:

View File

@@ -6,6 +6,7 @@ from datetime import timedelta
import frappe
from frappe import _, bold, json, msgprint
from frappe.query_builder.functions import Sum
from frappe.utils import add_to_date, cint, cstr, flt, now
from frappe.utils.data import DateTimeLikeObject
@@ -1014,6 +1015,102 @@ class StockReconciliation(StockController):
d.quantity_difference = flt(d.qty) - flt(d.current_qty)
d.amount_difference = flt(d.amount) - flt(d.current_amount)
def recalculate_difference_amount_from_ledger(self):
"""Sync the displayed current qty/rate and difference amount with the (reposted) ledger.
Submitted reconciliations freeze ``difference_amount`` and the per-row current values at
submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger
Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures
that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed
``stock_value_difference`` keeps the document and the GL consistent by construction.
"""
difference_amount = 0.0
for row in self.items:
stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name))
amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount"))
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
current_amount = flt(amount - amount_difference, row.precision("current_amount"))
current_qty = self.get_current_qty_from_ledger(row)
current_valuation_rate = (
flt(current_amount / current_qty, row.precision("current_valuation_rate"))
if current_qty
else 0.0
)
row.db_set(
{
"amount": amount,
"current_qty": current_qty,
"current_valuation_rate": current_valuation_rate,
"current_amount": current_amount,
"quantity_difference": flt(row.qty) - current_qty,
"amount_difference": amount_difference,
},
update_modified=False,
)
difference_amount += amount_difference
self.db_set(
"difference_amount",
flt(difference_amount, self.precision("difference_amount")),
update_modified=False,
)
def get_current_qty_from_ledger(self, row: StockReconciliationItem):
"""Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting.
Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation
(blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight
from the current bundle. Non-serial rows can float, so read the ledger balance just before the
reconciliation, excluding the reconciliation's own entries.
"""
if row.current_serial_and_batch_bundle:
total_qty = frappe.db.get_value(
"Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty"
)
return abs(flt(total_qty, row.precision("current_qty")))
reco_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"is_cancelled": 0,
},
["posting_datetime", "creation"],
as_dict=True,
)
if not reco_sle:
return flt(row.current_qty, row.precision("current_qty"))
sle = frappe.qb.DocType("Stock Ledger Entry")
previous_sle = (
frappe.qb.from_(sle)
.select(sle.qty_after_transaction)
.where(
(sle.item_code == row.item_code)
& (sle.warehouse == row.warehouse)
& (sle.is_cancelled == 0)
& (
(sle.posting_datetime < reco_sle.posting_datetime)
| (
(sle.posting_datetime == reco_sle.posting_datetime)
& (sle.creation < reco_sle.creation)
)
)
)
.orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run()
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
def submit(self):
if len(self.items) > 100:
msgprint(
@@ -1223,6 +1320,23 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
return itemwise_batch_data
def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str):
"""Net stock value change posted to the GL by a reconciliation row (sum of its SLEs)."""
sle = frappe.qb.DocType("Stock Ledger Entry")
result = (
frappe.qb.from_(sle)
.select(Sum(sle.stock_value_difference))
.where(
(sle.voucher_type == voucher_type)
& (sle.voucher_no == voucher_no)
& (sle.voucher_detail_no == voucher_detail_no)
& (sle.is_cancelled == 0)
)
).run()
return flt(result[0][0]) if result and result[0][0] else 0.0
@frappe.whitelist()
def get_stock_balance_for(
item_code: str,

View File

@@ -786,6 +786,172 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
sr1.load_from_db()
self.assertEqual(sr1.difference_amount, 10000)
def assert_reco_difference_matches_gl(self, reco_name):
"""The displayed Difference Amount (doc and per-row) must equal the reposted GL impact,
i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``."""
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
get_row_stock_value_difference,
)
reco = frappe.get_doc("Stock Reconciliation", reco_name)
total_difference = 0.0
for row in reco.items:
row_difference = flt(
get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name),
row.precision("amount_difference"),
)
self.assertEqual(flt(row.amount_difference), row_difference)
total_difference += row_difference
self.assertEqual(
flt(reco.difference_amount, reco.precision("difference_amount")),
flt(total_difference, reco.precision("difference_amount")),
)
def test_difference_amount_synced_with_gl_after_repost_non_serialized(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item().name
warehouse = "_Test Warehouse - _TC"
# Opening stock => 100 * 100 = 10000
make_stock_entry(
item_code=item_code,
target=warehouse,
qty=100,
basic_rate=100,
posting_date=add_days(nowdate(), -5),
posting_time="10:00:00",
)
# Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=100,
rate=200,
posting_date=add_days(nowdate(), -2),
)
self.assertEqual(reco.difference_amount, 10000)
self.assert_reco_difference_matches_gl(reco.name)
# Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500
create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=50,
rate=50,
posting_date=add_days(nowdate(), -3),
)
reco.load_from_db()
# Current is now 2500 => difference 20000 - 2500 = 17500
self.assertEqual(reco.difference_amount, 17500)
self.assert_reco_difference_matches_gl(reco.name)
def test_difference_amount_synced_with_gl_after_repost_batched(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
)
item_code = self.make_item(
"Test Batch Item Reco Difference Sync",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TEST-BATCH-DIFFSYNC-.###",
},
).name
warehouse = "_Test Warehouse - _TC"
# Receive 10 @ 100 (batch value 1000)
pr = make_purchase_receipt(
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=100,
posting_date=add_days(nowdate(), -5),
)
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
# Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=500,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(nowdate(), -2),
)
difference_on_submit = reco.difference_amount
self.assert_reco_difference_matches_gl(reco.name)
# Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco
make_landed_cost_voucher(
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=1000,
company="_Test Company",
)
reco.load_from_db()
self.assertNotEqual(reco.difference_amount, difference_on_submit)
self.assert_reco_difference_matches_gl(reco.name)
def test_difference_amount_synced_with_gl_after_repost_serialized(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
)
item_code = self.make_item(
"Test Serial Item Reco Difference Sync",
{
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "TSIRDS.####",
},
).name
warehouse = "_Test Warehouse - _TC"
# Receive 5 serial nos @ 100 (value 500)
pr = make_purchase_receipt(
item_code=item_code,
warehouse=warehouse,
qty=5,
rate=100,
posting_date=add_days(nowdate(), -5),
)
serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)
# Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=5,
rate=500,
serial_no="\n".join(serial_nos),
use_serial_batch_fields=1,
posting_date=add_days(nowdate(), -2),
)
difference_on_submit = reco.difference_amount
self.assert_reco_difference_matches_gl(reco.name)
# Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco
make_landed_cost_voucher(
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=1000,
company="_Test Company",
)
reco.load_from_db()
self.assertNotEqual(reco.difference_amount, difference_on_submit)
self.assert_reco_difference_matches_gl(reco.name)
def test_make_stock_zero_for_serial_batch_item(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry

View File

@@ -0,0 +1,51 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.stock.report.bom_search.bom_search import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestBomSearch(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict({"search_sub_assemblies": 0})
filters.update(extra)
return execute(filters)[1]
def test_bom_found_by_contained_item(self):
raw_material = "_Test Item"
finished_good = "_Test FG Item"
bom = frappe.get_doc(doctype="BOM", item=finished_good, company="_Test Company", currency="INR")
bom.append("items", {"item_code": raw_material, "qty": 1})
bom.insert()
bom.submit()
rows = self.run_report(item1=raw_material)
bom_names = [row[0] for row in rows]
self.assertIn(bom.name, bom_names)
def test_search_sub_assemblies_finds_top_level_bom(self):
raw_material = "_Test Item"
sub_assembly = "_Test FG Item" # its default BOM contains _Test Item
finished_good = "_Test FG Item 2"
# top-level BOM uses the sub-assembly (it does NOT list the raw material directly).
# the bootstrap sub-assembly BOM is in USD, so match its currency.
top_bom = frappe.get_doc(
doctype="BOM", item=finished_good, company="_Test Company", currency="USD", conversion_rate=1
)
top_bom.append("items", {"item_code": sub_assembly, "qty": 1})
top_bom.insert()
top_bom.submit()
# search_sub_assemblies=1 scans the exploded tree, so the raw material buried in the
# sub-assembly surfaces the top-level BOM
deep = [row[0] for row in self.run_report(search_sub_assemblies=1, item1=raw_material)]
self.assertIn(top_bom.name, deep)
# search_sub_assemblies=0 scans only direct BOM Items, so the top-level BOM (which lists
# the sub-assembly, not the raw material) is not returned for the raw material
direct = [row[0] for row in self.run_report(search_sub_assemblies=0, item1=raw_material)]
self.assertNotIn(top_bom.name, direct)

View File

@@ -0,0 +1,56 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.report.incorrect_stock_value_report.incorrect_stock_value_report import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company with perpetual inventory"
class TestIncorrectStockValueReport(ERPNextTestSuite):
"""Correctness tests for the Incorrect Stock Value report.
The report is a corruption detector: it walks stock account postings and flags
dates/vouchers where the stock ledger value diverges from the GL balance. Clean,
balanced perpetual transactions keep ledger value == GL balance, so they must
never surface as discrepancy rows.
"""
def run_report(self, **extra):
filters = frappe._dict(
company=COMPANY,
from_date="2026-01-01",
to_date="2026-12-31",
)
filters.update(extra)
return list(execute(filters)[1])
def test_balanced_account_has_no_discrepancy(self):
warehouse = create_warehouse("_Test ISV WH", company=COMPANY)
account = frappe.get_value("Warehouse", warehouse, "account")
item = "_Test Item"
make_stock_entry(
item_code=item,
to_warehouse=warehouse,
qty=10,
basic_rate=100,
company=COMPANY,
posting_date="2026-02-01",
)
make_stock_entry(
item_code=item,
from_warehouse=warehouse,
qty=4,
company=COMPANY,
posting_date="2026-03-01",
)
rows = self.run_report(account=account)
offending = [row for row in rows if row.get("warehouse") == warehouse or row.get("item_code") == item]
self.assertEqual(offending, [], f"Balanced perpetual account flagged as incorrect: {offending}")

View File

@@ -0,0 +1,43 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.stock.report.item_where_used.item_where_used import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestItemWhereUsed(ERPNextTestSuite):
"""Correctness tests for the Item Where Used report."""
def run_report(self, **extra):
filters = frappe._dict(company="_Test Company", **extra)
return execute(filters)[1]
def test_item_used_in_bom_listed(self):
raw_material = "_Test Item"
finished_good = "_Test FG Item"
bom = frappe.get_doc(
{
"doctype": "BOM",
"item": finished_good,
"company": "_Test Company",
"currency": "INR",
"quantity": 1,
"items": [{"item_code": raw_material, "qty": 1}],
}
)
bom.insert()
bom.submit()
rows = self.run_report(item=raw_material)
matching = [row for row in rows if row.document_name == bom.name]
self.assertTrue(matching, f"BOM {bom.name} not found in report rows for {raw_material}")
row = matching[0]
self.assertEqual(row.section, "Where Used")
self.assertEqual(row.reference_type, "BOM Component")
self.assertEqual(row.document_type, "BOM")
self.assertEqual(row.related_item, finished_good)
self.assertEqual(row.quantity, 1)

View File

@@ -18,12 +18,17 @@ def stock_balance(filters):
class TestStockBalance(ERPNextTestSuite):
# ----------- utils
# `_Test Item` is a committed bootstrap item that starts at zero stock in `Stores - _TC`,
# so transacting here keeps exact qty/value assertions deterministic.
test_warehouse = "Stores - _TC"
def setUp(self):
self.item = make_item()
self.item = frappe.get_doc("Item", "_Test Item")
self.filters = _dict(
{
"company": "_Test Company",
"item_code": [self.item.name],
"warehouse": self.test_warehouse,
"from_date": "2020-01-01",
"to_date": str(today()),
}
@@ -36,7 +41,7 @@ class TestStockBalance(ERPNextTestSuite):
def generate_stock_ledger(self, item_code: str, movements):
for movement in map(_dict, movements):
if "to_warehouse" not in movement:
movement.to_warehouse = "_Test Warehouse - _TC"
movement.to_warehouse = self.test_warehouse
make_stock_entry(item_code=item_code, **movement)
def assertInvariants(self, rows):
@@ -100,7 +105,7 @@ class TestStockBalance(ERPNextTestSuite):
self.item.name,
[
_dict(qty=5, rate=10),
_dict(qty=5, from_warehouse="_Test Warehouse - _TC", to_warehouse=None),
_dict(qty=5, from_warehouse=self.test_warehouse, to_warehouse=None),
],
)
@@ -153,8 +158,11 @@ class TestStockBalance(ERPNextTestSuite):
self.assertInvariants(rows)
def test_item_group(self):
self.generate_stock_ledger(self.item.name, [_dict(qty=5, rate=10)])
self.filters.pop("item_code", None)
rows = stock_balance(self.filters.update({"item_group": self.item.item_group}))
self.assertTrue(rows)
self.assertTrue(all(r.item_group == self.item.item_group for r in rows))
def test_child_warehouse_balances(self):
@@ -172,12 +180,8 @@ class TestStockBalance(ERPNextTestSuite):
def test_show_item_attr(self):
from erpnext.controllers.item_variant import create_variant
self.item.has_variants = True
self.item.append("attributes", {"attribute": "Test Size"})
self.item.save()
attributes = {"Test Size": "Large"}
variant = create_variant(self.item.name, attributes)
variant = create_variant("_Test Variant Item", attributes)
variant.save()
self.generate_stock_ledger(variant.name, [_dict(qty=5, rate=10)])
@@ -185,12 +189,18 @@ class TestStockBalance(ERPNextTestSuite):
self.assertPartialDictEq(attributes, rows[0])
self.assertInvariants(rows)
def make_alt_uom_item(self, uoms=None):
"""Fresh item with a controlled UOM table; `_Test Item` already carries an alternate
UOM, which would shadow the "first alternate" assertions in these tests."""
item = make_item(uoms=uoms)
self.filters.update({"item_code": [item.name]})
return item
def test_alt_uom_balance_single_uom(self):
"""Alt UOM columns show correct name and converted qty for an item with one alternate UOM."""
self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"})
self.item.save()
item = self.make_alt_uom_item(uoms=[{"conversion_factor": 12, "uom": "Box"}])
self.generate_stock_ledger(self.item.name, [_dict(qty=24, rate=10)])
self.generate_stock_ledger(item.name, [_dict(qty=24, rate=10)])
rows = stock_balance(self.filters.update({"show_alt_uom_balance": 1}))
self.assertEqual(len(rows), 1)
@@ -199,7 +209,8 @@ class TestStockBalance(ERPNextTestSuite):
def test_alt_uom_balance_no_alternate_uom(self):
"""Alt UOM columns are not added when no items in the report have alt UOMs."""
self.generate_stock_ledger(self.item.name, [_dict(qty=5, rate=10)])
item = self.make_alt_uom_item()
self.generate_stock_ledger(item.name, [_dict(qty=5, rate=10)])
columns, _ = execute(self.filters.update({"show_alt_uom_balance": 1}))
col_fieldnames = [c.get("fieldname") for c in columns if isinstance(c, dict)]
@@ -208,10 +219,9 @@ class TestStockBalance(ERPNextTestSuite):
def test_alt_uom_balance_filter_disabled(self):
"""No alt UOM columns are injected when show_alt_uom_balance is not set."""
self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"})
self.item.save()
item = self.make_alt_uom_item(uoms=[{"conversion_factor": 12, "uom": "Box"}])
self.generate_stock_ledger(self.item.name, [_dict(qty=24, rate=10)])
self.generate_stock_ledger(item.name, [_dict(qty=24, rate=10)])
columns, _ = execute(self.filters)
col_fieldnames = [c.get("fieldname") for c in columns if isinstance(c, dict)]
@@ -221,11 +231,14 @@ class TestStockBalance(ERPNextTestSuite):
def test_alt_uom_balance_uses_first_alternate_uom(self):
"""When an item has multiple alt UOMs, only the first (lowest idx) is shown."""
frappe.get_doc({"doctype": "UOM", "uom_name": "Carton"}).insert(ignore_if_duplicate=True)
self.item.append("uoms", {"conversion_factor": 12, "uom": "Box"})
self.item.append("uoms", {"conversion_factor": 144, "uom": "Carton"})
self.item.save()
item = self.make_alt_uom_item(
uoms=[
{"conversion_factor": 12, "uom": "Box"},
{"conversion_factor": 144, "uom": "Carton"},
]
)
self.generate_stock_ledger(self.item.name, [_dict(qty=144, rate=10)])
self.generate_stock_ledger(item.name, [_dict(qty=144, rate=10)])
rows = stock_balance(self.filters.update({"show_alt_uom_balance": 1}))
self.assertEqual(len(rows), 1)

View File

@@ -4,12 +4,11 @@
import frappe
from frappe.utils import add_days, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.stock_ledger.stock_ledger import execute
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "_Test Warehouse - _TC"
WAREHOUSE = "Stores - _TC"
class TestStockLedgerReport(ERPNextTestSuite):
@@ -17,7 +16,8 @@ class TestStockLedgerReport(ERPNextTestSuite):
A shared `make_movements`/`run` pair keeps each test small without persisting
any data: movements are created per test and rolled back, while the report runs
read-only.
read-only. Tests reuse bootstrap items and transact in `Stores - _TC`, which
starts clean (zero balance) for these items.
"""
def make_movements(self, item_code, movements):
@@ -35,7 +35,7 @@ class TestStockLedgerReport(ERPNextTestSuite):
return list(execute(filters)[1])
def test_in_out_quantities_and_running_balance(self):
item = make_item().name
item = "_Test Item"
self.make_movements(
item,
[
@@ -54,7 +54,7 @@ class TestStockLedgerReport(ERPNextTestSuite):
self.assertEqual(issue["qty_after_transaction"], 6)
def test_opening_balance_reflects_movements_before_from_date(self):
item = make_item().name
item = "_Test Item"
self.make_movements(
item,
[
@@ -79,8 +79,8 @@ class TestStockLedgerReport(ERPNextTestSuite):
self.assertEqual(issue["qty_after_transaction"], 6)
def test_filters_to_requested_item_only(self):
item_a = make_item().name
item_b = make_item().name
item_a = "_Test Item"
item_b = "_Test Item 2"
self.make_movements(item_a, [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 100}])
self.make_movements(item_b, [{"qty": 7, "to_warehouse": WAREHOUSE, "basic_rate": 100}])

View File

@@ -4,29 +4,31 @@
import frappe
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.stock_projected_qty.stock_projected_qty import execute
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "_Test Warehouse - _TC"
# Use a clean warehouse (zero baseline) so projected-qty assertions are exact.
WAREHOUSE = "Stores - _TC"
class TestStockProjectedQty(ERPNextTestSuite):
"""Correctness tests for the Stock Projected Qty report (a current-Bin snapshot)."""
def run_report(self, item_code):
def run_report(self, item_code, warehouse=None):
filters = frappe._dict(company="_Test Company", item_code=item_code)
if warehouse:
filters.warehouse = warehouse
columns, data = execute(filters)
fields = [column["fieldname"] for column in columns]
return [dict(zip(fields, row, strict=False)) for row in data]
def test_projected_qty_includes_actual_and_ordered(self):
item = make_item().name
item = "_Test Item"
make_stock_entry(item_code=item, qty=10, to_warehouse=WAREHOUSE, basic_rate=100)
create_purchase_order(item_code=item, qty=5, rate=100, warehouse=WAREHOUSE)
row = self.run_report(item)[0]
row = self.run_report(item, warehouse=WAREHOUSE)[0]
self.assertEqual(row["actual_qty"], 10)
self.assertEqual(row["ordered_qty"], 5)
self.assertEqual(row["projected_qty"], 15)
@@ -35,7 +37,7 @@ class TestStockProjectedQty(ERPNextTestSuite):
"""projected_qty = actual + ordered + requested + planned
- reserved - reserved_for_production - reserved_for_subcontract - reserved_for_production_plan
and every component is surfaced as its own column."""
item = make_item().name
item = "_Test Item"
make_stock_entry(item_code=item, qty=100, to_warehouse=WAREHOUSE, basic_rate=100)
bin_doc = frappe.get_doc("Bin", {"item_code": item, "warehouse": WAREHOUSE})
@@ -57,7 +59,7 @@ class TestStockProjectedQty(ERPNextTestSuite):
# 100 + 50 + 30 + 20 - 10 - 8 - 6 - 4
self.assertEqual(bin_doc.projected_qty, 172)
row = self.run_report(item)[0]
row = self.run_report(item, warehouse=WAREHOUSE)[0]
self.assertEqual(row["actual_qty"], 100)
self.assertEqual(row["ordered_qty"], 50)
self.assertEqual(row["indented_qty"], 30)
@@ -69,7 +71,7 @@ class TestStockProjectedQty(ERPNextTestSuite):
self.assertEqual(row["projected_qty"], 172)
def test_shortage_qty_from_reorder_level(self):
item = make_item().name
item = "_Test Item"
doc = frappe.get_doc("Item", item)
doc.append(
"reorder_levels",
@@ -83,14 +85,14 @@ class TestStockProjectedQty(ERPNextTestSuite):
doc.save()
make_stock_entry(item_code=item, qty=10, to_warehouse=WAREHOUSE, basic_rate=100)
row = self.run_report(item)[0]
row = self.run_report(item, warehouse=WAREHOUSE)[0]
self.assertEqual(row["re_order_level"], 20)
self.assertEqual(row["projected_qty"], 10)
self.assertEqual(row["shortage_qty"], 10) # reorder level 20 - projected 10
def test_item_filter_returns_only_requested_item(self):
item_a = make_item().name
item_b = make_item().name
item_a = "_Test Item"
item_b = "_Test Item 2"
make_stock_entry(item_code=item_a, qty=5, to_warehouse=WAREHOUSE, basic_rate=100)
make_stock_entry(item_code=item_b, qty=7, to_warehouse=WAREHOUSE, basic_rate=100)

View File

@@ -1345,6 +1345,11 @@ class update_entries_after:
Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return
In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount
"""
if sle.voucher_type == "Stock Reconciliation":
if flt(sle.actual_qty) <= 0 and not self.args.get("sle_id"):
self.update_rate_on_stock_reconciliation(sle)
return
if sle.actual_qty and sle.voucher_detail_no:
outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty)
@@ -1356,8 +1361,6 @@ class update_entries_after:
self.update_rate_on_purchase_receipt(sle, outgoing_rate)
elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt":
self.update_rate_on_subcontracting_receipt(sle, outgoing_rate)
elif sle.voucher_type == "Stock Reconciliation":
self.update_rate_on_stock_reconciliation(sle)
def update_rate_on_stock_entry(self, sle, outgoing_rate):
frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate)
@@ -1451,36 +1454,13 @@ class update_entries_after:
d.db_update()
def update_rate_on_stock_reconciliation(self, sle):
if not sle.serial_no and not sle.batch_no:
sr = frappe.get_lazy_doc("Stock Reconciliation", sle.voucher_no, for_update=True)
for item in sr.items:
# Skip for Serial and Batch Items
if item.name != sle.voucher_detail_no or item.serial_no or item.batch_no:
continue
previous_sle = get_previous_sle(
{
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": sr.posting_date,
"posting_time": sr.posting_time,
"sle": sle.name,
}
)
item.current_qty = previous_sle.get("qty_after_transaction") or 0.0
item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0
item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate)
item.amount = flt(item.qty) * flt(item.valuation_rate)
item.quantity_difference = item.qty - item.current_qty
item.amount_difference = item.amount - item.current_amount
sr.difference_amount = sum([item.amount_difference for item in sr.items])
sr.db_update()
for item in sr.items:
item.db_update()
# Refresh the reconciliation's difference amount and per-row current qty/rate from the reposted
# ledger so the document keeps matching the GL entries. Handles serialized, batched and
# non-serialized items uniformly (the document method reads the current bundle for serial/batch
# rows and the pre-reconciliation ledger balance for non-serial rows).
frappe.get_lazy_doc(
"Stock Reconciliation", sle.voucher_no, for_update=True
).recalculate_difference_amount_from_ledger()
@staticmethod
def get_incoming_value_for_serial_nos(sle, serial_nos):