diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml
index 94b76b12ce7..3b9d953db1c 100644
--- a/.github/workflows/linters.yml
+++ b/.github/workflows/linters.yml
@@ -18,7 +18,19 @@ jobs:
cache: pip
- name: Install and Run Pre-commit
- uses: pre-commit/action@v3.0.0
+ uses: pre-commit/action@v3.0.1
+
+ semgrep:
+ name: semgrep
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.10'
+ cache: pip
- name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 13cbf66a5af..c09e5cdedb9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -50,7 +50,6 @@ repos:
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
- erpnext/public/js/controllers/.*|
erpnext/templates/pages/order.js|
erpnext/templates/includes/.*
)$
diff --git a/erpnext/__init__.py b/erpnext/__init__.py
index dbda07fc37e..bfb1c251270 100644
--- a/erpnext/__init__.py
+++ b/erpnext/__init__.py
@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
-__version__ = "15.108.3"
+__version__ = "15.112.0"
def get_default_company(user=None):
diff --git a/erpnext/accounts/doctype/accounting_period/accounting_period.py b/erpnext/accounts/doctype/accounting_period/accounting_period.py
index 300d216618e..426a4d57064 100644
--- a/erpnext/accounts/doctype/accounting_period/accounting_period.py
+++ b/erpnext/accounts/doctype/accounting_period/accounting_period.py
@@ -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):
@@ -34,8 +35,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()
diff --git a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py
index 16cae9683f9..671a28e3956 100644
--- a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py
+++ b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py
@@ -4,7 +4,7 @@
import unittest
import frappe
-from frappe.utils import add_months, nowdate
+from frappe.utils import nowdate
from erpnext.accounts.doctype.accounting_period.accounting_period import (
ClosedAccountingPeriod,
@@ -47,7 +47,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})
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
index 18a0a86fd52..62b6d5c0b6a 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
@@ -79,6 +79,7 @@
"acc_frozen_upto",
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
+ "pcv_job_timeout",
"column_break_25",
"frozen_accounts_modifier",
"tab_break_dpet",
@@ -651,6 +652,14 @@
"fieldtype": "Check",
"label": "Show Party Balance"
},
+ {
+ "default": "3600",
+ "depends_on": "eval: !doc.use_legacy_controller_for_pcv",
+ "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
+ "fieldname": "pcv_job_timeout",
+ "fieldtype": "Int",
+ "label": "PCV Job Timeout (seconds)"
+ },
{
"default": "30, 60, 90, 120",
"fieldname": "default_ageing_range",
@@ -663,7 +672,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2026-05-18 12:16:33.679345",
+ "modified": "2026-06-24 12:59:41.868865",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
index 5cd4955cdd7..83ece261895 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
@@ -60,6 +60,7 @@ class AccountsSettings(Document):
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
post_change_gl_entries: DF.Check
+ pcv_job_timeout: DF.Int
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
index 9ea87ef0ae7..f249cf9c19d 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
@@ -57,7 +57,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
filters.append(["date", "<=", to_date])
if from_date:
filters.append(["date", ">=", from_date])
- transactions = frappe.get_all(
+ transactions = frappe.get_list(
"Bank Transaction",
fields=[
"date",
@@ -82,6 +82,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
@frappe.whitelist()
def get_account_balance(bank_account, till_date, company):
# returns account balance till the specified date
+ frappe.has_permission("Bank Account", "read", bank_account, throw=True)
account = frappe.db.get_value("Bank Account", bank_account, "account")
filters = frappe._dict(
{
diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
index 4294c4462b1..05a9c055078 100644
--- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
@@ -115,6 +115,36 @@ class TestBankTransaction(FrappeTestCase):
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
+ # Amending a reconciled payment entry must not carry over its clearance date
+ def test_clearance_date_cleared_on_amend(self):
+ bank_transaction = frappe.get_doc(
+ "Bank Transaction",
+ dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
+ )
+ payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
+ vouchers = json.dumps(
+ [
+ {
+ "payment_doctype": "Payment Entry",
+ "payment_name": payment.name,
+ "amount": bank_transaction.unallocated_amount,
+ }
+ ]
+ )
+ reconcile_vouchers(bank_transaction.name, vouchers)
+
+ self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
+
+ payment.reload()
+ payment.cancel()
+
+ amended = frappe.copy_doc(payment)
+ amended.amended_from = payment.name
+ amended.docstatus = 0
+ amended.insert()
+
+ self.assertFalse(amended.clearance_date)
+
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc(
diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py
index 4ef1f164aaa..e1038b6af33 100644
--- a/erpnext/accounts/doctype/budget/budget.py
+++ b/erpnext/accounts/doctype/budget/budget.py
@@ -103,8 +103,8 @@ class Budget(Document):
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_(
- "Budget cannot be assigned against {0}, as it's not an Income or Expense account"
- ).format(d.account)
+ "Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
+ ).format(self.account)
)
if d.account in account_list:
diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
index 96272c0d901..41249662624 100644
--- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
+++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
@@ -619,6 +619,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
def get_account_details(
company, posting_date, account, party_type=None, party=None, rounding_loss_allowance: float | None = None
):
+ if not account:
+ return
+ frappe.has_permission("Account", doc=account, throw=True)
+
if not (company and posting_date):
frappe.throw(_("Company and Posting Date is mandatory"))
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js
index ae3ee00e535..232c33d4def 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.js
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js
@@ -40,6 +40,10 @@ frappe.ui.form.on("Journal Entry", {
},
refresh: function (frm) {
+ if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
+ frm.set_read_only();
+ }
+
erpnext.toggle_naming_series();
if (frm.doc.docstatus > 0) {
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index aa048a71ff2..762585601e5 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details,
)
+from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
@@ -123,6 +124,9 @@ class JournalEntry(AccountsController):
if not self.is_opening:
self.is_opening = "No"
+ if self.is_opening == "Yes":
+ validate_opening_entry_against_pcv(self.company)
+
self.clearance_date = None
self.validate_party()
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index 14fc2b51b19..5a5815ae4c7 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -769,17 +769,21 @@ frappe.ui.form.on("Payment Entry", {
frm.set_paid_amount_based_on_received_amount = true;
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
- if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
- frm.set_value("base_paid_amount", frm.doc.base_received_amount);
+ if (frm.doc.paid_amount && frm.doc.source_exchange_rate) {
+ frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
+ frm.set_value("base_received_amount", frm.doc.base_paid_amount);
// target exchange rate should always be same as source if both account currencies is same
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
+ frm.set_value("received_amount", frm.doc.paid_amount);
} else {
- frm.set_value(
- "paid_amount",
- flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
- );
+ const target_rate =
+ flt(frm.doc.target_exchange_rate) ||
+ (company_currency == frm.doc.paid_to_account_currency ? 1 : 0);
+ if (target_rate) {
+ frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate);
+ }
}
// set_unallocated_amount is called by below method,
@@ -795,18 +799,23 @@ frappe.ui.form.on("Payment Entry", {
target_exchange_rate: function (frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
- if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
- frm.set_value("base_received_amount", frm.doc.base_paid_amount);
- if (
- !frm.doc.source_exchange_rate &&
- frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
- ) {
+ if (frm.doc.received_amount && frm.doc.target_exchange_rate) {
+ frm.set_value(
+ "base_received_amount",
+ flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
+ );
+ frm.set_value("base_paid_amount", frm.doc.base_received_amount);
+
+ if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
+ frm.set_value("paid_amount", frm.doc.received_amount);
} else {
- frm.set_value(
- "received_amount",
- flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
- );
+ const source_rate =
+ flt(frm.doc.source_exchange_rate) ||
+ (company_currency == frm.doc.paid_from_account_currency ? 1 : 0);
+ if (source_rate) {
+ frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate);
+ }
}
// set_unallocated_amount is called by below method,
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index e341490ed7f..f13569e0d9b 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -834,6 +834,7 @@ def resend_payment_email(docname):
@frappe.whitelist()
def make_payment_entry(docname):
doc = frappe.get_doc("Payment Request", docname)
+ doc.check_permission("read")
return doc.create_payment_entry(submit=False).as_dict()
diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
index e4e31a9adf4..e9bad6d7494 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
@@ -379,12 +379,15 @@ class TestPeriodClosingVoucher(unittest.TestCase):
self.make_period_closing_voucher(posting_date="2021-03-31")
- # Passed posting_date is after PCV end date, so cancellation should not fail.
- make_reverse_gl_entries(
- voucher_type="Journal Entry",
- voucher_no=jv.name,
- posting_date="2022-01-01",
- )
+ frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", "2021-12-31")
+
+ try:
+ make_reverse_gl_entries(
+ voucher_type="Journal Entry",
+ voucher_no=jv.name,
+ )
+ finally:
+ frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None)
totals_after_cancel = frappe.db.sql(
"""
diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py
index e6a330a4d14..6315560b89f 100644
--- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py
+++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py
@@ -86,48 +86,55 @@ class ProcessPeriodClosingVoucher(Document):
cancel_pcv_processing(self.name)
+def initialize_parallel_threads(docname: str):
+ threads = 4
+ timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
+ ppcvd = qb.DocType("Process Period Closing Voucher Detail")
+
+ frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
+
+ if normal_balances := (
+ qb.from_(ppcvd)
+ .select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
+ .where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
+ .orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
+ .limit(threads)
+ .for_update(skip_locked=True)
+ .run(as_dict=True)
+ ):
+ if not is_scheduler_inactive():
+ for x in normal_balances:
+ frappe.db.set_value(
+ "Process Period Closing Voucher Detail",
+ x.name,
+ "status",
+ "Running",
+ )
+ frappe.enqueue(
+ method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
+ queue="long",
+ timeout=timeout,
+ is_async=True,
+ enqueue_after_commit=True,
+ docname=docname,
+ row_name=x.name,
+ date=x.processing_date,
+ report_type=x.report_type,
+ parentfield=x.parentfield,
+ )
+ # keep transaction on PPCV and PPCVD short
+ # prevents concurrency errors - REPEATABLE READ
+ if not frappe.in_test:
+ frappe.db.commit() # nosemgrep
+ else:
+ frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
+
+
@frappe.whitelist()
def start_pcv_processing(docname: str):
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
- frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
- frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
-
- ppcvd = qb.DocType("Process Period Closing Voucher Detail")
- if normal_balances := (
- qb.from_(ppcvd)
- .select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
- .where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
- .orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
- .limit(4)
- .for_update(skip_locked=True)
- .run(as_dict=True)
- ):
- if not is_scheduler_inactive():
- for x in normal_balances:
- frappe.db.set_value(
- "Process Period Closing Voucher Detail",
- {
- "processing_date": x.processing_date,
- "parent": docname,
- "report_type": x.report_type,
- "parentfield": x.parentfield,
- },
- "status",
- "Running",
- )
- frappe.enqueue(
- method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
- queue="long",
- timeout="3600",
- is_async=True,
- enqueue_after_commit=True,
- docname=docname,
- date=x.processing_date,
- report_type=x.report_type,
- parentfield=x.parentfield,
- )
- else:
- frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
+ frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
+ initialize_parallel_threads(docname)
@frappe.whitelist()
@@ -244,10 +251,12 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
+ timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
+
if to_process := (
qb.from_(ppcvd)
- .select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
+ .select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(1)
@@ -257,22 +266,23 @@ def schedule_next_date(docname: str):
if not is_scheduler_inactive():
frappe.db.set_value(
"Process Period Closing Voucher Detail",
- {
- "processing_date": to_process[0].processing_date,
- "parent": docname,
- "report_type": to_process[0].report_type,
- "parentfield": to_process[0].parentfield,
- },
+ to_process[0].name,
"status",
"Running",
)
+ # keep transaction on PPCV and PPCVD short
+ # prevents concurrency errors - REPEATABLE READ
+ if not frappe.in_test:
+ frappe.db.commit() # nosemgrep
+
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
- timeout="3600",
+ timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
+ row_name=to_process[0].name,
date=to_process[0].processing_date,
report_type=to_process[0].report_type,
parentfield=to_process[0].parentfield,
@@ -299,7 +309,7 @@ def schedule_next_date(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
queue="long",
- timeout="3600",
+ timeout=timeout,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
@@ -437,6 +447,11 @@ def summarize_and_post_ledger_entries(docname):
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
+ # keep transaction on PPCV and PPCVD short
+ # prevents concurrency errors - REPEATABLE READ
+ if not frappe.in_test:
+ frappe.db.commit() # nosemgrep
+
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@@ -522,10 +537,10 @@ def build_dimension_wise_balance_dict(gl_entries):
return dimension_balances
-def process_individual_date(docname: str, date, report_type, parentfield):
+def process_individual_date(docname: str, row_name, date, report_type, parentfield):
current_date_status = frappe.db.get_value(
"Process Period Closing Voucher Detail",
- {"processing_date": date, "report_type": report_type, "parentfield": parentfield},
+ row_name,
"status",
)
if current_date_status != "Running":
@@ -572,17 +587,20 @@ def process_individual_date(docname: str, date, report_type, parentfield):
# save results
frappe.db.set_value(
"Process Period Closing Voucher Detail",
- {"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
+ row_name,
"closing_balance",
frappe.json.dumps(res),
)
frappe.db.set_value(
"Process Period Closing Voucher Detail",
- {"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
+ row_name,
"status",
"Completed",
)
+ # commit heavy computation before touching PPCV or PPCVD
+ if not frappe.in_test:
+ frappe.db.commit() # nosemgrep
# chain call
schedule_next_date(docname)
diff --git a/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py b/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py
index f3a8302ac5b..0e0b905c96a 100644
--- a/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py
+++ b/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py
@@ -1,7 +1,7 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-# import frappe
+import frappe
from frappe.model.document import Document
@@ -24,3 +24,10 @@ class ProcessPeriodClosingVoucherDetail(Document):
# end: auto-generated types
pass
+
+
+def on_doctype_update():
+ frappe.db.add_index(
+ "Process Period Closing Voucher Detail",
+ ["parent", "status", "parentfield", "idx", "processing_date"],
+ )
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
index cd1e357e3bc..c60de4c29c7 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
@@ -13,7 +13,7 @@
{% endif %}
-
{% if filters.party[0] == filters.party_name[0] %}
{{ _("Customer: ") }} {{ filters.party_name[0] }}
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
index bf130db8e22..9f0680de3ee 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
@@ -390,7 +390,6 @@ def get_context(customer, doc):
return {
"doc": template_doc,
"customer": frappe.get_doc("Customer", customer),
- "frappe": frappe.utils,
}
@@ -440,6 +439,8 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
when Is Billing Contact checked
and Primary email- email with Is Primary checked"""
+ frappe.has_permission("Customer", "read", customer_name, throw=True)
+
billing_email = frappe.db.sql(
"""
SELECT
@@ -483,6 +484,7 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
@frappe.whitelist()
def download_statements(document_name):
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
+ doc.check_permission("read")
report = get_report_pdf(doc)
if report:
frappe.local.response.filename = doc.name + ".pdf"
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 9002f3cbd66..491f25c11cb 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -2924,6 +2924,24 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
self.assertRaises(frappe.ValidationError, pi.submit)
+ @change_settings("Accounts Settings", {"over_billing_allowance": 0})
+ def test_non_stock_item_over_billing_against_po_is_blocked(self):
+ service_item = create_item(
+ "_Test Service Item Non Stock PI",
+ is_stock_item=0,
+ is_purchase_item=1,
+ ).name
+
+ po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
+ po.submit()
+
+ pi = make_pi_from_po(po.name)
+ pi.items[0].qty = 10 # overbill by 100 %
+ pi.save()
+
+ with self.assertRaises(frappe.ValidationError):
+ pi.submit()
+
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
pi = make_purchase_invoice(do_not_save=True)
discount_amount = 7
@@ -2935,8 +2953,6 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
pi.save()
self.assertEqual(pi.discount_amount, discount_amount)
-<<<<<<< HEAD
-=======
def test_returned_item_purchase_receipt(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
make_purchase_receipt as make_purchase_receipt_from_pi,
@@ -2960,7 +2976,7 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
pr = make_purchase_receipt_from_pi(pi.name)
self.assertFalse(pr.items)
- @ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
+ @change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_purchase_invoice_return_common_party_je_has_no_negative_amounts(self):
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
@@ -3014,7 +3030,6 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
finally:
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
->>>>>>> f4b827cb3d (fix: honor account freezing date when cancelling vouchers)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 5d6676aa537..97b2afd7751 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -455,8 +455,8 @@ class SalesInvoice(SellingController):
self.calculate_taxes_and_totals()
def before_save(self):
- self.set_account_for_mode_of_payment()
self.set_paid_amount()
+ self.set_account_for_mode_of_payment()
def before_submit(self):
self.add_remarks()
@@ -791,6 +791,13 @@ class SalesInvoice(SellingController):
def set_paid_amount(self):
paid_amount = 0.0
base_paid_amount = 0.0
+
+ if not cint(self.is_pos) and self.is_return:
+ self.set("payments", [])
+ self.paid_amount = paid_amount
+ self.base_paid_amount = base_paid_amount
+ return
+
for data in self.payments:
data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount"))
paid_amount += data.amount
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index e210903c450..0b1f1e922bf 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -1049,6 +1049,21 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(pos_return.get("payments")[0].amount, -500)
self.assertEqual(pos_return.get("payments")[1].amount, -500)
+ def test_non_pos_return_clears_payment_rows(self):
+ from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+
+ si = create_sales_invoice(do_not_save=True)
+ si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
+ si.insert()
+ si.submit()
+
+ si_return = make_sales_return(si.name)
+ si_return.insert()
+
+ self.assertEqual(si_return.is_pos, 0)
+ self.assertEqual(si_return.get("payments"), [])
+ self.assertEqual(si_return.paid_amount, 0)
+
def test_pos_change_amount(self):
make_pos_profile(
company="_Test Company with perpetual inventory",
@@ -3685,6 +3700,51 @@ class TestSalesInvoice(FrappeTestCase):
self.assertTrue("cannot overbill" in str(err.exception).lower())
dn.cancel()
+ @change_settings("Accounts Settings", {"over_billing_allowance": 0})
+ def test_non_stock_item_over_billing_against_so_is_blocked(self):
+ from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
+ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+
+ service_item = create_item(
+ "_Test Service Item Non Stock SI",
+ is_stock_item=0,
+ ).name
+
+ so = make_sales_order(item_code=service_item, qty=5, rate=100)
+ so.submit()
+
+ si = make_si_from_so(so.name)
+ si.items[0].qty = 10 # overbill by 100 %
+ si.save()
+
+ with self.assertRaises(frappe.ValidationError):
+ si.submit()
+
+ @change_settings("Accounts Settings", {"over_billing_allowance": 0})
+ def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self):
+ from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation
+ from erpnext.selling.doctype.quotation.test_quotation import make_quotation
+ from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
+
+ service_item = create_item(
+ "_Test Service Item Non Stock SI Quot",
+ is_stock_item=0,
+ ).name
+
+ quotation = make_quotation(item_code=service_item, qty=5, rate=100)
+
+ so = make_so_from_quotation(quotation.name)
+ so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
+ so.insert()
+ so.submit()
+
+ si = make_si_from_so(so.name)
+ si.items[0].qty = 10 # overbill by 100 %
+ si.save()
+
+ with self.assertRaises(frappe.ValidationError):
+ si.submit()
+
@change_settings(
"Accounts Settings",
{
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index fff2becf2bb..aee7e1e81db 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -701,9 +701,12 @@ def make_reverse_gl_entries(
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
- # For reverse entries, use the posting_date parameter if provided and valid
- # Otherwise fall back to original posting_date
- validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
+ if immutable_ledger_enabled:
+ validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
+ else:
+ validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
+
+ check_freezing_date(validation_date, adv_adj)
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -770,7 +773,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
- new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
+ new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date
@@ -802,13 +805,24 @@ def check_freezing_date(posting_date, adv_adj=False):
)
-def validate_against_pcv(is_opening, posting_date, company):
- if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
+def validate_opening_entry_against_pcv(company):
+ if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
frappe.throw(
- _("Opening Entry can not be created after Period Closing Voucher is created."),
+ _(
+ "A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
+ ).format(
+ '
'
+ + _("Read the docs")
+ + ""
+ ),
title=_("Invalid Opening Entry"),
)
+
+def validate_against_pcv(is_opening, posting_date, company):
+ if is_opening:
+ validate_opening_entry_against_pcv(company)
+
# Local import so you don't have to touch file-level imports
from frappe.query_builder.functions import Max
diff --git a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json
index 88c7cae3f69..34e42ac7cfe 100644
--- a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json
+++ b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json
@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Purchase Invoice",
- "dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
- "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
+ "dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
+ "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Bills",
- "modified": "2024-11-20 19:08:37.043777",
+ "modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Bills",
diff --git a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json
index a53b222ed7d..d0f125df5bf 100644
--- a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json
+++ b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json
@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
- "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
- "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\",false]]",
+ "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
+ "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Payment",
- "modified": "2020-07-22 13:06:20.237689",
+ "modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Payment",
diff --git a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json
index 092defd94bd..5eff4005fda 100644
--- a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json
+++ b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json
@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Sales Invoice",
- "dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
- "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
+ "dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
+ "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Bills",
- "modified": "2020-07-22 13:07:19.633101",
+ "modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Bills",
diff --git a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json
index d60f30f7c9a..a78f73c1dc5 100644
--- a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json
+++ b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json
@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
- "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
- "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\",false]]",
+ "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
+ "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Payment",
- "modified": "2020-07-22 12:49:34.942896",
+ "modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Payment",
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index d02230f0ee7..b39c5a7dc62 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -922,6 +922,15 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
if party_type == "Supplier":
info["total_unpaid"] = -1 * info["total_unpaid"]
+ if info["total_unpaid"] < 0:
+ info["balance_label"] = (
+ "Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
+ )
+ info["balance_amount"] = abs(info["total_unpaid"])
+ else:
+ info["balance_label"] = "Total Unpaid"
+ info["balance_amount"] = info["total_unpaid"]
+
company_wise_info.append(info)
return company_wise_info
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index 21f999197c2..8ecfe51d244 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -562,7 +562,12 @@ class GrossProfitGenerator:
row.base_amount = packed_item.base_amount
# get buying amount
- if row.item_code in product_bundles:
+ if row.is_debit_note:
+ # Rate adjustment debit notes have no stock movement, so buying amount is zero
+ if not grouped_by_invoice:
+ row.qty = 0
+ row.buying_amount = 0
+ elif row.item_code in product_bundles:
row.buying_amount = flt(
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
self.currency_precision,
@@ -925,6 +930,7 @@ class GrossProfitGenerator:
SalesInvoice.customer_group,
SalesInvoice.customer_name,
SalesInvoice.territory,
+ SalesInvoice.is_debit_note,
SalesInvoiceItem.item_code,
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
SalesInvoiceItem.item_name,
@@ -1104,6 +1110,7 @@ class GrossProfitGenerator:
"posting_time": row.posting_time,
"project": row.project,
"update_stock": row.update_stock,
+ "is_debit_note": row.is_debit_note,
"customer": row.customer,
"customer_group": row.customer_group,
"customer_name": row.customer_name,
@@ -1142,6 +1149,7 @@ class GrossProfitGenerator:
"description": item.description,
"warehouse": item.warehouse or row.warehouse,
"update_stock": row.update_stock,
+ "is_debit_note": row.is_debit_note,
"item_group": "",
"brand": "",
"dn_detail": row.dn_detail,
diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py
index 9a0a9cc5174..d24d472710d 100644
--- a/erpnext/accounts/report/gross_profit/test_gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py
@@ -727,6 +727,160 @@ class TestGrossProfit(FrappeTestCase):
self.assertEqual(total[7], 1000.0)
self.assertEqual(total[8], 100.0)
+ def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
+ """Create a rate adjustment debit note with no stock movement."""
+ dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
+ if item_code:
+ dn.items[0].item_code = item_code
+ dn.items[0].item_name = item_code
+ dn.is_debit_note = 1
+ dn.return_against = against_invoice.name
+ dn.items[0].allow_zero_valuation_rate = 1
+ return dn.save().submit()
+
+ def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
+ """
+ Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
+ since there is no stock movement. Gross profit equals the adjustment amount
+ and gross profit % equals 100%.
+ """
+ make_stock_entry(
+ company=self.company,
+ item_code=self.item,
+ target=self.warehouse,
+ qty=1,
+ basic_rate=100,
+ )
+
+ sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
+ sinv.update_stock = 1
+ sinv = sinv.save().submit()
+
+ debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
+
+ filters = frappe._dict(
+ company=self.company,
+ from_date=nowdate(),
+ to_date=nowdate(),
+ group_by="Invoice",
+ )
+
+ columns, data = execute(filters=filters)
+
+ dn_item_rows = [
+ x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
+ ]
+ self.assertEqual(len(dn_item_rows), 1)
+
+ dn_row = dn_item_rows[0]
+ self.assertEqual(dn_row.buying_amount, 0.0)
+ self.assertEqual(dn_row.selling_amount, 20.0)
+ self.assertEqual(dn_row.gross_profit, 20.0)
+ self.assertEqual(dn_row["gross_profit_%"], 100.0)
+
+ def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
+ """
+ The original invoice's GP should be derived solely from its own selling
+ amount and COGS — the rate adjustment debit note must not alter it.
+ """
+ make_stock_entry(
+ company=self.company,
+ item_code=self.item,
+ target=self.warehouse,
+ qty=1,
+ basic_rate=100,
+ )
+
+ sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
+ sinv.update_stock = 1
+ sinv = sinv.save().submit()
+
+ self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
+
+ filters = frappe._dict(
+ company=self.company,
+ from_date=nowdate(),
+ to_date=nowdate(),
+ group_by="Invoice",
+ )
+
+ columns, data = execute(filters=filters)
+
+ sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
+ self.assertEqual(len(sinv_item_rows), 1)
+
+ sinv_row = sinv_item_rows[0]
+ self.assertEqual(sinv_row.selling_amount, 200.0)
+ self.assertEqual(sinv_row.buying_amount, 100.0)
+ self.assertEqual(sinv_row.gross_profit, 100.0)
+ self.assertEqual(sinv_row["gross_profit_%"], 50.0)
+
+ def test_debit_note_qty_not_inflated_in_grouped_report(self):
+ """
+ When grouped by Item Code, the debit note (qty=0) must not inflate
+ the group's qty or buying_amount. The selling amount and average
+ selling rate correctly reflect the rate adjustment.
+ """
+ item = create_item("_Test Rate Adjustment Debit Note Item")
+
+ make_stock_entry(
+ company=self.company,
+ item_code=item.item_code,
+ target=self.warehouse,
+ qty=1,
+ basic_rate=100,
+ )
+
+ sinv = create_sales_invoice(
+ qty=1,
+ rate=200,
+ company=self.company,
+ customer=self.customer,
+ item_code=item.item_code,
+ item_name=item.item_code,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_to,
+ parent_cost_center=self.cost_center,
+ update_stock=1,
+ currency="INR",
+ income_account=self.income_account,
+ expense_account=self.expense_account,
+ )
+
+ self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
+
+ filters = frappe._dict(
+ company=self.company,
+ from_date=nowdate(),
+ to_date=nowdate(),
+ group_by="Item Code",
+ )
+
+ columns, data = execute(filters=filters)
+
+ # group_by="Item Code" column order:
+ # [item_code, item_name, brand, description, qty, base_rate,
+ # buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
+ item_row = next((row for row in data if row[0] == item.item_code), None)
+ self.assertIsNotNone(item_row)
+
+ qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
+ item_row[4],
+ item_row[5],
+ item_row[8],
+ item_row[7],
+ item_row[9],
+ item_row[10],
+ )
+
+ self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
+ self.assertEqual(buying_amount, 100.0) # only original invoice COGS
+ self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
+ self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
+ self.assertEqual(gross_profit, 120.0) # 220 - 100
+ self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
+
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py
index bf604a36db0..8d1730ab294 100644
--- a/erpnext/accounts/report/utils.py
+++ b/erpnext/accounts/report/utils.py
@@ -146,7 +146,6 @@ def get_appropriate_company(filters):
return company
-@frappe.whitelist()
def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False):
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py
index fbe9d7fcf7d..6ffb23659c8 100644
--- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py
@@ -30,10 +30,7 @@ class BulkTransactionLog(Document):
def load_from_db(self):
log_detail = qb.DocType("Bulk Transaction Log Detail")
- has_records = frappe.db.sql(
- "select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
- (self.name,),
- )[0][0]
+ has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
if not has_records:
raise frappe.DoesNotExistError
diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json
index 91019104949..a41638966f1 100644
--- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json
+++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json
@@ -30,11 +30,15 @@
"stock_qty",
"sec_break_price_list",
"price_list_rate",
+ "base_price_list_rate",
+ "discount_and_margin_section",
+ "margin_type",
+ "margin_rate_or_amount",
+ "rate_with_margin",
+ "col_break_6",
"discount_percentage",
"discount_amount",
"distributed_discount_amount",
- "col_break_price_list",
- "base_price_list_rate",
"sec_break1",
"rate",
"amount",
@@ -531,10 +535,6 @@
"fieldname": "sec_break_price_list",
"fieldtype": "Section Break"
},
- {
- "fieldname": "col_break_price_list",
- "fieldtype": "Column Break"
- },
{
"collapsible": 1,
"fieldname": "ad_sec_break",
@@ -572,13 +572,48 @@
"fieldtype": "Currency",
"label": "Distributed Discount Amount",
"options": "currency"
+ },
+ {
+ "depends_on": "price_list_rate",
+ "fieldname": "margin_type",
+ "fieldtype": "Select",
+ "label": "Margin Type",
+ "options": "\nPercentage\nAmount",
+ "print_hide": 1
+ },
+ {
+ "depends_on": "eval:doc.margin_type && doc.price_list_rate",
+ "fieldname": "margin_rate_or_amount",
+ "fieldtype": "Float",
+ "label": "Margin Rate or Amount",
+ "print_hide": 1
+ },
+ {
+ "collapsible": 1,
+ "collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount",
+ "fieldname": "discount_and_margin_section",
+ "fieldtype": "Section Break",
+ "label": "Discount and Margin"
+ },
+ {
+ "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount",
+ "fieldname": "rate_with_margin",
+ "fieldtype": "Currency",
+ "label": "Rate With Margin",
+ "options": "currency",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "col_break_6",
+ "fieldtype": "Column Break"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2024-06-02 06:22:18.864822",
+ "modified": "2025-06-17 12:05:52.441645",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",
@@ -589,4 +624,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py
index a51b9500fd8..ac313cbf182 100644
--- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py
+++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py
@@ -38,6 +38,8 @@ class SupplierQuotationItem(Document):
lead_time_days: DF.Int
manufacturer: DF.Link | None
manufacturer_part_no: DF.Data | None
+ margin_rate_or_amount: DF.Float
+ margin_type: DF.Literal["", "Percentage", "Amount"]
material_request: DF.Link | None
material_request_item: DF.Data | None
net_amount: DF.Currency
@@ -52,6 +54,7 @@ class SupplierQuotationItem(Document):
project: DF.Link | None
qty: DF.Float
rate: DF.Currency
+ rate_with_margin: DF.Currency
request_for_quotation: DF.Link | None
request_for_quotation_item: DF.Data | None
sales_order: DF.Link | None
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 0d3f13dde8c..365e481890f 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -141,6 +141,26 @@ class AccountsController(TransactionBase):
if self.doctype in relevant_docs:
self.set_payment_schedule()
+ def before_insert(self):
+ self.clear_clearance_date_on_amend()
+
+ def clear_clearance_date_on_amend(self):
+ """Drop the bank reconciliation clearance date copied over while amending.
+
+ The framework copies `no_copy` fields when amending, so a reconciled
+ voucher would carry a stale clearance date into its amendment even though
+ the linked bank transaction gets unreconciled on cancellation.
+ """
+ if not self.get("amended_from"):
+ return
+
+ if self.meta.has_field("clearance_date"):
+ self.clearance_date = None
+
+ for payment in self.get("payments") or []:
+ if payment.meta.has_field("clearance_date"):
+ payment.clearance_date = None
+
def remove_bundle_for_non_stock_invoices(self):
has_sabb = False
if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock:
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index e1e3ba3e84e..c58580739e3 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -143,7 +143,7 @@ def validate_returned_items(doc):
ref.rate
and flt(d.rate) > ref.rate
and doc.doctype in ("Delivery Note", "Sales Invoice")
- and get_valuation_method(ref.item_code) != "Moving Average"
+ and get_valuation_method(d.item_code) != "Moving Average"
):
frappe.throw(
_("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format(
diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py
index 21e6d3ea8b9..c695d17e80f 100644
--- a/erpnext/controllers/status_updater.py
+++ b/erpnext/controllers/status_updater.py
@@ -135,7 +135,7 @@ status_map = {
],
[
"Partially Ordered",
- "eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type != 'Material Transfer'",
+ "eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
],
],
"POS Opening Entry": [
@@ -275,6 +275,12 @@ class StatusUpdater(Document):
item["idx"] = d.idx
item["target_ref_field"] = args["target_ref_field"].replace("_", " ")
+ # skip qty over-allowance check for non-stock items
+ if "qty" in args.get("target_ref_field", "") and not frappe.get_cached_value(
+ "Item", item["item_code"], "is_stock_item"
+ ):
+ continue
+
# if not item[args['target_ref_field']]:
# msgprint(_("Note: System will not check over-delivery and over-booking for Item {0} as quantity or amount is 0").format(item.item_code))
if args.get("no_allowance"):
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 32968952fc6..c48eb2bd620 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -263,6 +263,10 @@ class StockController(AccountsController):
parent_details = self.get_parent_details_for_packed_items()
for row in self.get(table_name):
+ item_code = row.get("rm_item_code") or row.get("item_code")
+ if not item_code or not self.is_serial_batch_item(item_code):
+ continue
+
if (
not via_landed_cost_voucher
and row.serial_and_batch_bundle
@@ -1490,6 +1494,9 @@ class StockController(AccountsController):
"remarks": remarks,
}
+ if project:
+ gl_entry.update({"project": project})
+
if voucher_detail_no:
gl_entry.update({"voucher_detail_no": voucher_detail_no})
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index 54aee03e083..e218e9a44cb 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -38,7 +38,9 @@ class calculate_taxes_and_totals:
self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items")
- get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)
+ get_round_off_applicable_accounts(
+ self.doc.company, frappe.flags.round_off_applicable_accounts, self.doc
+ )
self.calculate()
def filter_rows(self):
@@ -183,7 +185,7 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
- do_not_round_fields = ["valuation_rate", "incoming_rate"]
+ do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
@@ -1128,14 +1130,14 @@ def get_itemised_tax_breakup_html(doc):
@frappe.whitelist()
-def get_round_off_applicable_accounts(company, account_list):
+def get_round_off_applicable_accounts(company, account_list, doc=None):
# required to set correct region
with temporary_flag("company", company):
- return get_regional_round_off_accounts(company, account_list)
+ return get_regional_round_off_accounts(company, account_list, doc)
@erpnext.allow_regional
-def get_regional_round_off_accounts(company, account_list):
+def get_regional_round_off_accounts(company, account_list, doc=None):
pass
diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py
index 504716f4684..715acf8782f 100644
--- a/erpnext/controllers/tests/test_taxes_and_totals.py
+++ b/erpnext/controllers/tests/test_taxes_and_totals.py
@@ -1,3 +1,5 @@
+from unittest.mock import patch
+
import frappe
from frappe.tests.utils import FrappeTestCase
@@ -6,6 +8,28 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde
class TestTaxesAndTotals(FrappeTestCase):
+ def test_regional_round_off_accounts(self):
+ """
+ Regional overrides cannot extend the list in-place — the return
+ value must be assigned back to frappe.flags.round_off_applicable_accounts.
+ """
+ test_account = "_Test Round Off Account"
+
+ def mock_regional(company, account_list: list, doc=None) -> list:
+ # Simulates a regional override
+ account_list.extend([test_account])
+ return account_list
+
+ so = make_sales_order(do_not_save=True)
+
+ with patch(
+ "erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts",
+ mock_regional,
+ ):
+ calculate_taxes_and_totals(so)
+
+ self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
+
def test_disabling_rounded_total_resets_base_fields(self):
"""Disabling rounded total should also clear base rounded values."""
so = make_sales_order(do_not_save=True)
diff --git a/erpnext/controllers/tests/test_website_list_for_contact.py b/erpnext/controllers/tests/test_website_list_for_contact.py
new file mode 100644
index 00000000000..d62254d8a0c
--- /dev/null
+++ b/erpnext/controllers/tests/test_website_list_for_contact.py
@@ -0,0 +1,36 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import json
+
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestWebsiteListForContact(FrappeTestCase):
+ def test_get_list_context_currency_symbols(self):
+ # get_list_context builds the enabled-currency symbol map via frappe.get_all (converted from
+ # raw SQL). Exercises that query and asserts a known enabled currency is present.
+ from erpnext.controllers.website_list_for_contact import get_list_context
+
+ context = get_list_context()
+
+ symbols = json.loads(context["currency_symbols"])
+ self.assertIsInstance(symbols, dict)
+ self.assertIn("USD", symbols)
+
+ def test_rfq_transaction_list_returns_supplier_rfq(self):
+ # rfq_transaction_list filters RFQs by the supplier (parties[0]) and uses SELECT DISTINCT with
+ # ORDER BY creation -- both must be valid on Postgres, and the supplier filter must compare to the
+ # party value (not a stray `party[0]` column reference).
+ from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import (
+ make_request_for_quotation,
+ )
+ from erpnext.controllers.website_list_for_contact import rfq_transaction_list
+
+ rfq = make_request_for_quotation()
+ supplier = rfq.suppliers[0].supplier
+
+ rows = rfq_transaction_list(
+ "Request for Quotation Supplier", "Request for Quotation", [supplier], 0, 20
+ )
+ self.assertIn(rfq.name, [row.name for row in rows])
diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py
index ea7b47bd487..a62fccc752c 100644
--- a/erpnext/controllers/website_list_for_contact.py
+++ b/erpnext/controllers/website_list_for_contact.py
@@ -181,9 +181,10 @@ def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_p
party = frappe.qb.DocType(parties_doctype)
data = (
frappe.qb.from_(party)
- .select(party.parent.as_("name"), party.supplier)
+ # creation must be selected: Postgres requires SELECT DISTINCT order-by exprs in the select list
+ .select(party.parent.as_("name"), party.supplier, party.creation)
.distinct()
- .where((party.supplier == party[0]) & (party.docstatus == 1))
+ .where((party.supplier == parties[0]) & (party.docstatus == 1))
.orderby(party.creation, order=frappe.qb.desc)
.limit(limit_page_length)
.offset(limit_start)
diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json
index 28d43c97d29..8822dd7ea02 100644
--- a/erpnext/crm/doctype/crm_settings/crm_settings.json
+++ b/erpnext/crm/doctype/crm_settings/crm_settings.json
@@ -20,7 +20,11 @@
"section_break_13",
"carry_forward_communication_and_comments",
"column_break_junk",
- "update_timestamp_on_new_communication"
+ "update_timestamp_on_new_communication",
+ "frappe_crm_section",
+ "enable_frappe_crm_data_synchronization",
+ "column_break_jbzj",
+ "allowed_users"
],
"fields": [
{
@@ -105,13 +109,37 @@
"fieldname": "enable_opportunity_creation_from_contact_us",
"fieldtype": "Check",
"label": "Enable Opportunity Creation from Contact Us"
+ },
+ {
+ "fieldname": "frappe_crm_section",
+ "fieldtype": "Section Break",
+ "label": "Frappe CRM"
+ },
+ {
+ "fieldname": "column_break_jbzj",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
+ "fieldname": "allowed_users",
+ "fieldtype": "Table MultiSelect",
+ "label": "Allowed Users",
+ "options": "Frappe CRM Allowed User",
+ "permlevel": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_frappe_crm_data_synchronization",
+ "fieldtype": "Check",
+ "label": "Enable Frappe CRM Data Synchronization",
+ "permlevel": 1
}
],
"icon": "fa fa-cog",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2026-06-11 23:09:49.750381",
+ "modified": "2026-06-22 01:26:13.474915",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",
@@ -145,6 +173,16 @@
"role": "Sales Master Manager",
"share": 1,
"write": 1
+ },
+ {
+ "delete": 1,
+ "email": 1,
+ "permlevel": 1,
+ "print": 1,
+ "read": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
}
],
"sort_field": "modified",
diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py
index 01cdaf41bde..7ca341adb77 100644
--- a/erpnext/crm/doctype/crm_settings/crm_settings.py
+++ b/erpnext/crm/doctype/crm_settings/crm_settings.py
@@ -3,6 +3,7 @@
import frappe
from frappe import _
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.model.document import Document
@@ -15,12 +16,16 @@ class CRMSettings(Document):
if TYPE_CHECKING:
from frappe.types import DF
+ from erpnext.crm.doctype.frappe_crm_allowed_user.frappe_crm_allowed_user import FrappeCRMAllowedUser
+
allow_lead_duplication_based_on_emails: DF.Check
+ allowed_users: DF.TableMultiSelect[FrappeCRMAllowedUser]
auto_creation_of_contact: DF.Check
campaign_naming_by: DF.Literal["Campaign Name", "Naming Series"]
carry_forward_communication_and_comments: DF.Check
close_opportunity_after_days: DF.Int
default_valid_till: DF.Data | None
+ enable_frappe_crm_data_synchronization: DF.Check
enable_opportunity_creation_from_contact_us: DF.Check
update_timestamp_on_new_communication: DF.Check
# end: auto-generated types
@@ -28,6 +33,7 @@ class CRMSettings(Document):
def validate(self):
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
self.validate_enable_opportunity_creation_from_contact_us()
+ self.validate_allowed_users()
def validate_enable_opportunity_creation_from_contact_us(self):
contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
@@ -38,3 +44,43 @@ class CRMSettings(Document):
"Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
)
)
+
+ def validate_allowed_users(self):
+ if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
+ frappe.throw(
+ _(
+ "Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
+ )
+ )
+
+ def before_save(self):
+ self.clear_allowed_users()
+
+ def on_update(self):
+ self.custom_fields_for_frappe_crm_data_sync()
+
+ def clear_allowed_users(self):
+ if not self.enable_frappe_crm_data_synchronization:
+ self.allowed_users = []
+
+ def custom_fields_for_frappe_crm_data_sync(self):
+ custom_fields = {
+ "Quotation": [
+ {
+ "fieldname": "crm_deal",
+ "fieldtype": "Data",
+ "label": "Frappe CRM Deal",
+ "insert_after": "party_name",
+ }
+ ],
+ "Customer": [
+ {
+ "fieldname": "crm_deal",
+ "fieldtype": "Data",
+ "label": "Frappe CRM Deal",
+ "insert_after": "prospect_name",
+ }
+ ],
+ }
+
+ create_custom_fields(custom_fields, ignore_validate=True)
diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/__init__.py b/erpnext/crm/doctype/frappe_crm_allowed_user/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json
new file mode 100644
index 00000000000..403a5573460
--- /dev/null
+++ b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json
@@ -0,0 +1,36 @@
+{
+ "actions": [],
+ "allow_bulk_edit": 1,
+ "allow_rename": 1,
+ "creation": "2026-06-22 00:47:12.265968",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "user"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "User",
+ "options": "User",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-06-22 01:49:54.586410",
+ "modified_by": "Administrator",
+ "module": "CRM",
+ "name": "Frappe CRM Allowed User",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py
new file mode 100644
index 00000000000..0cbc5b8ab76
--- /dev/null
+++ b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py
@@ -0,0 +1,23 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class FrappeCRMAllowedUser(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ user: DF.Link
+ # end: auto-generated types
+
+ _DOCTYPE_NAME = "Frappe CRM Allowed User"
diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py
index 94e99a612e8..42b82395719 100644
--- a/erpnext/crm/doctype/lead/lead.py
+++ b/erpnext/crm/doctype/lead/lead.py
@@ -438,6 +438,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None):
out = frappe._dict()
lead_doc = frappe.get_doc("Lead", lead)
+ lead_doc.check_permission()
lead = lead_doc
out.update(
diff --git a/erpnext/crm/doctype/prospect/prospect.py b/erpnext/crm/doctype/prospect/prospect.py
index 7ecbe637f04..02bd2a33be4 100644
--- a/erpnext/crm/doctype/prospect/prospect.py
+++ b/erpnext/crm/doctype/prospect/prospect.py
@@ -136,7 +136,7 @@ def make_opportunity(source_name, target_doc=None):
@frappe.whitelist()
def get_opportunities(prospect):
- return frappe.get_all(
+ return frappe.get_list(
"Opportunity",
filters={"opportunity_from": "Prospect", "party_name": prospect},
fields=[
diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py
index 586ebe91eed..5db9b7dc652 100644
--- a/erpnext/crm/frappe_crm_api.py
+++ b/erpnext/crm/frappe_crm_api.py
@@ -2,35 +2,12 @@ import json
import frappe
from frappe import _
-from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
-
-
-@frappe.whitelist()
-def create_custom_fields_for_frappe_crm():
- frappe.only_for("System Manager")
- custom_fields = {
- "Quotation": [
- {
- "fieldname": "crm_deal",
- "fieldtype": "Data",
- "label": "Frappe CRM Deal",
- "insert_after": "party_name",
- }
- ],
- "Customer": [
- {
- "fieldname": "crm_deal",
- "fieldtype": "Data",
- "label": "Frappe CRM Deal",
- "insert_after": "prospect_name",
- }
- ],
- }
- create_custom_fields(custom_fields, ignore_validate=True)
@frappe.whitelist()
def create_prospect_against_crm_deal():
+ validate_frappe_crm_sync()
+
doc = frappe.form_dict
prospect = frappe.new_doc("Prospect")
prospect.company_name = doc.organization or doc.lead_name
@@ -161,6 +138,8 @@ CUSTOMER_ALLOWED_FIELDS = {
@frappe.whitelist()
def create_customer(customer_data=None):
+ validate_frappe_crm_sync()
+
if not customer_data:
customer_data = frappe.form_dict
@@ -181,3 +160,21 @@ def create_customer(customer_data=None):
except Exception:
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
pass
+
+
+def validate_frappe_crm_sync():
+ CRMSettings = frappe.get_single("CRM Settings")
+ if not CRMSettings.enable_frappe_crm_data_synchronization:
+ frappe.throw(
+ _("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
+ )
+
+ allowed_users = [d.user for d in CRMSettings.allowed_users]
+
+ if frappe.session.user not in allowed_users:
+ frappe.throw(
+ _(
+ "User not allowed to synchronize data from Frappe CRM on ERPNext. Contact System Manager of ERPNext."
+ ),
+ exc=frappe.PermissionError,
+ )
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js
index e096c73cc61..cc8bdf04176 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card.js
@@ -72,8 +72,9 @@ frappe.ui.form.on("Job Card", {
frm.toggle_enable("for_quantity", !has_stock_entry);
if (!frm.is_new() && has_items && frm.doc.docstatus < 2) {
- let to_request = frm.doc.for_quantity > frm.doc.transferred_qty;
- let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
+ const excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
+ const to_transfer = frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty));
+ const to_request = to_transfer;
if (to_request || excess_transfer_allowed) {
frm.add_custom_button(
@@ -85,10 +86,6 @@ frappe.ui.form.on("Job Card", {
);
}
- // check if any row has untransferred materials
- // in case of multiple items in JC
- let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty);
-
if (to_transfer || excess_transfer_allowed) {
frm.add_custom_button(
__("Material Transfer"),
@@ -120,7 +117,8 @@ frappe.ui.form.on("Job Card", {
frm.doc.docstatus == 0 &&
!frm.is_new() &&
(frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) &&
- (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)
+ (!frm.doc.items.length ||
+ !frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty)))
) {
// if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started"
// and if stock mvt for WIP is required
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json
index ba680df99f9..45ddb9cf609 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.json
+++ b/erpnext/manufacturing/doctype/job_card/job_card.json
@@ -234,7 +234,7 @@
"fieldtype": "Select",
"label": "Status",
"no_copy": 1,
- "options": "Open\nWork In Progress\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
+ "options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
"read_only": 1
},
{
@@ -513,7 +513,7 @@
],
"is_submittable": 1,
"links": [],
- "modified": "2026-05-12 12:17:17.750857",
+ "modified": "2026-06-22 11:51:16.526778",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index ae60bcb1ca8..9fddeda3e96 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -107,6 +107,7 @@ class JobCard(Document):
status: DF.Literal[
"Open",
"Work In Progress",
+ "Partially Transferred",
"Material Transferred",
"On Hold",
"Submitted",
@@ -927,6 +928,8 @@ class JobCard(Document):
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty))
+ self.set_status(update_status=True)
+
def set_transferred_qty(self, update_status=False):
"Set total FG Qty in Job Card for which RM was transferred."
if not self.items:
@@ -980,7 +983,22 @@ class JobCard(Document):
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
if self.docstatus < 2:
- if flt(self.for_quantity) <= flt(self.transferred_qty):
+ if self.items:
+ item_data = frappe.get_all(
+ "Job Card Item",
+ filters={"parent": self.name},
+ fields=["transferred_qty", "required_qty"],
+ )
+ all_transferred = item_data and all(
+ flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data
+ )
+ any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data)
+
+ if all_transferred:
+ self.status = "Material Transferred"
+ elif any_transferred:
+ self.status = "Partially Transferred"
+ elif flt(self.for_quantity) <= flt(self.transferred_qty):
self.status = "Material Transferred"
if self.time_logs:
@@ -1224,12 +1242,13 @@ def time_diff_in_minutes(string_ed_date, string_st_date):
@frappe.whitelist()
-def get_job_details(start, end, filters=None):
+def get_job_details(start: str, end: str, filters: str | None = None):
events = []
event_color = {
"Completed": "#cdf5a6",
"Material Transferred": "#ffdd9e",
+ "Partially Transferred": "#ffe5b4",
"Work In Progress": "#D3D3D3",
}
diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js
index e417b7f576d..bddb11d00bb 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card_list.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js
@@ -7,6 +7,7 @@ frappe.listview_settings["Job Card"] = {
Completed: "green",
Cancelled: "red",
"Material Transferred": "blue",
+ "Partially Transferred": "yellow",
Open: "red",
};
const status = doc.status || "Open";
diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json
index 93a0b8960e5..f21e94ebbe0 100644
--- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json
+++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json
@@ -26,7 +26,8 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
- "options": "Item"
+ "options": "Item",
+ "reqd": 1
},
{
"fieldname": "source_warehouse",
@@ -107,7 +108,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-05-12 12:22:18.506904",
+ "modified": "2026-06-23 16:52:37.669110",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Item",
diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.py b/erpnext/manufacturing/doctype/job_card_item/job_card_item.py
index ecf082392a3..f1b0f53e9c4 100644
--- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.py
+++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.py
@@ -16,7 +16,7 @@ class JobCardItem(Document):
allow_alternative_item: DF.Check
description: DF.Text | None
- item_code: DF.Link | None
+ item_code: DF.Link
item_group: DF.Link | None
item_name: DF.Data | None
parent: DF.Data
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 67323d42d40..cb8f24fc9f1 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -9,6 +9,7 @@ from collections import defaultdict
import frappe
from frappe import _, msgprint
from frappe.model.document import Document
+from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import (
add_days,
@@ -1375,7 +1376,7 @@ def get_material_request_items(
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
)
- if required_qty > 0:
+ if flt(row.get("qty")) > 0:
return {
"item_code": row.item_code,
"item_name": row.item_name,
@@ -1880,7 +1881,12 @@ def get_reserved_qty_for_production_plan(item_code, warehouse):
frappe.qb.from_(table)
.inner_join(child)
.on(table.name == child.parent)
- .select(Sum(child.quantity * child.conversion_factor))
+ .select(
+ Sum(
+ (Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity))
+ * child.conversion_factor
+ )
+ )
.where(
(table.docstatus == 1)
& (child.item_code == item_code)
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 5af1fdb36b5..e64a8c16b84 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -212,13 +212,15 @@ class TestProductionPlan(FrappeTestCase):
quantities = [d["quantity"] for d in mr_items]
rm_qty = sum(quantities)
- # Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour)
- self.assertEqual(len(mr_items), 2)
- self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)")
+ # 3 MR items: SO1's requirement is covered by stock (qty=0 but reserved), SO2 and SO3 need 1 each
+ self.assertEqual(len(mr_items), 3)
+ self.assertEqual(
+ rm_qty, 2, "Cascading failed: total purchase qty should be 2 (3 needed - 1 in stock)"
+ )
self.assertEqual(
quantities,
- [1, 1],
- "Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock",
+ [0, 1, 1],
+ "SO1 stock-covered item should appear with qty=0 for reservation; SO2 and SO3 need qty=1",
)
sr.cancel()
@@ -251,11 +253,13 @@ class TestProductionPlan(FrappeTestCase):
pln = create_production_plan(
item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=0
)
- self.assertFalse(len(pln.mr_items))
+ items_needing_purchase = [row.item_code for row in pln.mr_items if row.quantity > 0]
+ self.assertFalse(len(items_needing_purchase))
+
+ pln.cancel()
sr1.cancel()
sr2.cancel()
- pln.cancel()
def test_production_plan_sales_orders(self):
"Test if previously fulfilled SO (with WO) is pulled into Prod Plan."
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index bf56182ee41..3d599e311bb 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -687,6 +687,28 @@ class TestWorkOrder(FrappeTestCase):
ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
+ @change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
+ def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
+ # "_Test Item Group" is master data with buying_cost_center already set to
+ # "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
+ # BOM need to be created, since no existing item in that group has one.
+ fg_item = make_item(
+ "_Test FG Item For Item Group Cost Center",
+ {"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
+ )
+
+ if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
+ make_bom(item=fg_item.name, raw_materials=["_Test Item"])
+
+ wo_order = make_wo_order_test_record(
+ production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
+ )
+ ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
+ ste.insert()
+
+ fg_row = next(d for d in ste.items if d.is_finished_item)
+ self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
+
def test_operation_time_with_batch_size(self):
fg_item = "Test Batch Size Item For BOM"
rm1 = "Test Batch Size Item RM 1 For BOM"
@@ -1461,6 +1483,68 @@ class TestWorkOrder(FrappeTestCase):
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
+ def test_material_transferred_min_fraction_on_partial_pick_list(self):
+ """Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
+ must reflect the least-transferred required item (the bottleneck), instead of being
+ marked fully transferred prematurely when only some materials are transferred.
+ """
+ work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
+ test_stock_entry.make_stock_entry(
+ item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
+ )
+ test_stock_entry.make_stock_entry(
+ item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
+ )
+
+ required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
+
+ # pick-list transfer: For Quantity = 0
+ transfer_entry = frappe.get_doc(
+ make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
+ )
+ self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
+
+ for item in transfer_entry.items:
+ full_qty = required_qty[item.item_code]
+ item.qty = full_qty if item.item_code == "_Test Item" else full_qty / 2
+ item.transfer_qty = item.qty
+ transfer_entry.submit()
+
+ work_order.reload()
+ transferred_qty = {row.item_code: flt(row.transferred_qty) for row in work_order.required_items}
+ self.assertEqual(transferred_qty["_Test Item"], required_qty["_Test Item"])
+ self.assertEqual(
+ transferred_qty["_Test Item Home Desktop 100"],
+ required_qty["_Test Item Home Desktop 100"] / 2,
+ )
+ # bottleneck fraction = 0.5 -> 0.5 * qty(2) = 1.0
+ self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)
+
+ def test_material_transferred_full_via_pick_list_flow(self):
+ """Pick-list flow with every required item fully transferred marks the work order
+ as fully transferred (min fraction = 1.0)."""
+ work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
+ test_stock_entry.make_stock_entry(
+ item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
+ )
+ test_stock_entry.make_stock_entry(
+ item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
+ )
+
+ required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
+
+ transfer_entry = frappe.get_doc(
+ make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
+ )
+ self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
+ for item in transfer_entry.items:
+ item.qty = required_qty[item.item_code]
+ item.transfer_qty = item.qty
+ transfer_entry.submit()
+
+ work_order.reload()
+ self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
+
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js
index 3b3448333d9..5905d40b2f3 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.js
+++ b/erpnext/manufacturing/doctype/work_order/work_order.js
@@ -979,17 +979,24 @@ erpnext.work_order = {
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
- this.show_prompt_for_qty_input(frm, purpose)
- .then((data) => {
- return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", {
+ const max = this.get_max_transferable_qty(frm, purpose);
+
+ const get_pick_list = (for_qty) =>
+ frappe
+ .xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", {
source_name: frm.doc.name,
- for_qty: data.qty,
+ for_qty: for_qty,
+ })
+ .then((pick_list) => {
+ frappe.model.sync(pick_list);
+ frappe.set_route("Form", pick_list.doctype, pick_list.name);
});
- })
- .then((pick_list) => {
- frappe.model.sync(pick_list);
- frappe.set_route("Form", pick_list.doctype, pick_list.name);
- });
+
+ if (max <= 0) {
+ get_pick_list(frm.doc.qty);
+ } else {
+ this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
+ }
},
make_consumption_se: function (frm, backflush_raw_materials_based_on) {
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 9d3100302e7..d6764005a80 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -1241,6 +1241,36 @@ class WorkOrder(Document):
"transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False
)
+ self.recompute_material_transferred_for_manufacturing(transferred_items)
+
+ def recompute_material_transferred_for_manufacturing(self, transferred_items):
+ """Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
+ # When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
+ # SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
+ sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")
+ if sum_fg_completed_qty:
+ self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
+ return
+
+ # Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
+ # so partial availability does not prematurely mark the work order as fully transferred.
+ required_by_item = {}
+ for row in self.required_items:
+ if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
+ continue
+ required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
+
+ if not required_by_item:
+ return
+
+ min_fraction = min(
+ flt(transferred_items.get(item_code) or 0) / required_qty
+ for item_code, required_qty in required_by_item.items()
+ )
+ min_fraction = min(min_fraction, 1.0)
+ material_transferred = min_fraction * flt(self.qty)
+ self.db_set("material_transferred_for_manufacturing", material_transferred)
+
def update_returned_qty(self):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 3b0ac5cf552..b48f16a7550 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -437,3 +437,4 @@ 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
+execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js
index dec0f1c024d..1b1c45e38ea 100644
--- a/erpnext/public/js/controllers/accounts.js
+++ b/erpnext/public/js/controllers/accounts.js
@@ -16,13 +16,15 @@ erpnext.accounts.taxes = {
}
});
},
- onload: function(frm) {
- if(frm.get_field("taxes")) {
- frm.set_query("account_head", "taxes", function(doc) {
- if(frm.cscript.tax_table == "Sales Taxes and Charges") {
- var account_type = ["Tax", "Chargeable", "Expense Account"];
+ 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") {
+ 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 {
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 7b07c13bf11..1f091f3934d 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -207,7 +207,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
"method": "erpnext.controllers.taxes_and_totals.get_round_off_applicable_accounts",
"args": {
"company": me.frm.doc.company,
- "account_list": frappe.flags.round_off_applicable_accounts
+ "account_list": frappe.flags.round_off_applicable_accounts,
+ "doc": me.frm.doc,
},
callback(r) {
if (r.message) {
@@ -497,7 +498,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
} else if(tax.charge_type == "On Net Total") {
if (tax.account_head in item_tax_map) {
current_net_amount = item.net_amount
- };
+ }
current_tax_amount = (tax_rate / 100.0) * item.net_amount;
} else if(tax.charge_type == "On Previous Row Amount") {
current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item
@@ -861,12 +862,13 @@ 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((grand_total - this.frm.doc.total_advance
+ 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),
precision("base_grand_total")
@@ -900,14 +902,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,
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 5fce7783c38..88dc01d5845 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -1007,13 +1007,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(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc[party_account_field])) {
@@ -1427,7 +1422,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
let first_row = this.frm.doc.items[0];
if (!first_row) {
return false
- };
+ }
let mapped_rows = mappped_fields.filter(d => first_row[d])
@@ -1599,7 +1594,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
this.frm.set_currency_labels(["operating_cost", "hour_rate"], this.frm.doc.currency, "operations");
this.frm.set_currency_labels(["base_operating_cost", "base_hour_rate"], company_currency, "operations");
- 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);
@@ -1610,7 +1605,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items");
this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items");
- var item_grid = this.frm.fields_dict["scrap_items"].grid;
+ let item_grid = this.frm.fields_dict["scrap_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);
@@ -2005,7 +2000,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;
@@ -2237,8 +2232,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
},
callback: function(r) {
if (!r.exc) {
- $.each(me.frm.doc.items || [], function(i, item) {
- if (item.name && r.message.hasOwnProperty(item.name) && r.message[item.name].item_tax_template) {
+ $.each(me.frm.doc.items || [], function (i, item) {
+ if (
+ 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;
item.item_tax_rate = r.message[item.name].item_tax_rate;
me.add_taxes_from_item_tax_template(item.item_tax_rate);
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js
index 3ae0b3d1835..8746aa822aa 100755
--- a/erpnext/public/js/utils.js
+++ b/erpnext/public/js/utils.js
@@ -93,11 +93,19 @@ $.extend(erpnext.utils, {
]),
"blue"
);
+ var info = company_wise_info[0];
+ var is_advance = info.balance_label !== "Total Unpaid";
+ var indicator_label =
+ info.balance_label === "Total Advance Paid"
+ ? __("Total Advance Paid: {0}", [format_currency(info.balance_amount, info.currency)])
+ : info.balance_label === "Total Advance Received"
+ ? __("Total Advance Received: {0}", [
+ format_currency(info.balance_amount, info.currency),
+ ])
+ : __("Total Unpaid: {0}", [format_currency(info.balance_amount, info.currency)]);
frm.dashboard.add_indicator(
- __("Total Unpaid: {0}", [
- format_currency(company_wise_info[0].total_unpaid, company_wise_info[0].currency),
- ]),
- company_wise_info[0].total_unpaid ? "orange" : "green"
+ indicator_label,
+ is_advance ? "green" : info.balance_amount ? "orange" : "green"
);
if (company_wise_info[0].loyalty_points) {
@@ -140,7 +148,14 @@ $.extend(erpnext.utils, {
frm.dashboard.stats_area_row.addClass("flex");
frm.dashboard.stats_area_row.css("flex-wrap", "wrap");
- var color = info.total_unpaid ? "orange" : "green";
+ var is_advance = info.balance_label !== "Total Unpaid";
+ var color = is_advance ? "green" : info.balance_amount ? "orange" : "green";
+ var balance_label_text =
+ info.balance_label === "Total Advance Paid"
+ ? __("Total Advance Paid")
+ : info.balance_label === "Total Advance Received"
+ ? __("Total Advance Received")
+ : __("Total Unpaid");
var indicator = $(
'
' +
@@ -154,8 +169,10 @@ $.extend(erpnext.utils, {
'
' +
'Total Unpaid: ' +
- format_currency(info.total_unpaid, info.currency) +
+ '">' +
+ balance_label_text +
+ ": " +
+ format_currency(info.balance_amount, info.currency) +
"
" +
"
"
).appendTo(frm.dashboard.stats_area_row);
diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js
index e232464b53a..f042718045c 100644
--- a/erpnext/selling/page/point_of_sale/pos_controller.js
+++ b/erpnext/selling/page/point_of_sale/pos_controller.js
@@ -40,15 +40,6 @@ erpnext.PointOfSale.Controller = class {
in_list_view: 1,
label: __("Opening Amount"),
options: "company:company_currency",
- onchange: function () {
- dialog.fields_dict.balance_details.df.data.some((d) => {
- if (d.idx == this.doc.idx) {
- d.opening_amount = this.value;
- dialog.fields_dict.balance_details.grid.refresh();
- return true;
- }
- });
- },
},
];
const fetch_pos_payment_methods = () => {
diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py
index 405159215cd..23ed83cca84 100644
--- a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py
+++ b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py
@@ -4,7 +4,7 @@
import frappe
from frappe import _, msgprint, qb
-from frappe.query_builder import Criterion
+from frappe.query_builder import Case, Criterion
from erpnext import get_company_currency
@@ -155,50 +155,60 @@ def get_columns(filters):
def get_entries(filters):
- date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date"
- if filters["doc_type"] == "Sales Order":
- qty_field = "delivered_qty"
- else:
- qty_field = "qty"
- conditions, values = get_conditions(filters, date_field)
+ doc_type = filters["doc_type"]
- entries = frappe.db.sql(
- """
- SELECT
- dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code,
- st.sales_person, st.allocated_percentage, dt_item.warehouse,
- CASE
- WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor
- ELSE dt_item.stock_qty
- END as stock_qty,
- CASE
- WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor)
- ELSE dt_item.base_net_amount
- END as base_net_amount,
- CASE
- WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100)
- ELSE dt_item.base_net_amount * st.allocated_percentage/100
- END as contribution_amt
- FROM
- `tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st
- WHERE
- st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {}
- and dt.docstatus = 1 {} order by st.sales_person, dt.name desc
- """.format(
- date_field,
- qty_field,
- qty_field,
- qty_field,
- filters["doc_type"],
- filters["doc_type"],
- "%s",
- conditions,
- ),
- tuple([filters["doc_type"], *values]),
- as_dict=1,
+ date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date"
+ qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty"
+
+ dt = frappe.qb.DocType(doc_type)
+ dt_item = frappe.qb.DocType(f"{doc_type} Item")
+ st = frappe.qb.DocType("Sales Team")
+
+ calc_qty = dt_item[qty_field] * dt_item.conversion_factor
+ calc_net_amount = dt_item.base_net_rate * calc_qty
+
+ stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty")
+
+ base_net_amount_case = (
+ Case()
+ .when(dt.status == "Closed", calc_net_amount)
+ .else_(dt_item.base_net_amount)
+ .as_("base_net_amount")
)
- return entries
+ contribution_amt_case = (
+ Case()
+ .when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100))
+ .else_(dt_item.base_net_amount * st.allocated_percentage / 100)
+ .as_("contribution_amt")
+ )
+
+ query = (
+ frappe.get_query(dt, filters=filters, ignore_permissions=False)
+ .join(dt_item)
+ .on(dt.name == dt_item.parent)
+ .join(st)
+ .on(dt.name == st.parent)
+ .select(
+ dt.name,
+ dt.customer,
+ dt.territory,
+ dt[date_field].as_("posting_date"),
+ dt_item.item_code,
+ st.sales_person,
+ st.allocated_percentage,
+ dt_item.warehouse,
+ stock_qty_case,
+ base_net_amount_case,
+ contribution_amt_case,
+ )
+ .where(st.parenttype == doc_type)
+ .where(dt.docstatus == 1)
+ )
+
+ query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
+
+ return query.run(as_dict=True)
def get_conditions(filters, date_field):
diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js
index 33a7ecc8714..799686fbbb1 100644
--- a/erpnext/setup/doctype/company/company.js
+++ b/erpnext/setup/doctype/company/company.js
@@ -208,7 +208,7 @@ frappe.ui.form.on("Company", {
reqd: 1,
description: __(
"Please make sure you really want to delete all the transactions for {0}. Your master data will remain as it is. This action cannot be undone.",
- [frappe.utils.bold(frm.doc.name)]
+ [frappe.utils.escape_html(frm.doc.name).bold()]
),
},
function (data) {
@@ -228,7 +228,9 @@ frappe.ui.form.on("Company", {
},
});
},
- __("Delete all the Transactions for {0}", [frappe.utils.bold(frm.doc.name)]),
+ __("Delete all the Transactions for {0}", [
+ frappe.utils.escape_html(frm.doc.name).bold(),
+ ]),
__("Delete")
);
d.get_primary_btn().addClass("btn-danger");
diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json
index fc6533a1e89..380320f0399 100644
--- a/erpnext/setup/doctype/company/company.json
+++ b/erpnext/setup/doctype/company/company.json
@@ -330,33 +330,48 @@
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Round Off Account",
+ "no_copy": 1,
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_cost_center",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Round Off Cost Center",
+ "no_copy": 1,
"options": "Cost Center"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "write_off_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Write Off Account",
+ "no_copy": 1,
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "exchange_gain_loss_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Exchange Gain / Loss Account",
+ "no_copy": 1,
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_exchange_gain_loss_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Unrealized Exchange Gain/Loss Account",
+ "no_copy": 1,
"options": "Account"
},
{
@@ -482,6 +497,7 @@
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "expenses_included_in_valuation",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@@ -490,15 +506,19 @@
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "accumulated_depreciation_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Accumulated Depreciation Account",
"no_copy": 1,
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_expense_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Depreciation Expense Account",
"no_copy": 1,
"options": "Account"
@@ -519,29 +539,39 @@
"fieldtype": "Column Break"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "disposal_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Gain/Loss Account on Asset Disposal",
"no_copy": 1,
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_cost_center",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Asset Depreciation Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "capital_work_in_progress_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Capital Work In Progress Account",
+ "no_copy": 1,
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "asset_received_but_not_billed",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Asset Received But Not Billed",
+ "no_copy": 1,
"options": "Account"
},
{
@@ -673,15 +703,21 @@
"options": "Warehouse"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_profit_loss_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Unrealized Profit / Loss Account",
+ "no_copy": 1,
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "default_discount_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Default Payment Discount Account",
+ "no_copy": 1,
"options": "Account"
},
{
@@ -723,8 +759,10 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_received_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Default Advance Received Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
+ "no_copy": 1,
"options": "Account"
},
{
@@ -733,8 +771,10 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_paid_account",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Default Advance Paid Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
+ "no_copy": 1,
"options": "Account"
},
{
@@ -814,9 +854,12 @@
"options": "Account"
},
{
+ "depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_for_opening",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Round Off for Opening",
+ "no_copy": 1,
"options": "Account"
},
{
@@ -865,7 +908,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
- "modified": "2025-11-16 16:51:27.624096",
+ "modified": "2026-07-02 07:21:21.794533",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index 299ae82cb69..c53dfa5fb40 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -74,6 +74,7 @@ class Company(NestedSet):
default_operating_cost_account: DF.Link | None
default_payable_account: DF.Link | None
default_provisional_account: DF.Link | None
+ default_purchase_price_variance_account: DF.Link | None
default_receivable_account: DF.Link | None
default_sales_contact: DF.Link | None
default_selling_terms: DF.Link | None
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 0ad8bc781a5..1f119e9aa50 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -9,6 +9,7 @@ from frappe import _
from frappe.contacts.doctype.address.address import get_company_address
from frappe.contacts.doctype.contact.contact import get_default_contact
from frappe.desk.notifications import clear_doctype_notifications
+from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.query_builder import DocType
@@ -910,7 +911,9 @@ def get_returned_qty_map(delivery_note):
@frappe.whitelist()
-def make_sales_invoice(source_name, target_doc=None, args=None):
+def make_sales_invoice(
+ source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None
+):
if args is None:
args = {}
if isinstance(args, str):
@@ -1015,7 +1018,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None):
frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
- if not doc.is_return:
+ if doc.is_return:
+ # A credit note made from a return Delivery Note should roll back the billed
+ # amount on the linked Sales Order too, so that per_billed stays consistent with
+ # per_delivered (which the return already reset).
+ doc.update_billed_amount_in_sales_order = True
+ else:
so, doctype, fieldname = doc.get_order_details()
if (
doc.linked_order_has_payment_terms(so, fieldname, doctype)
diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
index 90bae7f68f5..e77940b1661 100644
--- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
@@ -2599,6 +2599,92 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(dn.per_returned, 100)
self.assertEqual(returned.status, "Return")
+ def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn):
+ """Given a fully billed Sales Order and a submitted Delivery Note that delivers it,
+ a credit note made from the return of that Delivery Note must reset per_billed to 0
+ while leaving the delivery quantities exactly as the return already set them."""
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+
+ so.load_from_db()
+ self.assertEqual(so.per_delivered, 100)
+ self.assertEqual(so.per_billed, 100)
+
+ return_dn = make_sales_return(dn.name)
+ return_dn.insert()
+ return_dn.submit()
+
+ # the return reverses the delivery quantities
+ so.load_from_db()
+ self.assertEqual(so.per_delivered, 0)
+ self.assertEqual(so.items[0].delivered_qty, 0)
+
+ credit_note = make_sales_invoice(return_dn.name)
+ self.assertTrue(credit_note.is_return)
+ self.assertTrue(credit_note.update_billed_amount_in_sales_order)
+ # A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the
+ # credit note only rolls back billing and never re-reverses the delivery quantities.
+ self.assertFalse(credit_note.update_stock)
+ credit_note.insert()
+ credit_note.submit()
+
+ # per_billed is reset, and the delivery state stays exactly as the return left it
+ so.load_from_db()
+ self.assertEqual(so.per_billed, 0)
+ self.assertEqual(so.per_delivered, 0)
+ self.assertEqual(so.items[0].delivered_qty, 0)
+ self.assertEqual(so.items[0].returned_qty, 0)
+
+ # Cancelling the credit note should restore the billed amount on the Sales Order.
+ credit_note.cancel()
+ so.load_from_db()
+ self.assertEqual(so.per_billed, 100)
+
+ def test_sales_order_per_billed_after_credit_note_from_return_dn(self):
+ # Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note.
+ # The DN carries si_detail in this path.
+ from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
+ from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
+
+ make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
+
+ so = make_sales_order(qty=2)
+
+ si = make_si_from_so(so.name)
+ si.insert()
+ si.submit()
+
+ dn = make_delivery_note(si.name)
+ dn.insert()
+ dn.submit()
+
+ self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
+
+ def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self):
+ # SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note.
+ # SO per_billed rolls back via the status_updater in update_prevdoc_status.
+ from erpnext.selling.doctype.sales_order.sales_order import (
+ make_delivery_note as make_dn_from_so,
+ )
+ from erpnext.selling.doctype.sales_order.sales_order import (
+ make_sales_invoice as make_si_from_so,
+ )
+
+ make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
+
+ so = make_sales_order(qty=2)
+
+ si = make_si_from_so(so.name)
+ si.insert()
+ si.submit()
+
+ dn = make_dn_from_so(so.name)
+ dn.insert()
+ dn.submit()
+
+ self.assertIsNone(dn.items[0].si_detail)
+
+ self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
+
def test_sales_return_for_product_bundle(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index 05e8a2f3779..7f077cfd4dd 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -217,6 +217,7 @@ class Item(Document):
self.validate_item_defaults()
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
+ self.validate_serialized_change_with_bundle()
self.validate_item_tax_net_rate_range()
if not self.is_new():
@@ -1074,6 +1075,25 @@ class Item(Document):
frappe.throw(msg, title=_("Linked with submitted documents"))
+ def validate_serialized_change_with_bundle(self):
+ """Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
+ for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
+ if self.is_new() or self.has_serial_no or not self._doc_before_save:
+ return
+
+ # Only relevant when the item was serialized before and is now being unset.
+ if not self._doc_before_save.has_serial_no:
+ return
+
+ # Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't.
+ if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}):
+ frappe.throw(
+ _(
+ "Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
+ ).format(frappe.bold(self.name)),
+ title=_("Serial and Batch Bundle Exists"),
+ )
+
def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None:
linked_doctypes = [
"Delivery Note Item",
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index 8072437a173..a2319ca9488 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -966,6 +966,47 @@ class TestItem(FrappeTestCase):
self.assertRaises(frappe.ValidationError, item_doc.save)
+ def test_cannot_unset_serialized_while_bundle_exists(self):
+ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
+ make_serial_batch_bundle,
+ )
+
+ item = make_item(
+ properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
+ ).name
+
+ serial_no = f"{item}-SN-01"
+ frappe.get_doc(
+ {"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
+ ).insert()
+
+ # A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
+ bundle = make_serial_batch_bundle(
+ {
+ "item_code": item,
+ "warehouse": "_Test Warehouse - _TC",
+ "company": "_Test Company",
+ "qty": 1,
+ "rate": 100,
+ "voucher_type": "Stock Entry",
+ "serial_nos": [serial_no],
+ "type_of_transaction": "Inward",
+ "do_not_submit": True,
+ "ignore_sabb_validation": True,
+ }
+ )
+
+ doc = frappe.get_doc("Item", item)
+ doc.has_serial_no = 0
+ self.assertRaises(frappe.ValidationError, doc.save)
+
+ # Once the bundle is removed, the item can be made non-serialized.
+ frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
+ doc = frappe.get_doc("Item", item)
+ doc.has_serial_no = 0
+ doc.save()
+ self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
+
def set_item_variant_settings(fields):
doc = frappe.get_doc("Item Variant Settings")
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 41624477882..6d42f51a8d6 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -1577,7 +1577,7 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
stock_entry.from_bom = 1
stock_entry.bom_no = work_order.bom_no
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
- stock_entry.fg_completed_qty = pick_list.for_qty
+ stock_entry.fg_completed_qty = 0
if work_order.bom_no:
stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index c27e2e40f30..10099631a75 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -493,6 +493,7 @@ class PurchaseReceipt(BuyingController):
remarks=remarks,
against_account=stock_asset_rbnb,
account_currency=account_currency,
+ project=item.project,
item=item,
)
@@ -535,6 +536,7 @@ class PurchaseReceipt(BuyingController):
against_account=stock_asset_account_name,
debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
account_currency=account_currency,
+ project=item.project,
item=item,
)
@@ -559,6 +561,7 @@ class PurchaseReceipt(BuyingController):
against_account=self.supplier,
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
account_currency=account_currency,
+ project=item.project,
item=item,
)
@@ -572,6 +575,7 @@ class PurchaseReceipt(BuyingController):
against_account=self.supplier,
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
account_currency=account_currency,
+ project=item.project,
item=item,
)
@@ -634,6 +638,7 @@ class PurchaseReceipt(BuyingController):
remarks=remarks,
against_account=stock_asset_account_name,
account_currency=supplier_warehouse_account_currency,
+ project=item.project,
item=item,
)
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 3799e773a7d..edde28a04e6 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -1662,6 +1662,93 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(query[0].value, 0)
+ def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self):
+ """Internal-transfer PR's inward SLE must use DN.incoming_rate even when
+ PR.item.valuation_rate was wrong at submit, so divisional_loss does not
+ leak to COGS."""
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+ from erpnext.stock.stock_ledger import update_entries_after
+
+ prepare_data_for_internal_transfer()
+ customer = "_Test Internal Customer 2"
+ company = "_Test Company with perpetual inventory"
+
+ from_warehouse = create_warehouse("_Test Drift From", company=company)
+ transit_warehouse = create_warehouse("_Test Drift Transit", company=company)
+ to_warehouse = create_warehouse("_Test Drift Receiver", company=company)
+ item_doc = create_item("Test Internal Drift Item")
+
+ make_purchase_receipt(
+ item_code=item_doc.name,
+ company=company,
+ posting_date=add_days(today(), -1),
+ warehouse=from_warehouse,
+ qty=10,
+ rate=100,
+ )
+
+ dn = create_delivery_note(
+ item_code=item_doc.name,
+ company=company,
+ customer=customer,
+ cost_center="Main - TCP1",
+ expense_account="Cost of Goods Sold - TCP1",
+ qty=1,
+ rate=100,
+ warehouse=from_warehouse,
+ target_warehouse=transit_warehouse,
+ )
+ self.assertEqual(flt(dn.items[0].incoming_rate), 100.0)
+
+ pr = make_inter_company_purchase_receipt(dn.name)
+ pr.items[0].warehouse = to_warehouse
+ pr.submit()
+
+ inward_sle = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {
+ "voucher_type": "Purchase Receipt",
+ "voucher_no": pr.name,
+ "warehouse": to_warehouse,
+ "is_cancelled": 0,
+ },
+ ["name", "item_code", "warehouse", "posting_date", "posting_time", "creation", "incoming_rate"],
+ as_dict=True,
+ )
+ self.assertEqual(flt(inward_sle.incoming_rate), 100.0)
+
+ frappe.db.set_value(
+ "Purchase Receipt Item",
+ pr.items[0].name,
+ {"sales_incoming_rate": 0, "valuation_rate": 80},
+ )
+ frappe.db.set_value(
+ "Stock Ledger Entry",
+ inward_sle.name,
+ {"incoming_rate": 80, "stock_value_difference": 80},
+ )
+
+ update_entries_after(
+ {
+ "item_code": inward_sle.item_code,
+ "warehouse": inward_sle.warehouse,
+ "posting_date": inward_sle.posting_date,
+ "posting_time": inward_sle.posting_time,
+ "sle_id": inward_sle.name,
+ "creation": inward_sle.creation,
+ }
+ )
+
+ refreshed = frappe.db.get_value(
+ "Stock Ledger Entry",
+ inward_sle.name,
+ ["incoming_rate", "stock_value_difference"],
+ as_dict=True,
+ )
+ self.assertEqual(flt(refreshed.incoming_rate), 100.0)
+ self.assertEqual(flt(refreshed.stock_value_difference), 100.0)
+
def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_invoice(
self,
):
diff --git a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json
index 418b4825f2f..f95c4a69482 100644
--- a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json
+++ b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json
@@ -1,5 +1,6 @@
{
"actions": [],
+ "allow_rename": 1,
"autoname": "field:parameter",
"creation": "2020-12-28 17:06:00.254129",
"doctype": "DocType",
@@ -34,7 +35,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-02-19 20:33:30.657406",
+ "modified": "2026-06-19 10:55:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Quality Inspection Parameter",
@@ -93,4 +94,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index ef42da67668..f7cc4b90c36 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -86,7 +86,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:
@@ -306,6 +306,9 @@ class RepostItemValuation(Document):
def _recalculate_valuation_rate(self):
doc = frappe.get_doc(self.voucher_type, self.voucher_no)
+ if doc.get("is_internal_supplier"):
+ doc.set_sales_incoming_rate_for_internal_transfer()
+
doc.update_valuation_rate()
for item in doc.items:
item.db_set("valuation_rate", item.valuation_rate)
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index f8facea5f78..4fa630fb8a8 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -513,10 +513,12 @@ class SerialandBatchBundle(Document):
]
# Added to handle rejected warehouse case
+ return_warehouse = None
if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]:
warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no)
if self.warehouse in warehouses:
- filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse])
+ return_warehouse = self.warehouse
+ filters.append(["Serial and Batch Entry", "warehouse", "=", return_warehouse])
bundle_data = frappe.get_all(
"Serial and Batch Bundle",
@@ -529,6 +531,11 @@ class SerialandBatchBundle(Document):
order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`",
)
+ if not bundle_data:
+ bundle_data = self.get_legacy_valuation_rate_for_return_entry(
+ return_against, return_against_voucher_detail_no, return_warehouse
+ )
+
if not bundle_data:
return {}
@@ -540,6 +547,49 @@ class SerialandBatchBundle(Document):
return valuation_details
+ def get_legacy_valuation_rate_for_return_entry(
+ self, return_against, return_against_voucher_detail_no, return_warehouse=None
+ ):
+ """Return the original line's incoming rate per serial no / batch from the SLE, for legacy receipts with no bundle."""
+ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+
+ if not (self.has_serial_no or self.has_batch_no):
+ return []
+
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ query = (
+ frappe.qb.from_(sle)
+ .select(sle.serial_no, sle.batch_no, sle.incoming_rate)
+ .where(
+ (sle.voucher_no == return_against)
+ & (sle.voucher_detail_no == return_against_voucher_detail_no)
+ & (sle.item_code == self.item_code)
+ & (sle.is_cancelled == 0)
+ & (sle.serial_and_batch_bundle.isnull())
+ )
+ )
+
+ if return_warehouse:
+ query = query.where(sle.warehouse == return_warehouse)
+
+ data = []
+ for d in query.run(as_dict=True):
+ if d.serial_no:
+ for serial_no in get_serial_nos(d.serial_no):
+ data.append(
+ frappe._dict(
+ {"serial_no": serial_no, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate}
+ )
+ )
+ elif d.batch_no:
+ data.append(
+ frappe._dict(
+ {"serial_no": None, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate}
+ )
+ )
+
+ return data
+
def calculate_total_qty(self, save=True):
self.total_qty = 0.0
for d in self.entries:
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
index 2cee2bb6f0a..100e62deafb 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
@@ -1246,6 +1246,91 @@ class TestSerialandBatchBundle(FrappeTestCase):
self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name)
+ def _assert_legacy_return_valuation(self, item_code, props, batch_no=None):
+ """Return against a legacy serial/batch receipt (no Serial and Batch Bundle) must value outgoing stock from the original ledger rate."""
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ make_item(item_code, props)
+ if batch_no and not frappe.db.exists("Batch", batch_no):
+ frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert()
+
+ pr = make_purchase_receipt(
+ item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True
+ )
+
+ # Simulate a receipt migrated from an older version: serial nos / batch tracked via the
+ # deprecated fields on the Stock Ledger Entry, with no Serial and Batch Bundle.
+ serial_nos = []
+ for row in pr.items:
+ if row.serial_and_batch_bundle:
+ serial_nos = frappe.get_all(
+ "Serial and Batch Entry",
+ filters={"parent": row.serial_and_batch_bundle},
+ pluck="serial_no",
+ )
+ frappe.db.delete("Serial and Batch Bundle", {"name": row.serial_and_batch_bundle})
+ frappe.db.set_value("Purchase Receipt Item", row.name, "serial_and_batch_bundle", None)
+
+ serial_nos = [sn for sn in serial_nos if sn]
+ legacy = {"serial_and_batch_bundle": None}
+ if batch_no:
+ legacy["batch_no"] = batch_no
+ if serial_nos:
+ legacy["serial_no"] = "\n".join(serial_nos)
+ for sle in frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name}, pluck="name"):
+ frappe.db.set_value("Stock Ledger Entry", sle, legacy)
+
+ rt = make_return_doc("Purchase Receipt", pr.name)
+ rt.items[0].qty = -4
+ rt.items[0].received_qty = -4
+ rt.items[0].use_serial_batch_fields = 1
+ if batch_no:
+ rt.items[0].batch_no = batch_no
+ if serial_nos:
+ rt.items[0].serial_no = "\n".join(serial_nos[:4])
+ rt.submit()
+
+ difference_in_stock_value = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": rt.name, "is_cancelled": 0, "voucher_type": "Purchase Receipt"},
+ "stock_value_difference",
+ )
+ # 4 units returned at the original ledger rate of 100 -> -400 (must not be zero)
+ self.assertEqual(flt(difference_in_stock_value, 2), -400.0)
+
+ def test_return_valuation_for_legacy_batch_without_bundle(self):
+ self._assert_legacy_return_valuation(
+ "Test Legacy Batch Return Valuation",
+ {
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "LBRV-.#####",
+ "is_stock_item": 1,
+ },
+ batch_no="LBRV-BATCH-0001",
+ )
+
+ def test_return_valuation_for_legacy_serial_without_bundle(self):
+ self._assert_legacy_return_valuation(
+ "Test Legacy Serial Return Valuation",
+ {"has_serial_no": 1, "serial_no_series": "LSRV-.#####", "is_stock_item": 1},
+ )
+
+ def test_return_valuation_for_legacy_serial_and_batch_without_bundle(self):
+ self._assert_legacy_return_valuation(
+ "Test Legacy Serial Batch Return Valuation",
+ {
+ "has_serial_no": 1,
+ "serial_no_series": "LSBRV-.#####",
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "LSBRVB-.#####",
+ "is_stock_item": 1,
+ },
+ batch_no="LSBRV-BATCH-0001",
+ )
+
def get_batch_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_batch_nos
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 3ed696e0a02..3aea5271d3d 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -1146,10 +1146,12 @@ class StockEntry(StockController):
if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]:
return
- if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
+ if not self.fg_completed_qty:
+ if self.work_order and self.purpose == "Material Transfer for Manufacture":
+ self._validate_no_excess_transfer()
return
- if not self.fg_completed_qty:
+ if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
return
raw_materials = self.get_bom_raw_materials(self.fg_completed_qty)
@@ -1174,6 +1176,59 @@ class StockEntry(StockController):
title=_("Missing Item"),
)
+ def _validate_no_excess_transfer(self):
+ if self.is_return:
+ return
+
+ if (
+ frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on")
+ == "Material Transferred for Manufacture"
+ ):
+ return
+
+ wo = self.pro_doc
+ if not wo:
+ return
+
+ pending_by_item = {}
+ for r in wo.required_items:
+ pending_by_item[r.item_code] = (
+ pending_by_item.get(r.item_code, 0.0) + flt(r.required_qty) - flt(r.transferred_qty)
+ )
+
+ transfer_by_item = {}
+ first_row_by_item = {}
+ for item in self.items:
+ if not item.s_warehouse:
+ continue
+
+ key = (
+ item.item_code if item.item_code in pending_by_item else getattr(item, "original_item", None)
+ )
+ if key not in pending_by_item:
+ continue
+
+ transfer_by_item[key] = transfer_by_item.get(key, 0.0) + flt(item.qty)
+ first_row_by_item.setdefault(key, item)
+
+ for key, transfer_qty in transfer_by_item.items():
+ pending_qty = max(0.0, pending_by_item[key])
+ if transfer_qty > pending_qty:
+ item = first_row_by_item[key]
+ frappe.throw(
+ _(
+ "Row #{0}: Cannot transfer {1} {2} of Item {3}. "
+ "Maximum transferable quantity is {4} {2}."
+ ).format(
+ item.idx,
+ transfer_qty,
+ item.uom,
+ frappe.bold(item.item_code),
+ pending_qty,
+ ),
+ title=_("Excess Material Transfer"),
+ )
+
def validate_same_source_target_warehouse_during_material_transfer(self):
"""
Validate Material Transfer entries where source and target warehouses are identical.
@@ -2005,6 +2060,8 @@ class StockEntry(StockController):
] += flt(t.base_amount * multiply_based_on) / divide_based_on
if item_account_wise_additional_cost:
+ precision = self.get_debit_field_precision()
+
for d in self.get("items"):
for account, amount in item_account_wise_additional_cost.get(
(d.item_code, d.name), {}
@@ -2012,6 +2069,9 @@ class StockEntry(StockController):
if not amount:
continue
+ amount["amount"] = flt(amount["amount"], precision)
+ amount["base_amount"] = flt(amount["base_amount"], precision)
+
gl_entries.append(
self.get_gl_dict(
{
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index d0d60276a97..ea231ff466c 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -547,6 +547,60 @@ class TestStockEntry(FrappeTestCase):
),
)
+ def test_additional_cost_no_rounding_residual_on_stock_adjustment(self):
+ company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
+ warehouse = "Stores - TCP1"
+ items = [
+ make_item(f"_Test Addl Cost Rounding {x}", {"is_stock_item": 1}).name for x in ("A", "B", "C")
+ ]
+
+ for item_code in items:
+ make_stock_entry(item_code=item_code, target=warehouse, company=company, qty=100, basic_rate=10)
+
+ transfer = make_stock_entry(company=company, purpose="Material Transfer", do_not_save=True)
+ transfer.from_warehouse = warehouse
+ transfer.to_warehouse = warehouse
+ transfer.items = []
+ for item_code in items:
+ transfer.append(
+ "items",
+ {
+ "item_code": item_code,
+ "qty": 100,
+ "s_warehouse": warehouse,
+ "t_warehouse": warehouse,
+ "uom": "Nos",
+ "conversion_factor": 1,
+ },
+ )
+ transfer.append(
+ "additional_costs",
+ {
+ "expense_account": "Expenses Included In Valuation - TCP1",
+ "description": "freight",
+ "amount": 100,
+ },
+ )
+ transfer.insert()
+ transfer.submit()
+
+ gl_entries = frappe.get_all(
+ "GL Entry",
+ filters={"voucher_type": "Stock Entry", "voucher_no": transfer.name},
+ fields=["account", "debit", "credit"],
+ )
+ gl_map = {}
+ for row in gl_entries:
+ account = gl_map.setdefault(row.account, frappe._dict(debit=0.0, credit=0.0))
+ account.debit += row.debit
+ account.credit += row.credit
+
+ self.assertNotIn("Stock Adjustment - TCP1", gl_map)
+
+ stock_in_hand_account = get_inventory_account(company, warehouse)
+ self.assertEqual(flt(gl_map[stock_in_hand_account].debit, 2), 99.99)
+ self.assertEqual(flt(gl_map["Expenses Included In Valuation - TCP1"].credit, 2), 99.99)
+
def check_stock_ledger_entries(self, voucher_type, voucher_no, expected_sle):
expected_sle.sort(key=lambda x: x[1])
diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
index 4e422e320b9..ec73d0c6fee 100644
--- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
+++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
@@ -337,7 +337,6 @@
"print_hide": 1
},
{
- "default": ":Company",
"depends_on": "eval:cint(erpnext.is_perpetual_inventory_enabled(parent.company))",
"fieldname": "cost_center",
"fieldtype": "Link",
@@ -616,7 +615,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-04-27 11:40:38.294196",
+ "modified": "2026-07-03 12:11:53.714931",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",
diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
index 917aba9803e..6aea0ef5337 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
@@ -1257,6 +1257,148 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
self.assertEqual(sle[0].qty_after_transaction, 105)
self.assertEqual(sle[0].actual_qty, 100)
+ def test_update_qty_in_future_sle_shifts_same_timestamp_later_entry(self):
+ # update_qty_in_future_sle treats "future" as strictly after the current entry in the
+ # (posting_datetime, creation) order. An entry sharing the exact posting timestamp but created
+ # later must still have its running balance shifted; comparing posting_datetime alone would skip
+ # it. The current entry itself (same timestamp, same creation) must not be shifted.
+ from erpnext.stock.stock_ledger import update_qty_in_future_sle
+
+ item = make_item().name
+ warehouse = "_Test Warehouse - _TC"
+
+ receipt1 = make_purchase_receipt(
+ item_code=item,
+ warehouse=warehouse,
+ qty=10,
+ rate=10,
+ posting_date="2021-01-01",
+ posting_time="02:00:00",
+ )
+ time.sleep(1)
+ receipt2 = make_purchase_receipt(
+ item_code=item,
+ warehouse=warehouse,
+ qty=20,
+ rate=10,
+ posting_date="2021-01-01",
+ posting_time="02:00:00", # identical timestamp, later creation
+ )
+
+ def sle(voucher):
+ return frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": voucher.name, "is_cancelled": 0},
+ ["name", "posting_date", "posting_time", "creation", "qty_after_transaction"],
+ as_dict=True,
+ )
+
+ sle1, sle2 = sle(receipt1), sle(receipt2)
+ self.assertEqual(sle1.qty_after_transaction, 10)
+ self.assertEqual(sle2.qty_after_transaction, 30)
+
+ # Simulate a +5 qty shift originating at receipt1's ledger position.
+ args = frappe._dict(
+ {
+ "item_code": item,
+ "warehouse": warehouse,
+ "voucher_type": "Purchase Receipt",
+ "voucher_no": receipt1.name,
+ "posting_date": sle1.posting_date,
+ "posting_time": sle1.posting_time,
+ "creation": sle1.creation,
+ "actual_qty": 5,
+ }
+ )
+ update_qty_in_future_sle(args, allow_negative_stock=True)
+
+ # receipt2 (same timestamp, later creation) is shifted; receipt1 (the current entry) is not.
+ self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35)
+ self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10)
+
+ def test_cancel_first_of_two_same_timestamp_entries(self):
+ # Two receipts of the same item+warehouse at the exact same posting timestamp: balances 10 -> 20.
+ # Cancelling the first must leave the second standing alone on a zero base (qty 10), not
+ # double-decremented. The same-timestamp sibling is corrected by the cancellation reprocessing,
+ # so update_qty_in_future_sle must not shift it again.
+ item = make_item().name
+ warehouse = "_Test Warehouse - _TC"
+
+ receipt1 = make_purchase_receipt(
+ item_code=item,
+ warehouse=warehouse,
+ qty=10,
+ rate=10,
+ posting_date="2026-06-01",
+ posting_time="10:00:00",
+ )
+ time.sleep(1)
+ receipt2 = make_purchase_receipt(
+ item_code=item,
+ warehouse=warehouse,
+ qty=10,
+ rate=10,
+ posting_date="2026-06-01",
+ posting_time="10:00:00", # identical timestamp, later creation
+ )
+
+ def qty_after(voucher):
+ return frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": voucher.name, "is_cancelled": 0},
+ "qty_after_transaction",
+ )
+
+ self.assertEqual(qty_after(receipt1), 10)
+ self.assertEqual(qty_after(receipt2), 20)
+
+ receipt1.cancel()
+
+ # receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
+ self.assertEqual(qty_after(receipt2), 10)
+
+ def test_get_next_stock_reco_respects_creation_order(self):
+ # A stock reco sharing the exact posting timestamp of the current entry must only count as the
+ # "next" reco when it was created after that entry. A reco created before it actually precedes
+ # the entry and must not bound (truncate) the qty-shift range.
+ from erpnext.stock.stock_ledger import get_next_stock_reco
+
+ item = make_item().name
+ warehouse = "_Test Warehouse - _TC"
+
+ reco = create_stock_reconciliation(
+ item_code=item,
+ warehouse=warehouse,
+ qty=10,
+ rate=100,
+ posting_date="2021-01-01",
+ posting_time="02:00:00",
+ )
+ reco_sle = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": reco.name, "is_cancelled": 0},
+ ["posting_date", "posting_time", "creation"],
+ as_dict=True,
+ )
+
+ base_kwargs = {
+ "item_code": item,
+ "warehouse": warehouse,
+ "voucher_no": "SOME-OTHER-VOUCHER",
+ "posting_date": reco_sle.posting_date,
+ "posting_time": reco_sle.posting_time,
+ }
+
+ # Current entry created AFTER the reco at the same timestamp -> reco precedes it -> not returned.
+ after = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=5)}
+ self.assertFalse(get_next_stock_reco(after))
+
+ # Current entry created BEFORE the reco at the same timestamp -> reco follows it -> returned.
+ before = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=-5)}
+ result = get_next_stock_reco(before)
+ self.assertTrue(result)
+ self.assertEqual(result[0].voucher_no, reco.name)
+
@change_settings("System Settings", {"float_precision": 3, "currency_precision": 2})
def test_transfer_invariants(self):
"""Extact stock value should be transferred."""
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index 67f9b57c172..ab1358e8293 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -993,6 +993,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):
+ """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(
@@ -1179,6 +1275,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,
diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
index 795ea870cf1..11d7850913e 100644
--- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
@@ -782,6 +782,172 @@ class TestStockReconciliation(FrappeTestCase, 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
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 8c205a10874..13ccb29cc7a 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -12,6 +12,7 @@ from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import add_days, add_months, cint, cstr, flt, get_link_to_form, getdate, parse_json
+import erpnext
from erpnext import get_company_currency
from erpnext.accounts.doctype.pricing_rule.pricing_rule import (
get_pricing_rule_for_item,
@@ -410,12 +411,26 @@ def get_basic_details(args, item, overwrite_warehouse=True):
expense_account = None
- if args.get("doctype") == "Purchase Invoice" and item.is_fixed_asset:
- from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
+ if item.is_fixed_asset:
+ from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
- expense_account = get_asset_category_account(
- fieldname="fixed_asset_account", item=args.item_code, company=args.company
- )
+ if is_cwip_accounting_enabled(item.asset_category):
+ expense_account = get_asset_account(
+ "capital_work_in_progress_account",
+ asset_category=item.asset_category,
+ company=args.company,
+ )
+ elif args.get("doctype") in (
+ "Purchase Invoice",
+ "Purchase Receipt",
+ "Purchase Order",
+ "Material Request",
+ ):
+ from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
+
+ expense_account = get_asset_category_account(
+ fieldname="fixed_asset_account", item=args.item_code, company=args.company
+ )
# Set the UOM to the Default Sales UOM or Default Purchase UOM if configured in the Item Master
if not args.get("uom"):
@@ -518,10 +533,21 @@ def get_basic_details(args, item, overwrite_warehouse=True):
args.name, args.conversion_rate, item.name, out.conversion_factor
)
+ expense_account_field = "default_expense_account"
+ if (
+ item.is_stock_item
+ and erpnext.is_perpetual_inventory_enabled(args.company)
+ and (
+ args.doctype == "Purchase Receipt"
+ or (args.doctype == "Purchase Invoice" and args.get("update_stock"))
+ )
+ ):
+ expense_account_field = "stock_received_but_not_billed"
+
# if default specified in item is for another company, fetch from company
for d in [
["Account", "income_account", "default_income_account"],
- ["Account", "expense_account", "default_expense_account"],
+ ["Account", "expense_account", expense_account_field],
["Cost Center", "cost_center", "cost_center"],
["Warehouse", "warehouse", ""],
]:
diff --git a/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json b/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json
index a8ab8f6ac7d..9b5a71aae02 100644
--- a/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json
+++ b/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json
@@ -8,7 +8,7 @@
"docstatus": 0,
"doctype": "Print Format",
"font_size": 14,
- "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
\\t\\t\\t\\t
Purchase Receipt
{{ doc.name }}\\t\\t\\t\\t\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"supplier_name\", \"print_hide\": 0, \"label\": \"Supplier Name\"}, {\"fieldname\": \"supplier_delivery_note\", \"print_hide\": 0, \"label\": \"Supplier Delivery Note\"}, {\"fieldname\": \"rack\", \"print_hide\": 0, \"label\": \"Rack\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"apply_putaway_rule\", \"print_hide\": 0, \"label\": \"Apply Putaway Rule\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Accounting Dimensions\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"region\", \"print_hide\": 0, \"label\": \"Region\"}, {\"fieldname\": \"function\", \"print_hide\": 0, \"label\": \"Function\"}, {\"fieldname\": \"depot\", \"print_hide\": 0, \"label\": \"Depot\"}, {\"fieldname\": \"cost_center\", \"print_hide\": 0, \"label\": \"Cost Center\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"location\", \"print_hide\": 0, \"label\": \"Location\"}, {\"fieldname\": \"country\", \"print_hide\": 0, \"label\": \"Country\"}, {\"fieldname\": \"project\", \"print_hide\": 0, \"label\": \"Project\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Items\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"scan_barcode\", \"print_hide\": 0, \"label\": \"Scan Barcode\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"set_from_warehouse\", \"print_hide\": 0, \"label\": \"Set From Warehouse\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"
\\n\\t\\n\\t\\t\\n\\t\\t\\t| Sr | \\n\\t\\t\\tItem Name | \\n\\t\\t\\tDescription | \\n\\t\\t\\tQty | \\n\\t\\t\\tRate | \\n\\t\\t\\tAmount | \\n\\t\\t
\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = get_serial_or_batch_nos(row.serial_and_batch_bundle) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n\\n\\t\\t\\t| {{ row.idx }} | \\n\\t\\t\\t\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t | \\n\\t\\t\\t\\n\\t\\t\\t\\t {{ row.description }} | \\n\\t\\t\\t{{ row.qty }} {{ row.uom or row.stock_uom }} | \\n\\t\\t\\t{{\\n\\t\\t\\t\\trow.get_formatted(\\\"rate\\\", doc) }} | \\n\\t\\t\\t{{\\n\\t\\t\\t\\trow.get_formatted(\\\"amount\\\", doc) }} | \\n\\t\\t\\t\\n\\t\\t
\\n\\t\\t{%- endfor -%}\\n\\t\\n
\\n\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total_qty\", \"print_hide\": 0, \"label\": \"Total Quantity\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total\", \"print_hide\": 0, \"label\": \"Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"taxes\", \"print_hide\": 0, \"label\": \"Purchase Taxes and Charges\", \"visible_columns\": [{\"fieldname\": \"category\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"add_deduct_tax\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"charge_type\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"row_id\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_print_rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_paid_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_head\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"description\", \"print_width\": \"300px\", \"print_hide\": 0}, {\"fieldname\": \"rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"region\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"function\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"location\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"cost_center\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"depot\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"country\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_currency\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"tax_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"total\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Section Break\", \"label\": \"Totals\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"grand_total\", \"print_hide\": 0, \"label\": \"Grand Total\"}, {\"fieldname\": \"rounded_total\", \"print_hide\": 0, \"label\": \"Rounded Total\"}, {\"fieldname\": \"in_words\", \"print_hide\": 0, \"label\": \"In Words\"}, {\"fieldname\": \"disable_rounded_total\", \"print_hide\": 0, \"label\": \"Disable Rounded Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Supplier Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"address_display\", \"print_hide\": 0, \"label\": \"Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"contact_display\", \"print_hide\": 0, \"label\": \"Contact\"}, {\"fieldname\": \"contact_mobile\", \"print_hide\": 0, \"label\": \"Mobile No\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Company Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address_display\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldname\": \"terms\", \"print_hide\": 0, \"label\": \"Terms and Conditions\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"
\\n\\t\\n\\t\\t\\n\\t\\t\\t| Sr | \\n\\t\\t\\tItem Name | \\n\\t\\t\\tQty | \\n\\t\\t\\tSerial Nos | \\n\\t\\t\\tBatch Nos (Qty) | \\n\\t\\t
\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = frappe.get_all(\\\"Serial and Batch Entry\\\", \\n\\t\\t fields=[\\\"serial_no\\\", \\\"batch_no\\\", \\\"qty\\\"], \\n\\t\\t filters={\\\"parent\\\": row.serial_and_batch_bundle}) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n \\n {% if bundle_data %}\\n\\t\\t\\t {% for data in bundle_data %}\\n\\t\\t\\t {% if data.serial_no %}\\n\\t\\t\\t {{ serial_nos.append(data.serial_no) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t \\n\\t\\t\\t {% if data.batch_no %}\\n\\t\\t\\t {{ batches.update({data.batch_no: data.qty}) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t {% endfor %}\\n\\t\\t\\t{% endif %}\\n\\n\\t\\t\\t| {{ row.idx }} | \\n\\t\\t\\t\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t | \\n\\t\\t\\t{{ row.qty }} {{ row.uom or row.stock_uom }} | \\n\\t\\t\\t\\n\\t\\t\\t{{ serial_nos|join(',') }} | \\n\\t\\t\\t\\n\\t\\t\\t {% if batches %}\\n {% for batch_no, qty in batches.items() %}\\n {{batch_no}} : {{qty}} {{ row.uom or row.stock_uom }} \\n {% endfor %}\\n {% endif %}\\n\\t\\t\\t | \\n\\t\\t\\t\\n\\t\\t
\\n\\t\\t{%- endfor -%}\\n\\t\\n
\\n\"}]",
+ "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
\\t\\t\\t\\t
Purchase Receipt
{{ doc.name }}\\t\\t\\t\\t\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"supplier_name\", \"print_hide\": 0, \"label\": \"Supplier Name\"}, {\"fieldname\": \"supplier_delivery_note\", \"print_hide\": 0, \"label\": \"Supplier Delivery Note\"}, {\"fieldname\": \"rack\", \"print_hide\": 0, \"label\": \"Rack\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"apply_putaway_rule\", \"print_hide\": 0, \"label\": \"Apply Putaway Rule\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Accounting Dimensions\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"region\", \"print_hide\": 0, \"label\": \"Region\"}, {\"fieldname\": \"function\", \"print_hide\": 0, \"label\": \"Function\"}, {\"fieldname\": \"depot\", \"print_hide\": 0, \"label\": \"Depot\"}, {\"fieldname\": \"cost_center\", \"print_hide\": 0, \"label\": \"Cost Center\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"location\", \"print_hide\": 0, \"label\": \"Location\"}, {\"fieldname\": \"country\", \"print_hide\": 0, \"label\": \"Country\"}, {\"fieldname\": \"project\", \"print_hide\": 0, \"label\": \"Project\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Items\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"scan_barcode\", \"print_hide\": 0, \"label\": \"Scan Barcode\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"set_from_warehouse\", \"print_hide\": 0, \"label\": \"Set From Warehouse\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"
\\n\\t\\n\\t\\t\\n\\t\\t\\t| Sr | \\n\\t\\t\\tItem Name | \\n\\t\\t\\tDescription | \\n\\t\\t\\tQty | \\n\\t\\t\\tRate | \\n\\t\\t\\tAmount | \\n\\t\\t
\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t\\t| {{ row.idx }} | \\n\\t\\t\\t\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t | \\n\\t\\t\\t\\n\\t\\t\\t\\t {{ row.description }} | \\n\\t\\t\\t{{ row.qty }} {{ row.uom or row.stock_uom }} | \\n\\t\\t\\t{{\\n\\t\\t\\t\\trow.get_formatted(\\\"rate\\\", doc) }} | \\n\\t\\t\\t{{\\n\\t\\t\\t\\trow.get_formatted(\\\"amount\\\", doc) }} | \\n\\t\\t\\t\\n\\t\\t
\\n\\t\\t{%- endfor -%}\\n\\t\\n
\\n\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total_qty\", \"print_hide\": 0, \"label\": \"Total Quantity\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total\", \"print_hide\": 0, \"label\": \"Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"taxes\", \"print_hide\": 0, \"label\": \"Purchase Taxes and Charges\", \"visible_columns\": [{\"fieldname\": \"category\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"add_deduct_tax\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"charge_type\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"row_id\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_print_rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_paid_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_head\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"description\", \"print_width\": \"300px\", \"print_hide\": 0}, {\"fieldname\": \"rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"region\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"function\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"location\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"cost_center\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"depot\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"country\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_currency\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"tax_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"total\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Section Break\", \"label\": \"Totals\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"grand_total\", \"print_hide\": 0, \"label\": \"Grand Total\"}, {\"fieldname\": \"rounded_total\", \"print_hide\": 0, \"label\": \"Rounded Total\"}, {\"fieldname\": \"in_words\", \"print_hide\": 0, \"label\": \"In Words\"}, {\"fieldname\": \"disable_rounded_total\", \"print_hide\": 0, \"label\": \"Disable Rounded Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Supplier Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"address_display\", \"print_hide\": 0, \"label\": \"Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"contact_display\", \"print_hide\": 0, \"label\": \"Contact\"}, {\"fieldname\": \"contact_mobile\", \"print_hide\": 0, \"label\": \"Mobile No\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Company Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address_display\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldname\": \"terms\", \"print_hide\": 0, \"label\": \"Terms and Conditions\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"
\\n\\t\\n\\t\\t\\n\\t\\t\\t| Sr | \\n\\t\\t\\tItem Name | \\n\\t\\t\\tQty | \\n\\t\\t\\tSerial Nos | \\n\\t\\t\\tBatch Nos (Qty) | \\n\\t\\t
\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = frappe.get_all(\\\"Serial and Batch Entry\\\", \\n\\t\\t fields=[\\\"serial_no\\\", \\\"batch_no\\\", \\\"qty\\\"], \\n\\t\\t filters={\\\"parent\\\": row.serial_and_batch_bundle}) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n \\n {% if bundle_data %}\\n\\t\\t\\t {% for data in bundle_data %}\\n\\t\\t\\t {% if data.serial_no %}\\n\\t\\t\\t {{ serial_nos.append(data.serial_no) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t \\n\\t\\t\\t {% if data.batch_no %}\\n\\t\\t\\t {{ batches.update({data.batch_no: data.qty}) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t {% endfor %}\\n\\t\\t\\t{% endif %}\\n\\n\\t\\t\\t| {{ row.idx }} | \\n\\t\\t\\t\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t | \\n\\t\\t\\t{{ row.qty }} {{ row.uom or row.stock_uom }} | \\n\\t\\t\\t\\n\\t\\t\\t{{ serial_nos|join(',') }} | \\n\\t\\t\\t\\n\\t\\t\\t {% if batches %}\\n {% for batch_no, qty in batches.items() %}\\n {{batch_no}} : {{qty}} {{ row.uom or row.stock_uom }} \\n {% endfor %}\\n {% endif %}\\n\\t\\t\\t | \\n\\t\\t\\t\\n\\t\\t
\\n\\t\\t{%- endfor -%}\\n\\t\\n
\\n\"}]",
"idx": 0,
"line_breaks": 0,
"margin_bottom": 15.0,
@@ -27,4 +27,4 @@
"raw_printing": 0,
"show_section_headings": 0,
"standard": "Yes"
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py
index 2e11fa1664b..b9ec3e7204f 100644
--- a/erpnext/stock/report/stock_ageing/stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/stock_ageing.py
@@ -492,7 +492,7 @@ class FIFOSlots:
self._add_serial_fifo_slots(row, fifo_queue, serial_nos)
elif batch_nos and row.get("has_batch_no"):
self._add_batch_fifo_slots(row, fifo_queue, batch_nos)
- elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
+ elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
self._add_to_negative_fifo_head(row, fifo_queue)
else:
fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)])
diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
index 003d1a51d93..54f9ef9241e 100644
--- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
@@ -1438,6 +1438,47 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(item_result["total_qty"], -4.0)
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
+ def test_untagged_receipt_with_negative_batch_head(self):
+ """An incoming SLE without batch details must not treat a negative
+ batch slot at the queue head as a qty slot (TypeError: str += float)."""
+ sle = [
+ frappe._dict(
+ name="Enclosure Item",
+ actual_qty=-10,
+ qty_after_transaction=-10,
+ stock_value_difference=-100,
+ warehouse="WH 1",
+ posting_date="2021-12-01",
+ voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False,
+ has_batch_no=True,
+ serial_no=None,
+ batch_no="QI-06448",
+ ),
+ frappe._dict(
+ name="Enclosure Item",
+ actual_qty=45,
+ qty_after_transaction=35,
+ stock_value_difference=1051.65,
+ warehouse="WH 1",
+ posting_date="2021-12-05",
+ voucher_type="Purchase Receipt",
+ voucher_no="002",
+ has_serial_no=False,
+ serial_no=None,
+ batch_no=None,
+ serial_and_batch_bundle="SABB-00001294",
+ ),
+ ]
+
+ slots = FIFOSlots(self.filters, sle).generate()
+ queue = slots["Enclosure Item"]["fifo_queue"]
+
+ self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0)
+ self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0])
+ self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65])
+
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
from frappe.utils import add_days, getdate, nowdate
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index 6bceb0483f8..2c25b2c5b2b 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -100,8 +100,6 @@ class StockBalanceReport:
self.filters["show_warehouse_wise_stock"] = True
item_wise_fifo_queue = FIFOSlots(self.filters, self.sle_entries).generate()
- _func = itemgetter(1)
-
del self.sle_entries
sre_details = self.get_sre_reserved_qty_details()
@@ -126,16 +124,7 @@ class StockBalanceReport:
stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0}
if opening_fifo_queue:
- fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func)
- fifo_queue = normalize_fifo_queue(fifo_queue)
- if not fifo_queue:
- continue
-
- to_date = self.to_date
- stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date)
- stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1])
- stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1])
- stock_ageing_data["fifo_queue"] = fifo_queue
+ stock_ageing_data.update(get_stock_ageing_data(opening_fifo_queue, self.to_date))
report_data.update(stock_ageing_data)
@@ -694,6 +683,21 @@ class StockBalanceReport:
return opening_fifo_queue
+def get_stock_ageing_data(fifo_queue: list, to_date: str) -> dict:
+ stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0}
+ fifo_queue = sorted(filter(itemgetter(1), normalize_fifo_queue(fifo_queue)), key=itemgetter(1))
+
+ if not fifo_queue:
+ return stock_ageing_data
+
+ stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date)
+ stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1])
+ stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1])
+ stock_ageing_data["fifo_queue"] = fifo_queue
+
+ return stock_ageing_data
+
+
def filter_items_with_no_transactions(
iwb_map, float_precision: float, inventory_dimensions: list | None = None
):
diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py
index 0985e4783c3..347c14c7eb0 100644
--- a/erpnext/stock/report/stock_balance/test_stock_balance.py
+++ b/erpnext/stock/report/stock_balance/test_stock_balance.py
@@ -7,7 +7,7 @@ from frappe.utils import 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_balance.stock_balance import execute
+from erpnext.stock.report.stock_balance.stock_balance import execute, get_stock_ageing_data
def stock_balance(filters):
@@ -168,3 +168,19 @@ class TestStockBalance(FrappeTestCase):
rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": [variant.name]}))
self.assertPartialDictEq(attributes, rows[0])
self.assertInvariants(rows)
+
+ def test_stock_ageing_data_accepts_batchwise_valuation_slots(self):
+ fifo_queue = [
+ ["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0],
+ ["SA-BATCH-OLDER", 1, 3.0, "2021-12-01", 30.0],
+ ]
+
+ stock_ageing_data = get_stock_ageing_data(fifo_queue, "2021-12-10")
+
+ self.assertEqual(stock_ageing_data["average_age"], 7.4)
+ self.assertEqual(stock_ageing_data["earliest_age"], 9)
+ self.assertEqual(stock_ageing_data["latest_age"], 5)
+ self.assertEqual(
+ stock_ageing_data["fifo_queue"],
+ [[3.0, "2021-12-01", 30.0], [2.0, "2021-12-05", 20.0]],
+ )
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
index 954acf998d8..aef9fec6414 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
@@ -20,6 +20,7 @@ SLE_FIELDS = (
"outgoing_rate",
"stock_queue",
"batch_no",
+ "serial_no",
"stock_value",
"stock_value_difference",
"valuation_rate",
@@ -52,16 +53,16 @@ def add_invariant_check_fields(sles, filters):
balance_qty = 0.0
balance_stock_value = 0.0
- incorrect_idx = 0
- precision = frappe.get_precision("Stock Ledger Entry", "actual_qty")
+ incorrect_idx = None
+ float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
+ currency_precision = (
+ cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
+ )
for idx, sle in enumerate(sles):
- queue = json.loads(sle.stock_queue) if sle.stock_queue else []
-
- fifo_qty = 0.0
- fifo_value = 0.0
- for qty, rate in queue:
- fifo_qty += qty
- fifo_value += qty * rate
+ if sle.batch_no:
+ sle.use_batchwise_valuation = frappe.db.get_value(
+ "Batch", sle.batch_no, "use_batchwise_valuation", cache=True
+ )
if sle.actual_qty < 0:
sle.consumption_rate = sle.stock_value_difference / sle.actual_qty
@@ -77,57 +78,67 @@ def add_invariant_check_fields(sles, filters):
if balance_qty is None:
balance_qty = sle.qty_after_transaction
- sle.fifo_queue_qty = fifo_qty
- sle.fifo_stock_value = fifo_value
- sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
sle.balance_value_by_qty = (
sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
)
sle.expected_qty_after_transaction = balance_qty
sle.stock_value_from_diff = balance_stock_value
- # set difference fields
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction
- sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
- sle.fifo_value_diff = sle.stock_value - fifo_value
- sle.fifo_valuation_diff = (
- sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
- )
sle.valuation_diff = (
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None
)
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
- if not incorrect_idx and filters.get("show_incorrect_entries"):
- if is_sle_has_correct_data(sle, precision):
- continue
- else:
- incorrect_idx = idx
+ if maintains_fifo_queue(sle):
+ add_fifo_fields(sle, sles[idx - 1] if idx else None)
- if idx > 0:
- sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
- sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
-
- if sle.batch_no:
- sle.use_batchwise_valuation = frappe.db.get_value(
- "Batch", sle.batch_no, "use_batchwise_valuation", cache=True
- )
+ if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision):
+ incorrect_idx = idx
if filters.get("show_incorrect_entries"):
- if incorrect_idx > 0:
- sles = sles[cint(incorrect_idx) - 1 :]
-
- return []
+ if incorrect_idx is None:
+ return []
+ return sles[max(incorrect_idx - 1, 0) :]
return sles
-def is_sle_has_correct_data(sle, precision):
- if flt(sle.difference_in_qty, precision) != 0.0 or flt(sle.diff_value_diff, precision) != 0:
- print(flt(sle.difference_in_qty, precision), flt(sle.diff_value_diff, precision))
- return False
+def maintains_fifo_queue(sle):
+ # no queue is maintained for serialized/batchwise-valued stock
+ return not (
+ sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation)
+ )
- return True
+
+def add_fifo_fields(sle, prev_sle):
+ queue = json.loads(sle.stock_queue) if sle.stock_queue else []
+
+ fifo_qty = 0.0
+ fifo_value = 0.0
+ for qty, rate in queue:
+ fifo_qty += qty
+ fifo_value += qty * rate
+
+ sle.fifo_queue_qty = fifo_qty
+ sle.fifo_stock_value = fifo_value
+ sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
+ sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
+ sle.fifo_value_diff = sle.stock_value - fifo_value
+ sle.fifo_valuation_diff = (
+ sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
+ )
+ # prev row may not maintain a queue; H and H - F stay blank across the gap
+ if prev_sle and prev_sle.fifo_stock_value is not None:
+ sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value
+ sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
+
+
+def is_sle_has_correct_data(sle, float_precision, currency_precision):
+ return (
+ flt(sle.difference_in_qty, float_precision) == 0.0
+ and flt(sle.diff_value_diff, currency_precision) == 0.0
+ )
def get_columns():
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py
new file mode 100644
index 00000000000..b82e341c84a
--- /dev/null
+++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py
@@ -0,0 +1,75 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+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_invariant_check.stock_ledger_invariant_check import execute
+
+WAREHOUSE = "Stores - _TC"
+COMPANY = "_Test Company"
+
+
+class TestStockLedgerInvariantCheck(FrappeTestCase):
+ def run_report(self, **extra):
+ filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE})
+ filters.update(extra)
+ return execute(filters)[1]
+
+ def make_movements(self) -> str:
+ # fresh item per test: db is only rolled back at class teardown on v15
+ item = make_item(properties={"valuation_method": "FIFO"}).name
+ make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01")
+ make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02")
+ make_stock_entry(item_code=item, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03")
+ return item
+
+ def test_diagnostic_rows_have_no_discrepancy(self):
+ item = self.make_movements()
+
+ data = self.run_report(item_code=item)
+
+ self.assertEqual(len(data), 3)
+ for row in data:
+ self.assertLess(abs(row.difference_in_qty), 0.01)
+ self.assertLess(abs(row.fifo_qty_diff), 0.01)
+ self.assertLess(abs(row.diff_value_diff), 0.01)
+
+ def test_running_balance_matches(self):
+ item = self.make_movements()
+
+ data = self.run_report(item_code=item)
+
+ self.assertEqual(data[-1].qty_after_transaction, 11)
+
+ def test_show_incorrect_entries(self):
+ item = self.make_movements()
+
+ self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
+
+ sle = frappe.get_last_doc(
+ "Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0}
+ )
+ frappe.db.set_value(
+ "Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5
+ )
+
+ data = self.run_report(item_code=item, show_incorrect_entries=1)
+ self.assertEqual(len(data), 2) # incorrect entry + one before it for context
+ self.assertEqual(data[-1].name, sle.name)
+
+ def test_batch_item_skips_fifo_queue_checks(self):
+ item = make_item(
+ properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"}
+ ).name
+ make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100)
+
+ data = self.run_report(item_code=item)
+ self.assertTrue(data)
+ for row in data:
+ self.assertIsNone(row.fifo_qty_diff)
+ self.assertIsNone(row.fifo_value_diff)
+
+ self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py
index 327f158e3f6..04b888d85fe 100644
--- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py
+++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py
@@ -205,7 +205,10 @@ def get_data(filters=None):
data = []
if item_warehouse_map:
- precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
+ float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
+ currency_precision = (
+ cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
+ )
for item_warehouse in item_warehouse_map:
report_data = stock_ledger_invariant_check(item_warehouse)
@@ -215,7 +218,11 @@ def get_data(filters=None):
for row in report_data:
if has_difference(
- row, precision, filters.difference_in, item_warehouse.valuation_method or valuation_method
+ row,
+ float_precision,
+ currency_precision,
+ filters.difference_in,
+ item_warehouse.valuation_method or valuation_method,
):
row.update(
{
@@ -261,23 +268,26 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict:
return query.run(as_dict=1)
-def has_difference(row, precision, difference_in, valuation_method):
+def has_difference(row, float_precision, currency_precision, difference_in, valuation_method):
if valuation_method == "Moving Average":
- qty_diff = flt(row.difference_in_qty, precision)
- value_diff = flt(row.diff_value_diff, precision)
- valuation_diff = flt(row.valuation_diff, precision)
+ qty_diff = flt(row.difference_in_qty, float_precision)
+ value_diff = flt(row.diff_value_diff, currency_precision)
+ valuation_diff = flt(row.valuation_diff, currency_precision)
else:
- qty_diff = flt(row.difference_in_qty, precision)
- value_diff = flt(row.diff_value_diff, precision)
+ qty_diff = flt(row.difference_in_qty, float_precision)
+ value_diff = flt(row.diff_value_diff, currency_precision)
if row.stock_queue and json.loads(row.stock_queue):
value_diff = value_diff or (
- flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision)
+ flt(row.fifo_value_diff, currency_precision)
+ or flt(row.fifo_difference_diff, currency_precision)
)
- qty_diff = qty_diff or flt(row.fifo_qty_diff, precision)
+ qty_diff = qty_diff or flt(row.fifo_qty_diff, float_precision)
- valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision)
+ valuation_diff = flt(row.valuation_diff, currency_precision) or flt(
+ row.fifo_valuation_diff, currency_precision
+ )
if difference_in == "Qty" and qty_diff:
return True
@@ -287,3 +297,5 @@ def has_difference(row, precision, difference_in, valuation_method):
return True
elif difference_in not in ["Qty", "Value", "Valuation"] and (qty_diff or value_diff or valuation_diff):
return True
+
+ return False
diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js
index f80126bcb0a..40ad8843871 100644
--- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js
+++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js
@@ -28,28 +28,30 @@ frappe.query_reports["Stock Qty vs Batch Qty"] = {
},
],
onload: function (report) {
- report.page.add_inner_button(__("Update Batch Qty"), function () {
- let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
- let selected_rows = indexes
- .map((i) => frappe.query_report.data[i])
- .filter((row) => row.difference != 0);
+ if (frappe.model.can_write("Batch")) {
+ report.page.add_inner_button(__("Update Batch Qty"), function () {
+ let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
+ let selected_rows = indexes
+ .map((i) => frappe.query_report.data[i])
+ .filter((row) => row.difference != 0);
- if (selected_rows.length) {
- frappe.call({
- method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty",
- args: {
- selected_batches: selected_rows,
- },
- callback: function (r) {
- if (!r.exc) {
- report.refresh();
- }
- },
- });
- } else {
- frappe.msgprint(__("Please select at least one row with difference value"));
- }
- });
+ if (selected_rows.length) {
+ frappe.call({
+ method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty",
+ args: {
+ selected_batches: selected_rows,
+ },
+ callback: function (r) {
+ if (!r.exc) {
+ report.refresh();
+ }
+ },
+ });
+ } else {
+ frappe.msgprint(__("Please select at least one row with difference value"));
+ }
+ });
+ }
},
formatter: function (value, row, column, data, default_formatter) {
diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py
index 87c5e1419cc..e9ccde483ae 100644
--- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py
+++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py
@@ -101,6 +101,7 @@ def get_data(filters=None):
@frappe.whitelist()
def update_batch_qty(selected_batches=None):
+ frappe.has_permission("Batch", "write", throw=True, ignore_share_permissions=True)
if not selected_batches:
return
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index bcc5b3e8a26..ba578f69814 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -134,6 +134,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou
"sle_id": args.get("name"),
"creation": args.get("creation"),
"reserved_stock": args.get("reserved_stock"),
+ "cancelled": args.get("is_cancelled"),
},
allow_negative_stock=allow_negative_stock,
via_landed_cost_voucher=via_landed_cost_voucher,
@@ -869,10 +870,16 @@ class update_entries_after:
if (
sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"]
and sle.voucher_detail_no
- and sle.actual_qty < 0
and is_internal_transfer(sle)
):
- sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle)
+ # Anchor both legs of an internal-transfer PR/PI to the DN/SI incoming_rate;
+ # otherwise an inward SLE that inherits a stale PR.valuation_rate leaks the
+ # gap to COGS via divisional_loss.
+ rate = get_incoming_rate_for_inter_company_transfer(sle)
+ if sle.actual_qty < 0:
+ sle.outgoing_rate = rate
+ elif rate:
+ sle.incoming_rate = rate
dimensions = get_inventory_dimensions()
has_dimensions = False
@@ -908,6 +915,16 @@ class update_entries_after:
and not has_dimensions
):
# assert
+ if (
+ sle.voucher_detail_no
+ and self.repost_doc
+ and self.repost_doc.get("recalculate_valuation_rate")
+ ):
+ source_rate = frappe.get_cached_value(
+ "Stock Reconciliation Item", sle.voucher_detail_no, "valuation_rate"
+ )
+ if source_rate:
+ sle.valuation_rate = source_rate
self.wh_data.valuation_rate = sle.valuation_rate
self.wh_data.qty_after_transaction = sle.qty_after_transaction
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(
@@ -1069,7 +1086,11 @@ class update_entries_after:
self.wh_data.stock_queue = json.loads(stock_queue[0]) if stock_queue else []
self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount)
- self.wh_data.qty_after_transaction += flt(doc.total_qty, self.flt_precision)
+ # Replay the immutable qty recorded on the SLE at submission, not the bundle's recomputed
+ # total_qty. A valuation repost must never rewrite physical quantities; if the bundle's child
+ # rows were edited after submission, doc.total_qty would silently corrupt qty_after_transaction
+ # (and every downstream balance). sle.actual_qty is the frozen movement for this entry.
+ self.wh_data.qty_after_transaction += flt(sle.actual_qty, self.flt_precision)
if flt(self.wh_data.qty_after_transaction, self.flt_precision):
self.wh_data.valuation_rate = flt(self.wh_data.stock_value, self.flt_precision) / flt(
self.wh_data.qty_after_transaction, self.flt_precision
@@ -1319,6 +1340,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)
@@ -1330,8 +1356,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)
@@ -1425,37 +1449,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_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
- else:
- 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()
def get_incoming_value_for_serial_nos(self, sle, serial_nos):
# get rate from serial nos within same company
@@ -2052,36 +2052,47 @@ def get_valuation_rate(
def update_qty_in_future_sle(args, allow_negative_stock=False):
"""Recalculate Qty after Transaction in future SLEs based on current SLE."""
- datetime_limit_condition = ""
qty_shift = args.actual_qty
- args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
+ posting_datetime = get_combine_datetime(args["posting_date"], args["posting_time"])
+ args["posting_datetime"] = posting_datetime
# find difference/shift in qty caused by stock reconciliation
if args.voucher_type == "Stock Reconciliation":
qty_shift = get_stock_reco_qty_shift(args)
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+
+ # SLEs are ordered by (posting_datetime, creation). "Future" therefore means strictly after the
+ # current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a
+ # later creation. Comparing posting_datetime alone would skip same-timestamp entries created after
+ # this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second).
+ # On cancellation `args` is a freshly inserted reversal entry, so its `creation` is the cancel time
+ # (not the original entry's position) and same-timestamp siblings are already recomputed by the
+ # cancelled path in update_entries_after; applying the tiebreaker here would double-shift them.
+ future_condition = sle.posting_datetime > posting_datetime
+ if args.get("creation") and not args.get("is_cancelled"):
+ future_condition = future_condition | (
+ (sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation"))
+ )
+
+ query = (
+ frappe.qb.update(sle)
+ .set(sle.qty_after_transaction, sle.qty_after_transaction + qty_shift)
+ .where(
+ (sle.item_code == args.get("item_code"))
+ & (sle.warehouse == args.get("warehouse"))
+ & (sle.is_cancelled == 0)
+ & future_condition
+ )
+ )
+
# find the next nearest stock reco so that we only recalculate SLEs till that point
next_stock_reco_detail = get_next_stock_reco(args)
if next_stock_reco_detail:
- detail = next_stock_reco_detail[0]
- datetime_limit_condition = get_datetime_limit_condition(detail)
+ query = query.where(get_datetime_limit_condition(sle, next_stock_reco_detail[0]))
- frappe.db.sql( # nosemgrep
- f"""
- update `tabStock Ledger Entry`
- set qty_after_transaction = qty_after_transaction + {qty_shift}
- where
- item_code = %(item_code)s
- and warehouse = %(warehouse)s
- and is_cancelled = 0
- and (
- posting_datetime > %(posting_datetime)s
- )
- {datetime_limit_condition}
- """,
- args,
- )
+ query.run()
validate_negative_qty_in_future_sle(args, allow_negative_stock)
@@ -2116,6 +2127,22 @@ def get_stock_reco_qty_shift(args):
return stock_reco_qty_shift
+def get_next_reco_datetime_condition(sle, kwargs):
+ """Match stock recos that come strictly after the current entry in (posting_datetime, creation)
+ order. Using posting_datetime alone (>=) could pick a reco sharing this exact timestamp but created
+ earlier — i.e. one that actually precedes this entry — and wrongly truncate the qty-shift range."""
+ current_datetime = get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
+
+ creation = kwargs.get("creation")
+ if not creation:
+ # No creation tiebreaker available; fall back to the posting_datetime-only bound.
+ return sle.posting_datetime >= current_datetime
+
+ return (sle.posting_datetime > current_datetime) | (
+ (sle.posting_datetime == current_datetime) & (sle.creation > creation)
+ )
+
+
def get_next_stock_reco(kwargs):
"""Returns next nearest stock reconciliaton's details."""
@@ -2141,10 +2168,7 @@ def get_next_stock_reco(kwargs):
& (sle.voucher_type == "Stock Reconciliation")
& (sle.voucher_no != kwargs.get("voucher_no"))
& (sle.is_cancelled == 0)
- & (
- sle.posting_datetime
- >= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
- )
+ & get_next_reco_datetime_condition(sle, kwargs)
)
.orderby(sle.posting_datetime)
.orderby(sle.creation)
@@ -2157,17 +2181,12 @@ def get_next_stock_reco(kwargs):
return query.run(as_dict=True)
-def get_datetime_limit_condition(detail):
+def get_datetime_limit_condition(sle, detail):
posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time)
- return f"""
- and
- (posting_datetime < '{posting_datetime}'
- or (
- posting_datetime = '{posting_datetime}'
- and creation < '{detail.creation}'
- )
- )"""
+ return (sle.posting_datetime < posting_datetime) | (
+ (sle.posting_datetime == posting_datetime) & (sle.creation < detail.creation)
+ )
def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
@@ -2417,7 +2436,16 @@ def get_incoming_rate_for_inter_company_transfer(sle) -> float:
if lcv_amount:
lcv_rate = flt(lcv_amount / abs(sle.actual_qty))
- return rate + lcv_rate
+ charges_rate = 0.0
+ if flt(sle.actual_qty) > 0:
+ charge_fields = ["item_tax_amount", "rm_supp_cost"]
+ charges = frappe.db.get_value(
+ f"{sle.voucher_type} Item", sle.voucher_detail_no, charge_fields, as_dict=True
+ )
+ if charges:
+ charges_rate = flt(sum(flt(charges.get(f)) for f in charge_fields)) / abs(sle.actual_qty)
+
+ return rate + lcv_rate + charges_rate
def is_internal_transfer(sle):
diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py
index fc19bac0a44..af98aa43980 100644
--- a/erpnext/stock/tests/test_get_item_details.py
+++ b/erpnext/stock/tests/test_get_item_details.py
@@ -35,6 +35,52 @@ class TestGetItemDetail(FrappeTestCase):
details = get_item_details(args)
self.assertEqual(details.get("price_list_rate"), 100)
+ def test_fetch_asset_category_expense_account_on_purchase_receipt(self):
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ asset_category = "Test Expense Account Asset Category"
+ if not frappe.db.exists("Asset Category", asset_category):
+ frappe.get_doc(
+ {
+ "doctype": "Asset Category",
+ "asset_category_name": asset_category,
+ "enable_cwip_accounting": 0,
+ "depreciation_method": "Straight Line",
+ "total_number_of_depreciations": 12,
+ "frequency_of_depreciation": 1,
+ "accounts": [
+ {
+ "company_name": "_Test Company",
+ "fixed_asset_account": "_Test Fixed Asset - _TC",
+ "accumulated_depreciation_account": "_Test Accumulated Depreciations - _TC",
+ "depreciation_expense_account": "_Test Depreciations - _TC",
+ }
+ ],
+ }
+ ).insert()
+
+ asset_item = make_item(
+ "Test Expense Account Asset Item",
+ {"is_stock_item": 0, "is_fixed_asset": 1, "asset_category": asset_category},
+ ).item_code
+
+ args = frappe._dict(
+ {
+ "item_code": asset_item,
+ "company": "_Test Company",
+ "conversion_rate": 1.0,
+ "price_list_currency": "USD",
+ "plc_conversion_rate": 1.0,
+ "doctype": "Purchase Receipt",
+ "supplier": "_Test Supplier",
+ "price_list": "_Test Buying Price List",
+ "ignore_pricing_rule": 1,
+ "qty": 1,
+ }
+ )
+ details = get_item_details(args)
+ self.assertEqual(details.get("expense_account"), "_Test Fixed Asset - _TC")
+
# making this test in get_item_details test file as feat/fix is present in that method
def test_fetch_price_from_list_rate_on_doc_save(self):
# create item
diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py
index c35b76cf37d..a08c9b22c23 100644
--- a/erpnext/support/doctype/issue/issue.py
+++ b/erpnext/support/doctype/issue/issue.py
@@ -226,7 +226,6 @@ def set_multiple_status(names, status):
@frappe.whitelist()
def set_status(name, status):
frappe.has_permission("Issue", "write", name, throw=True)
-
frappe.db.set_value("Issue", name, "status", status)
diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py
index c334a2251b3..09ae0d516bb 100644
--- a/erpnext/support/doctype/issue/test_issue.py
+++ b/erpnext/support/doctype/issue/test_issue.py
@@ -524,6 +524,38 @@ class TestFirstResponseTime(TestSetUp):
)
self.assertEqual(issue.first_response_time, 1.0)
+ def _get_no_perm_user(self):
+ email = "test_no_issue_perm@example.com"
+ if not frappe.db.exists("User", email):
+ user = frappe.new_doc("User")
+ user.email = email
+ user.first_name = "No Perm"
+ user.send_welcome_email = 0
+ user.insert(ignore_permissions=True)
+ return email
+
+ def test_set_status_requires_write_permission(self):
+ from erpnext.support.doctype.issue.issue import set_status
+
+ issue = frappe.new_doc("Issue")
+ issue.subject = "_Test Permission Issue"
+ issue.insert(ignore_permissions=True)
+ frappe.set_user(self._get_no_perm_user())
+ self.assertRaises(frappe.PermissionError, set_status, issue.name, "Closed")
+ frappe.set_user("Administrator")
+
+ def test_set_multiple_status_requires_write_permission(self):
+ import json
+
+ from erpnext.support.doctype.issue.issue import set_multiple_status
+
+ issue = frappe.new_doc("Issue")
+ issue.subject = "_Test Permission Issue"
+ issue.insert(ignore_permissions=True)
+ frappe.set_user(self._get_no_perm_user())
+ self.assertRaises(frappe.PermissionError, set_multiple_status, json.dumps([issue.name]), "Closed")
+ frappe.set_user("Administrator")
+
def create_issue_and_communication(issue_creation, first_responded_on):
issue = make_issue(issue_creation, index=1)
diff --git a/erpnext/templates/emails/daily_project_summary.html b/erpnext/templates/emails/daily_project_summary.html
index 5ccc6101665..689a503a7d1 100644
--- a/erpnext/templates/emails/daily_project_summary.html
+++ b/erpnext/templates/emails/daily_project_summary.html
@@ -10,7 +10,7 @@
|
{% if user.image %}
-
+
{% else %}
|