diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 58c14a2bc5d..b0c69be4a1c 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -273,6 +273,8 @@ class POSInvoice(SalesInvoice): against_psi_doc.delete_loyalty_point_entry() against_psi_doc.make_loyalty_point_entry() + self.db_set("status", "Cancelled") + if self.coupon_code: from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 1c4bb66357f..714ed623796 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -129,8 +129,8 @@ def get_statement_dict(doc, get_statement_dict=False): tax_id = frappe.get_doc("Customer", entry.customer).tax_id presentation_currency = ( - get_party_account_currency("Customer", entry.customer, doc.company) - or doc.currency + doc.currency + or get_party_account_currency("Customer", entry.customer, doc.company) or get_company_currency(doc.company) ) diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py index 92dbb5ef273..ebfe96e771e 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py @@ -97,6 +97,7 @@ def create_process_soa(**args): company=args.company or "_Test Company", customers=args.customers or [{"customer": "_Test Customer"}], enable_auto_email=1 if args.enable_auto_email else 0, + currency=args.currency or "", frequency=args.frequency or "Weekly", report=args.report or "General Ledger", from_date=args.from_date or getdate(today()), diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 13430b46449..c3935029670 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -425,6 +425,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. this.frm.set_value("is_paid", 0); frappe.msgprint(__("Please specify Company to proceed")); } + } else { + this.frm.set_value("paid_amount", 0); } this.calculate_outstanding_amount(); this.frm.refresh_fields(); diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index e45802ca9f2..fa9b226374c 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -671,6 +671,7 @@ def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers): conditions.append(ple.party.isin(parties)) conditions.append(ple.voucher_no == ple.against_voucher_no) conditions.append(ple.company == inv.company) + conditions.append(ple.posting_date[tax_details.from_date : tax_details.to_date]) advance_amt = ( qb.from_(ple).select(Abs(Sum(ple.amount))).where(Criterion.all(conditions)).run()[0][0] or 0.0 diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index ae0a098137a..3ea6801ad35 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -288,17 +288,18 @@ class TestTaxWithholdingCategory(FrappeTestCase): frappe.db.set_value( "Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS" ) + fiscal_year = get_fiscal_year(today(), company="_Test Company") vouchers = [] # create advance payment - pe = create_payment_entry( + pe1 = create_payment_entry( payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=20000 ) - pe.paid_from = "Debtors - _TC" - pe.paid_to = "Cash - _TC" - pe.submit() - vouchers.append(pe) + pe1.paid_from = "Debtors - _TC" + pe1.paid_to = "Cash - _TC" + pe1.submit() + vouchers.append(pe1) # create invoice si1 = create_sales_invoice(customer="Test TCS Customer", rate=5000) @@ -320,6 +321,17 @@ class TestTaxWithholdingCategory(FrappeTestCase): # make another invoice # sum of unallocated amount from payment entry and this sales invoice will breach cumulative threashold # TDS should be calculated + + # this payment should not be considered for TCS calculation as it is outside of fiscal year + pe2 = create_payment_entry( + payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=10000 + ) + pe2.paid_from = "Debtors - _TC" + pe2.paid_to = "Cash - _TC" + pe2.posting_date = add_days(fiscal_year[1], -10) + pe2.submit() + vouchers.append(pe2) + si2 = create_sales_invoice(customer="Test TCS Customer", rate=15000) si2.submit() vouchers.append(si2) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 419baaa3d47..52550f148b7 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -460,6 +460,12 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals if (account and account_currency != existing_gle_currency) or not account: account = get_party_gle_account(party_type, party, company) + # get default account on the basis of party type + if not account: + account_type = frappe.get_cached_value("Party Type", party_type, "account_type") + default_account_name = "default_" + account_type.lower() + "_account" + account = frappe.get_cached_value("Company", company, default_account_name) + if include_advance and party_type in ["Customer", "Supplier", "Student"]: advance_account = get_party_advance_account(party_type, party, company) if advance_account: diff --git a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py index 963fb3556a6..b90f922d82b 100644 --- a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py +++ b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py @@ -8,6 +8,7 @@ from frappe.query_builder import Criterion, Tuple from frappe.query_builder.functions import IfNull from frappe.utils import getdate, nowdate from frappe.utils.nestedset import get_descendants_of +from pypika.terms import LiteralValue from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -15,7 +16,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( ) TREE_DOCTYPES = frozenset( - ["Customer Group", "Terrirtory", "Supplier Group", "Sales Partner", "Sales Person", "Cost Center"] + ["Customer Group", "Territory", "Supplier Group", "Sales Partner", "Sales Person", "Cost Center"] ) @@ -77,13 +78,12 @@ class PartyLedgerSummaryReport: from frappe.desk.reportview import build_match_conditions - query, params = query.walk() match_conditions = build_match_conditions(party_type) if match_conditions: - query += "and" + match_conditions + query = query.where(LiteralValue(match_conditions)) - party_details = frappe.db.sql(query, params, as_dict=True) + party_details = query.run(as_dict=True) for row in party_details: self.parties.append(row.party) @@ -458,9 +458,16 @@ class PartyLedgerSummaryReport: def get_children(doctype, value): - children = get_descendants_of(doctype, value) + if not isinstance(value, list): + value = [d.strip() for d in value.strip().split(",") if d] - return [value, *children] + all_children = [] + + for d in value: + all_children += get_descendants_of(doctype, value) + all_children.append(d) + + return list(set(all_children)) def execute(filters=None): diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js index 2afc1ecb439..5d47cc13e5b 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js @@ -402,7 +402,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s args: { item_code: item.item_code, warehouse: cstr(item.warehouse), - qty: flt(item.stock_qty), + qty: -1 * flt(item.stock_qty), serial_no: item.serial_no, posting_date: me.frm.doc.posting_date, posting_time: me.frm.doc.posting_time, diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py index 5bddea56183..f9c4913b1a2 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py @@ -255,8 +255,10 @@ class AssetDepreciationSchedule(Document): value_after_depreciation, ): asset_doc.validate_asset_finance_books(row) - - if not value_after_depreciation: + if ( + not value_after_depreciation + and not asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment + ): value_after_depreciation = _get_value_after_depreciation_for_making_schedule(asset_doc, row) row.value_after_depreciation = value_after_depreciation @@ -1068,8 +1070,6 @@ def make_new_active_asset_depr_schedules_and_cancel_current_ones( ) new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc) - if asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment and not value_after_depreciation: - value_after_depreciation = row.value_after_depreciation - difference_amount if asset_doc.flags.increase_in_asset_value_due_to_repair and row.depreciation_method in ( "Written Down Value", diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index 6766b827f7f..0f4d8a9ae95 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import flt, formatdate, get_link_to_form, getdate +from frappe.utils import cstr, flt, formatdate, get_link_to_form, getdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_checks_for_pl_and_bs_accounts, @@ -188,12 +188,21 @@ class AssetValueAdjustment(Document): get_link_to_form(self.get("doctype"), self.get("name")), ) + difference_amount = self.difference_amount if self.docstatus == 1 else -1 * self.difference_amount + if asset.calculate_depreciation: + for row in asset.finance_books: + if cstr(row.finance_book) == cstr(self.finance_book): + row.value_after_depreciation += flt(difference_amount) + row.db_update() + + asset.db_update() + make_new_active_asset_depr_schedules_and_cancel_current_ones( asset, notes, value_after_depreciation=asset_value, ignore_booked_entry=True, - difference_amount=self.difference_amount, + difference_amount=difference_amount, ) asset.flags.ignore_validate_update_after_submit = True asset.save() diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index ae854f29343..1b1df3fa1e0 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -25,6 +25,9 @@ "disable_last_purchase_rate", "show_pay_button", "use_transaction_date_exchange_rate", + "allow_zero_qty_in_request_for_quotation", + "allow_zero_qty_in_supplier_quotation", + "allow_zero_qty_in_purchase_order", "subcontract", "backflush_raw_materials_of_subcontract_based_on", "column_break_11", @@ -207,14 +210,36 @@ "fieldtype": "Select", "label": "Update frequency of Project", "options": "Each Transaction\nManual" + }, + { + "default": "0", + "description": "Allows users to submit Purchase Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_purchase_order", + "fieldtype": "Check", + "label": "Allow Purchase Order with Zero Quantity" + }, + { + "default": "0", + "description": "Allows users to submit Request for Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_request_for_quotation", + "fieldtype": "Check", + "label": "Allow Request for Quotation with Zero Quantity" + }, + { + "default": "0", + "description": "Allows users to submit Supplier Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_supplier_quotation", + "fieldtype": "Check", + "label": "Allow Supplier Quotation with Zero Quantity" } ], + "grid_page_length": 50, "icon": "fa fa-cog", "idx": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-01-31 13:34:18.101256", + "modified": "2025-05-06 15:21:49.639642", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py index ec9b88888b7..4dde7c8dabf 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.py +++ b/erpnext/buying/doctype/buying_settings/buying_settings.py @@ -18,6 +18,9 @@ class BuyingSettings(Document): from frappe.types import DF allow_multiple_items: DF.Check + allow_zero_qty_in_purchase_order: DF.Check + allow_zero_qty_in_request_for_quotation: DF.Check + allow_zero_qty_in_supplier_quotation: DF.Check auto_create_purchase_receipt: DF.Check auto_create_subcontracting_order: DF.Check backflush_raw_materials_of_subcontract_based_on: DF.Literal[ diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 0b7c9de467a..7925d59d25a 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -26,7 +26,15 @@ frappe.ui.form.on("Purchase Order", { } frm.set_indicator_formatter("item_code", function (doc) { - return doc.qty <= doc.received_qty ? "green" : "orange"; + let color; + if (!doc.qty && frm.doc.has_unit_price_items) { + color = "yellow"; + } else if (doc.qty <= doc.received_qty) { + color = "green"; + } else { + color = "orange"; + } + return color; }); frm.set_query("expense_account", "items", function () { @@ -63,6 +71,10 @@ frappe.ui.form.on("Purchase Order", { } }); } + + if (frm.doc.docstatus == 0) { + erpnext.set_unit_price_items_note(frm); + } }, supplier: function (frm) { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 7c9362ebaf9..59b44a22e61 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -24,6 +24,7 @@ "apply_tds", "tax_withholding_category", "is_subcontracted", + "has_unit_price_items", "supplier_warehouse", "amended_from", "accounting_dimensions_section", @@ -1285,6 +1286,14 @@ "label": "Dispatch Address Details", "print_hide": 1, "read_only": 1 + }, + { + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 } ], "grid_page_length": 50, diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index a18d9fce186..43ef287854e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -97,6 +97,7 @@ class PurchaseOrder(BuyingController): from_date: DF.Date | None grand_total: DF.Currency group_same_items: DF.Check + has_unit_price_items: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -191,6 +192,10 @@ class PurchaseOrder(BuyingController): self.set_onload("supplier_tds", supplier_tds) self.set_onload("can_update_items", self.can_update_items()) + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): super().validate() @@ -223,6 +228,17 @@ class PurchaseOrder(BuyingController): ) self.reset_default_field_value("set_warehouse", "items", "warehouse") + def set_has_unit_price_items(self): + """ + If permitted in settings and any item has 0 qty, the PO has unit price items. + """ + if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order"): + return + + self.has_unit_price_items = any( + not row.qty for row in self.get("items") if (row.item_code and not row.qty) + ) + def validate_with_previous_doc(self): mri_compare_fields = [["project", "="], ["item_code", "="]] if self.is_subcontracted: @@ -707,8 +723,13 @@ def set_missing_values(source, target): @frappe.whitelist() def make_purchase_receipt(source_name, target_doc=None): + has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) - flt(obj.received_qty) + target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) target.base_amount = ( @@ -739,7 +760,9 @@ def make_purchase_receipt(source_name, target_doc=None): "wip_composite_asset": "wip_composite_asset", }, "postprocess": update_item, - "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) + "condition": lambda doc: ( + True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) + ) and doc.delivered_by_supplier != 1, }, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 272f76816f8..c6ee35d6090 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -52,6 +52,13 @@ class TestPurchaseOrder(FrappeTestCase): po.save() self.assertEqual(po.items[1].qty, 1) + def test_purchase_order_zero_qty(self): + po = create_purchase_order(qty=0, do_not_save=True) + + with change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}): + po.save() + self.assertEqual(po.items[0].qty, 0) + def test_make_purchase_receipt(self): po = create_purchase_order(do_not_submit=True) self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name) @@ -801,8 +808,6 @@ class TestPurchaseOrder(FrappeTestCase): po_doc.reload() self.assertEqual(po_doc.advance_paid, 5000) - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice - company_doc.book_advance_payments_in_separate_party_account = False company_doc.save() @@ -1207,6 +1212,80 @@ class TestPurchaseOrder(FrappeTestCase): po.reload() self.assertEqual(po.per_billed, 100) + @change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}) + def test_receive_zero_qty_purchase_order(self): + """ + Test the flow of a Unit Price PO and PR creation against it until completion. + Flow: + PO Qty 0 -> Receive +5 -> Receive +5 -> Update PO Qty +10 -> PO is 100% received + """ + po = create_purchase_order(qty=0) + pr = make_purchase_receipt(po.name) + + self.assertEqual(pr.items[0].qty, 0) + pr.items[0].qty = 5 + pr.submit() + + po.reload() + self.assertEqual(po.items[0].received_qty, 5) + self.assertFalse(po.per_received) + self.assertEqual(po.status, "To Receive and Bill") + + # Update PO Item Qty to 10 after receipt of items + first_item_of_po = po.items[0] + trans_item = json.dumps( + [ + { + "item_code": first_item_of_po.item_code, + "rate": first_item_of_po.rate, + "qty": 10, + "docname": first_item_of_po.name, + } + ] + ) + update_child_qty_rate("Purchase Order", trans_item, po.name) + + # Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty + pr2 = make_purchase_receipt(po.name) + + po.reload() + self.assertEqual(po.items[0].qty, 10) + self.assertEqual(pr2.items[0].qty, 5) + + pr2.submit() + + # PO should be updated to 100% received + po.reload() + self.assertEqual(po.items[0].qty, 10) + self.assertEqual(po.items[0].received_qty, 10) + self.assertEqual(po.per_received, 100.0) + self.assertEqual(po.status, "To Bill") + + @change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}) + def test_bill_zero_qty_purchase_order(self): + po = create_purchase_order(qty=0) + + self.assertEqual(po.grand_total, 0) + self.assertFalse(po.per_billed) + self.assertEqual(po.items[0].qty, 0) + self.assertEqual(po.items[0].rate, 500) + + pi = make_pi_from_po(po.name) + self.assertEqual(pi.items[0].qty, 0) + self.assertEqual(pi.items[0].rate, 500) + + pi.items[0].qty = 5 + pi.submit() + + self.assertEqual(pi.grand_total, 2500) + + po.reload() + self.assertEqual(po.items[0].amount, 0) + self.assertEqual(po.items[0].billed_amt, 2500) + # PO still has qty 0, so billed % should be unset + self.assertFalse(po.per_billed) + self.assertEqual(po.status, "To Receive and Bill") + def create_po_for_sc_testing(): from erpnext.controllers.tests.test_subcontracting_controller import ( diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 96597bd9753..e88a98759d0 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -28,6 +28,10 @@ frappe.ui.form.on("Request for Quotation", { is_group: 0, }, })); + + frm.set_indicator_formatter("item_code", function (doc) { + return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : ""; + }); }, onload: function (frm) { @@ -163,6 +167,10 @@ frappe.ui.form.on("Request for Quotation", { __("View") ); } + + if (frm.doc.docstatus === 0) { + erpnext.set_unit_price_items_note(frm); + } }, show_supplier_quotation_comparison(frm) { diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index fd73f77ff8f..824484f9c20 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -16,6 +16,7 @@ "transaction_date", "schedule_date", "status", + "has_unit_price_items", "amended_from", "suppliers_section", "suppliers", @@ -306,13 +307,22 @@ "fieldtype": "Small Text", "label": "Billing Address Details", "read_only": 1 + }, + { + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 } ], + "grid_page_length": 50, "icon": "fa fa-shopping-cart", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-11-06 12:45:28.898706", + "modified": "2025-03-03 16:48:39.856779", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", @@ -377,6 +387,7 @@ "role": "All" } ], + "row_format": "Dynamic", "search_fields": "status, transaction_date", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 650251ed7de..27793236dc3 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -42,6 +42,7 @@ class RequestforQuotation(BuyingController): billing_address_display: DF.SmallText | None company: DF.Link email_template: DF.Link | None + has_unit_price_items: DF.Check incoterm: DF.Link | None items: DF.Table[RequestforQuotationItem] letter_head: DF.Link | None @@ -61,6 +62,10 @@ class RequestforQuotation(BuyingController): vendor: DF.Link | None # end: auto-generated types + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): self.validate_duplicate_supplier() self.validate_supplier_list() @@ -73,6 +78,17 @@ class RequestforQuotation(BuyingController): # after amend and save, status still shows as cancelled, until submit self.db_set("status", "Draft") + def set_has_unit_price_items(self): + """ + If permitted in settings and any item has 0 qty, the RFQ has unit price items. + """ + if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_request_for_quotation"): + return + + self.has_unit_price_items = any( + not row.qty for row in self.get("items") if (row.item_code and not row.qty) + ) + def validate_duplicate_supplier(self): supplier_list = [d.supplier for d in self.suppliers] if len(supplier_list) != len(set(supplier_list)): @@ -440,11 +456,10 @@ def create_supplier_quotation(doc): def add_items(sq_doc, supplier, items): for data in items: - if data.get("qty") > 0: - if isinstance(data, dict): - data = frappe._dict(data) + if isinstance(data, dict): + data = frappe._dict(data) - create_rfq_items(sq_doc, supplier, data) + create_rfq_items(sq_doc, supplier, data) def create_rfq_items(sq_doc, supplier, data): diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index 4effe13d02c..1a8b3a8ac47 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import nowdate from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( @@ -32,6 +32,16 @@ class TestRequestforQuotation(FrappeTestCase): rfq.save() self.assertEqual(rfq.items[0].qty, 1) + def test_rfq_zero_qty(self): + """ + Test if RFQ with zero qty (Unit Price Item) is conditionally allowed. + """ + rfq = make_request_for_quotation(qty=0, do_not_save=True) + + with change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}): + rfq.save() + self.assertEqual(rfq.items[0].qty, 0) + def test_quote_status(self): rfq = make_request_for_quotation() @@ -172,6 +182,32 @@ class TestRequestforQuotation(FrappeTestCase): supplier_doc.reload() self.assertTrue(supplier_doc.portal_users[0].user) + @change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}) + def test_supplier_quotation_from_zero_qty_rfq(self): + rfq = make_request_for_quotation(qty=0) + sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier) + + self.assertEqual(len(sq.items), 1) + self.assertEqual(sq.items[0].qty, 0) + self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code) + + @change_settings( + "Buying Settings", + { + "allow_zero_qty_in_request_for_quotation": 1, + "allow_zero_qty_in_supplier_quotation": 1, + }, + ) + def test_supplier_quotation_from_zero_qty_rfq_in_portal(self): + rfq = make_request_for_quotation(qty=0) + rfq.supplier = rfq.suppliers[0].supplier + sq_name = create_supplier_quotation(rfq) + + sq = frappe.get_doc("Supplier Quotation", sq_name) + self.assertEqual(len(sq.items), 1) + self.assertEqual(sq.items[0].qty, 0) + self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code) + def make_request_for_quotation(**args) -> "RequestforQuotation": """ diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index c1698710135..fccca81f8ce 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -11,6 +11,11 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e Quotation: "Quotation", }; + const me = this; + this.frm.set_indicator_formatter("item_code", function (doc) { + return !doc.qty && me.frm.doc.has_unit_price_items ? "yellow" : ""; + }); + super.setup(); } @@ -26,6 +31,8 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e cur_frm.page.set_inner_btn_group_as_primary(__("Create")); cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create")); } else if (this.frm.doc.docstatus === 0) { + erpnext.set_unit_price_items_note(this.frm); + this.frm.add_custom_button( __("Material Request"), function () { diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 4a8cd8bf9e6..6682c7e3585 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -19,6 +19,7 @@ "transaction_date", "valid_till", "quotation_number", + "has_unit_price_items", "amended_from", "accounting_dimensions_section", "cost_center", @@ -921,14 +922,23 @@ "fieldname": "accounting_dimensions_section", "fieldtype": "Section Break", "label": "Accounting Dimensions" + }, + { + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 } ], + "grid_page_length": 50, "icon": "fa fa-shopping-cart", "idx": 29, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-28 10:20:30.231915", + "modified": "2025-03-03 17:39:38.459977", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", @@ -989,6 +999,7 @@ "write": 1 } ], + "row_format": "Dynamic", "search_fields": "status, transaction_date, supplier,grand_total", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 215022e18a6..5557f1a80ae 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -60,6 +60,7 @@ class SupplierQuotation(BuyingController): discount_amount: DF.Currency grand_total: DF.Currency group_same_items: DF.Check + has_unit_price_items: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -103,6 +104,10 @@ class SupplierQuotation(BuyingController): valid_till: DF.Date | None # end: auto-generated types + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): super().validate() @@ -129,6 +134,17 @@ class SupplierQuotation(BuyingController): def on_trash(self): pass + def set_has_unit_price_items(self): + """ + If permitted in settings and any item has 0 qty, the SQ has unit price items. + """ + if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_supplier_quotation"): + return + + self.has_unit_price_items = any( + not row.qty for row in self.get("items") if (row.item_code and not row.qty) + ) + def validate_with_previous_doc(self): super().validate_with_previous_doc( { diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index 88249335b5a..60c82bbc05f 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -3,9 +3,10 @@ import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today +from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order from erpnext.controllers.accounts_controller import InvalidQtyError @@ -22,8 +23,6 @@ class TestPurchaseOrder(FrappeTestCase): self.assertEqual(sq.items[0].qty, 1) def test_make_purchase_order(self): - from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order - sq = frappe.copy_doc(test_records[0]).insert() self.assertRaises(frappe.ValidationError, make_purchase_order, sq.name) @@ -43,5 +42,16 @@ class TestPurchaseOrder(FrappeTestCase): po.insert() + @change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}) + def test_map_purchase_order_from_zero_qty_supplier_quotation(self): + sq = frappe.copy_doc(test_records[0]).insert() + sq.items[0].qty = 0 + sq.submit() + + po = make_purchase_order(sq.name) + self.assertEqual(len(po.get("items")), 1) + self.assertEqual(po.get("items")[0].qty, 0) + self.assertEqual(po.get("items")[0].item_code, sq.get("items")[0].item_code) + test_records = frappe.get_test_records("Supplier Quotation") diff --git a/erpnext/buying/utils.py b/erpnext/buying/utils.py index 259c262d0d7..54f00417a89 100644 --- a/erpnext/buying/utils.py +++ b/erpnext/buying/utils.py @@ -20,6 +20,9 @@ def update_last_purchase_rate(doc, is_submit) -> None: this_purchase_date = getdate(doc.get("posting_date") or doc.get("transaction_date")) for d in doc.get("items"): + if d.get("is_free_item"): + continue + # get last purchase details last_purchase_details = get_last_purchase_details(d.item_code, doc.name) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 9e1a05d66a5..237f19274e3 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -649,6 +649,9 @@ class AccountsController(TransactionBase): self.base_paid_amount = flt( self.paid_amount * self.conversion_rate, self.precision("base_paid_amount") ) + else: + self.paid_amount = 0 + self.base_paid_amount = 0 def set_missing_values(self, for_validate=False): if frappe.flags.in_test: @@ -1252,6 +1255,9 @@ class AccountsController(TransactionBase): ) def validate_qty_is_not_zero(self): + if self.flags.allow_zero_qty: + return + for item in self.items: if self.doctype == "Purchase Receipt" and item.rejected_qty: continue @@ -3732,9 +3738,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) if amount_below_billed_amt and row_rate > 0.0: frappe.throw( - _("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.").format( - child_item.idx, child_item.item_code - ) + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) ) else: child_item.rate = row_rate diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 3fdf92e7990..385dce625fb 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -70,6 +70,14 @@ class BuyingController(SubcontractingController): frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on"), ) + if self.docstatus == 1 and self.doctype in ["Purchase Receipt", "Purchase Invoice"]: + self.set_onload( + "allow_to_make_qc_after_submission", + frappe.db.get_single_value( + "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" + ), + ) + def create_package_for_transfer(self) -> None: """Create serial and batch package for Sourece Warehouse in case of inter transfer.""" diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 26ac65589f1..73ce4c83e4c 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -31,6 +31,14 @@ class SellingController(StockController): ) ) + if self.docstatus == 1 and self.doctype in ["Delivery Note", "Sales Invoice"]: + self.set_onload( + "allow_to_make_qc_after_submission", + frappe.db.get_single_value( + "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" + ), + ) + def validate(self): super().validate() self.validate_items() diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index f079274528c..3654ad5e6c5 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -892,7 +892,7 @@ class StockController(AccountsController): or sl_dict.actual_qty < 0 and self.get("is_return") ) - and self.doctype in ["Purchase Invoice", "Purchase Receipt"] + and self.doctype in ["Purchase Invoice", "Purchase Receipt", "Stock Entry"] ) or ( ( sl_dict.actual_qty < 0 @@ -902,6 +902,15 @@ class StockController(AccountsController): ) and self.doctype in ["Sales Invoice", "Delivery Note", "Stock Entry"] ): + if self.doctype == "Stock Entry": + if row.get("t_warehouse") == sl_dict.warehouse and sl_dict.get("actual_qty") > 0: + fieldname = f"to_{dimension.source_fieldname}" + if dimension.source_fieldname.startswith("to_"): + fieldname = f"{dimension.source_fieldname}" + + sl_dict[dimension.target_fieldname] = row.get(fieldname) + return + sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) else: fieldname_start_with = "to" diff --git a/erpnext/crm/doctype/contract/contract.js b/erpnext/crm/doctype/contract/contract.js index 8d44c22db28..42bd7f9b769 100644 --- a/erpnext/crm/doctype/contract/contract.js +++ b/erpnext/crm/doctype/contract/contract.js @@ -29,4 +29,10 @@ frappe.ui.form.on("Contract", { }); } }, + party_name: function (frm) { + let field = frm.doc.party_type.toLowerCase() + "_name"; + frappe.db.get_value(frm.doc.party_type, frm.doc.party_name, field, (r) => { + frm.set_value("party_full_name", r[field]); + }); + }, }); diff --git a/erpnext/crm/doctype/contract/contract.json b/erpnext/crm/doctype/contract/contract.json index de3230f0e67..948243402fe 100755 --- a/erpnext/crm/doctype/contract/contract.json +++ b/erpnext/crm/doctype/contract/contract.json @@ -14,6 +14,7 @@ "party_user", "status", "fulfilment_status", + "party_full_name", "sb_terms", "start_date", "cb_date", @@ -244,11 +245,18 @@ "fieldname": "authorised_by_section", "fieldtype": "Section Break", "label": "Authorised By" + }, + { + "fieldname": "party_full_name", + "fieldtype": "Data", + "label": "Party Full Name", + "read_only": 1 } ], + "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2020-12-07 11:15:58.385521", + "modified": "2025-05-23 13:54:03.346537", "modified_by": "Administrator", "module": "CRM", "name": "Contract", @@ -315,9 +323,10 @@ "write": 1 } ], + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/erpnext/crm/doctype/contract/contract.py b/erpnext/crm/doctype/contract/contract.py index 6c3aace6fd8..64f89552062 100644 --- a/erpnext/crm/doctype/contract/contract.py +++ b/erpnext/crm/doctype/contract/contract.py @@ -34,6 +34,7 @@ class Contract(Document): fulfilment_terms: DF.Table[ContractFulfilmentChecklist] ip_address: DF.Data | None is_signed: DF.Check + party_full_name: DF.Data | None party_name: DF.DynamicLink party_type: DF.Literal["Customer", "Supplier", "Employee"] party_user: DF.Link | None @@ -59,10 +60,17 @@ class Contract(Document): self.name = _(name) def validate(self): + self.set_missing_values() self.validate_dates() self.update_contract_status() self.update_fulfilment_status() + def set_missing_values(self): + if not self.party_full_name: + field = self.party_type.lower() + "_name" + if res := frappe.db.get_value(self.party_type, self.party_name, field): + self.party_full_name = res + def before_submit(self): self.signed_by_company = frappe.session.user diff --git a/erpnext/patches.txt b/erpnext/patches.txt index cf8ae44a8d7..d78e86bd1d7 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -404,3 +404,6 @@ erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices erpnext.patches.v15_0.rename_group_by_to_categorize_by execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor") erpnext.patches.v14_0.set_update_price_list_based_on +erpnext.patches.v15_0.set_cancelled_status_to_cancelled_pos_invoice +erpnext.patches.v15_0.rename_group_by_to_categorize_by_in_custom_reports +erpnext.patches.v14_0.update_full_name_in_contract diff --git a/erpnext/patches/v14_0/update_full_name_in_contract.py b/erpnext/patches/v14_0/update_full_name_in_contract.py new file mode 100644 index 00000000000..19ee055ad12 --- /dev/null +++ b/erpnext/patches/v14_0/update_full_name_in_contract.py @@ -0,0 +1,15 @@ +import frappe +from frappe import qb + + +def execute(): + con = qb.DocType("Contract") + for c in ( + qb.from_(con) + .select(con.name, con.party_type, con.party_name) + .where(con.party_full_name.isnull()) + .run(as_dict=True) + ): + field = c.party_type.lower() + "_name" + if res := frappe.db.get_value(c.party_type, c.party_name, field): + frappe.db.set_value("Contract", c.name, "party_full_name", res) diff --git a/erpnext/patches/v15_0/rename_group_by_to_categorize_by_in_custom_reports.py b/erpnext/patches/v15_0/rename_group_by_to_categorize_by_in_custom_reports.py new file mode 100644 index 00000000000..bc671a4a6ac --- /dev/null +++ b/erpnext/patches/v15_0/rename_group_by_to_categorize_by_in_custom_reports.py @@ -0,0 +1,24 @@ +import json + +import frappe + + +def execute(): + custom_reports = frappe.get_all( + "Report", + filters={ + "report_type": "Custom Report", + "reference_report": ["in", ["General Ledger", "Supplier Quotation Comparison"]], + }, + fields=["name", "json"], + ) + + for report in custom_reports: + report_json = json.loads(report.json) + + if "filters" in report_json and "group_by" in report_json["filters"]: + report_json["filters"]["categorize_by"] = ( + report_json["filters"].pop("group_by").replace("Group", "Categorize") + ) + + frappe.db.set_value("Report", report.name, "json", json.dumps(report_json)) diff --git a/erpnext/patches/v15_0/set_cancelled_status_to_cancelled_pos_invoice.py b/erpnext/patches/v15_0/set_cancelled_status_to_cancelled_pos_invoice.py new file mode 100644 index 00000000000..a4141ec20e6 --- /dev/null +++ b/erpnext/patches/v15_0/set_cancelled_status_to_cancelled_pos_invoice.py @@ -0,0 +1,8 @@ +import frappe +from frappe.query_builder import DocType + + +def execute(): + POSInvoice = DocType("POS Invoice") + + frappe.qb.update(POSInvoice).set(POSInvoice.status, "Cancelled").where(POSInvoice.docstatus == 2).run() diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 1fcdd459a3f..db4783582ec 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -42,6 +42,29 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } item.base_rate_with_margin = item.rate_with_margin * flt(frm.doc.conversion_rate); + if (item.item_code && item.rate) { + frappe.call({ + method: "frappe.client.get_value", + args: { + doctype: "Item Tax", + parent: "Item", + filters: { + parent: item.item_code, + minimum_net_rate: ["<=", item.rate], + maximum_net_rate: [">=", item.rate] + }, + fieldname: "item_tax_template" + }, + callback: function(r) { + const tax_rule = r.message; + + let matched_template = tax_rule ? tax_rule.item_tax_template : null; + + frappe.model.set_value(cdt, cdn, 'item_tax_template', matched_template); + } + }); + } + cur_frm.cscript.set_gross_profit(item); cur_frm.cscript.calculate_taxes_and_totals(); cur_frm.cscript.calculate_stock_uom_rate(frm, cdt, cdn); @@ -330,7 +353,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } const me = this; - if (!this.frm.is_new() && this.frm.doc.docstatus === 0 && frappe.model.can_create("Quality Inspection") && show_qc_button) { + if (!this.frm.is_new() + && (this.frm.doc.docstatus === 0 || this.frm.doc.__onload?.allow_to_make_qc_after_submission) + && frappe.model.can_create("Quality Inspection") + && show_qc_button) { this.frm.add_custom_button(__("Quality Inspection(s)"), () => { me.make_quality_inspection(); }, __("Create")); @@ -2773,3 +2799,19 @@ erpnext.apply_putaway_rule = (frm, purpose=null) => { } }); }; + +erpnext.set_unit_price_items_note = (frm) => { + if (frm.doc.has_unit_price_items && !frm.is_new()) { + // Remove existing note + const $note = $(frm.layout.wrapper.find(".unit-price-items-note")); + if ($note.length) { $note.parent().remove(); } + + frm.layout.show_message( + `