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