diff --git a/.greptile/config.json b/.greptile/config.json
new file mode 100644
index 00000000000..8d9c41c662e
--- /dev/null
+++ b/.greptile/config.json
@@ -0,0 +1,10 @@
+{
+ "disabledLabels": [
+ "conflicts"
+ ],
+ "context": {
+ "repos": [
+ "frappe/frappe"
+ ]
+ }
+}
diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py
index 43a7b21aabc..5dc862cc4cd 100644
--- a/erpnext/accounts/doctype/account/account.py
+++ b/erpnext/accounts/doctype/account/account.py
@@ -579,10 +579,12 @@ def update_account_number(name, account_name, account_number=None, from_descenda
@frappe.whitelist()
def merge_account(old, new):
_ensure_idle_system()
- # Validate properties before merging
new_account = frappe.get_cached_doc("Account", new)
old_account = frappe.get_cached_doc("Account", old)
+ new_account.check_permission("write")
+ old_account.check_permission("write")
+
if not new_account:
throw(_("Account {0} does not exist").format(new))
diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
index 36cbb321518..1467400b598 100644
--- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
+++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
@@ -90,7 +90,14 @@ class BankClearance(Document):
@frappe.whitelist()
def update_clearance_date(self):
- clearance_date_updated = False
+ payment_docs = []
+ for d in self.get("payment_entries"):
+ if d.payment_document not in payment_docs:
+ payment_docs.append(d.payment_document)
+
+ for doctype in payment_docs:
+ frappe.has_permission(doctype, "write", throw=True)
+
for d in self.get("payment_entries"):
if d.clearance_date:
if not d.payment_document:
diff --git a/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py
index da273b9f891..f0c6a15cade 100644
--- a/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py
+++ b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py
@@ -121,7 +121,7 @@ class BisectAccountingStatements(Document):
cur_node.save()
- @frappe.whitelist()
+ @frappe.whitelist(methods=["POST"])
def build_tree(self):
frappe.db.delete("Bisect Nodes")
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js
index 40f0938ee1c..950092a2382 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js
@@ -11,22 +11,28 @@ frappe.ui.form.on("Currency Exchange Settings", {
},
callback: function (r) {
if (r && r.message) {
+ let result = [],
+ params = {};
if (frm.doc.service_provider == "exchangerate.host") {
- let result = ["result"];
- let params = {
+ result = ["result"];
+ params = {
date: "{transaction_date}",
from: "{from_currency}",
to: "{to_currency}",
};
- add_param(frm, r.message, params, result);
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
- let result = ["rates", "{to_currency}"];
- let params = {
+ result = ["rates", "{to_currency}"];
+ params = {
base: "{from_currency}",
symbols: "{to_currency}",
};
- add_param(frm, r.message, params, result);
+ } else if (frm.doc.service_provider == "frankfurter.dev - v2") {
+ result = ["rate"];
+ params = {
+ date: "{transaction_date}",
+ };
}
+ add_param(frm, r.message, params, result);
}
},
});
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
index 614f4e6d3e5..a3aea6016b9 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json
@@ -78,7 +78,7 @@
"fieldname": "service_provider",
"fieldtype": "Select",
"label": "Service Provider",
- "options": "frankfurter.dev\nexchangerate.host\nCustom",
+ "options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
"reqd": 1
},
{
@@ -104,7 +104,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2025-11-25 13:03:41.896424",
+ "modified": "2026-06-15 11:25:55.873110",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
@@ -121,24 +121,11 @@
"write": 1
},
{
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "role": "Accounts Manager",
- "share": 1,
- "write": 1
- },
- {
- "create": 1,
- "delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts User",
- "share": 1,
- "write": 1
+ "share": 1
}
],
"row_format": "Dynamic",
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
index 28e4158cc8d..e57805ccea8 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
disabled: DF.Check
req_params: DF.Table[CurrencyExchangeSettingsDetails]
result_key: DF.Table[CurrencyExchangeSettingsResult]
- service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
+ service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
url: DF.Data | None
use_http: DF.Check
# end: auto-generated types
@@ -70,6 +70,14 @@ class CurrencyExchangeSettings(Document):
self.append("req_params", {"key": "base", "value": "{from_currency}"})
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
+ elif self.service_provider == "frankfurter.dev - v2":
+ self.set("result_key", [])
+ self.set("req_params", [])
+
+ self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
+ self.append("result_key", {"key": "rate"})
+ self.append("req_params", {"key": "date", "value": "{transaction_date}"})
+
def validate_parameters(self):
params = {}
for row in self.req_params:
@@ -105,13 +113,20 @@ class CurrencyExchangeSettings(Document):
@frappe.whitelist()
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
- if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
+ if service_provider and service_provider in [
+ "exchangerate.host",
+ "frankfurter.dev",
+ "frankfurter.app",
+ "frankfurter.dev - v2",
+ ]:
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
api = "api.frankfurter.app/{transaction_date}"
elif service_provider == "frankfurter.dev":
api = "api.frankfurter.dev/v1/{transaction_date}"
+ elif service_provider == "frankfurter.dev - v2":
+ api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}"
protocol = "https://"
if use_http:
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 5a2c90a89d5..26c980a1946 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -3038,7 +3038,7 @@ def get_payment_entry(
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
)
- pe.set_exchange_rate(ref_doc=doc)
+ pe.set_exchange_rate()
pe.set_amounts()
# If PE is created from PR directly, then no need to find open PRs for the references
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 0a8b69206ed..79bd66f735a 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -537,6 +537,8 @@ class TestPaymentEntry(FrappeTestCase):
si.submit()
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700)
+ pe.source_exchange_rate = 50
+ pe.set_amounts()
pe.reference_no = si.name
pe.reference_date = nowdate()
@@ -612,6 +614,8 @@ class TestPaymentEntry(FrappeTestCase):
pe = get_payment_entry(
"Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900
)
+ pe.source_exchange_rate = 50
+ pe.set_amounts()
pe.reference_no = "1"
pe.reference_date = "2016-01-01"
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index 8ff021660cc..e341490ed7f 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -11,11 +11,12 @@ from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
+from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account
from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_payment_entry,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
-from erpnext.accounts.party import get_party_account, get_party_bank_account
+from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import get_account_currency, get_currency_precision
from erpnext.utilities import payment_app_import_guard
diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py
index ae8ccffc639..df28b623488 100644
--- a/erpnext/accounts/doctype/payment_request/test_payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py
@@ -195,7 +195,12 @@ class TestPaymentRequest(FrappeTestCase):
return_doc=1,
)
- pe = pr.set_as_paid()
+ pe = pr.create_payment_entry(submit=False)
+ pe.source_exchange_rate = 50
+ pe.target_exchange_rate = 50
+ pe.set_amounts()
+ pe.insert(ignore_permissions=True)
+ pe.submit()
expected_gle = dict(
(d[0], d)
@@ -281,7 +286,12 @@ class TestPaymentRequest(FrappeTestCase):
pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com")
pr = frappe.get_doc(pr).save().submit()
- pe = pr.create_payment_entry()
+ pe = pr.create_payment_entry(submit=False)
+ pe.target_exchange_rate = 80
+ pe.paid_amount = 800
+ pe.set_amounts()
+ pe.insert(ignore_permissions=True)
+ pe.submit()
self.assertEqual(pe.base_paid_amount, 800)
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)
diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py
index 82c3103a039..5edaa2b89c3 100644
--- a/erpnext/accounts/doctype/pos_profile/pos_profile.py
+++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py
@@ -308,32 +308,3 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
)
return pos_profile
-
-
-@frappe.whitelist()
-def set_default_profile(pos_profile, company):
- modified = now()
- user = frappe.session.user
-
- if pos_profile and company:
- frappe.db.sql(
- """ update `tabPOS Profile User` pfu, `tabPOS Profile` pf
- set
- pfu.default = 0, pf.modified = %s, pf.modified_by = %s
- where
- pfu.user = %s and pf.name = pfu.parent and pf.company = %s
- and pfu.default = 1""",
- (modified, user, user, company),
- auto_commit=1,
- )
-
- frappe.db.sql(
- """ update `tabPOS Profile User` pfu, `tabPOS Profile` pf
- set
- pfu.default = 1, pf.modified = %s, pf.modified_by = %s
- where
- pfu.user = %s and pf.name = pfu.parent and pf.company = %s and pf.name = %s
- """,
- (modified, user, user, company, pos_profile),
- auto_commit=1,
- )
diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
index c4c6160fe52..02b7188ad6d 100644
--- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
@@ -128,6 +128,7 @@ def is_job_running(job_name: str) -> bool:
@frappe.whitelist()
def pause_job_for_doc(docname: str | None = None):
if docname:
+ frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Paused")
log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname})
if log:
@@ -142,6 +143,8 @@ def trigger_job_for_doc(docname: str | None = None):
if not docname:
return
+ frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
+
if not frappe.db.get_single_value("Accounts Settings", "auto_reconcile_payments"):
frappe.throw(
_("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format(
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 e23751a8200..e6a330a4d14 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
@@ -89,6 +89,7 @@ class ProcessPeriodClosingVoucher(Document):
@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")
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 26a45bbdc55..bf130db8e22 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
@@ -493,6 +493,7 @@ def download_statements(document_name):
@frappe.whitelist()
def send_emails(document_name, from_scheduler=False, posting_date=None):
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
+ doc.check_permission()
report = get_report_pdf(doc, consolidated=False)
if report:
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index 0c5a73d6a01..f92252df2a7 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -2086,6 +2086,7 @@ def make_stock_entry(source_name, target_doc=None):
def change_release_date(name, release_date=None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_doc("Purchase Invoice", name)
+ pi.check_permission()
pi.db_set("release_date", release_date)
diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py
index a8e468415c1..f11aeda4383 100644
--- a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py
+++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py
@@ -154,12 +154,13 @@ class RepostAccountingLedger(Document):
@frappe.whitelist()
-def start_repost(account_repost_doc=str) -> None:
+def start_repost(account_repost_doc: str | None = None) -> None:
from erpnext.accounts.general_ledger import make_reverse_gl_entries
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
+ repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index 5c0860b4fd3..d02230f0ee7 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -510,11 +510,6 @@ def get_party_advance_account(party_type, party, company):
return account
-@frappe.whitelist()
-def get_party_bank_account(party_type, party):
- return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})
-
-
def get_party_account_currency(party_type, party, company):
def generator():
party_account = get_party_account(party_type, party, company)
@@ -549,11 +544,19 @@ def get_party_gle_currency(party_type, party, company):
def get_party_gle_account(party_type, party, company):
def generator():
- existing_gle_account = frappe.db.sql(
- """select account from `tabGL Entry`
- where docstatus=1 and company=%(company)s and party_type=%(party_type)s and party=%(party)s
- limit 1""",
- {"company": company, "party_type": party_type, "party": party},
+ gl = qb.DocType("GL Entry")
+ existing_gle_account = (
+ qb.from_(gl)
+ .select(gl.account)
+ .where(
+ (gl.docstatus == 1)
+ & (gl.company == company)
+ & (gl.party_type == party_type)
+ & (gl.party == party)
+ & (gl.is_cancelled == 0)
+ )
+ .limit(1)
+ .run()
)
return existing_gle_account[0][0] if existing_gle_account else None
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index d28f886a4fd..e83311647b2 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -922,8 +922,28 @@ class ReceivablePayableReport:
if self.filters.project:
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
+ self.add_user_permission_filters()
+
self.add_accounting_dimensions_filters()
+ def add_user_permission_filters(self):
+ # Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions
+ from frappe.core.doctype.user_permission.user_permission import get_user_permissions
+ from frappe.permissions import get_allowed_docs_for_doctype
+
+ user_permissions = get_user_permissions()
+ if not user_permissions:
+ return
+
+ for party_type in self.party_type:
+ if party_type not in user_permissions:
+ continue
+
+ allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type)
+ self.qb_selection_filter.append(
+ (self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""])
+ )
+
def get_cost_center_conditions(self):
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index 93130fa353a..4a73d62ee2e 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -1253,3 +1253,53 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
+
+ def test_accounts_receivable_respects_user_permissions(self):
+ # Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
+ # must be applied explicitly. The report should only show permitted customers.
+
+ # Running the report writes an access log that commits, so these invoices survive
+ # tearDown's rollback. Delete and commit them so they don't leak into other tests.
+ def remove_committed_entries():
+ self.clear_old_entries()
+ frappe.db.commit() # nosemgrep
+
+ self.addCleanup(remove_committed_entries)
+
+ original_customer = self.customer
+ second_customer = "_Test AR Perm Customer"
+
+ # create_customer overrides self.customer, so build the restricted invoice first
+ self.create_customer(customer_name=second_customer)
+ self.create_sales_invoice(no_payment_schedule=True)
+
+ self.customer = original_customer
+ allowed_invoice = self.create_sales_invoice(no_payment_schedule=True)
+
+ test_user = "test_ar_user_permission@example.com"
+ if not frappe.db.exists("User", test_user):
+ user = frappe.new_doc("User")
+ user.email = test_user
+ user.first_name = "AR Perm"
+ user.append("roles", {"role": "Accounts User"})
+ user.save()
+
+ frappe.permissions.add_user_permission("Customer", original_customer, test_user)
+
+ filters = {
+ "company": self.company,
+ "party_type": "Customer",
+ "report_date": today(),
+ "range": "30, 60, 90, 120",
+ }
+
+ frappe.set_user(test_user)
+ try:
+ report = execute(filters)
+ finally:
+ frappe.set_user("Administrator")
+
+ parties = {row.party for row in report[1]}
+ self.assertIn(original_customer, parties)
+ self.assertNotIn(second_customer, parties)
+ self.assertEqual(allowed_invoice.customer, original_customer)
diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py
index f89b47b2dfe..b86c8161f03 100644
--- a/erpnext/accounts/test/test_utils.py
+++ b/erpnext/accounts/test/test_utils.py
@@ -89,6 +89,8 @@ class TestUtils(unittest.TestCase):
purchase_invoice.submit()
payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name)
+ payment_entry.target_exchange_rate = 82.32
+ payment_entry.set_amounts()
payment_entry.paid_amount = 15725
payment_entry.deductions = []
payment_entry.save()
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index db820ca9154..05b4c0219a3 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -176,7 +176,6 @@ def validate_fiscal_year(date, fiscal_year, company, label="Date", doc=None):
throw(_("{0} '{1}' not in Fiscal Year {2}").format(_(label), formatdate(date), fiscal_year))
-@frappe.whitelist()
def get_balance_on(
account=None,
date=None,
@@ -1387,6 +1386,7 @@ def update_cost_center(docname, cost_center_name, cost_center_number, company, m
Renames the document by adding the number as a prefix to the current name and updates
all transaction where it was present.
"""
+ frappe.has_permission("Cost Center", "write", doc=docname, throw=True)
validate_field_number("Cost Center", docname, cost_center_number, company, "cost_center_number")
if cost_center_number:
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index d80f116e042..95b67d49429 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -662,7 +662,7 @@ class PurchaseOrder(BuyingController):
def update_subcontracting_order_status(self):
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
- update_subcontracting_order_status as update_sco_status,
+ set_subcontracting_order_status as update_sco_status,
)
if self.is_subcontracted and not self.is_old_subcontracting_flow:
diff --git a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py
index 5f0ae83dca8..d30f501d067 100644
--- a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py
+++ b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py
@@ -201,6 +201,7 @@ def refresh_scorecards():
def make_all_scorecards(docname):
sc = frappe.get_doc("Supplier Scorecard", docname)
supplier = frappe.get_doc("Supplier", sc.supplier)
+ supplier.check_permission("write")
start_date = getdate(supplier.creation)
end_date = get_scorecard_date(sc.period, start_date)
diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py
index db93a3d7e79..7c11dda7225 100644
--- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py
+++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py
@@ -297,7 +297,8 @@ def get_message():
@frappe.whitelist()
-def set_default_supplier(item_code, supplier, company):
+def set_default_supplier(item_code: str, supplier: str, company: str):
+ frappe.has_permission("Item", "write", doc=item_code, throw=True)
frappe.db.set_value(
"Item Default",
{"parent": item_code, "company": company},
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index 15e595a3ca1..ef5feafb45c 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -533,6 +533,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
target_doc.so_detail = source_doc.so_detail
target_doc.expense_account = source_doc.expense_account
target_doc.dn_detail = source_doc.name
+ target_doc.cost_center = source_doc.cost_center
if default_warehouse_for_sales_return:
target_doc.warehouse = default_warehouse_for_sales_return
elif doctype == "Sales Invoice" or doctype == "POS Invoice":
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index d9ca43bab60..9e681bce814 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -1694,7 +1694,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
inspection_fieldname = inspection_fieldname_map.get(doctype)
if inspection_fieldname is None:
- return []
+ return items if doctype == "Stock Entry" else []
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json
index f9dc3902c8f..28d43c97d29 100644
--- a/erpnext/crm/doctype/crm_settings/crm_settings.json
+++ b/erpnext/crm/doctype/crm_settings/crm_settings.json
@@ -14,6 +14,7 @@
"opportunity_section",
"close_opportunity_after_days",
"column_break_9",
+ "enable_opportunity_creation_from_contact_us",
"quotation_section",
"default_valid_till",
"section_break_13",
@@ -98,13 +99,19 @@
"fieldname": "update_timestamp_on_new_communication",
"fieldtype": "Check",
"label": "Update timestamp on new communication"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_opportunity_creation_from_contact_us",
+ "fieldtype": "Check",
+ "label": "Enable Opportunity Creation from Contact Us"
}
],
"icon": "fa fa-cog",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2025-01-16 16:12:14.889455",
+ "modified": "2026-06-11 23:09:49.750381",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",
@@ -144,4 +151,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py
index 23992043145..01cdaf41bde 100644
--- a/erpnext/crm/doctype/crm_settings/crm_settings.py
+++ b/erpnext/crm/doctype/crm_settings/crm_settings.py
@@ -2,6 +2,7 @@
# For license information, please see license.txt
import frappe
+from frappe import _
from frappe.model.document import Document
@@ -20,8 +21,20 @@ class CRMSettings(Document):
carry_forward_communication_and_comments: DF.Check
close_opportunity_after_days: DF.Int
default_valid_till: DF.Data | None
+ enable_opportunity_creation_from_contact_us: DF.Check
update_timestamp_on_new_communication: DF.Check
# end: auto-generated types
def validate(self):
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
+ self.validate_enable_opportunity_creation_from_contact_us()
+
+ def validate_enable_opportunity_creation_from_contact_us(self):
+ contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
+
+ if self.enable_opportunity_creation_from_contact_us and contact_disabled:
+ frappe.throw(
+ _(
+ "Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
+ )
+ )
diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py
index f0f492191fb..94e99a612e8 100644
--- a/erpnext/crm/doctype/lead/lead.py
+++ b/erpnext/crm/doctype/lead/lead.py
@@ -9,7 +9,7 @@ from frappe.contacts.address_and_contact import (
)
from frappe.email.inbox import link_communication_to_document
from frappe.model.mapper import get_mapped_doc
-from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
+from frappe.utils import comma_and, get_link_to_form, validate_email_address
from erpnext.accounts.party import set_taxes
from erpnext.controllers.selling_controller import SellingController
@@ -171,9 +171,6 @@ class Lead(SellingController, CRMNote):
if self.email_id == self.lead_owner:
frappe.throw(_("Lead Owner cannot be same as the Lead Email Address"))
- if self.is_new() or not self.image:
- self.image = has_gravatar(self.email_id)
-
def link_to_contact(self):
# update contact links
if self.contact_doc:
@@ -471,7 +468,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None):
@frappe.whitelist()
-def make_lead_from_communication(communication, ignore_communication_links=False):
+def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
"""raise a issue from email"""
doc = frappe.get_doc("Communication", communication)
@@ -490,7 +487,6 @@ def make_lead_from_communication(communication, ignore_communication_links=False
}
)
lead.flags.ignore_mandatory = True
- lead.flags.ignore_permissions = True
lead.insert()
lead_name = lead.name
@@ -523,7 +519,7 @@ def get_lead_with_phone_number(number):
def add_lead_to_prospect(lead, prospect):
prospect = frappe.get_doc("Prospect", prospect)
prospect.append("leads", {"lead": lead})
- prospect.save(ignore_permissions=True)
+ prospect.save()
carry_forward_communication_and_comments = frappe.db.get_single_value(
"CRM Settings", "carry_forward_communication_and_comments"
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index 91d60c924bf..b68ab28ee82 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -522,7 +522,9 @@ def auto_close_opportunity():
@frappe.whitelist()
-def make_opportunity_from_communication(communication, company, ignore_communication_links=False):
+def make_opportunity_from_communication(
+ communication: str, company: str, ignore_communication_links: bool = False
+):
from erpnext.crm.doctype.lead.lead import make_lead_from_communication
doc = frappe.get_doc("Communication", communication)
@@ -540,7 +542,7 @@ def make_opportunity_from_communication(communication, company, ignore_communica
"opportunity_from": opportunity_from,
"party_name": lead,
}
- ).insert(ignore_permissions=True)
+ ).insert()
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)
diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py
index eb784c28ca7..8e6574bde4d 100644
--- a/erpnext/crm/utils.py
+++ b/erpnext/crm/utils.py
@@ -5,6 +5,11 @@ from frappe.utils import cstr, now, today
from pypika import functions
+def disable_opportunity_creation_on_contact_us_disabled(doc, method):
+ if doc.is_disabled:
+ frappe.db.set_single_value("CRM Settings", "enable_opportunity_creation_from_contact_us", 0)
+
+
def update_lead_phone_numbers(contact, method):
if contact.phone_nos:
contact_lead = contact.get_link_for("Lead")
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index f3ccc5783a7..a1c64b60377 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -355,6 +355,9 @@ doc_events = {
"Event": {
"after_insert": "erpnext.crm.utils.link_events_with_prospect",
},
+ "Contact Us Settings": {
+ "on_update": "erpnext.crm.utils.disable_opportunity_creation_on_contact_us_disabled",
+ },
"Sales Invoice": {
"on_submit": [
"erpnext.regional.create_transaction_log",
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index 06b329e169e..e83f7e232ad 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -75,6 +75,9 @@ frappe.ui.form.on("BOM", {
with_operations: function (frm) {
frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0);
+ if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
+ frm.trigger("routing");
+ }
},
fg_based_operating_cost: function (frm) {
@@ -438,7 +441,7 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
- if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
+ if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
frappe.call({
doc: frm.doc,
method: "get_routing",
diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py
index 55d37b3e588..fd32d29fb50 100644
--- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py
+++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py
@@ -152,6 +152,7 @@ class BOMCreator(Document):
@frappe.whitelist()
def add_boms(self):
+ self.check_permission("submit")
self.submit()
def set_rate_for_items(self):
@@ -209,10 +210,14 @@ class BOMCreator(Document):
frappe.throw(_("Please set {0} in BOM Creator {1}").format(_(label), self.name))
def on_submit(self):
- self.enqueue_create_boms()
+ self.enqueue_bom_creation()
@frappe.whitelist()
def enqueue_create_boms(self):
+ self.check_permission("submit")
+ self.enqueue_bom_creation()
+
+ def enqueue_bom_creation(self):
frappe.enqueue(
self.create_boms,
queue="short",
@@ -281,6 +286,21 @@ class BOMCreator(Document):
frappe.msgprint(_("BOMs creation failed"))
+ @frappe.whitelist()
+ def edit_qty(self, docname: str, qty: float):
+ if not frappe.db.exists("BOM Creator Item", {"name": docname, "parent": self.name}):
+ frappe.throw(_("BOM Creator Item {0} does not exist").format(docname))
+
+ for row in self.items:
+ if row.name == docname:
+ row.qty = flt(qty)
+ break
+
+ self.set_rate_for_items()
+ self.save()
+
+ return self
+
def create_bom(self, row, production_item_wise_rm):
bom_creator_item = row.name if row.name != self.name else ""
if frappe.db.exists(
@@ -336,18 +356,157 @@ class BOMCreator(Document):
production_item_wise_rm[(row.item_code, row.name)].bom_no = bom.name
@frappe.whitelist()
- def get_default_bom(self, item_code) -> str:
+ def get_default_bom(self, item_code: str) -> str:
+ self.check_permission("read")
return frappe.get_cached_value("Item", item_code, "default_bom")
+ @frappe.whitelist()
+ def add_item(self, **kwargs):
+ if isinstance(kwargs, str):
+ kwargs = frappe.parse_json(kwargs)
+
+ if isinstance(kwargs, dict):
+ kwargs = frappe._dict(kwargs)
+
+ item_info = get_item_details(kwargs.item_code)
+
+ parent_row_no = ""
+ if kwargs.fg_reference_id and self.name != kwargs.fg_reference_id:
+ parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
+
+ kwargs.update(
+ {
+ "uom": item_info.stock_uom,
+ "stock_uom": item_info.stock_uom,
+ "conversion_factor": 1,
+ }
+ )
+
+ if parent_row_no:
+ kwargs.update({"parent_row_no": parent_row_no})
+
+ for key in BOM_ITEM_FIELDS:
+ if key not in kwargs:
+ kwargs[key] = ""
+
+ self.append("items", kwargs)
+ self.save()
+
+ return self
+
+ @frappe.whitelist()
+ def add_sub_assembly(self, **kwargs):
+ if isinstance(kwargs, str):
+ kwargs = frappe.parse_json(kwargs)
+
+ if isinstance(kwargs, dict):
+ kwargs = frappe._dict(kwargs)
+
+ bom_item = frappe.parse_json(kwargs.bom_item)
+
+ name = kwargs.fg_reference_id
+ parent_row_no = ""
+ if not kwargs.convert_to_sub_assembly:
+ item_info = get_item_details(bom_item.item_code)
+ parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
+
+ item_row = self.append(
+ "items",
+ {
+ "item_code": bom_item.item_code,
+ "qty": bom_item.qty,
+ "uom": item_info.stock_uom,
+ "fg_item": kwargs.fg_item,
+ "conversion_factor": 1,
+ "parent_row_no": parent_row_no,
+ "fg_reference_id": name,
+ "stock_qty": bom_item.qty,
+ "do_not_explode": 1,
+ "is_expandable": 1,
+ "stock_uom": item_info.stock_uom,
+ "allow_alternative_item": kwargs.allow_alternative_item,
+ },
+ )
+
+ parent_row_no = item_row.idx
+ name = ""
+ else:
+ parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
+
+ for row in bom_item.get("items"):
+ row = frappe._dict(row)
+ item_info = get_item_details(row.item_code)
+ self.append(
+ "items",
+ {
+ "item_code": row.item_code,
+ "qty": row.qty,
+ "fg_item": bom_item.item_code,
+ "uom": item_info.stock_uom,
+ "fg_reference_id": name,
+ "parent_row_no": parent_row_no,
+ "conversion_factor": 1,
+ "do_not_explode": 1,
+ "stock_qty": row.qty,
+ "stock_uom": item_info.stock_uom,
+ },
+ )
+
+ self.save()
+
+ return self
+
+ @frappe.whitelist()
+ def delete_node(self, **kwargs):
+ if isinstance(kwargs, str):
+ kwargs = frappe.parse_json(kwargs)
+
+ if isinstance(kwargs, dict):
+ kwargs = frappe._dict(kwargs)
+
+ updated = False
+ if kwargs.docname:
+ row = next((row for row in self.items if row.name == kwargs.docname), None)
+ if not row:
+ frappe.throw(_("BOM Creator Item with name {0} does not exist").format(kwargs.docname))
+
+ row.delete()
+ self.remove(row)
+ updated = True
+
+ items = get_children(parent=kwargs.fg_item, parent_id=self.name)
+ if items:
+ for item in items:
+ updated = True
+ child_row = next((row for row in self.items if row.name == item.name), None)
+ if child_row:
+ child_row.delete()
+ self.remove(child_row)
+
+ if item.expandable:
+ self.delete_node(fg_item=item.value)
+
+ if updated:
+ self.set_rate_for_items()
+ self.save()
+
+ return self
+
+ return frappe._dict()
+
@frappe.whitelist()
-def get_children(doctype=None, parent=None, **kwargs):
+def get_children(doctype: str | None = None, parent: str | None = None, **kwargs):
+ # by default get_children takes first parameter as doctype, so added in the function
+
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
+ frappe.has_permission("BOM Creator", "read", doc=kwargs.parent_id, throw=True)
+
fields = [
"item_code as value",
"item_name as title",
@@ -373,102 +532,6 @@ def get_children(doctype=None, parent=None, **kwargs):
return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx")
-@frappe.whitelist()
-def add_item(**kwargs):
- if isinstance(kwargs, str):
- kwargs = frappe.parse_json(kwargs)
-
- if isinstance(kwargs, dict):
- kwargs = frappe._dict(kwargs)
-
- doc = frappe.get_doc("BOM Creator", kwargs.parent)
- item_info = get_item_details(kwargs.item_code)
-
- parent_row_no = ""
- if kwargs.fg_reference_id and doc.name != kwargs.fg_reference_id:
- parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
-
- kwargs.update(
- {
- "uom": item_info.stock_uom,
- "stock_uom": item_info.stock_uom,
- "conversion_factor": 1,
- }
- )
-
- if parent_row_no:
- kwargs.update({"parent_row_no": parent_row_no})
-
- doc.append("items", kwargs)
- doc.save()
-
- return doc
-
-
-@frappe.whitelist()
-def add_sub_assembly(**kwargs):
- if isinstance(kwargs, str):
- kwargs = frappe.parse_json(kwargs)
-
- if isinstance(kwargs, dict):
- kwargs = frappe._dict(kwargs)
-
- doc = frappe.get_doc("BOM Creator", kwargs.parent)
- bom_item = frappe.parse_json(kwargs.bom_item)
-
- name = kwargs.fg_reference_id
- parent_row_no = ""
- if not kwargs.convert_to_sub_assembly:
- item_info = get_item_details(bom_item.item_code)
- parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
-
- item_row = doc.append(
- "items",
- {
- "item_code": bom_item.item_code,
- "qty": bom_item.qty,
- "uom": item_info.stock_uom,
- "fg_item": kwargs.fg_item,
- "conversion_factor": 1,
- "parent_row_no": parent_row_no,
- "fg_reference_id": name,
- "stock_qty": bom_item.qty,
- "do_not_explode": 1,
- "is_expandable": 1,
- "stock_uom": item_info.stock_uom,
- "allow_alternative_item": kwargs.allow_alternative_item,
- },
- )
-
- parent_row_no = item_row.idx
- name = ""
- else:
- parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
-
- for row in bom_item.get("items"):
- row = frappe._dict(row)
- item_info = get_item_details(row.item_code)
- doc.append(
- "items",
- {
- "item_code": row.item_code,
- "qty": row.qty,
- "fg_item": bom_item.item_code,
- "uom": item_info.stock_uom,
- "fg_reference_id": name,
- "parent_row_no": parent_row_no,
- "conversion_factor": 1,
- "do_not_explode": 1,
- "stock_qty": row.qty,
- "stock_uom": item_info.stock_uom,
- },
- )
-
- doc.save()
-
- return doc
-
-
def get_item_details(item_code):
return frappe.get_cached_value(
"Item", item_code, ["item_name", "description", "image", "stock_uom", "default_bom"], as_dict=1
@@ -486,37 +549,3 @@ def get_parent_row_no(doc, name):
frappe.msgprint(_("Parent Row No not found for {0}").format(name), alert=True)
return None
-
-
-@frappe.whitelist()
-def delete_node(**kwargs):
- if isinstance(kwargs, str):
- kwargs = frappe.parse_json(kwargs)
-
- if isinstance(kwargs, dict):
- kwargs = frappe._dict(kwargs)
-
- items = get_children(parent=kwargs.fg_item, parent_id=kwargs.parent)
- if kwargs.docname:
- frappe.delete_doc("BOM Creator Item", kwargs.docname)
-
- for item in items:
- frappe.delete_doc("BOM Creator Item", item.name)
- if item.expandable:
- delete_node(fg_item=item.value, parent=item.parent_id)
-
- doc = frappe.get_doc("BOM Creator", kwargs.parent)
- doc.set_rate_for_items()
- doc.save()
-
- return doc
-
-
-@frappe.whitelist()
-def edit_qty(doctype, docname, qty, parent):
- frappe.db.set_value(doctype, docname, "qty", qty)
- doc = frappe.get_doc("BOM Creator", parent)
- doc.set_rate_for_items()
- doc.save()
-
- return doc
diff --git a/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py
index a835de78274..331fdf61768 100644
--- a/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py
+++ b/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py
@@ -6,10 +6,6 @@ import random
import frappe
from frappe.tests.utils import FrappeTestCase
-from erpnext.manufacturing.doctype.bom_creator.bom_creator import (
- add_item,
- add_sub_assembly,
-)
from erpnext.stock.doctype.item.test_item import make_item
@@ -38,8 +34,7 @@ class TestBOMCreator(FrappeTestCase):
conversion_rate=1,
)
- add_sub_assembly(
- parent=doc.name,
+ doc.add_sub_assembly(
fg_item=final_product,
fg_reference_id=doc.name,
bom_item={
@@ -91,8 +86,7 @@ class TestBOMCreator(FrappeTestCase):
conversion_rate=1,
)
- add_item(
- parent=doc.name,
+ doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -133,8 +127,7 @@ class TestBOMCreator(FrappeTestCase):
conversion_rate=1,
)
- add_item(
- parent=doc.name,
+ doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -144,9 +137,8 @@ class TestBOMCreator(FrappeTestCase):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
- add_sub_assembly(
+ doc.add_sub_assembly(
convert_to_sub_assembly=1,
- parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
@@ -199,8 +191,7 @@ class TestBOMCreator(FrappeTestCase):
conversion_rate=1,
)
- add_item(
- parent=doc.name,
+ doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -210,9 +201,8 @@ class TestBOMCreator(FrappeTestCase):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
- add_sub_assembly(
+ doc.add_sub_assembly(
convert_to_sub_assembly=1,
- parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index f4a0d6f6145..bf56182ee41 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -3631,6 +3631,58 @@ class TestWorkOrder(FrappeTestCase):
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0)
+ @change_settings(
+ "Manufacturing Settings",
+ {"allow_editing_of_items_and_quantities_in_work_order": 1},
+ )
+ def test_manufacture_se_fetches_edited_qty_from_work_order(self):
+ """When a raw material qty is edited on the Work Order, the Manufacture Stock Entry
+ must consume the edited quantity (scaled to fg_completed_qty) from the Work Order,
+ not the original BOM quantity."""
+ warehouse = "_Test Warehouse - _TC"
+ wo_order = make_wo_order_test_record(
+ item="_Test FG Item", qty=10, skip_transfer=1, source_warehouse=warehouse
+ )
+
+ # edit a required item's qty
+ wo_order.required_items[0].db_set("required_qty", flt(wo_order.required_items[0].required_qty) + 7)
+ wo_order.reload()
+ edited_row = wo_order.required_items[0]
+
+ fg_qty = 5
+ se = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", fg_qty))
+ se_qty = {row.item_code: row.qty for row in se.items if row.s_warehouse}
+
+ precision = frappe.get_precision("Stock Entry Detail", "qty")
+ expected = flt(edited_row.required_qty / wo_order.qty * fg_qty, precision)
+ self.assertEqual(flt(se_qty.get(edited_row.item_code)), expected)
+
+ @change_settings(
+ "Manufacturing Settings",
+ {"allow_editing_of_items_and_quantities_in_work_order": 1},
+ )
+ def test_manufacture_se_fetches_item_not_in_bom_from_work_order(self):
+ """A raw material that is present on the Work Order but not on the BOM must still be
+ fetched into the Manufacture Stock Entry, proving items are sourced from the Work
+ Order's required_items rather than re-derived from the BOM."""
+ extra_item = make_item(
+ "_Test WO Extra Raw Material", {"is_stock_item": 1, "valuation_rate": 100}
+ ).name
+ warehouse = "_Test Warehouse - _TC"
+ wo_order = make_wo_order_test_record(
+ item="_Test FG Item", qty=10, skip_transfer=1, source_warehouse=warehouse
+ )
+
+ original_item = wo_order.required_items[0].item_code
+ wo_order.required_items[0].db_set("item_code", extra_item)
+ wo_order.reload()
+
+ se = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5))
+ se_items = [row.item_code for row in se.items if row.s_warehouse]
+
+ self.assertIn(extra_item, se_items)
+ self.assertNotIn(original_item, se_items)
+
def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py
index 510e69cd272..c45fd7852f4 100644
--- a/erpnext/manufacturing/doctype/workstation/workstation.py
+++ b/erpnext/manufacturing/doctype/workstation/workstation.py
@@ -19,6 +19,7 @@ from frappe.utils import (
time_diff_in_seconds,
to_timedelta,
)
+from frappe.utils.data import DateTimeLikeObject
from erpnext.support.doctype.issue.issue import get_holidays
@@ -65,7 +66,7 @@ class Workstation(Document):
# end: auto-generated types
def before_save(self):
- self.set_data_based_on_workstation_type()
+ self._set_data_based_on_workstation_type()
self.set_hour_rate()
self.set_total_working_hours()
@@ -92,6 +93,10 @@ class Workstation(Document):
@frappe.whitelist()
def set_data_based_on_workstation_type(self):
+ self.check_permission("write")
+ self._set_data_based_on_workstation_type()
+
+ def _set_data_based_on_workstation_type(self):
if self.workstation_type:
fields = [
"hour_rate_labour",
@@ -166,23 +171,27 @@ class Workstation(Document):
return schedule_date
@frappe.whitelist()
- def start_job(self, job_card, from_time, employee):
+ def start_job(self, job_card: str, from_time: DateTimeLikeObject, employee: str):
doc = frappe.get_doc("Job Card", job_card)
+ doc.check_permission("write")
+
doc.append("time_logs", {"from_time": from_time, "employee": employee})
- doc.save(ignore_permissions=True)
+ doc.save()
return doc
@frappe.whitelist()
- def complete_job(self, job_card, qty, to_time):
+ def complete_job(self, job_card: str, qty: float, to_time: DateTimeLikeObject):
doc = frappe.get_doc("Job Card", job_card)
+ doc.check_permission("submit")
+
for row in doc.time_logs:
if not row.to_time:
row.to_time = to_time
row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60
row.completed_qty = qty
- doc.save(ignore_permissions=True)
+ doc.save()
doc.submit()
return doc
@@ -364,6 +373,8 @@ def check_workstation_for_holiday(workstation, from_datetime, to_datetime):
@frappe.whitelist()
def get_workstations(**kwargs):
+ frappe.has_permission("Workstation", "read", throw=True)
+
kwargs = frappe._dict(kwargs)
_workstation = frappe.qb.DocType("Workstation")
diff --git a/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js b/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js
index fcb7e884ecc..753fb4e896d 100644
--- a/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js
+++ b/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js
@@ -96,8 +96,8 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
return `
| ${frappe.meta.get_label(doctype, fieldname)} |
- ${value1} |
- ${value2} |
+ ${frappe.utils.escape_html(cstr(value1))} |
+ ${frappe.utils.escape_html(cstr(value2))} |
`;
})
@@ -138,13 +138,17 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
.map((change, i) => {
let [fieldname, value1, value2] = change;
let th =
- i === 0 ? `${item_code} | ` : "";
+ i === 0
+ ? `${frappe.utils.escape_html(
+ cstr(item_code)
+ )} | `
+ : "";
return `
${th}
| ${frappe.meta.get_label(child_doctype, fieldname)} |
- ${value1} |
- ${value2} |
+ ${frappe.utils.escape_html(cstr(value1))} |
+ ${frappe.utils.escape_html(cstr(value2))} |
`;
})
@@ -177,7 +181,9 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
let html = rows
.map((row) => {
let [, doc] = row;
- let cells = fields.map((df) => `${doc[df.fieldname]} | `).join("");
+ let cells = fields
+ .map((df) => `${frappe.utils.escape_html(cstr(doc[df.fieldname]))} | `)
+ .join("");
return `${cells}
`;
})
.join("");
diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py
index f382118042a..a188015fb55 100644
--- a/erpnext/projects/doctype/project/project.py
+++ b/erpnext/projects/doctype/project/project.py
@@ -717,7 +717,7 @@ def set_project_status(project, status):
frappe.throw(_("Status must be Cancelled or Completed"))
project = frappe.get_doc("Project", project)
- frappe.has_permission(doc=project, throw=True)
+ project.check_permission("write")
for task in frappe.get_all("Task", dict(project=project.name)):
frappe.db.set_value("Task", task.name, "status", status)
diff --git a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js
index d25ca212b41..240c86a90d9 100644
--- a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js
+++ b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js
@@ -219,14 +219,10 @@ class BOMConfigurator {
},
],
(data) => {
- if (!node.data.parent_id) {
- node.data.parent_id = this.frm.doc.name;
- }
-
frappe.call({
- method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_item",
+ method: "add_item",
+ doc: this.frm.doc,
args: {
- parent: node.data.parent_id,
fg_item: node.data.value,
item_code: data.item_code,
fg_reference_id: node.data.name || this.frm.doc.name,
@@ -255,14 +251,10 @@ class BOMConfigurator {
dialog.set_primary_action(__("Add"), () => {
let bom_item = dialog.get_values();
- if (!node.data?.parent_id) {
- node.data.parent_id = this.frm.doc.name;
- }
-
frappe.call({
- method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
+ method: "add_sub_assembly",
+ doc: this.frm.doc,
args: {
- parent: node.data.parent_id,
fg_item: node.data.value,
fg_reference_id: node.data.name || this.frm.doc.name,
bom_item: bom_item,
@@ -357,9 +349,9 @@ class BOMConfigurator {
let bom_item = dialog.get_values();
frappe.call({
- method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
+ method: "add_sub_assembly",
+ doc: this.frm.doc,
args: {
- parent: node.data.parent_id,
fg_item: node.data.value,
bom_item: bom_item,
fg_reference_id: node.data.name || this.frm.doc.name,
@@ -389,11 +381,10 @@ class BOMConfigurator {
delete_node(node, view) {
frappe.confirm(__("Are you sure you want to delete this Item?"), () => {
frappe.call({
- method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.delete_node",
+ method: "delete_node",
+ doc: this.frm.doc,
args: {
- parent: node.data.parent_id,
fg_item: node.data.value,
- doctype: node.data.doctype,
docname: node.data.name,
},
callback: (r) => {
@@ -408,16 +399,14 @@ class BOMConfigurator {
frappe.prompt(
[{ label: __("Qty"), fieldname: "qty", default: qty, fieldtype: "Float", reqd: 1 }],
(data) => {
- let doctype = node.data.doctype || this.frm.doc.doctype;
let docname = node.data.name || this.frm.doc.name;
frappe.call({
- method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.edit_qty",
+ method: "edit_qty",
+ doc: this.frm.doc,
args: {
- doctype: doctype,
docname: docname,
qty: data.qty,
- parent: node.data.parent_id ? node.data.parent_id : this.frm.doc.name,
},
callback: (r) => {
node.data.qty = data.qty;
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index e6eb9180eeb..5fce7783c38 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -362,8 +362,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}, __("Create"));
}
- const inspection_type = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(this.frm.doc.doctype)
- ? "Incoming" : "Outgoing";
+ const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"];
+ const incoming_purposes = ["Manufacture", "Material Receipt"];
+ const inspection_type =
+ incoming_doctypes.includes(this.frm.doc.doctype) ||
+ (this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose))
+ ? "Incoming"
+ : "Outgoing";
let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection");
quality_inspection_field.get_route_options_for_new_doc = function(row) {
@@ -2474,6 +2479,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
];
const me = this;
+ const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"];
+ const incoming_purposes = ["Manufacture", "Material Receipt"];
+ const inspection_type =
+ incoming_doctypes.includes(this.frm.doc.doctype) ||
+ (this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose))
+ ? "Incoming"
+ : "Outgoing";
const dialog = new frappe.ui.Dialog({
title: __("Select Items for Quality Inspection"),
size: "extra-large",
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 f8cde141fe4..405159215cd 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
@@ -13,6 +13,8 @@ def execute(filters=None):
if not filters:
filters = {}
+ validate_filters(filters)
+
columns = get_columns(filters)
entries = get_entries(filters)
item_details = get_item_details()
@@ -49,10 +51,17 @@ def execute(filters=None):
return columns, data
-def get_columns(filters):
+def validate_filters(filters):
+ ALLOWED_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note"]
+
if not filters.get("doc_type"):
msgprint(_("Please select the document type first"), raise_exception=1)
+ if filters.get("doc_type") not in ALLOWED_DOCTYPES:
+ frappe.throw(_("{0}, {1} or {2} are the only allowed options.").format(*ALLOWED_DOCTYPES))
+
+
+def get_columns(filters):
columns = [
{
"label": _(filters["doc_type"]),
diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js
index 032ec707330..33a7ecc8714 100644
--- a/erpnext/setup/doctype/company/company.js
+++ b/erpnext/setup/doctype/company/company.js
@@ -207,7 +207,8 @@ frappe.ui.form.on("Company", {
label: __("Please enter the company name to confirm"),
reqd: 1,
description: __(
- "Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone."
+ "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)]
),
},
function (data) {
@@ -227,7 +228,7 @@ frappe.ui.form.on("Company", {
},
});
},
- __("Delete all the Transactions for this Company"),
+ __("Delete all the Transactions for {0}", [frappe.utils.bold(frm.doc.name)]),
__("Delete")
);
d.get_primary_btn().addClass("btn-danger");
diff --git a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py
index 86fae223b45..6cece137366 100644
--- a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py
+++ b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py
@@ -68,13 +68,16 @@ def patched_requests_get(*args, **kwargs):
if kwargs["params"].get("date") and kwargs["params"].get("from") and kwargs["params"].get("to"):
if test_exchange_values.get(kwargs["params"]["date"]):
return PatchResponse({"result": test_exchange_values[kwargs["params"]["date"]]}, 200)
- elif args[0].startswith("https://api.frankfurter.dev") and kwargs.get("params"):
+ elif args[0].startswith("https://api.frankfurter.dev/v1") and kwargs.get("params"):
if kwargs["params"].get("base") and kwargs["params"].get("symbols"):
date = args[0].replace("https://api.frankfurter.dev/v1/", "")
if test_exchange_values.get(date):
return PatchResponse(
{"rates": {kwargs["params"].get("symbols"): test_exchange_values.get(date)}}, 200
)
+ elif args[0].startswith("https://api.frankfurter.dev/v2") and kwargs.get("params"):
+ if kwargs["params"].get("date") and test_exchange_values.get(kwargs["params"]["date"]):
+ return PatchResponse({"rate": test_exchange_values.get(kwargs["params"]["date"])}, 200)
return PatchResponse({"rates": None}, 404)
diff --git a/erpnext/setup/doctype/driver/driver.json b/erpnext/setup/doctype/driver/driver.json
index 2e994b5ff9c..6d9556b29e2 100644
--- a/erpnext/setup/doctype/driver/driver.json
+++ b/erpnext/setup/doctype/driver/driver.json
@@ -118,7 +118,7 @@
}
],
"icon": "fa fa-user",
- "modified": "2022-06-28 10:29:14.151380",
+ "modified": "2026-06-16 16:04:12.762960",
"modified_by": "Administrator",
"module": "Setup",
"name": "Driver",
@@ -173,6 +173,18 @@
"role": "Delivery Manager",
"share": 1,
"write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
}
],
"quick_entry": 1,
diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py
index 812613bb6af..ead82ef8bf3 100755
--- a/erpnext/setup/doctype/employee/employee.py
+++ b/erpnext/setup/doctype/employee/employee.py
@@ -64,15 +64,11 @@ class Employee(NestedSet):
)
def validate_user_details(self):
- if self.user_id:
- data = frappe.db.get_value("User", self.user_id, ["enabled"], as_dict=1)
+ if not self.user_id:
+ return
- if not data:
- self.user_id = None
- return
-
- self.validate_for_enabled_user_id(data.get("enabled", 0))
- self.validate_duplicate_user_id()
+ self.validate_for_enabled_user_id()
+ self.validate_duplicate_user_id()
def update_nsm_model(self):
frappe.utils.nestedset.update_nsm(self)
@@ -83,6 +79,7 @@ class Employee(NestedSet):
if self.user_id:
self.update_user()
self.update_user_permissions()
+ self.update_user_status()
self.reset_employee_emails_cache()
def update_user_permissions(self):
@@ -184,12 +181,20 @@ class Employee(NestedSet):
if not self.relieving_date:
throw(_("Please enter relieving date."))
- def validate_for_enabled_user_id(self, enabled):
- if enabled is None:
+ def validate_for_enabled_user_id(self):
+ if not frappe.db.exists("User", self.user_id):
frappe.throw(_("User {0} does not exist").format(self.user_id))
+ def update_user_status(self):
+ if not self.user_id:
+ return
+
+ user = frappe.get_doc("User", self.user_id)
+ enabled = user.enabled
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
- frappe.db.set_value("User", self.user_id, "enabled", not enabled)
+ user.enabled = not enabled
+ # Keep linked User status in sync from the Employee lifecycle and record the audit log.
+ user.save(ignore_permissions=True)
def validate_duplicate_user_id(self):
Employee = frappe.qb.DocType("Employee")
@@ -321,6 +326,9 @@ def deactivate_sales_person(status=None, employee=None):
@frappe.whitelist()
def create_user(employee, user=None, email=None):
emp = frappe.get_doc("Employee", employee)
+ emp.check_permission("write")
+ if emp.user_id:
+ frappe.throw(_("Employee {0} already has a linked user").format(emp.name))
employee_name = emp.employee_name.split(" ")
middle_name = last_name = ""
diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json
index 30598c9143b..c5fe12977a9 100644
--- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json
+++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json
@@ -89,7 +89,7 @@
"icon": "icon-legal",
"idx": 1,
"links": [],
- "modified": "2026-04-29 22:51:49.285298",
+ "modified": "2026-06-06 16:35:34.394675",
"modified_by": "Administrator",
"module": "Setup",
"name": "Terms and Conditions",
@@ -135,13 +135,32 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "Accounts User",
+ "role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"read": 1,
"role": "Stock User"
+ },
+ {
+ "role": "HR User",
+ "select": 1
+ },
+ {
+ "create": 1,
+ "read": 1,
+ "role": "HR Manager",
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1
}
],
"quick_entry": 1,
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
index 9fa32c9d5ec..919dd29b040 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
@@ -209,6 +209,8 @@ class TransactionDeletionRecord(Document):
@frappe.whitelist()
def start_deletion_tasks(self):
+ self.check_permission("write")
+
# This method is the entry point for the chain of events that follow
self.db_set("status", "Running")
self.enqueue_task(task="Delete Bins")
diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py
index 8c288b783c1..03fc31b253e 100644
--- a/erpnext/setup/install.py
+++ b/erpnext/setup/install.py
@@ -90,14 +90,7 @@ def set_single_defaults():
def setup_currency_exchange():
ces = frappe.get_single("Currency Exchange Settings")
try:
- ces.set("result_key", [])
- ces.set("req_params", [])
-
- ces.api_endpoint = "https://api.frankfurter.dev/v1/{transaction_date}"
- ces.append("result_key", {"key": "rates"})
- ces.append("result_key", {"key": "{to_currency}"})
- ces.append("req_params", {"key": "base", "value": "{from_currency}"})
- ces.append("req_params", {"key": "symbols", "value": "{to_currency}"})
+ ces.service_provider = "frankfurter.dev - v2"
ces.save()
except frappe.ValidationError:
pass
diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py
index b7436a140f8..7de420ceea9 100644
--- a/erpnext/setup/utils.py
+++ b/erpnext/setup/utils.py
@@ -130,7 +130,7 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No
if entries:
return flt(entries[0].exchange_rate)
- if frappe.get_cached_value("Currency Exchange Settings", "Currency Exchange Settings", "disabled"):
+ if frappe.get_single_value("Currency Exchange Settings", "disabled"):
return 0.00
pegged_currencies = {}
diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json
index 4bc14a976d6..e318f113713 100644
--- a/erpnext/stock/doctype/batch/batch.json
+++ b/erpnext/stock/doctype/batch/batch.json
@@ -58,6 +58,7 @@
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Item",
+ "link_filters": "[\n [\"Item\", \"has_batch_no\", \"=\", 1],\n [\"Item\", \"is_stock_item\", \"=\", 1]\n]",
"oldfieldname": "item",
"oldfieldtype": "Link",
"options": "Item",
@@ -208,7 +209,7 @@
"image_field": "image",
"links": [],
"max_attachments": 5,
- "modified": "2023-11-09 12:17:28.339975",
+ "modified": "2026-06-16 16:01:26.556324",
"modified_by": "Administrator",
"module": "Stock",
"name": "Batch",
@@ -236,4 +237,4 @@
"states": [],
"title_field": "batch_id",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py
index 58f393df5d0..e1e308c735e 100644
--- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py
+++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py
@@ -335,7 +335,9 @@ def get_default_address(out, name):
@frappe.whitelist()
-def get_contact_display(contact):
+def get_contact_display(contact: str):
+ frappe.has_permission("Contact", "read", doc=contact, throw=True)
+
contact_info = frappe.db.get_value(
"Contact", contact, ["first_name", "last_name", "phone", "mobile_no"], as_dict=1
)
@@ -373,6 +375,7 @@ def sanitize_address(address):
@frappe.whitelist()
def notify_customers(delivery_trip):
delivery_trip = frappe.get_doc("Delivery Trip", delivery_trip)
+ delivery_trip.check_permission()
context = delivery_trip.as_dict()
@@ -436,7 +439,9 @@ def get_attachments(delivery_stop):
@frappe.whitelist()
-def get_driver_email(driver):
+def get_driver_email(driver: str):
+ frappe.has_permission("Driver", "read", doc=driver, throw=True)
+
employee = frappe.db.get_value("Driver", driver, "employee")
email = frappe.db.get_value("Employee", employee, "prefered_email")
return {"email": email}
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js
index c514b25c8ef..0c46f2a41f5 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js
@@ -90,6 +90,8 @@ frappe.ui.form.on("Repost Item Valuation", {
}).addClass("btn-primary");
}
+ frm.trigger("show_update_valuation_field");
+
frm.trigger("show_reposting_progress");
if (frm.doc.status === "Queued" && frm.doc.docstatus === 1) {
@@ -97,6 +99,13 @@ frappe.ui.form.on("Repost Item Valuation", {
}
},
+ show_update_valuation_field(frm) {
+ frm.toggle_display(
+ "recalculate_valuation_rate",
+ ["Purchase Receipt", "Purchase Invoice", "Stock Entry"].includes(frm.doc.voucher_type)
+ );
+ },
+
execute_reposting(frm) {
frm.add_custom_button(__("Start Reposting"), () => {
frappe.call({
@@ -157,6 +166,7 @@ frappe.ui.form.on("Repost Item Valuation", {
voucher_type: function (frm) {
frm.trigger("set_company_on_transaction");
+ frm.trigger("show_update_valuation_field");
},
voucher_no: function (frm) {
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
index 3affd1e4be9..55b1d696f89 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
@@ -20,7 +20,7 @@
"via_landed_cost_voucher",
"allow_zero_rate",
"recreate_stock_ledgers",
- "amended_from",
+ "recalculate_valuation_rate",
"error_section",
"error_log",
"reposting_info_section",
@@ -31,6 +31,7 @@
"gl_reposting_index",
"reposting_data_file",
"vouchers_based_on_item_and_warehouse_section",
+ "amended_from",
"total_vouchers",
"column_break_yqwo",
"vouchers_posted"
@@ -237,13 +238,21 @@
"label": "Reposting Data File",
"no_copy": 1,
"read_only": 1
+ },
+ {
+ "default": "0",
+ "description": "Only works for Purchase Receipt, Purchase Invoice and Stock Entry",
+ "fieldname": "recalculate_valuation_rate",
+ "fieldtype": "Check",
+ "label": "Recalculate Valuation Rate",
+ "show_description_on_click": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2026-03-27 19:59:58.637964",
+ "modified": "2026-06-16 17:30:42.715321",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",
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 1fc0f19a4d5..ef42da67668 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -45,6 +45,7 @@ class RepostItemValuation(Document):
items_to_be_repost: DF.Code | None
posting_date: DF.Date
posting_time: DF.Time | None
+ recalculate_valuation_rate: DF.Check
recreate_stock_ledgers: DF.Check
reposting_data_file: DF.Attach | None
reposting_reference: DF.Data | None
@@ -303,6 +304,12 @@ class RepostItemValuation(Document):
filters,
)
+ def _recalculate_valuation_rate(self):
+ doc = frappe.get_doc(self.voucher_type, self.voucher_no)
+ doc.update_valuation_rate()
+ for item in doc.items:
+ item.db_set("valuation_rate", item.valuation_rate)
+
def recreate_stock_ledger_entries(self):
"""Recreate Stock Ledger Entries for the transaction."""
if self.based_on == "Transaction" and self.recreate_stock_ledgers:
@@ -331,6 +338,12 @@ def repost(doc):
if not frappe.flags.in_test:
frappe.db.commit()
+ if (
+ doc.voucher_type in ["Purchase Receipt", "Purchase Invoice", "Stock Entry"]
+ and doc.recalculate_valuation_rate
+ ):
+ doc._recalculate_valuation_rate()
+
if doc.recreate_stock_ledgers:
doc.recreate_stock_ledger_entries()
diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
index 5cc3736fb0f..5291b2e4381 100644
--- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
@@ -419,6 +419,62 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin):
self.assertRaises(frappe.ValidationError, riv.save)
doc.cancel()
+ def test_recalculate_valuation_rate_for_purchase_receipt(self):
+ item = self.make_item().name
+
+ # receive item at rate 100
+ pr = make_purchase_receipt(item_code=item, qty=1, rate=100)
+ self.assertSLEs(pr, [{"incoming_rate": 100}])
+
+ # change the rate from 100 to 150
+ pr.load_from_db()
+ pr.items[0].db_set(
+ {
+ "base_net_amount": 150,
+ "net_rate": 150,
+ }
+ )
+
+ # repost with recalculate valuation rate
+ riv = frappe.get_doc(
+ doctype="Repost Item Valuation",
+ based_on="Transaction",
+ voucher_type=pr.doctype,
+ voucher_no=pr.name,
+ recalculate_valuation_rate=1,
+ posting_date=pr.posting_date,
+ posting_time=pr.posting_time,
+ )
+ riv.submit()
+
+ # incoming rate after reposting should be 150
+ self.assertSLEs(pr, [{"incoming_rate": 150}])
+
+ def test_recalculate_valuation_rate_for_stock_entry(self):
+ item = self.make_item().name
+
+ # receive item at rate 100
+ se = make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=1, rate=100)
+ self.assertSLEs(se, [{"incoming_rate": 100}])
+
+ # change the rate from 100 to 150
+ se.items[0].db_set("basic_rate", 150)
+
+ # repost with recalculate valuation rate
+ riv = frappe.get_doc(
+ doctype="Repost Item Valuation",
+ based_on="Transaction",
+ voucher_type=se.doctype,
+ voucher_no=se.name,
+ recalculate_valuation_rate=1,
+ posting_date=se.posting_date,
+ posting_time=se.posting_time,
+ )
+ riv.submit()
+
+ # incoming rate after reposting should be 150
+ self.assertSLEs(se, [{"incoming_rate": 150}])
+
def test_remove_attached_file(self):
item_code = make_item("_Test Remove Attached File Item", properties={"is_stock_item": 1})
diff --git a/erpnext/stock/doctype/shipment/shipment.py b/erpnext/stock/doctype/shipment/shipment.py
index 880f6b5e1c1..e1fb199c10b 100644
--- a/erpnext/stock/doctype/shipment/shipment.py
+++ b/erpnext/stock/doctype/shipment/shipment.py
@@ -123,7 +123,9 @@ def get_contact_name(ref_doctype, docname):
@frappe.whitelist()
-def get_company_contact(user):
+def get_company_contact(user: str):
+ frappe.has_permission("User", "read", throw=True)
+
contact = frappe.db.get_value(
"User",
user,
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index 3e17748bb8f..e2db1c7c98a 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -201,10 +201,11 @@ frappe.ui.form.on("Stock Entry", {
}
let quality_inspection_field = frm.get_docfield("items", "quality_inspection");
+ const incoming_purposes = ["Manufacture", "Material Receipt"];
quality_inspection_field.get_route_options_for_new_doc = function (row) {
if (frm.is_new()) return {};
return {
- inspection_type: "Incoming",
+ inspection_type: incoming_purposes.includes(frm.doc.purpose) ? "Incoming" : "Outgoing",
reference_type: frm.doc.doctype,
reference_name: frm.doc.name,
child_row_reference: row.doc.name,
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 87715e1e516..aebcc6dffb2 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -2467,9 +2467,24 @@ class StockEntry(StockController):
):
self.get_unconsumed_raw_materials()
+ elif self.pro_doc and (
+ self.purpose == "Manufacture" or self.purpose == "Material Consumption for Manufacture"
+ ):
+ if not self.fg_completed_qty:
+ frappe.throw(_("{0} is mandatory").format(_(self.meta.get_label("fg_completed_qty"))))
+
+ item_dict = self.get_work_order_raw_materials(self.fg_completed_qty)
+
+ for item in item_dict.values():
+ if self.pro_doc.from_wip_warehouse:
+ item["from_warehouse"] = self.pro_doc.wip_warehouse
+ item["to_warehouse"] = ""
+
+ self.add_to_stock_entry_detail(item_dict)
+
else:
if not self.fg_completed_qty:
- frappe.throw(_("Manufacturing Quantity is mandatory"))
+ frappe.throw(_("{0} is mandatory").format(_(self.meta.get_label("fg_completed_qty"))))
item_dict = self.get_bom_raw_materials(self.fg_completed_qty)
@@ -2724,6 +2739,56 @@ class StockEntry(StockController):
return item_dict
+ def get_work_order_raw_materials(self, qty):
+ item_dict = frappe._dict()
+
+ used_alternative_items = get_used_alternative_items(
+ subcontract_order_field=self.subcontract_data.order_field, work_order=self.work_order
+ )
+
+ for d in self.pro_doc.get("required_items"):
+ item_qty = flt(
+ (d.required_qty / self.pro_doc.qty) * qty, frappe.get_precision("Stock Entry Detail", "qty")
+ )
+ from_warehouse = (
+ d.source_warehouse
+ if self.pro_doc.skip_transfer and not self.pro_doc.from_wip_warehouse
+ else self.from_warehouse or d.source_warehouse
+ )
+
+ item_row = frappe._dict(
+ {
+ "item_code": d.item_code,
+ "item_name": d.item_name,
+ "description": d.description,
+ "qty": item_qty,
+ "stock_uom": d.stock_uom,
+ "uom": d.stock_uom,
+ "conversion_factor": 1,
+ "from_warehouse": from_warehouse,
+ "allow_alternative_item": d.allow_alternative_item
+ and self.pro_doc.allow_alternative_item,
+ }
+ )
+
+ if d.item_code in used_alternative_items:
+ alt = used_alternative_items.get(d.item_code)
+ item_row.update(
+ {
+ "item_code": alt.item_code,
+ "item_name": alt.item_name,
+ "stock_uom": alt.stock_uom,
+ "uom": alt.uom,
+ "conversion_factor": alt.conversion_factor,
+ "description": alt.description,
+ "original_item": d.item_code,
+ }
+ )
+
+ item_dict[d.item_code] = item_row
+
+ return item_dict
+
def get_bom_scrap_material(self, qty):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
@@ -3410,10 +3475,12 @@ class StockEntry(StockController):
def update_subcontracting_order_status(self):
if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]:
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
- update_subcontracting_order_status,
+ set_subcontracting_order_status,
)
- update_subcontracting_order_status(self.subcontracting_order)
+ # Trusted submit/cancel flow — a Stock operation must not require Subcontracting Order
+ # write permission, so use the no-check internal helper (not the whitelisted boundary).
+ set_subcontracting_order_status(self.subcontracting_order)
def update_pick_list_status(self):
from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index 870091932b6..d0d60276a97 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -1094,6 +1094,327 @@ class TestStockEntry(FrappeTestCase):
repack.insert()
self.assertRaises(frappe.ValidationError, repack.submit)
+ def test_check_item_quality_inspection_returns_items_for_stock_entry(self):
+ from erpnext.controllers.stock_controller import check_item_quality_inspection
+
+ items = [
+ {"item_code": "_Test Item", "qty": 1},
+ {"item_code": "_Test Item Home Desktop 100", "qty": 1},
+ ]
+
+ se_result = check_item_quality_inspection("Stock Entry", 0, items)
+ self.assertEqual(len(se_result), 2)
+
+ # a doctype not in the inspection fieldname map and not a Stock Entry returns nothing
+ self.assertEqual(check_item_quality_inspection("Material Request", 0, items), [])
+
+ @change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
+ def test_quality_inspection_across_stock_entry_purposes(self):
+ from erpnext.controllers.stock_controller import (
+ QualityInspectionRejectedError,
+ QualityInspectionRequiredError,
+ check_item_quality_inspection,
+ )
+ from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
+ create_quality_inspection,
+ )
+
+ item_code = "_Test Item For QI Purposes"
+ if not frappe.db.exists("Item", item_code):
+ create_item(item_code, is_stock_item=1)
+
+ s_wh = "Stores - _TC"
+ t_wh = "_Test Warehouse - _TC"
+ # stock the source warehouse for transfer / issue purposes
+ make_stock_entry(item_code=item_code, target=s_wh, qty=100, basic_rate=100)
+
+ # purpose -> warehouses for the moved row; inward (with target) requires QI
+ purposes = {
+ "Material Receipt": {"to_warehouse": t_wh},
+ "Material Transfer": {"from_warehouse": s_wh, "to_warehouse": t_wh},
+ "Material Issue": {"from_warehouse": s_wh},
+ }
+
+ for purpose, warehouses in purposes.items():
+ with self.subTest(purpose=purpose):
+ needs_qi = "to_warehouse" in warehouses
+
+ se = make_stock_entry(
+ item_code=item_code,
+ qty=5,
+ basic_rate=100,
+ purpose=purpose,
+ inspection_required=True,
+ do_not_submit=True,
+ **warehouses,
+ )
+
+ # QI can be created from the Stock Entry for any purpose
+ allowed = check_item_quality_inspection("Stock Entry", 0, se.as_dict().get("items"))
+ self.assertTrue(any(row.get("item_code") == item_code for row in allowed))
+
+ if not needs_qi:
+ # outward-only entry: QI is not enforced
+ se.submit()
+ self.assertEqual(se.docstatus, 1)
+ continue
+
+ # inward entry without QI must block submission
+ self.assertRaises(QualityInspectionRequiredError, se.submit)
+
+ # a rejected QI must also block submission
+ se_rej = make_stock_entry(
+ item_code=item_code,
+ qty=5,
+ basic_rate=100,
+ purpose=purpose,
+ inspection_required=True,
+ do_not_submit=True,
+ **warehouses,
+ )
+ create_quality_inspection(
+ reference_type="Stock Entry",
+ reference_name=se_rej.name,
+ item_code=item_code,
+ inspection_type="Incoming",
+ status="Rejected",
+ )
+ se_rej.reload()
+ self.assertRaises(QualityInspectionRejectedError, se_rej.submit)
+
+ # a submitted, accepted QI links itself to the inward row; submission then succeeds
+ se_ok = make_stock_entry(
+ item_code=item_code,
+ qty=5,
+ basic_rate=100,
+ purpose=purpose,
+ inspection_required=True,
+ do_not_submit=True,
+ **warehouses,
+ )
+ create_quality_inspection(
+ reference_type="Stock Entry",
+ reference_name=se_ok.name,
+ item_code=item_code,
+ inspection_type="Incoming",
+ status="Accepted",
+ )
+ se_ok.reload()
+ se_ok.submit()
+ self.assertEqual(se_ok.docstatus, 1)
+
+ @change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
+ def test_quality_inspection_required_for_manufacture(self):
+ from erpnext.controllers.stock_controller import (
+ QualityInspectionRejectedError,
+ QualityInspectionRequiredError,
+ )
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_wo_stock_entry,
+ )
+ from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
+ create_quality_inspection,
+ )
+
+ wo = make_wo_order_test_record(qty=1)
+ make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
+ make_stock_entry(
+ item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100
+ )
+
+ # transfer raw materials to WIP (no inspection on the transfer)
+ transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1))
+ for d in transfer.get("items"):
+ d.s_warehouse = "Stores - _TC"
+ transfer.insert()
+ transfer.submit()
+
+ # manufacture with inspection required
+ mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1))
+ mfg.inspection_required = 1
+ mfg.insert()
+ self.assertRaises(QualityInspectionRequiredError, mfg.submit)
+
+ # a rejected QI on the finished-good row must also block submission
+ qi = create_quality_inspection(
+ reference_type="Stock Entry",
+ reference_name=mfg.name,
+ item_code=wo.production_item,
+ inspection_type="Incoming",
+ status="Rejected",
+ )
+ mfg.reload()
+ self.assertRaises(QualityInspectionRejectedError, mfg.submit)
+
+ # accepting the QI then allows submission
+ frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
+ mfg.reload()
+ mfg.submit()
+ self.assertEqual(mfg.docstatus, 1)
+
+ @change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
+ def test_quality_inspection_required_for_material_transfer_for_manufacture(self):
+ from erpnext.controllers.stock_controller import (
+ QualityInspectionRejectedError,
+ QualityInspectionRequiredError,
+ )
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_wo_stock_entry,
+ )
+ from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
+ create_quality_inspection,
+ )
+
+ wo = make_wo_order_test_record(qty=1)
+ make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
+ make_stock_entry(
+ item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100
+ )
+
+ transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1))
+ for d in transfer.get("items"):
+ d.s_warehouse = "Stores - _TC"
+ transfer.inspection_required = 1
+ transfer.insert()
+ self.assertRaises(QualityInspectionRequiredError, transfer.submit)
+
+ # a rejected QI on any row moved into WIP must block submission;
+ # every raw-material row moved into WIP needs a QI
+ qis = []
+ for item_code in {d.item_code for d in transfer.items if d.t_warehouse}:
+ qis.append(
+ create_quality_inspection(
+ reference_type="Stock Entry",
+ reference_name=transfer.name,
+ item_code=item_code,
+ inspection_type="Incoming",
+ status="Rejected",
+ )
+ )
+ transfer.reload()
+ self.assertRaises(QualityInspectionRejectedError, transfer.submit)
+
+ # accepting every QI then allows submission
+ for qi in qis:
+ frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
+ transfer.reload()
+ transfer.submit()
+ self.assertEqual(transfer.docstatus, 1)
+
+ def test_quality_inspection_required_for_send_to_subcontractor(self):
+ from erpnext.controllers.stock_controller import QualityInspectionRequiredError
+ from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
+ from erpnext.controllers.tests.test_subcontracting_controller import (
+ get_subcontracting_order,
+ make_service_item,
+ )
+ from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
+ create_quality_inspection,
+ )
+
+ make_service_item("Subcontracted Service Item 1")
+ sco = get_subcontracting_order(
+ service_items=[
+ {
+ "warehouse": "_Test Warehouse - _TC",
+ "item_code": "Subcontracted Service Item 1",
+ "qty": 10,
+ "rate": 500,
+ "fg_item": "_Test FG Item",
+ "fg_item_qty": 10,
+ }
+ ]
+ )
+ make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=100, basic_rate=100)
+ make_stock_entry(
+ item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=100, basic_rate=100
+ )
+
+ se = frappe.get_doc(make_rm_stock_entry(sco.name))
+ se.from_warehouse = "_Test Warehouse - _TC"
+ se.to_warehouse = "_Test Warehouse - _TC"
+ se.stock_entry_type = "Send to Subcontractor"
+ se.inspection_required = 1
+ se.insert()
+ self.assertRaises(QualityInspectionRequiredError, se.submit)
+
+ for item_code in {row.item_code for row in se.items if row.t_warehouse}:
+ create_quality_inspection(
+ reference_type="Stock Entry",
+ reference_name=se.name,
+ item_code=item_code,
+ inspection_type="Outgoing",
+ status="Accepted",
+ )
+ se.reload()
+ se.submit()
+ self.assertEqual(se.docstatus, 1)
+
+ @change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"})
+ def test_quality_inspection_required_for_disassemble(self):
+ from erpnext.controllers.stock_controller import (
+ QualityInspectionRejectedError,
+ QualityInspectionRequiredError,
+ )
+ from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_wo_stock_entry,
+ )
+ from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
+ create_quality_inspection,
+ )
+
+ source_warehouse = "Stores - _TC"
+ fg_item = make_item("Test Disassemble FG QI", {"is_stock_item": 1}).name
+ raw_materials = ["Test Disassemble RM QI 1", "Test Disassemble RM QI 2"]
+ for item in raw_materials:
+ make_item(item, {"is_stock_item": 1})
+ make_stock_entry(item_code=item, target=source_warehouse, qty=5, basic_rate=100)
+
+ make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=raw_materials)
+
+ wo = make_wo_order_test_record(
+ item=fg_item, qty=1, source_warehouse=source_warehouse, skip_transfer=1
+ )
+
+ # manufacture the FG so there is something to disassemble
+ mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1))
+ for row in mfg.items:
+ if row.item_code in raw_materials:
+ row.s_warehouse = source_warehouse
+ mfg.submit()
+
+ # disassemble with inspection required -> the component rows need a QI
+ dis = frappe.get_doc(make_wo_stock_entry(wo.name, "Disassemble", 1))
+ dis.inspection_required = 1
+ dis.insert()
+ self.assertRaises(QualityInspectionRequiredError, dis.submit)
+
+ # a rejected QI on any disassembled component row must also block submission
+ qis = []
+ for item_code in {row.item_code for row in dis.items if row.t_warehouse}:
+ qis.append(
+ create_quality_inspection(
+ reference_type="Stock Entry",
+ reference_name=dis.name,
+ item_code=item_code,
+ inspection_type="Outgoing",
+ status="Rejected",
+ )
+ )
+ dis.reload()
+ self.assertRaises(QualityInspectionRejectedError, dis.submit)
+
+ # accepting every QI then allows submission
+ for qi in qis:
+ frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted")
+ dis.reload()
+ dis.submit()
+ self.assertEqual(dis.docstatus, 1)
+
def test_customer_provided_parts_se(self):
create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0)
se = make_stock_entry(
diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py
index c2b3d81c5c7..c8c97116e20 100644
--- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py
+++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py
@@ -46,7 +46,7 @@ class StockRepostingSettings(Document):
if diff < 10:
self.end_time = get_time_str(add_to_date(self.start_time, hours=10, as_datetime=True))
- @frappe.whitelist()
+ @frappe.whitelist(methods=["POST"])
def convert_to_item_wh_reposting(self):
"""Convert Transaction reposting to Item Warehouse based reposting if Item Based Reposting has enabled."""
diff --git a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json
index 6f28651b99b..ead7db1e24e 100644
--- a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json
+++ b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json
@@ -32,7 +32,7 @@
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
- "reqd": 0,
+ "reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
@@ -74,7 +74,7 @@
"issingle": 0,
"istable": 1,
"max_attachments": 0,
- "modified": "2016-07-11 03:28:09.626948",
+ "modified": "2026-06-11 23:02:54.800673",
"modified_by": "Administrator",
"module": "Stock",
"name": "UOM Conversion Detail",
@@ -84,4 +84,4 @@
"read_only": 0,
"read_only_onload": 0,
"track_seen": 0
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.py b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.py
index d73ba65ca95..3944e899029 100644
--- a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.py
+++ b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.py
@@ -18,7 +18,7 @@ class UOMConversionDetail(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
- uom: DF.Link | None
+ uom: DF.Link
# end: auto-generated types
pass
diff --git a/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.py b/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.py
index 0b27d697a4d..2a9640bab9e 100644
--- a/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.py
+++ b/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.py
@@ -134,12 +134,15 @@ def get_linked_cancelled_sabb(filters):
@frappe.whitelist()
-def fix_sabb_entries(selected_rows):
+def fix_sabb_entries(selected_rows: str | list):
+ frappe.has_permission("Serial and Batch Bundle", "write", throw=True)
+
if isinstance(selected_rows, str):
selected_rows = frappe.parse_json(selected_rows)
for row in selected_rows:
doc = frappe.get_doc("Serial and Batch Bundle", row.get("name"))
+ doc.check_permission("write")
if doc.is_cancelled == 0 and not frappe.db.get_value(
"Stock Ledger Entry",
{"serial_and_batch_bundle": doc.name, "is_cancelled": 0},
diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py
index 238b72fa957..d0e96d640fe 100644
--- a/erpnext/stock/report/stock_ageing/stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/stock_ageing.py
@@ -306,6 +306,11 @@ class FIFOSlots:
# prepare single sle voucher detail lookup
self.prepare_stock_reco_voucher_wise_count()
+ if stock_ledger_entries is None:
+ # nested queries invalidate the streaming cursor below,
+ # so batchwise valuation flags must be resolved beforehand
+ self._prefetch_batchwise_valuations()
+
with frappe.db.unbuffered_cursor():
if stock_ledger_entries is None:
stock_ledger_entries = self._get_stock_ledger_entries()
@@ -423,12 +428,38 @@ class FIFOSlots:
def _get_batchwise_valuation(self, batch_no: str):
if batch_no not in self.batchwise_valuation_by_batch:
+ # only reachable when stock ledger entries are passed in directly;
+ # the streaming path prefetches all flags before iteration
self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value(
"Batch", batch_no, "use_batchwise_valuation"
)
return self.batchwise_valuation_by_batch[batch_no]
+ def _prefetch_batchwise_valuations(self) -> None:
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ batch = frappe.qb.DocType("Batch")
+ to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
+
+ query = (
+ frappe.qb.from_(sle)
+ .left_join(batch)
+ .on(sle.batch_no == batch.name)
+ .select(sle.batch_no, batch.use_batchwise_valuation)
+ .distinct()
+ .where(
+ (sle.batch_no.isnotnull())
+ & (sle.company == self.filters.get("company"))
+ & (sle.posting_datetime <= to_date)
+ & (sle.is_cancelled != 1)
+ )
+ )
+
+ query = self._apply_filter(query, sle, "item_code")
+
+ for batch_no, use_batchwise_valuation in query.run():
+ self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
+
def _init_key_stores(self, row: dict) -> tuple:
"Initialise keys and FIFO Queue."
diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
index 1c61306125e..59550f07aac 100644
--- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
@@ -1438,6 +1438,80 @@ class TestStockAgeing(FrappeTestCase):
item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]]
)
+ def test_legacy_batch_no_sle_with_streaming_cursor(self):
+ """SLEs carrying the legacy batch_no field must not trigger nested
+ queries while entries stream through an unbuffered cursor."""
+ from unittest.mock import patch
+
+ from frappe.utils import add_days, nowdate
+
+ from erpnext.stock.doctype.item.test_item import make_item
+ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
+ get_batch_from_bundle,
+ )
+ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+ create_stock_reconciliation,
+ )
+
+ suffix = frappe.generate_hash(length=8).upper()
+ item_code = make_item(
+ f"Test Stock Ageing Legacy Batch {suffix}",
+ {
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": f"SA-LEG-{suffix}-.###",
+ "valuation_method": "FIFO",
+ },
+ ).name
+ warehouse = "_Test Warehouse - _TC"
+ base_date = nowdate()
+
+ reco = create_stock_reconciliation(
+ item_code=item_code,
+ warehouse=warehouse,
+ qty=10,
+ rate=10,
+ posting_date=add_days(base_date, -2),
+ posting_time="10:00:00",
+ )
+ batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle)
+ frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
+
+ create_stock_reconciliation(
+ item_code=item_code,
+ warehouse=warehouse,
+ qty=5,
+ rate=10,
+ batch_no=batch_no,
+ posting_date=add_days(base_date, -1),
+ posting_time="10:00:00",
+ )
+
+ # mimic pre-bundle data where SLEs carry batch_no directly
+ frappe.db.set_value(
+ "Stock Ledger Entry",
+ {"item_code": item_code},
+ "batch_no",
+ batch_no,
+ )
+
+ filters = frappe._dict(
+ company="_Test Company",
+ to_date=base_date,
+ ranges=["30", "60", "90"],
+ item_code=item_code,
+ )
+ fifo_slots = FIFOSlots(filters)
+
+ # fetch row by row so the streaming result set is still active
+ # while each stock ledger entry is processed
+ with patch("frappe.database.database.SQL_ITERATOR_BATCH_SIZE", 1):
+ slots = fifo_slots.generate()
+
+ self.assertEqual(fifo_slots.batchwise_valuation_by_batch.get(batch_no), 1)
+ self.assertEqual(slots[item_code]["total_qty"], 5.0)
+
def generate_item_and_item_wh_wise_slots(filters, sle):
"Return results with and without 'show_warehouse_wise_stock'"
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index 0f09cc867c2..6bceb0483f8 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -277,12 +277,13 @@ class StockBalanceReport:
qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0)
qty_dict.bal_qty = 0.0
qty_diff = flt(entry.actual_qty)
+ value_diff = flt(entry.stock_value_difference)
else:
qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
+ value_diff = flt(entry.stock_value) - flt(qty_dict.bal_val)
else:
qty_diff = flt(entry.actual_qty)
-
- value_diff = flt(entry.stock_value_difference)
+ value_diff = flt(entry.stock_value_difference)
if entry.posting_date < self.from_date or entry.voucher_no in self.opening_vouchers.get(
entry.voucher_type, []
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index a1dc9d13a4e..7ae0a2fda49 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1188,6 +1188,7 @@ class update_entries_after:
sle.recalculate_rate
or self.has_landed_cost_based_on_pi(sle)
or (sle.voucher_type == "Stock Entry" and sle.actual_qty > 0 and is_repack_entry(sle.voucher_no))
+ or (self.repost_doc and self.repost_doc.get("recalculate_valuation_rate"))
):
rate = self.get_incoming_outgoing_rate_from_transaction(sle)
diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py
index aaf0ec87c23..2bc39655a97 100644
--- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py
+++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py
@@ -3,6 +3,7 @@
import frappe
from frappe import _
+from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt
@@ -363,9 +364,18 @@ def get_mapped_subcontracting_receipt(source_name, target_doc=None):
return target_doc
-@frappe.whitelist()
-def update_subcontracting_order_status(sco, status=None):
+def set_subcontracting_order_status(sco: str | Document, status: str | None = None):
if isinstance(sco, str):
sco = frappe.get_doc("Subcontracting Order", sco)
sco.update_status(status)
+
+
+@frappe.whitelist()
+def update_subcontracting_order_status(sco: str | Document, status: str | None = None):
+ """Whitelisted boundary for direct API/UI calls — enforces write permission, then delegates."""
+ if isinstance(sco, str):
+ sco = frappe.get_doc("Subcontracting Order", sco)
+
+ sco.check_permission("write")
+ set_subcontracting_order_status(sco, status)
diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py
index 43592b628ad..6094229c32d 100644
--- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py
+++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py
@@ -336,6 +336,62 @@ class TestSubcontractingOrder(FrappeTestCase):
bin_after_cancel_sco.reserved_qty_for_sub_contract, bin_before_sco.reserved_qty_for_sub_contract
)
+ def test_send_to_subcontractor_ste_submit_without_sco_write_permission(self):
+ """A Stock-only user (can submit Stock Entries but has no Subcontracting Order write) must be
+ able to submit and cancel a 'Send to Subcontractor' Stock Entry. The SCO status update on the
+ on_submit/on_cancel path goes through the no-permission-check internal helper, not the
+ whitelisted API boundary.
+
+ Regression: the permission hardening put check_permission('write') on the shared status
+ function, so a Stock Manager (no SCO write) hit PermissionError submitting/cancelling the
+ Stock Entry. The suite otherwise runs as Administrator and never caught it."""
+ from frappe.core.doctype.user_permission.test_user_permission import create_user
+
+ make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100)
+
+ service_items = [
+ {
+ "warehouse": "_Test Warehouse - _TC",
+ "item_code": "Subcontracted Service Item 1",
+ "qty": 10,
+ "rate": 100,
+ "fg_item": "_Test FG Item",
+ "fg_item_qty": 10,
+ },
+ ]
+ sco = get_subcontracting_order(service_items=service_items)
+
+ rm_items = [
+ {
+ "item_code": "_Test FG Item",
+ "rm_item_code": "_Test Item",
+ "item_name": "_Test Item",
+ "qty": 10,
+ "warehouse": "_Test Warehouse - _TC",
+ "rate": 100,
+ "amount": 1000,
+ "stock_uom": "Nos",
+ },
+ ]
+ ste = frappe.get_doc(make_rm_stock_entry(sco.name, rm_items))
+ ste.to_warehouse = "_Test Warehouse 1 - _TC"
+ ste.save()
+
+ stock_user = create_user("test_sco_stock_only@example.com", "Stock Manager")
+ self.assertFalse(
+ frappe.has_permission("Subcontracting Order", "write", user=stock_user.name),
+ "Precondition: the Stock-only user must not have Subcontracting Order write permission.",
+ )
+
+ frappe.set_user(stock_user.name)
+ try:
+ ste.reload()
+ ste.submit() # must not raise PermissionError on the SCO status update
+ ste.reload()
+ ste.cancel() # same on the cancel path
+ finally:
+ frappe.set_user("Administrator")
+
def test_exploded_items(self):
item_code = "_Test Subcontracted FG Item 11"
make_subcontracted_item(item_code=item_code)
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
index cd20986dc5a..8be5f453632 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
@@ -161,7 +161,6 @@ class SubcontractingReceipt(SubcontractingController):
def on_submit(self):
self.validate_closed_subcontracting_order()
- self.validate_available_qty_for_consumption()
self.validate_bom_required_qty()
self.update_status_updater_args()
self.update_prevdoc_status()
@@ -506,32 +505,6 @@ class SubcontractingReceipt(SubcontractingController):
_("Row #{0}: Accepted Warehouse and Rejected Warehouse cannot be same").format(item.idx)
)
- def validate_available_qty_for_consumption(self):
- if (
- frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on")
- == "BOM"
- ):
- return
-
- for item in self.get("supplied_items"):
- precision = item.precision("consumed_qty")
- if (
- item.available_qty_for_consumption
- and flt(item.available_qty_for_consumption, precision) - flt(item.consumed_qty, precision) < 0
- ):
- msg = _(
- """Row {0}: Consumed Qty {1} {2} must be less than or equal to Available Qty For Consumption
- {3} {4} in Consumed Items Table."""
- ).format(
- item.idx,
- flt(item.consumed_qty, precision),
- item.stock_uom,
- flt(item.available_qty_for_consumption, precision),
- item.stock_uom,
- )
-
- frappe.throw(msg)
-
def validate_bom_required_qty(self):
if (
frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on")
diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py
index faa12bd5419..c35b76cf37d 100644
--- a/erpnext/support/doctype/issue/issue.py
+++ b/erpnext/support/doctype/issue/issue.py
@@ -118,7 +118,9 @@ class Issue(Document):
communication.save()
@frappe.whitelist()
- def split_issue(self, subject, communication_id):
+ def split_issue(self, subject: str, communication_id: str):
+ self.check_permission("write")
+
# Bug: Pressing enter doesn't send subject
from copy import deepcopy
@@ -274,7 +276,7 @@ def make_task(source_name, target_doc=None):
@frappe.whitelist()
-def make_issue_from_communication(communication, ignore_communication_links=False):
+def make_issue_from_communication(communication: str, ignore_communication_links: bool = False):
"""raise a issue from email"""
doc = frappe.get_doc("Communication", communication)
@@ -286,7 +288,7 @@ def make_issue_from_communication(communication, ignore_communication_links=Fals
"raised_by": doc.sender or "",
"raised_by_phone": doc.phone_no or "",
}
- ).insert(ignore_permissions=True)
+ ).insert()
link_communication_to_document(doc, "Issue", issue.name, ignore_communication_links)
diff --git a/erpnext/templates/includes/projects.css b/erpnext/templates/includes/projects.css
index 5d9fc50385e..0ee177442c9 100644
--- a/erpnext/templates/includes/projects.css
+++ b/erpnext/templates/includes/projects.css
@@ -79,10 +79,6 @@
padding: 8px;
}
-.gravatar-top{
- margin-top:8px;
-}
-
.progress-hg{
margin-bottom: 30!important;
height:2px;
diff --git a/erpnext/templates/utils.py b/erpnext/templates/utils.py
index 15af9f0f014..fef82b8ea9e 100644
--- a/erpnext/templates/utils.py
+++ b/erpnext/templates/utils.py
@@ -3,10 +3,12 @@
import frappe
+from frappe.rate_limiter import rate_limit
from frappe.utils import escape_html
-@frappe.whitelist(allow_guest=True)
+@frappe.whitelist(allow_guest=True, methods=["POST"])
+@rate_limit(limit=10, seconds=3 * 60)
def send_message(sender, message, subject="Website Query"):
from frappe.www.contact import send_message as website_send_message
@@ -14,6 +16,14 @@ def send_message(sender, message, subject="Website Query"):
message = escape_html(message)
+ oppotunity_creation = frappe.get_single_value(
+ "CRM Settings", "enable_opportunity_creation_from_contact_us"
+ )
+
+ if not oppotunity_creation:
+ # Meant to silently fail instead of throwing error.
+ return
+
lead = customer = None
customer = frappe.db.sql(
"""select distinct dl.link_name from `tabDynamic Link` dl