Merge pull request #55980 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
Diptanil Saha
2026-06-17 03:21:59 +05:30
committed by GitHub
75 changed files with 1259 additions and 354 deletions

10
.greptile/config.json Normal file
View File

@@ -0,0 +1,10 @@
{
"disabledLabels": [
"conflicts"
],
"context": {
"repos": [
"frappe/frappe"
]
}
}

View File

@@ -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))

View File

@@ -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:

View File

@@ -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")

View File

@@ -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);
}
},
});

View File

@@ -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",

View File

@@ -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:

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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")

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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:

View File

@@ -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)

View File

@@ -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},

View File

@@ -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":

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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."
)
)

View File

@@ -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"

View File

@@ -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)

View File

@@ -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")

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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={

View File

@@ -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 (

View File

@@ -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")

View File

@@ -96,8 +96,8 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
return `
<tr>
<td>${frappe.meta.get_label(doctype, fieldname)}</td>
<td>${value1}</td>
<td>${value2}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
</tr>
`;
})
@@ -138,13 +138,17 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
.map((change, i) => {
let [fieldname, value1, value2] = change;
let th =
i === 0 ? `<th rowspan="${values_changed.length}">${item_code}</th>` : "";
i === 0
? `<th rowspan="${values_changed.length}">${frappe.utils.escape_html(
cstr(item_code)
)}</th>`
: "";
return `
<tr>
${th}
<td>${frappe.meta.get_label(child_doctype, fieldname)}</td>
<td>${value1}</td>
<td>${value2}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
</tr>
`;
})
@@ -177,7 +181,9 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
let html = rows
.map((row) => {
let [, doc] = row;
let cells = fields.map((df) => `<td>${doc[df.fieldname]}</td>`).join("");
let cells = fields
.map((df) => `<td>${frappe.utils.escape_html(cstr(doc[df.fieldname]))}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");

View File

@@ -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)

View File

@@ -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;

View File

@@ -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",

View File

@@ -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"]),

View File

@@ -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");

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 = ""

View File

@@ -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,

View File

@@ -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")

View File

@@ -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

View File

@@ -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 = {}

View File

@@ -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
}
}

View File

@@ -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}

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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()

View File

@@ -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})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -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."""

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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},

View File

@@ -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."

View File

@@ -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'"

View File

@@ -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, []

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -79,10 +79,6 @@
padding: 8px;
}
.gravatar-top{
margin-top:8px;
}
.progress-hg{
margin-bottom: 30!important;
height:2px;

View File

@@ -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