mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-11 21:51:48 +00:00
Merge pull request #55980 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
10
.greptile/config.json
Normal file
10
.greptile/config.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"disabledLabels": [
|
||||
"conflicts"
|
||||
],
|
||||
"context": {
|
||||
"repos": [
|
||||
"frappe/frappe"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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, []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -79,10 +79,6 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.gravatar-top{
|
||||
margin-top:8px;
|
||||
}
|
||||
|
||||
.progress-hg{
|
||||
margin-bottom: 30!important;
|
||||
height:2px;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user