mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-07 15:25:19 +00:00
Merge pull request #54461 from Jatin3128/CL_pre_submit
feat: add pre-submit credit limit warning on save
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"enable_common_party_accounting",
|
"enable_common_party_accounting",
|
||||||
"allow_multi_currency_invoices_against_single_party_account",
|
"allow_multi_currency_invoices_against_single_party_account",
|
||||||
"confirm_before_resetting_posting_date",
|
"confirm_before_resetting_posting_date",
|
||||||
|
"preview_mode",
|
||||||
"analytics_section",
|
"analytics_section",
|
||||||
"enable_accounting_dimensions",
|
"enable_accounting_dimensions",
|
||||||
"column_break_vtnr",
|
"column_break_vtnr",
|
||||||
@@ -716,6 +717,13 @@
|
|||||||
"fieldtype": "Table",
|
"fieldtype": "Table",
|
||||||
"label": "Allowed Doctypes",
|
"label": "Allowed Doctypes",
|
||||||
"options": "Repost Allowed Types"
|
"options": "Repost Allowed Types"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"description": "Runs a preview check on save before submission without making any actual changes.",
|
||||||
|
"fieldname": "preview_mode",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Preview Mode"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
@@ -724,7 +732,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-04-13 15:30:28.729627",
|
"modified": "2026-04-22 01:38:42.418238",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Accounts Settings",
|
"name": "Accounts Settings",
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class AccountsSettings(Document):
|
|||||||
make_payment_via_journal_entry: DF.Check
|
make_payment_via_journal_entry: DF.Check
|
||||||
merge_similar_account_heads: DF.Check
|
merge_similar_account_heads: DF.Check
|
||||||
over_billing_allowance: DF.Currency
|
over_billing_allowance: DF.Currency
|
||||||
|
preview_mode: DF.Check
|
||||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"]
|
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"]
|
||||||
receivable_payable_remarks_length: DF.Int
|
receivable_payable_remarks_length: DF.Int
|
||||||
reconciliation_queue_size: DF.Int
|
reconciliation_queue_size: DF.Int
|
||||||
|
|||||||
264
erpnext/accounts/test/test_pre_submit_validation.py
Normal file
264
erpnext/accounts/test/test_pre_submit_validation.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
from erpnext.accounts.utils import (
|
||||||
|
_check_credit_limit_warn,
|
||||||
|
_check_packed_qty_warn,
|
||||||
|
)
|
||||||
|
from erpnext.selling.doctype.customer.test_customer import set_credit_limit
|
||||||
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
def _get_orange_warnings():
|
||||||
|
return [m for m in frappe.message_log if m.get("indicator") == "orange"]
|
||||||
|
|
||||||
|
|
||||||
|
class _CreditLimitBase(ERPNextTestSuite):
|
||||||
|
COMPANY = "_Test Company"
|
||||||
|
CUSTOMER = "_Test Customer"
|
||||||
|
CREDIT_LIMIT = 100.0
|
||||||
|
OVER = 200.0
|
||||||
|
UNDER = 50.0
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
set_credit_limit(self.CUSTOMER, self.COMPANY, self.CREDIT_LIMIT)
|
||||||
|
frappe.message_log.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreditLimitWarnSalesInvoice(_CreditLimitBase):
|
||||||
|
def _make_si(self, amount, is_return=0):
|
||||||
|
"""Build an in-memory (unsaved) draft SI."""
|
||||||
|
si = frappe.new_doc("Sales Invoice")
|
||||||
|
si.company = self.COMPANY
|
||||||
|
si.customer = self.CUSTOMER
|
||||||
|
si.is_return = is_return
|
||||||
|
si.base_grand_total = amount
|
||||||
|
si.append("items", {"item_code": "_Test Item", "qty": 1, "rate": amount})
|
||||||
|
return si
|
||||||
|
|
||||||
|
def test_warns_when_amount_exceeds_credit_limit(self):
|
||||||
|
"""Orange warning must appear when base_grand_total > credit_limit."""
|
||||||
|
si = self._make_si(self.OVER)
|
||||||
|
_check_credit_limit_warn(si)
|
||||||
|
self.assertTrue(_get_orange_warnings(), "Expected an orange credit-limit warning")
|
||||||
|
|
||||||
|
def test_no_warning_when_amount_within_credit_limit(self):
|
||||||
|
"""No warning when base_grand_total is safely within the credit limit."""
|
||||||
|
si = self._make_si(self.UNDER)
|
||||||
|
_check_credit_limit_warn(si)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_no_warning_for_return_invoices(self):
|
||||||
|
"""Credit limit check is skipped entirely for return transactions."""
|
||||||
|
si = self._make_si(self.OVER, is_return=1)
|
||||||
|
_check_credit_limit_warn(si)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_no_warning_when_customer_has_no_credit_limit(self):
|
||||||
|
"""If the customer has no credit limit configured, no warning is shown."""
|
||||||
|
frappe.db.delete("Customer Credit Limit", {"parent": self.CUSTOMER})
|
||||||
|
si = self._make_si(self.OVER)
|
||||||
|
_check_credit_limit_warn(si)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_no_warning_when_all_items_linked_to_so_or_dn(self):
|
||||||
|
"""
|
||||||
|
When every item on the SI already has a sales_order or delivery_note
|
||||||
|
reference, the check is skipped (the SO/DN already counted this amount).
|
||||||
|
"""
|
||||||
|
si = self._make_si(self.OVER)
|
||||||
|
si.items[0].sales_order = "SO-TEST-0001"
|
||||||
|
_check_credit_limit_warn(si)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreditLimitWarnSalesOrder(_CreditLimitBase):
|
||||||
|
def _make_so(self, amount):
|
||||||
|
"""Build an in-memory (unsaved) draft SO."""
|
||||||
|
so = frappe.new_doc("Sales Order")
|
||||||
|
so.company = self.COMPANY
|
||||||
|
so.customer = self.CUSTOMER
|
||||||
|
so.base_grand_total = amount
|
||||||
|
so.append("items", {"item_code": "_Test Item", "qty": 1, "rate": amount})
|
||||||
|
return so
|
||||||
|
|
||||||
|
def test_warns_on_first_save_when_limit_exceeded(self):
|
||||||
|
so = self._make_so(self.OVER)
|
||||||
|
self.assertTrue(so.is_new(), "Doc should be new (not yet in DB)")
|
||||||
|
_check_credit_limit_warn(so)
|
||||||
|
self.assertTrue(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_warns_when_amount_exceeds_credit_limit(self):
|
||||||
|
so = self._make_so(self.OVER)
|
||||||
|
_check_credit_limit_warn(so)
|
||||||
|
self.assertTrue(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_no_warning_when_amount_within_credit_limit(self):
|
||||||
|
so = self._make_so(self.UNDER)
|
||||||
|
_check_credit_limit_warn(so)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_no_warning_when_bypass_is_set(self):
|
||||||
|
"""
|
||||||
|
When bypass_credit_limit_check=1 on the Customer Credit Limit row,
|
||||||
|
SO's check_credit_limit skips entirely.
|
||||||
|
"""
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Customer Credit Limit",
|
||||||
|
{"parent": self.CUSTOMER, "company": self.COMPANY},
|
||||||
|
"bypass_credit_limit_check",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
so = self._make_so(self.OVER)
|
||||||
|
_check_credit_limit_warn(so)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreditLimitWarnDeliveryNote(_CreditLimitBase):
|
||||||
|
def _make_dn(self, amount, bypass=False, against_sales_order=None, against_sales_invoice=None):
|
||||||
|
"""Build an in-memory (unsaved) draft DN."""
|
||||||
|
dn = frappe.new_doc("Delivery Note")
|
||||||
|
dn.company = self.COMPANY
|
||||||
|
dn.customer = self.CUSTOMER
|
||||||
|
dn.base_grand_total = amount
|
||||||
|
dn.base_net_total = amount
|
||||||
|
item = {
|
||||||
|
"item_code": "_Test Item",
|
||||||
|
"qty": 1,
|
||||||
|
"rate": amount,
|
||||||
|
"amount": amount,
|
||||||
|
"base_amount": amount,
|
||||||
|
}
|
||||||
|
if against_sales_order:
|
||||||
|
item["against_sales_order"] = against_sales_order
|
||||||
|
if against_sales_invoice:
|
||||||
|
item["against_sales_invoice"] = against_sales_invoice
|
||||||
|
dn.append("items", item)
|
||||||
|
|
||||||
|
if bypass:
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Customer Credit Limit",
|
||||||
|
{"parent": self.CUSTOMER, "company": self.COMPANY},
|
||||||
|
"bypass_credit_limit_check",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
return dn
|
||||||
|
|
||||||
|
def test_bypass_false_warns_for_existing_draft(self):
|
||||||
|
"""bypass=False, existing draft: proportional extra_amount path still applies."""
|
||||||
|
dn = self._make_dn(self.OVER)
|
||||||
|
_check_credit_limit_warn(dn)
|
||||||
|
self.assertTrue(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_bypass_false_no_warning_when_under_limit(self):
|
||||||
|
dn = self._make_dn(self.UNDER)
|
||||||
|
_check_credit_limit_warn(dn)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_bypass_false_no_warning_when_all_items_linked_to_so(self):
|
||||||
|
"""
|
||||||
|
Items fully linked to a SO are excluded from unlinked_net.
|
||||||
|
extra_amount becomes 0 → check is skipped.
|
||||||
|
"""
|
||||||
|
dn = self._make_dn(self.OVER, against_sales_order="SO-TEST-0001")
|
||||||
|
_check_credit_limit_warn(dn)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_bypass_false_partial_link_warns_proportionally(self):
|
||||||
|
"""
|
||||||
|
Two items: one linked to SO, one unlinked.
|
||||||
|
Only the unlinked portion should count toward the credit limit check.
|
||||||
|
"""
|
||||||
|
dn = frappe.new_doc("Delivery Note")
|
||||||
|
dn.company = self.COMPANY
|
||||||
|
dn.customer = self.CUSTOMER
|
||||||
|
dn.append("items", {"item_code": "_Test Item", "qty": 1, "rate": 60, "amount": 60, "base_amount": 60})
|
||||||
|
dn.append(
|
||||||
|
"items",
|
||||||
|
{
|
||||||
|
"item_code": "_Test Item",
|
||||||
|
"qty": 1,
|
||||||
|
"rate": 50,
|
||||||
|
"amount": 50,
|
||||||
|
"base_amount": 50,
|
||||||
|
"against_sales_order": "SO-TEST-0001",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dn.base_net_total = 110
|
||||||
|
dn.base_grand_total = 110
|
||||||
|
|
||||||
|
_check_credit_limit_warn(dn)
|
||||||
|
self.assertFalse(_get_orange_warnings(), "60 < 100 credit limit, should not warn")
|
||||||
|
|
||||||
|
# bypass=True -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_bypass_true_warns_on_first_save_new_doc(self):
|
||||||
|
"""
|
||||||
|
bypass=True: existing doc.check_credit_limit() handles extra_amount
|
||||||
|
internally (base_grand_total for items not against SI).
|
||||||
|
"""
|
||||||
|
dn = self._make_dn(self.OVER, bypass=True)
|
||||||
|
self.assertTrue(dn.is_new())
|
||||||
|
_check_credit_limit_warn(dn)
|
||||||
|
self.assertTrue(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_bypass_true_no_warning_when_all_items_billed(self):
|
||||||
|
"""
|
||||||
|
bypass=True: items already linked to a SI are excluded from extra_amount.
|
||||||
|
If all items have against_sales_invoice set, extra_amount=0 → no check.
|
||||||
|
"""
|
||||||
|
dn = self._make_dn(self.OVER, bypass=True, against_sales_invoice="SINV-TEST-0001")
|
||||||
|
_check_credit_limit_warn(dn)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Packed Qty
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackedQtyWarn(ERPNextTestSuite):
|
||||||
|
COMPANY = "_Test Company"
|
||||||
|
CUSTOMER = "_Test Customer"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
frappe.message_log.clear()
|
||||||
|
|
||||||
|
def _make_dn(self):
|
||||||
|
dn = frappe.new_doc("Delivery Note")
|
||||||
|
dn.company = self.COMPANY
|
||||||
|
dn.customer = self.CUSTOMER
|
||||||
|
dn.append(
|
||||||
|
"items",
|
||||||
|
{"item_code": "_Test Item", "qty": 2, "rate": 100, "amount": 200, "base_amount": 200},
|
||||||
|
)
|
||||||
|
return dn
|
||||||
|
|
||||||
|
def test_no_warning_for_new_doc(self):
|
||||||
|
"""New doc has no packing slip in DB, so validate_packed_qty is skipped."""
|
||||||
|
dn = self._make_dn()
|
||||||
|
_check_packed_qty_warn(dn)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_warns_when_packed_qty_mismatches(self):
|
||||||
|
"""When validate_packed_qty raises, an orange warning is produced."""
|
||||||
|
dn = self._make_dn()
|
||||||
|
with patch.object(
|
||||||
|
dn,
|
||||||
|
"validate_packed_qty",
|
||||||
|
side_effect=frappe.ValidationError("Packed Qty must be equal to qty"),
|
||||||
|
):
|
||||||
|
_check_packed_qty_warn(dn)
|
||||||
|
self.assertTrue(_get_orange_warnings())
|
||||||
|
|
||||||
|
def test_no_warning_when_packed_qty_matches(self):
|
||||||
|
"""When validate_packed_qty passes silently, no warning is produced."""
|
||||||
|
dn = self._make_dn()
|
||||||
|
with patch.object(dn, "validate_packed_qty", return_value=None):
|
||||||
|
_check_packed_qty_warn(dn)
|
||||||
|
self.assertFalse(_get_orange_warnings())
|
||||||
@@ -2724,3 +2724,119 @@ def build_qb_match_conditions(doctype, user=None) -> list:
|
|||||||
|
|
||||||
def is_immutable_ledger_enabled():
|
def is_immutable_ledger_enabled():
|
||||||
return frappe.get_single_value("Accounts Settings", "enable_immutable_ledger")
|
return frappe.get_single_value("Accounts Settings", "enable_immutable_ledger")
|
||||||
|
|
||||||
|
|
||||||
|
PRE_SUBMIT_DOCTYPE_CONFIG = {
|
||||||
|
"Sales Invoice": {
|
||||||
|
"check_prev_docstatus": True,
|
||||||
|
"check_credit_limit": True,
|
||||||
|
},
|
||||||
|
"Purchase Invoice": {
|
||||||
|
"check_prev_docstatus": True,
|
||||||
|
},
|
||||||
|
"Delivery Note": {
|
||||||
|
"check_prev_docstatus": True,
|
||||||
|
"check_credit_limit": True,
|
||||||
|
"check_packed_qty": True,
|
||||||
|
},
|
||||||
|
"Purchase Receipt": {
|
||||||
|
"check_prev_docstatus": True,
|
||||||
|
},
|
||||||
|
"Sales Order": {
|
||||||
|
"check_credit_limit": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pre_submit_validation(doc, method=None):
|
||||||
|
cfg = PRE_SUBMIT_DOCTYPE_CONFIG.get(doc.doctype)
|
||||||
|
if (
|
||||||
|
doc.docstatus != 0
|
||||||
|
or not frappe.get_cached_value("Accounts Settings", None, "preview_mode")
|
||||||
|
or not cfg
|
||||||
|
or not doc.company
|
||||||
|
):
|
||||||
|
return
|
||||||
|
_run_pre_submit_checks(doc, cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pre_submit_checks(doc, cfg):
|
||||||
|
if cfg.get("check_prev_docstatus"):
|
||||||
|
_check_prev_docstatus(doc)
|
||||||
|
|
||||||
|
if cfg.get("check_credit_limit"):
|
||||||
|
_check_credit_limit_warn(doc)
|
||||||
|
|
||||||
|
if cfg.get("check_packed_qty"):
|
||||||
|
_check_packed_qty_warn(doc)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_prev_docstatus(doc):
|
||||||
|
try:
|
||||||
|
if hasattr(doc, "check_prev_docstatus"):
|
||||||
|
doc.check_prev_docstatus()
|
||||||
|
except Exception as e:
|
||||||
|
frappe.msgprint(str(e), title=_("Pre-Submit Warning"), indicator="orange")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_credit_limit_warn(doc):
|
||||||
|
if doc.get("is_return") or not doc.get("customer"):
|
||||||
|
return
|
||||||
|
|
||||||
|
from erpnext.selling.doctype.customer.customer import check_credit_limit
|
||||||
|
|
||||||
|
try:
|
||||||
|
bypass = cint(
|
||||||
|
frappe.db.get_value(
|
||||||
|
"Customer Credit Limit",
|
||||||
|
filters={"parent": doc.customer, "parenttype": "Customer", "company": doc.company},
|
||||||
|
fieldname="bypass_credit_limit_check",
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if doc.doctype == "Sales Invoice":
|
||||||
|
validate_against_credit_limit = bypass or any(
|
||||||
|
not (d.sales_order or d.delivery_note) for d in doc.get("items")
|
||||||
|
)
|
||||||
|
if validate_against_credit_limit:
|
||||||
|
check_credit_limit(doc.customer, doc.company, bypass, extra_amount=flt(doc.base_grand_total))
|
||||||
|
|
||||||
|
elif doc.doctype == "Sales Order":
|
||||||
|
if not bypass:
|
||||||
|
check_credit_limit(doc.customer, doc.company, extra_amount=flt(doc.base_grand_total))
|
||||||
|
|
||||||
|
elif doc.doctype == "Delivery Note":
|
||||||
|
if doc.per_billed == 100:
|
||||||
|
return
|
||||||
|
|
||||||
|
if bypass:
|
||||||
|
doc.check_credit_limit()
|
||||||
|
else:
|
||||||
|
unlinked = [
|
||||||
|
d for d in doc.get("items") if not (d.against_sales_order or d.against_sales_invoice)
|
||||||
|
]
|
||||||
|
if unlinked and flt(doc.base_net_total):
|
||||||
|
unlinked_net = sum(flt(d.base_amount) for d in unlinked)
|
||||||
|
extra_amount = (unlinked_net / flt(doc.base_net_total)) * flt(doc.base_grand_total)
|
||||||
|
if extra_amount:
|
||||||
|
check_credit_limit(doc.customer, doc.company, False, extra_amount=extra_amount)
|
||||||
|
|
||||||
|
except frappe.ValidationError as e:
|
||||||
|
frappe.msgprint(
|
||||||
|
_("Credit limit warning — submission may be blocked: {0}").format(str(e)),
|
||||||
|
title=_("Pre-Submit Warning: Credit Limit"),
|
||||||
|
indicator="orange",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_packed_qty_warn(doc):
|
||||||
|
try:
|
||||||
|
if hasattr(doc, "validate_packed_qty"):
|
||||||
|
doc.validate_packed_qty()
|
||||||
|
except frappe.ValidationError as e:
|
||||||
|
frappe.msgprint(
|
||||||
|
str(e),
|
||||||
|
title=_("Pre-Submit Warning: Packed Qty"),
|
||||||
|
indicator="orange",
|
||||||
|
)
|
||||||
|
|||||||
@@ -340,6 +340,14 @@ period_closing_doctypes = [
|
|||||||
"Subcontracting Receipt",
|
"Subcontracting Receipt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
pre_submit_validation_doctypes = [
|
||||||
|
"Sales Invoice",
|
||||||
|
"Purchase Invoice",
|
||||||
|
"Delivery Note",
|
||||||
|
"Purchase Receipt",
|
||||||
|
"Sales Order",
|
||||||
|
]
|
||||||
|
|
||||||
doc_events = {
|
doc_events = {
|
||||||
"*": {
|
"*": {
|
||||||
"validate": [
|
"validate": [
|
||||||
@@ -350,6 +358,9 @@ doc_events = {
|
|||||||
tuple(period_closing_doctypes): {
|
tuple(period_closing_doctypes): {
|
||||||
"validate": "erpnext.accounts.doctype.accounting_period.accounting_period.validate_accounting_period_on_doc_save",
|
"validate": "erpnext.accounts.doctype.accounting_period.accounting_period.validate_accounting_period_on_doc_save",
|
||||||
},
|
},
|
||||||
|
tuple(pre_submit_validation_doctypes): {
|
||||||
|
"validate": "erpnext.accounts.utils.pre_submit_validation",
|
||||||
|
},
|
||||||
"Stock Entry": {
|
"Stock Entry": {
|
||||||
"on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
|
"on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
|
||||||
"on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
|
"on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
|
||||||
|
|||||||
Reference in New Issue
Block a user