diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 3f343f610e0..2e59bd8461d 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -77,9 +77,12 @@ "reports_tab", "remarks_section", "general_ledger_remarks_length", - "ignore_is_opening_check_for_reporting", "column_break_lvjk", "receivable_payable_remarks_length", + "accounts_receivable_payable_tuning_section", + "receivable_payable_fetch_method", + "legacy_section", + "ignore_is_opening_check_for_reporting", "payment_request_settings", "create_pr_in_draft_status" ], @@ -532,6 +535,34 @@ "fieldtype": "Select", "label": "Posting Date Inheritance for Exchange Gain / Loss", "options": "Invoice\nPayment\nReconciliation Date" + }, + { + "fieldname": "column_break_xrnd", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "If enabled, Sales Invoice will be generated instead of POS Invoice in POS Transactions for real-time update of G/L and Stock Ledger.", + "fieldname": "use_sales_invoice_in_pos", + "fieldtype": "Check", + "label": "Use Sales Invoice" + }, + { + "default": "Buffered Cursor", + "fieldname": "receivable_payable_fetch_method", + "fieldtype": "Select", + "label": "Data Fetch Method", + "options": "Buffered Cursor\nUnBuffered Cursor" + }, + { + "fieldname": "accounts_receivable_payable_tuning_section", + "fieldtype": "Section Break", + "label": "Accounts Receivable / Payable Tuning" + }, + { + "fieldname": "legacy_section", + "fieldtype": "Section Break", + "label": "Legacy Fields" } ], "icon": "icon-cog", @@ -539,7 +570,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-01-23 13:15:44.077853", + "modified": "2025-05-05 12:29:38.302027", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 31249e22455..ad39350f1c0 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -54,6 +54,7 @@ class AccountsSettings(Document): merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency post_change_gl_entries: DF.Check + receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"] receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int role_allowed_to_over_bill: DF.Link | None diff --git a/erpnext/accounts/doctype/bank_clearance/test_bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/test_bank_clearance.py index 658a69a4803..57ba4a98c5e 100644 --- a/erpnext/accounts/doctype/bank_clearance/test_bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/test_bank_clearance.py @@ -7,6 +7,9 @@ import frappe from frappe.utils import add_months, getdate from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center +from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import ( + set_default_account_for_mode_of_payment, +) from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice @@ -191,11 +194,13 @@ def make_pos_sales_invoice(): customer = make_customer(customer="_Test Customer") + mode_of_payment = frappe.get_doc("Mode of Payment", "Wire Transfer") + + set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank Clearance - _TC") + si = create_sales_invoice(customer=customer, item="_Test Item", is_pos=1, qty=1, rate=1000, do_not_save=1) si.set("payments", []) - si.append( - "payments", {"mode_of_payment": "Cash", "account": "_Test Bank Clearance - _TC", "amount": 1000} - ) + si.append("payments", {"mode_of_payment": "Wire Transfer", "amount": 1000}) si.insert() si.submit() diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 3181a097c6a..303633ac4bb 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -12,6 +12,9 @@ from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool get_linked_payments, reconcile_vouchers, ) +from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import ( + set_default_account_for_mode_of_payment, +) from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice @@ -434,15 +437,13 @@ def add_vouchers(gl_account="_Test Bank - _TC"): except frappe.DuplicateEntryError: pass - mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Cash"}) + mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Wire Transfer"}) - if not frappe.db.get_value("Mode of Payment Account", {"company": "_Test Company", "parent": "Cash"}): - mode_of_payment.append("accounts", {"company": "_Test Company", "default_account": gl_account}) - mode_of_payment.save() + set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", gl_account) si = create_sales_invoice(customer="Fayva", qty=1, rate=109080, do_not_save=1) si.is_pos = 1 - si.append("payments", {"mode_of_payment": "Cash", "account": gl_account, "amount": 109080}) + si.append("payments", {"mode_of_payment": "Wire Transfer", "amount": 109080}) si.insert() si.submit() diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js index 974f037a81d..44aa2eac98d 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js @@ -197,7 +197,7 @@ frappe.ui.form.on("Invoice Discounting", { from_date: frm.doc.posting_date, to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), company: frm.doc.company, - group_by: "Group by Voucher (Consolidated)", + categorize_by: "Categorize by Voucher (Consolidated)", show_cancelled_entries: frm.doc.docstatus === 2, }; frappe.set_route("query-report", "General Ledger"); diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index a64aba417a6..f2f9f70e75d 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -35,7 +35,7 @@ frappe.ui.form.on("Journal Entry", { to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), company: frm.doc.company, finance_book: frm.doc.finance_book, - group_by: "", + categorize_by: "", show_cancelled_entries: frm.doc.docstatus === 2, }; frappe.set_route("query-report", "General Ledger"); diff --git a/erpnext/accounts/doctype/mode_of_payment/test_mode_of_payment.py b/erpnext/accounts/doctype/mode_of_payment/test_mode_of_payment.py index 9733fb89e2f..3241b15ad11 100644 --- a/erpnext/accounts/doctype/mode_of_payment/test_mode_of_payment.py +++ b/erpnext/accounts/doctype/mode_of_payment/test_mode_of_payment.py @@ -3,8 +3,25 @@ import unittest -# test_records = frappe.get_test_records('Mode of Payment') +import frappe class TestModeofPayment(unittest.TestCase): pass + + +def set_default_account_for_mode_of_payment(mode_of_payment, company, account): + mode_of_payment.reload() + if frappe.db.exists( + "Mode of Payment Account", {"parent": mode_of_payment.mode_of_payment, "company": company} + ): + frappe.db.set_value( + "Mode of Payment Account", + {"parent": mode_of_payment.mode_of_payment, "company": company}, + "default_account", + account, + ) + return + + mode_of_payment.append("accounts", {"company": company, "default_account": account}) + mode_of_payment.save() diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 2e13c932de4..47ffc5719e4 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -411,7 +411,7 @@ frappe.ui.form.on("Payment Entry", { from_date: frm.doc.posting_date, to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), company: frm.doc.company, - group_by: "", + categorize_by: "", show_cancelled_entries: frm.doc.docstatus === 2, }; frappe.set_route("query-report", "General Ledger"); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 1f9747a77cb..1c91b9ef8e3 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1994,7 +1994,7 @@ class PaymentEntry(AccountsController): # Re allocate amount to those references which have PR set (Higher priority) for ref in self.references: - if not ref.payment_request: + if not (ref.reference_doctype and ref.reference_name and ref.payment_request): continue # fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount @@ -2045,7 +2045,7 @@ class PaymentEntry(AccountsController): ) # Re allocate amount to those references which have no PR (Lower priority) for ref in self.references: - if ref.payment_request: + if ref.payment_request or not (ref.reference_doctype and ref.reference_name): continue key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term")) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js index 095310c7e70..15de8cec243 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js @@ -47,7 +47,7 @@ frappe.ui.form.on("Period Closing Voucher", { from_date: frm.doc.posting_date, to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), company: frm.doc.company, - group_by: "", + categorize_by: "", show_cancelled_entries: frm.doc.docstatus === 2, }; frappe.set_route("query-report", "General Ledger"); diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 6a537a2559a..ae2b0970d2f 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -323,3 +323,15 @@ frappe.ui.form.on("POS Invoice", { }); }, }); + +frappe.ui.form.on("Sales Invoice Payment", { + mode_of_payment: function (frm) { + frappe.call({ + doc: frm.doc, + method: "set_account_for_mode_of_payment", + callback: function (r) { + refresh_field("payments"); + }, + }); + }, +}); diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index cfe805be4ce..aa0a898bd42 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -7,6 +7,9 @@ import unittest import frappe from frappe import _ +from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import ( + set_default_account_for_mode_of_payment, +) from erpnext.accounts.doctype.pos_invoice.pos_invoice import PartialPaymentValidationError, make_sales_return from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice @@ -31,6 +34,8 @@ class TestPOSInvoice(unittest.TestCase): cls.test_user, cls.pos_profile = init_user_and_profile() create_opening_entry(cls.pos_profile, cls.test_user) + mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft") + set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank - _TC") def tearDown(self): if frappe.session.user != "Administrator": @@ -233,12 +238,8 @@ class TestPOSInvoice(unittest.TestCase): pos = create_pos_invoice(qty=10, do_not_save=True) pos.set("payments", []) - pos.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500} - ) - pos.append( - "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500, "default": 1} - ) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 500, "default": 1}) pos.insert() pos.submit() @@ -276,9 +277,7 @@ class TestPOSInvoice(unittest.TestCase): do_not_save=1, ) - pos.append( - "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1} - ) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1}) pos.insert() pos.submit() @@ -318,9 +317,7 @@ class TestPOSInvoice(unittest.TestCase): do_not_save=1, ) - pos.append( - "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 2000, "default": 1} - ) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 2000, "default": 1}) pos.insert() pos.submit() @@ -331,9 +328,7 @@ class TestPOSInvoice(unittest.TestCase): # partial return 1 pos_return1.get("items")[0].qty = -1 pos_return1.set("payments", []) - pos_return1.append( - "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": -1000, "default": 1} - ) + pos_return1.append("payments", {"mode_of_payment": "Cash", "amount": -1000, "default": 1}) pos_return1.paid_amount = -1000 pos_return1.submit() pos_return1.reload() @@ -350,9 +345,7 @@ class TestPOSInvoice(unittest.TestCase): # partial return 2 pos_return2 = make_sales_return(pos.name) pos_return2.set("payments", []) - pos_return2.append( - "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": -1000, "default": 1} - ) + pos_return2.append("payments", {"mode_of_payment": "Cash", "amount": -1000, "default": 1}) pos_return2.paid_amount = -1000 pos_return2.submit() @@ -372,10 +365,8 @@ class TestPOSInvoice(unittest.TestCase): ) pos.set("payments", []) - pos.append("payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 50}) - pos.append( - "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 60, "default": 1} - ) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 60, "default": 1}) pos.insert() pos.submit() @@ -393,7 +384,7 @@ class TestPOSInvoice(unittest.TestCase): pos_inv = create_pos_invoice(rate=10000, do_not_save=1) pos_inv.append( "payments", - {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 9000}, + {"mode_of_payment": "Cash", "amount": 9000}, ) pos_inv.insert() self.assertRaises(PartialPaymentValidationError, pos_inv.submit) @@ -424,9 +415,7 @@ class TestPOSInvoice(unittest.TestCase): do_not_save=1, ) - pos.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000} - ) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000}) pos.insert() pos.submit() @@ -445,9 +434,7 @@ class TestPOSInvoice(unittest.TestCase): do_not_save=1, ) - pos2.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000} - ) + pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000}) pos2.insert() self.assertRaises(frappe.ValidationError, pos2.submit) @@ -496,9 +483,7 @@ class TestPOSInvoice(unittest.TestCase): do_not_save=1, ) - pos2.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000} - ) + pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000}) pos2.insert() self.assertRaises(frappe.ValidationError, pos2.submit) @@ -561,9 +546,7 @@ class TestPOSInvoice(unittest.TestCase): ) pos.get("items")[0].has_serial_no = 1 pos.set("payments", []) - pos.append( - "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1} - ) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1}) pos = pos.save().submit() # make a return @@ -609,7 +592,7 @@ class TestPOSInvoice(unittest.TestCase): inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1) inv.append( "payments", - {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000}, + {"mode_of_payment": "Cash", "amount": 10000}, ) inv.insert() inv.submit() @@ -641,7 +624,7 @@ class TestPOSInvoice(unittest.TestCase): pos_inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1) pos_inv.append( "payments", - {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000}, + {"mode_of_payment": "Cash", "amount": 10000}, ) pos_inv.paid_amount = 10000 pos_inv.submit() @@ -656,7 +639,7 @@ class TestPOSInvoice(unittest.TestCase): inv.loyalty_amount = inv.loyalty_points * before_lp_details.conversion_factor inv.append( "payments", - {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000 - inv.loyalty_amount}, + {"mode_of_payment": "Cash", "amount": 10000 - inv.loyalty_amount}, ) inv.paid_amount = 10000 inv.submit() @@ -677,12 +660,12 @@ class TestPOSInvoice(unittest.TestCase): frappe.db.sql("delete from `tabPOS Invoice`") test_user, pos_profile = init_user_and_profile() pos_inv = create_pos_invoice(rate=300, additional_discount_percentage=10, do_not_submit=1) - pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 270}) + pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 270}) pos_inv.save() pos_inv.submit() pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1) - pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3200}) + pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 3200}) pos_inv2.save() pos_inv2.submit() @@ -703,7 +686,7 @@ class TestPOSInvoice(unittest.TestCase): frappe.db.sql("delete from `tabPOS Invoice`") test_user, pos_profile = init_user_and_profile() pos_inv = create_pos_invoice(rate=300, do_not_submit=1) - pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300}) + pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300}) pos_inv.append( "taxes", { @@ -720,7 +703,7 @@ class TestPOSInvoice(unittest.TestCase): pos_inv2 = create_pos_invoice(rate=300, qty=2, do_not_submit=1) pos_inv2.additional_discount_percentage = 10 - pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 540}) + pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 540}) pos_inv2.append( "taxes", { @@ -758,7 +741,7 @@ class TestPOSInvoice(unittest.TestCase): frappe.db.sql("delete from `tabPOS Invoice`") test_user, pos_profile = init_user_and_profile() pos_inv = create_pos_invoice(item=item, rate=300, do_not_submit=1) - pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300}) + pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300}) pos_inv.append( "taxes", { @@ -773,7 +756,7 @@ class TestPOSInvoice(unittest.TestCase): self.assertRaises(frappe.ValidationError, pos_inv.submit) pos_inv2 = create_pos_invoice(item=item, rate=400, do_not_submit=1) - pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 400}) + pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 400}) pos_inv2.append( "taxes", { @@ -818,7 +801,7 @@ class TestPOSInvoice(unittest.TestCase): pos_inv1 = create_pos_invoice(item="_BATCH ITEM Test For Reserve", rate=300, qty=15, do_not_save=1) pos_inv1.append( "payments", - {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 4500}, + {"mode_of_payment": "Cash", "amount": 4500}, ) pos_inv1.items[0].batch_no = batch_no pos_inv1.save() @@ -839,7 +822,7 @@ class TestPOSInvoice(unittest.TestCase): ) pos_inv2.append( "payments", - {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3000}, + {"mode_of_payment": "Cash", "amount": 3000}, ) pos_inv2.save() pos_inv2.submit() @@ -879,7 +862,7 @@ class TestPOSInvoice(unittest.TestCase): ) pos_inv1.append( "payments", - {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300}, + {"mode_of_payment": "Cash", "amount": 300}, ) pos_inv1.save() pos_inv1.submit() diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json index ed9a4d84f53..4041a8ecad7 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json @@ -12,7 +12,7 @@ "posting_date", "company", "account", - "group_by", + "categorize_by", "cost_center", "territory", "ignore_exchange_rate_revaluation_journals", @@ -174,14 +174,6 @@ "fieldtype": "Date", "label": "Start Date" }, - { - "default": "Group by Voucher (Consolidated)", - "depends_on": "eval:(doc.report == 'General Ledger');", - "fieldname": "group_by", - "fieldtype": "Select", - "label": "Group By", - "options": "\nGroup by Voucher\nGroup by Voucher (Consolidated)" - }, { "depends_on": "eval: (doc.report == 'General Ledger');", "fieldname": "currency", @@ -397,10 +389,18 @@ "fieldname": "show_remarks", "fieldtype": "Check", "label": "Show Remarks" + }, + { + "default": "Categorize by Voucher (Consolidated)", + "depends_on": "eval:(doc.report == 'General Ledger');", + "fieldname": "categorize_by", + "fieldtype": "Select", + "label": "Categorize By", + "options": "\nCategorize by Voucher\nCategorize by Voucher (Consolidated)" } ], "links": [], - "modified": "2024-12-11 12:11:13.543134", + "modified": "2025-04-30 14:43:23.643006", "modified_by": "Administrator", "module": "Accounts", "name": "Process Statement Of Accounts", @@ -436,4 +436,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} 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 48f400e1a46..1c4bb66357f 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 @@ -44,6 +44,7 @@ class ProcessStatementOfAccounts(Document): ageing_based_on: DF.Literal["Due Date", "Posting Date"] based_on_payment_terms: DF.Check body: DF.TextEditor | None + categorize_by: DF.Literal["", "Categorize by Voucher", "Categorize by Voucher (Consolidated)"] cc_to: DF.TableMultiSelect[ProcessStatementOfAccountsCC] collection_name: DF.DynamicLink | None company: DF.Link @@ -56,7 +57,6 @@ class ProcessStatementOfAccounts(Document): finance_book: DF.Link | None frequency: DF.Literal["Weekly", "Monthly", "Quarterly"] from_date: DF.Date | None - group_by: DF.Literal["", "Group by Voucher", "Group by Voucher (Consolidated)"] ignore_cr_dr_notes: DF.Check ignore_exchange_rate_revaluation_journals: DF.Check include_ageing: DF.Check @@ -204,7 +204,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency): "party": [entry.customer], "party_name": [entry.customer_name] if entry.customer_name else None, "presentation_currency": presentation_currency, - "group_by": doc.group_by, + "categorize_by": doc.categorize_by, "currency": doc.currency, "project": [p.project_name for p in doc.project], "show_opening_entries": 0, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 540f3778232..db9083a9f91 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -1084,6 +1084,18 @@ frappe.ui.form.on("Sales Invoice Timesheet", { }, }); +frappe.ui.form.on("Sales Invoice Payment", { + mode_of_payment: function (frm) { + frappe.call({ + doc: frm.doc, + method: "set_account_for_mode_of_payment", + callback: function (r) { + refresh_field("payments"); + }, + }); + }, +}); + var set_timesheet_detail_rate = function (cdt, cdn, currency, timelog) { frappe.call({ method: "erpnext.projects.doctype.timesheet.timesheet.get_timesheet_detail_rate", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index afe41846b70..3d46d95f5aa 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -751,10 +751,10 @@ class SalesInvoice(SellingController): self.paid_amount = paid_amount self.base_paid_amount = base_paid_amount + @frappe.whitelist() def set_account_for_mode_of_payment(self): for payment in self.payments: - if not payment.account: - payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account") + payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account") def validate_time_sheets_are_submitted(self): for data in self.timesheets: diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index d2398d21f4e..34b5e99dcca 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -12,6 +12,9 @@ from frappe.utils import add_days, flt, format_date, getdate, nowdate, today import erpnext from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account +from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import ( + set_default_account_for_mode_of_payment, +) from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import WarehouseMissingError from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import ( @@ -54,6 +57,11 @@ class TestSalesInvoice(FrappeTestCase): create_items(["_Test Internal Transfer Item"], uoms=[{"uom": "Box", "conversion_factor": 10}]) create_internal_parties() setup_accounts() + mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft") + set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank - _TC") + set_default_account_for_mode_of_payment( + mode_of_payment, "_Test Company with perpetual inventory", "_Test Bank - TCP1" + ) frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) def tearDown(self): @@ -964,10 +972,8 @@ class TestSalesInvoice(FrappeTestCase): pos.is_pos = 1 pos.update_stock = 1 - pos.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50} - ) - pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 50}) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 50}) taxes = get_taxes_and_charges() pos.taxes = [] @@ -996,10 +1002,8 @@ class TestSalesInvoice(FrappeTestCase): pos.is_pos = 1 pos.pos_profile = pos_profile.name - pos.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500} - ) - pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500}) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 500}) pos.insert() pos.submit() @@ -1042,10 +1046,8 @@ class TestSalesInvoice(FrappeTestCase): pos.is_pos = 1 pos.update_stock = 1 - pos.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50} - ) - pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 60}) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 60}) pos.write_off_outstanding_amount_automatically = 1 pos.insert() @@ -1085,10 +1087,8 @@ class TestSalesInvoice(FrappeTestCase): pos.is_pos = 1 pos.update_stock = 1 - pos.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50} - ) - pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 40}) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 40}) pos.write_off_outstanding_amount_automatically = 1 pos.insert() @@ -1102,7 +1102,7 @@ class TestSalesInvoice(FrappeTestCase): pos = create_sales_invoice(do_not_save=True) pos.is_pos = 1 - pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 100}) pos.save().submit() self.assertEqual(pos.outstanding_amount, 0.0) self.assertEqual(pos.status, "Paid") @@ -1173,10 +1173,8 @@ class TestSalesInvoice(FrappeTestCase): for tax in taxes: pos.append("taxes", tax) - pos.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50} - ) - pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 60}) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 60}) pos.insert() pos.submit() @@ -3904,10 +3902,8 @@ class TestSalesInvoice(FrappeTestCase): pos = create_sales_invoice(qty=10, do_not_save=True) pos.is_pos = 1 pos.pos_profile = pos_profile.name - pos.append( - "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500} - ) - pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500}) + pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 500}) pos.save().submit() pos_return = make_sales_return(pos.name) @@ -4278,7 +4274,7 @@ class TestSalesInvoice(FrappeTestCase): pos.is_pos = 1 pos.pos_profile = pos_profile.name pos.debit_to = "_Test Receivable USD - _TC" - pos.append("payments", {"mode_of_payment": "Cash", "account": "_Test Bank - _TC", "amount": 20.35}) + pos.append("payments", {"mode_of_payment": "Cash", "amount": 20.35}) pos.save().submit() pos_return = make_sales_return(pos.name) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index d6df45d2dfc..419baaa3d47 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -657,34 +657,34 @@ def get_due_date_from_template(template_name, posting_date, bill_date): return due_date -def validate_due_date(posting_date, due_date, bill_date=None, template_name=None): +def validate_due_date(posting_date, due_date, bill_date=None, template_name=None, doctype=None): if getdate(due_date) < getdate(posting_date): frappe.throw(_("Due Date cannot be before Posting / Supplier Invoice Date")) else: - if not template_name: - return + validate_due_date_with_template(posting_date, due_date, bill_date, template_name, doctype) - default_due_date = get_due_date_from_template(template_name, posting_date, bill_date).strftime( - "%Y-%m-%d" - ) - if not default_due_date: - return +def validate_due_date_with_template(posting_date, due_date, bill_date, template_name, doctype=None): + if not template_name: + return - if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date): - is_credit_controller = ( - frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles() + default_due_date = format(get_due_date_from_template(template_name, posting_date, bill_date)) + + if not default_due_date: + return + + if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date): + if frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles(): + party_type = "supplier" if doctype == "Purchase Invoice" else "customer" + + msgprint( + _("Note: Due Date exceeds allowed {0} credit days by {1} day(s)").format( + party_type, date_diff(due_date, default_due_date) + ) ) - if is_credit_controller: - msgprint( - _("Note: Due / Reference Date exceeds allowed customer credit days by {0} day(s)").format( - date_diff(due_date, default_due_date) - ) - ) - else: - frappe.throw( - _("Due / Reference Date cannot be after {0}").format(formatdate(default_due_date)) - ) + + else: + frappe.throw(_("Due / Reference Date cannot be after {0}").format(formatdate(default_due_date))) @frappe.whitelist() @@ -931,12 +931,16 @@ def get_party_shipping_address(doctype: str, name: str) -> str | None: ["is_shipping_address", "=", 1], ["address_type", "=", "Shipping"], ], - pluck="name", - limit=1, + fields=["name", "is_shipping_address"], order_by="is_shipping_address DESC", ) - return shipping_addresses[0] if shipping_addresses else None + if shipping_addresses and shipping_addresses[0].is_shipping_address == 1: + return shipping_addresses[0].name + if len(shipping_addresses) == 1: + return shipping_addresses[0].name + else: + return None def get_partywise_advanced_payment_amount( diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 9625a86f05f..663c1ff0a26 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -54,6 +54,10 @@ class ReceivablePayableReport: self.filters.range = "30, 60, 90, 120" self.ranges = [num.strip() for num in self.filters.range.split(",") if num.strip().isdigit()] self.range_numbers = [num for num in range(1, len(self.ranges) + 2)] + self.ple_fetch_method = ( + frappe.db.get_single_value("Accounts Settings", "receivable_payable_fetch_method") + or "Buffered Cursor" + ) # Fail Safe def run(self, args): self.filters.update(args) @@ -90,10 +94,7 @@ class ReceivablePayableReport: self.skip_total_row = 1 def get_data(self): - self.get_ple_entries() self.get_sales_invoices_or_customers_based_on_sales_person() - self.voucher_balance = OrderedDict() - self.init_voucher_balance() # invoiced, paid, credit_note, outstanding # Build delivery note map against all sales invoices self.build_delivery_note_map() @@ -110,12 +111,40 @@ class ReceivablePayableReport: # Get Exchange Rate Revaluations self.get_exchange_rate_revaluations() + self.prepare_ple_query() self.data = [] + self.voucher_balance = OrderedDict() + if self.ple_fetch_method == "Buffered Cursor": + self.fetch_ple_in_buffered_cursor() + elif self.ple_fetch_method == "UnBuffered Cursor": + self.fetch_ple_in_unbuffered_cursor() + + self.build_data() + + def fetch_ple_in_buffered_cursor(self): + self.ple_entries = frappe.db.sql(self.ple_query.get_sql(), as_dict=True) + + for ple in self.ple_entries: + self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding + + # This is unavoidable. Initialization and allocation cannot happen in same loop for ple in self.ple_entries: self.update_voucher_balance(ple) - self.build_data() + delattr(self, "ple_entries") + + def fetch_ple_in_unbuffered_cursor(self): + self.ple_entries = [] + with frappe.db.unbuffered_cursor(): + for ple in frappe.db.sql(self.ple_query.get_sql(), as_dict=True, as_iterator=True): + self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding + self.ple_entries.append(ple) + + # This is unavoidable. Initialization and allocation cannot happen in same loop + for ple in self.ple_entries: + self.update_voucher_balance(ple) + delattr(self, "ple_entries") def build_voucher_dict(self, ple): return frappe._dict( @@ -136,26 +165,22 @@ class ReceivablePayableReport: outstanding_in_account_currency=0.0, ) - def init_voucher_balance(self): - # build all keys, since we want to exclude vouchers beyond the report date - for ple in self.ple_entries: - # get the balance object for voucher_type + def init_voucher_balance(self, ple): + if self.filters.get("ignore_accounts"): + key = (ple.voucher_type, ple.voucher_no, ple.party) + else: + key = (ple.account, ple.voucher_type, ple.voucher_no, ple.party) - if self.filters.get("ignore_accounts"): - key = (ple.voucher_type, ple.voucher_no, ple.party) - else: - key = (ple.account, ple.voucher_type, ple.voucher_no, ple.party) + if key not in self.voucher_balance: + self.voucher_balance[key] = self.build_voucher_dict(ple) - if key not in self.voucher_balance: - self.voucher_balance[key] = self.build_voucher_dict(ple) + if ple.voucher_type == ple.against_voucher_type and ple.voucher_no == ple.against_voucher_no: + self.voucher_balance[key].cost_center = ple.cost_center - if ple.voucher_type == ple.against_voucher_type and ple.voucher_no == ple.against_voucher_no: - self.voucher_balance[key].cost_center = ple.cost_center + self.get_invoices(ple) - self.get_invoices(ple) - - if self.filters.get("group_by_party"): - self.init_subtotal_row(ple.party) + if self.filters.get("group_by_party"): + self.init_subtotal_row(ple.party) if self.filters.get("group_by_party") and not self.filters.get("in_party_currency"): self.init_subtotal_row("Total") @@ -778,7 +803,7 @@ class ReceivablePayableReport: ) row["range" + str(index + 1)] = row.outstanding - def get_ple_entries(self): + def prepare_ple_query(self): # get all the GL entries filtered by the given filters self.prepare_conditions() @@ -831,7 +856,7 @@ class ReceivablePayableReport: else: query = query.orderby(self.ple.posting_date, self.ple.party) - self.ple_entries = query.run(as_dict=True) + self.ple_query = query def get_sales_invoices_or_customers_based_on_sales_person(self): if self.filters.get("sales_person"): 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 3dfcd4463d9..68f5f4eab76 100644 --- a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py +++ b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py @@ -144,10 +144,10 @@ class PartyLedgerSummaryReport: if self.party_naming_by == "Naming Series": columns.append( { - "label": _(self.filters.party_type + "Name"), + "label": _(self.filters.party_type + " Name"), "fieldtype": "Data", "fieldname": "party_name", - "width": 110, + "width": 150, } ) @@ -252,12 +252,13 @@ class PartyLedgerSummaryReport: self.party_data = frappe._dict({}) for gle in self.gl_entries: party_details = self.party_details.get(gle.party) + party_name = party_details.get(f"{scrub(self.filters.party_type)}_name", "") self.party_data.setdefault( gle.party, frappe._dict( { **party_details, - "party_name": gle.party, + "party_name": party_name, "opening_balance": 0, "invoiced_amount": 0, "paid_amount": 0, diff --git a/erpnext/accounts/report/general_ledger/general_ledger.js b/erpnext/accounts/report/general_ledger/general_ledger.js index 6fa846910a6..4039ea27ebd 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.js +++ b/erpnext/accounts/report/general_ledger/general_ledger.js @@ -49,7 +49,7 @@ frappe.query_reports["General Ledger"] = { label: __("Voucher No"), fieldtype: "Data", on_change: function () { - frappe.query_report.set_filter_value("group_by", "Group by Voucher (Consolidated)"); + frappe.query_report.set_filter_value("categorize_by", "Categorize by Voucher (Consolidated)"); }, }, { @@ -112,29 +112,29 @@ frappe.query_reports["General Ledger"] = { hidden: 1, }, { - fieldname: "group_by", - label: __("Group by"), + fieldname: "categorize_by", + label: __("Categorize by"), fieldtype: "Select", options: [ "", { - label: __("Group by Voucher"), - value: "Group by Voucher", + label: __("Categorize by Voucher"), + value: "Categorize by Voucher", }, { - label: __("Group by Voucher (Consolidated)"), - value: "Group by Voucher (Consolidated)", + label: __("Categorize by Voucher (Consolidated)"), + value: "Categorize by Voucher (Consolidated)", }, { - label: __("Group by Account"), - value: "Group by Account", + label: __("Categorize by Account"), + value: "Categorize by Account", }, { - label: __("Group by Party"), - value: "Group by Party", + label: __("Categorize by Party"), + value: "Categorize by Party", }, ], - default: "Group by Voucher (Consolidated)", + default: "Categorize by Voucher (Consolidated)", }, { fieldname: "tax_id", diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index a62ba2e3732..28554125b67 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -63,13 +63,17 @@ def validate_filters(filters, account_details): if not account_details.get(account): frappe.throw(_("Account {0} does not exists").format(account)) - if filters.get("account") and filters.get("group_by") == "Group by Account": + if not filters.get("categorize_by") and filters.get("group_by"): + filters["categorize_by"] = filters["group_by"] + filters["categorize_by"] = filters["categorize_by"].replace("Group by", "Categorize by") + + if filters.get("account") and filters.get("categorize_by") == "Categorize by Account": filters.account = frappe.parse_json(filters.get("account")) for account in filters.account: if account_details[account].is_group == 0: frappe.throw(_("Can not filter based on Child Account, if grouped by Account")) - if filters.get("voucher_no") and filters.get("group_by") in ["Group by Voucher"]: + if filters.get("voucher_no") and filters.get("categorize_by") in ["Categorize by Voucher"]: frappe.throw(_("Can not filter based on Voucher No, if grouped by Voucher")) if filters.from_date > filters.to_date: @@ -163,9 +167,9 @@ def get_gl_entries(filters, accounting_dimensions): if filters.get("include_dimensions"): order_by_statement = "order by posting_date, creation" - if filters.get("group_by") == "Group by Voucher": + if filters.get("categorize_by") == "Categorize by Voucher": order_by_statement = "order by posting_date, voucher_type, voucher_no" - if filters.get("group_by") == "Group by Account": + if filters.get("categorize_by") == "Categorize by Account": order_by_statement = "order by account, posting_date, creation" if filters.get("include_default_book_entries"): @@ -260,7 +264,7 @@ def get_conditions(filters): if filters.get("voucher_no_not_in"): conditions.append("voucher_no not in %(voucher_no_not_in)s") - if filters.get("group_by") == "Group by Party" and not filters.get("party_type"): + if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"): conditions.append("party_type in ('Customer', 'Supplier')") if filters.get("party_type"): @@ -272,7 +276,7 @@ def get_conditions(filters): if not ( filters.get("account") or filters.get("party") - or filters.get("group_by") in ["Group by Account", "Group by Party"] + or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"] ): if not ignore_is_opening: conditions.append("(posting_date >=%(from_date)s or is_opening = 'Yes')") @@ -374,26 +378,26 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension # Opening for filtered account data.append(totals.opening) - if filters.get("group_by") != "Group by Voucher (Consolidated)": + if filters.get("categorize_by") != "Categorize by Voucher (Consolidated)": for _acc, acc_dict in gle_map.items(): # acc if acc_dict.entries: # opening data.append({"debit_in_transaction_currency": None, "credit_in_transaction_currency": None}) - if (not filters.get("group_by") and not filters.get("voucher_no")) or ( - filters.get("group_by") and filters.get("group_by") != "Group by Voucher" + if (not filters.get("categorize_by") and not filters.get("voucher_no")) or ( + filters.get("categorize_by") and filters.get("categorize_by") != "Categorize by Voucher" ): data.append(acc_dict.totals.opening) data += acc_dict.entries # totals - if filters.get("group_by") or not filters.voucher_no: + if filters.get("categorize_by") or not filters.voucher_no: data.append(acc_dict.totals.total) # closing - if (not filters.get("group_by") and not filters.get("voucher_no")) or ( - filters.get("group_by") and filters.get("group_by") != "Group by Voucher" + if (not filters.get("categorize_by") and not filters.get("voucher_no")) or ( + filters.get("categorize_by") and filters.get("categorize_by") != "Categorize by Voucher" ): data.append(acc_dict.totals.closing) @@ -430,9 +434,9 @@ def get_totals_dict(): def group_by_field(group_by): - if group_by == "Group by Party": + if group_by == "Categorize by Party": return "party" - elif group_by in ["Group by Voucher (Consolidated)", "Group by Account"]: + elif group_by in ["Categorize by Voucher (Consolidated)", "Categorize by Account"]: return "account" else: return "voucher_no" @@ -440,7 +444,7 @@ def group_by_field(group_by): def initialize_gle_map(gl_entries, filters, totals_dict): gle_map = OrderedDict() - group_by = group_by_field(filters.get("group_by")) + group_by = group_by_field(filters.get("categorize_by")) for gle in gl_entries: gle_map.setdefault(gle.get(group_by), _dict(totals=copy.deepcopy(totals_dict), entries=[])) @@ -450,8 +454,8 @@ def initialize_gle_map(gl_entries, filters, totals_dict): def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, totals): entries = [] consolidated_gle = OrderedDict() - group_by = group_by_field(filters.get("group_by")) - group_by_voucher_consolidated = filters.get("group_by") == "Group by Voucher (Consolidated)" + group_by = group_by_field(filters.get("categorize_by")) + group_by_voucher_consolidated = filters.get("categorize_by") == "Categorize by Voucher (Consolidated)" if filters.get("show_net_values_in_party_account"): account_type_map = get_account_type_map(filters.get("company")) diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py index af2c569949a..2684aa326e6 100644 --- a/erpnext/accounts/report/general_ledger/test_general_ledger.py +++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py @@ -155,7 +155,7 @@ class TestGeneralLedger(FrappeTestCase): "from_date": today(), "to_date": today(), "account": [account.name], - "group_by": "Group by Voucher (Consolidated)", + "categorize_by": "Categorize by Voucher (Consolidated)", } ) ) @@ -246,7 +246,7 @@ class TestGeneralLedger(FrappeTestCase): "from_date": today(), "to_date": today(), "account": [account.name], - "group_by": "Group by Voucher (Consolidated)", + "categorize_by": "Categorize by Voucher (Consolidated)", "ignore_err": True, } ) @@ -261,7 +261,7 @@ class TestGeneralLedger(FrappeTestCase): "from_date": today(), "to_date": today(), "account": [account.name], - "group_by": "Group by Voucher (Consolidated)", + "categorize_by": "Categorize by Voucher (Consolidated)", "ignore_err": False, } ) @@ -308,7 +308,7 @@ class TestGeneralLedger(FrappeTestCase): "from_date": si.posting_date, "to_date": si.posting_date, "account": [si.debit_to], - "group_by": "Group by Voucher (Consolidated)", + "categorize_by": "Categorize by Voucher (Consolidated)", "ignore_cr_dr_notes": False, } ) @@ -325,7 +325,7 @@ class TestGeneralLedger(FrappeTestCase): "from_date": si.posting_date, "to_date": si.posting_date, "account": [si.debit_to], - "group_by": "Group by Voucher (Consolidated)", + "categorize_by": "Categorize by Voucher (Consolidated)", "ignore_cr_dr_notes": True, } ) diff --git a/erpnext/accounts/test/test_reports.py b/erpnext/accounts/test/test_reports.py index 56b7832a32e..d8878328cfa 100644 --- a/erpnext/accounts/test/test_reports.py +++ b/erpnext/accounts/test/test_reports.py @@ -12,8 +12,8 @@ DEFAULT_FILTERS = { REPORT_FILTER_TEST_CASES: list[tuple[ReportName, ReportFilters]] = [ - ("General Ledger", {"group_by": "Group by Voucher (Consolidated)"}), - ("General Ledger", {"group_by": "Group by Voucher (Consolidated)", "include_dimensions": 1}), + ("General Ledger", {"categorize_by": "Categorize by Voucher (Consolidated)"}), + ("General Ledger", {"categorize_by": "Categorize by Voucher (Consolidated)", "include_dimensions": 1}), ("Accounts Payable", {"range": "30, 60, 90, 120"}), ("Accounts Receivable", {"range": "30, 60, 90, 120"}), ("Consolidated Financial Statement", {"report": "Balance Sheet"}), diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index 6a1410ac0f9..ea1a95e7a71 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -718,16 +718,15 @@ def get_gl_entries_on_asset_disposal( def get_asset_details(asset, finance_book=None): - fixed_asset_account, accumulated_depr_account, _ = get_depreciation_accounts( - asset.asset_category, asset.company + value_after_depreciation = asset.get_value_after_depreciation(finance_book) + accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation) + + fixed_asset_account, accumulated_depr_account, _ = get_asset_accounts( + asset.asset_category, asset.company, accumulated_depr_amount ) disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company) depreciation_cost_center = asset.cost_center or depreciation_cost_center - value_after_depreciation = asset.get_value_after_depreciation(finance_book) - - accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation) - return ( fixed_asset_account, asset, @@ -739,6 +738,48 @@ def get_asset_details(asset, finance_book=None): ) +def get_asset_accounts(asset_category, company, accumulated_depr_amount): + fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None + + accounts = frappe.db.get_value( + "Asset Category Account", + filters={"parent": asset_category, "company_name": company}, + fieldname=[ + "fixed_asset_account", + "accumulated_depreciation_account", + "depreciation_expense_account", + ], + as_dict=1, + ) + + if accounts: + fixed_asset_account = accounts.fixed_asset_account + accumulated_depreciation_account = accounts.accumulated_depreciation_account + depreciation_expense_account = accounts.depreciation_expense_account + + if not fixed_asset_account: + frappe.throw(_("Please set Fixed Asset Account in Asset Category {0}").format(asset_category)) + + if accumulated_depr_amount: + accounts = frappe.get_cached_value( + "Company", company, ["accumulated_depreciation_account", "depreciation_expense_account"] + ) + + if not accumulated_depreciation_account: + accumulated_depreciation_account = accounts[0] + if not depreciation_expense_account: + depreciation_expense_account = accounts[1] + + if not accumulated_depreciation_account or not depreciation_expense_account: + frappe.throw( + _("Please set Depreciation related Accounts in Asset Category {0} or Company {1}").format( + asset_category, company + ) + ) + + return fixed_asset_account, accumulated_depreciation_account, depreciation_expense_account + + def get_profit_gl_entries( asset, profit_amount, gl_entries, disposal_account, depreciation_cost_center, date=None ): diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js index 2c0be8f70d7..0df7e0787a9 100644 --- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js @@ -76,14 +76,14 @@ frappe.query_reports["Supplier Quotation Comparison"] = { }, }, { - fieldname: "group_by", - label: __("Group by"), + fieldname: "categorize_by", + label: __("Categorize by"), fieldtype: "Select", options: [ - { label: __("Group by Supplier"), value: "Group by Supplier" }, - { label: __("Group by Item"), value: "Group by Item" }, + { label: __("Categorize by Supplier"), value: "Categorize by Supplier" }, + { label: __("Categorize by Item"), value: "Categorize by Item" }, ], - default: __("Group by Supplier"), + default: __("Categorize by Supplier"), }, { fieldtype: "Check", diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py index ad181802c79..20267e9ae10 100644 --- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py @@ -15,6 +15,8 @@ def execute(filters=None): if not filters: return [], [] + validate_filters(filters) + columns = get_columns(filters) supplier_quotation_data = get_data(filters) @@ -24,6 +26,12 @@ def execute(filters=None): return columns, data, message, chart_data +def validate_filters(filters): + if not filters.get("categorize_by") and filters.get("group_by"): + filters["categorize_by"] = filters["group_by"] + filters["categorize_by"] = filters["categorize_by"].replace("Group by", "Categorize by") + + def get_data(filters): sq = frappe.qb.DocType("Supplier Quotation") sq_item = frappe.qb.DocType("Supplier Quotation Item") @@ -82,7 +90,9 @@ def prepare_data(supplier_quotation_data, filters): group_wise_map = defaultdict(list) supplier_qty_price_map = {} - group_by_field = "supplier_name" if filters.get("group_by") == "Group by Supplier" else "item_code" + group_by_field = ( + "supplier_name" if filters.get("categorize_by") == "Categorize by Supplier" else "item_code" + ) float_precision = cint(frappe.db.get_default("float_precision")) or 2 for data in supplier_quotation_data: @@ -266,7 +276,7 @@ def get_columns(filters): }, ] - if filters.get("group_by") == "Group by Item": + if filters.get("categorize_by") == "Categorize by Item": group_by_columns.reverse() columns[0:0] = group_by_columns # add positioned group by columns to the report diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f576bc91541..247fd5d009b 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -779,17 +779,10 @@ class AccountsController(TransactionBase): if not self.due_date: frappe.throw(_("Due Date is mandatory")) - validate_due_date( - posting_date, - self.due_date, - self.payment_terms_template, - ) + validate_due_date(posting_date, self.due_date, None, self.payment_terms_template, self.doctype) elif self.doctype == "Purchase Invoice": validate_due_date( - posting_date, - self.due_date, - self.bill_date, - self.payment_terms_template, + posting_date, self.due_date, self.bill_date, self.payment_terms_template, self.doctype ) def set_price_list_currency(self, buying_or_selling): diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 300ae9b8fec..0bf4128551e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -402,3 +402,6 @@ erpnext.patches.v15_0.recalculate_amount_difference_field #2025-03-18 erpnext.patches.v15_0.rename_sla_fields #2025-03-12 erpnext.patches.v15_0.set_purchase_receipt_row_item_to_capitalization_stock_item 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 diff --git a/erpnext/patches/v14_0/set_update_price_list_based_on.py b/erpnext/patches/v14_0/set_update_price_list_based_on.py new file mode 100644 index 00000000000..4ddef4b0c25 --- /dev/null +++ b/erpnext/patches/v14_0/set_update_price_list_based_on.py @@ -0,0 +1,14 @@ +import frappe +from frappe.utils import cint + + +def execute(): + frappe.db.set_single_value( + "Stock Settings", + "update_price_list_based_on", + ( + "Price List Rate" + if cint(frappe.db.get_single_value("Selling Settings", "editable_price_list_rate")) + else "Rate" + ), + ) diff --git a/erpnext/patches/v15_0/rename_group_by_to_categorize_by.py b/erpnext/patches/v15_0/rename_group_by_to_categorize_by.py new file mode 100644 index 00000000000..1490ec572f4 --- /dev/null +++ b/erpnext/patches/v15_0/rename_group_by_to_categorize_by.py @@ -0,0 +1,20 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + rename_field("Process Statement Of Accounts", "group_by", "categorize_by") + + frappe.db.sql( + """ + UPDATE + `tabProcess Statement Of Accounts` + SET + categorize_by = CASE + WHEN categorize_by = 'Group by Voucher (Consolidated)' THEN 'Categorize by Voucher (Consolidated)' + WHEN categorize_by = 'Group by Voucher' THEN 'Categorize by Voucher' + END + WHERE + categorize_by IN ('Group by Voucher (Consolidated)', 'Group by Voucher') + """ + ) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 09fdfad66ba..7d194b7c37e 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -513,7 +513,6 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 user = frappe.session.user # find customer name from contact. customer = "" - timesheets = [] contact = frappe.db.exists("Contact", {"user": user}) if contact: @@ -522,31 +521,43 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 customer = contact.get_link_for("Customer") if customer: - sales_invoices = [ - d.name for d in frappe.get_all("Sales Invoice", filters={"customer": customer}) - ] or [None] - projects = [d.name for d in frappe.get_all("Project", filters={"customer": customer})] - # Return timesheet related data to web portal. - timesheets = frappe.db.sql( - f""" - SELECT - ts.name, tsd.activity_type, ts.status, ts.total_billable_hours, - COALESCE(ts.sales_invoice, tsd.sales_invoice) AS sales_invoice, tsd.project - FROM `tabTimesheet` ts, `tabTimesheet Detail` tsd - WHERE tsd.parent = ts.name AND - ( - ts.sales_invoice IN %(sales_invoices)s OR - tsd.sales_invoice IN %(sales_invoices)s OR - tsd.project IN %(projects)s - ) - ORDER BY `end_date` ASC - LIMIT {limit_page_length} offset {limit_start} - """, - dict(sales_invoices=sales_invoices, projects=projects), - as_dict=True, - ) # nosec + sales_invoices = frappe.get_all("Sales Invoice", filters={"customer": customer}, pluck="name") + projects = frappe.get_all("Project", filters={"customer": customer}, pluck="name") - return timesheets + # Return timesheet related data to web portal. + table = frappe.qb.DocType("Timesheet") + child_table = frappe.qb.DocType("Timesheet Detail") + query = ( + frappe.qb.from_(table) + .join(child_table) + .on(table.name == child_table.parent) + .select( + table.name, + child_table.activity_type, + table.status, + table.total_billable_hours, + (table.sales_invoice | child_table.sales_invoice).as_("sales_invoice"), + child_table.project, + ) + .orderby(table.end_date) + .limit(limit_page_length) + .offset(limit_start) + ) + + conditions = [] + if sales_invoices: + conditions.extend( + [table.sales_invoice.isin(sales_invoices), child_table.sales_invoice.isin(sales_invoices)] + ) + if projects: + conditions.append(child_table.project.isin(projects)) + + if conditions: + query = query.where(frappe.qb.terms.Criterion.any(conditions)) + + return query.run(as_dict=True) + else: + return {} def get_list_context(context=None): diff --git a/erpnext/public/js/controllers/stock_controller.js b/erpnext/public/js/controllers/stock_controller.js index 3181d76857d..bc2df9c388d 100644 --- a/erpnext/public/js/controllers/stock_controller.js +++ b/erpnext/public/js/controllers/stock_controller.js @@ -87,7 +87,7 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con from_date: me.frm.doc.posting_date, to_date: moment(me.frm.doc.modified).format('YYYY-MM-DD'), company: me.frm.doc.company, - group_by: "Group by Voucher (Consolidated)", + categorize_by: "Categorize by Voucher (Consolidated)", show_cancelled_entries: me.frm.doc.docstatus === 2, ignore_prepared_report: true }; diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 39a341814db..e883d94c6a2 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -1203,9 +1203,9 @@ function set_time_to_resolve_and_response(frm, apply_sla_for_resolution) { if (apply_sla_for_resolution) { let time_to_resolve; if (!frm.doc.resolution_date) { - time_to_resolve = get_time_left(frm.doc.resolution_by, frm.doc.agreement_status); + time_to_resolve = get_time_left(frm.doc.sla_resolution_by, frm.doc.agreement_status); } else { - time_to_resolve = get_status(frm.doc.resolution_by, frm.doc.resolution_date); + time_to_resolve = get_status(frm.doc.sla_resolution_by, frm.doc.sla_resolution_date); } alert += ` diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 82a8535a66d..a1aae6c3caa 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -850,7 +850,13 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): def test_auto_insert_price(self): make_item("_Test Item for Auto Price List", {"is_stock_item": 0}) make_item("_Test Item for Auto Price List with Discount Percentage", {"is_stock_item": 0}) - frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 1) + frappe.db.set_single_value( + "Stock Settings", + { + "auto_insert_price_list_rate_if_missing": 1, + "update_price_list_based_on": "Price List Rate", + }, + ) item_price = frappe.db.get_value( "Item Price", {"price_list": "_Test Price List", "item_code": "_Test Item for Auto Price List"} @@ -862,6 +868,7 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): item_code="_Test Item for Auto Price List", selling_price_list="_Test Price List", rate=100 ) + # ensure price gets inserted based on rate if price list rate is not defined by user self.assertEqual( frappe.db.get_value( "Item Price", @@ -871,6 +878,8 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): 100, ) + # ensure price gets insterted based on user-defined *Price List Rate* + # if update_price_list_based_on is set to Price List Rate make_sales_order( item_code="_Test Item for Auto Price List with Discount Percentage", selling_price_list="_Test Price List", @@ -878,18 +887,43 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): discount_percentage=20, ) - self.assertEqual( - frappe.db.get_value( - "Item Price", - { - "price_list": "_Test Price List", - "item_code": "_Test Item for Auto Price List with Discount Percentage", - }, - "price_list_rate", - ), - 200, + item_price = frappe.db.get_value( + "Item Price", + { + "price_list": "_Test Price List", + "item_code": "_Test Item for Auto Price List with Discount Percentage", + }, + ("name", "price_list_rate"), + as_dict=True, ) + self.assertEqual(item_price.price_list_rate, 200) + frappe.delete_doc("Item Price", item_price.name) + + frappe.db.set_single_value("Stock Settings", "update_price_list_based_on", "Rate") + + # ensure price gets insterted based on user-defined *Rate* + # if update_price_list_based_on is set to Rate + make_sales_order( + item_code="_Test Item for Auto Price List with Discount Percentage", + selling_price_list="_Test Price List", + price_list_rate=200, + discount_percentage=20, + ) + + item_price = frappe.db.get_value( + "Item Price", + { + "price_list": "_Test Price List", + "item_code": "_Test Item for Auto Price List with Discount Percentage", + }, + ("name", "price_list_rate"), + as_dict=True, + ) + + self.assertEqual(item_price.price_list_rate, 160) + frappe.delete_doc("Item Price", item_price.name) + # do not update price list frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0) @@ -914,6 +948,63 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 1) + def test_update_existing_item_price(self): + item_code = "_Test Item for Price List Updation" + price_list = "_Test Price List" + + make_item(item_code, {"is_stock_item": 0}) + + frappe.db.set_single_value( + "Stock Settings", + { + "auto_insert_price_list_rate_if_missing": 1, + "update_existing_price_list_rate": 1, + "update_price_list_based_on": "Rate", + }, + ) + + # setup: price creation + make_sales_order(item_code=item_code, selling_price_list=price_list, rate=100) + + # test price updation based on Rate + make_sales_order(item_code=item_code, selling_price_list=price_list, rate=90) + + self.assertEqual( + frappe.db.get_value( + "Item Price", + {"price_list": price_list, "item_code": item_code}, + "price_list_rate", + ), + 90, + ) + + frappe.db.set_single_value( + "Stock Settings", + { + "update_price_list_based_on": "Price List Rate", + }, + ) + + # test price updation based on Price List Rate + make_sales_order( + item_code=item_code, + selling_price_list=price_list, + price_list_rate=200, + discount_percentage=20, + ) + + self.assertEqual( + frappe.db.get_value( + "Item Price", + {"price_list": price_list, "item_code": item_code}, + "price_list_rate", + ), + 200, + ) + + # reset `update_existing_price_list_rate` to 0 + frappe.db.set_single_value("Stock Settings", "update_existing_price_list_rate", 0) + def test_drop_shipping(self): from erpnext.buying.doctype.purchase_order.purchase_order import update_status from erpnext.selling.doctype.sales_order.sales_order import ( diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.js b/erpnext/selling/doctype/selling_settings/selling_settings.js index 4471458fb10..f7670e69d47 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.js +++ b/erpnext/selling/doctype/selling_settings/selling_settings.js @@ -2,5 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on("Selling Settings", { - refresh: function (frm) {}, + after_save(frm) { + frappe.boot.user.defaults.editable_price_list_rate = frm.doc.editable_price_list_rate; + }, }); diff --git a/erpnext/setup/setup_wizard/operations/defaults_setup.py b/erpnext/setup/setup_wizard/operations/defaults_setup.py index a93fd3bbd3f..e38f247073c 100644 --- a/erpnext/setup/setup_wizard/operations/defaults_setup.py +++ b/erpnext/setup/setup_wizard/operations/defaults_setup.py @@ -34,6 +34,7 @@ def set_default_settings(args): stock_settings.stock_uom = _("Nos") stock_settings.auto_indent = 1 stock_settings.auto_insert_price_list_rate_if_missing = 1 + stock_settings.update_price_list_based_on = "Rate" stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1 stock_settings.save() diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 9f68145e71d..0077a214e67 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -502,6 +502,7 @@ def update_stock_settings(): stock_settings.stock_uom = _("Nos") stock_settings.auto_indent = 1 stock_settings.auto_insert_price_list_rate_if_missing = 1 + stock_settings.update_price_list_based_on = "Rate" stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1 stock_settings.save() diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 800d4f70c40..c7d9823d144 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -223,6 +223,7 @@ def get_batch_qty( ignore_voucher_nos=None, for_stock_levels=False, consider_negative_batches=False, + do_not_check_future_batches=False, ): """Returns batch actual qty if warehouse is passed, or returns dict of qty by warehouse if warehouse is None @@ -249,6 +250,7 @@ def get_batch_qty( "ignore_voucher_nos": ignore_voucher_nos, "for_stock_levels": for_stock_levels, "consider_negative_batches": consider_negative_batches, + "do_not_check_future_batches": do_not_check_future_batches, } ) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 4195506ad3b..dd89d204cfb 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -7,6 +7,35 @@ const SALES_DOCTYPES = ["Quotation", "Sales Order", "Delivery Note", "Sales Invo const PURCHASE_DOCTYPES = ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]; frappe.ui.form.on("Item", { + valuation_method(frm) { + if (!frm.is_new() && frm.doc.valuation_method === "Moving Average") { + let stock_exists = frm.doc.__onload && frm.doc.__onload.stock_exists ? 1 : 0; + let current_valuation_method = frm.doc.__onload.current_valuation_method; + + if (stock_exists && current_valuation_method !== frm.doc.valuation_method) { + let msg = __( + "Changing the valuation method to Moving Average will affect new transactions. If backdated entries are added, earlier FIFO-based entries will be reposted, which may change closing balances." + ); + msg += "
"; + msg += __( + "Also you can't switch back to FIFO after setting the valuation method to Moving Average for this item." + ); + msg += "
"; + msg += __("Do you want to change valuation method?"); + + frappe.confirm( + msg, + () => { + frm.set_value("valuation_method", "Moving Average"); + }, + () => { + frm.set_value("valuation_method", current_valuation_method); + } + ); + } + } + }, + setup: function (frm) { frm.add_fetch("attribute", "numeric_values", "numeric_values"); frm.add_fetch("attribute", "from_range", "from_range"); diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index c07ea6cdac1..0536caf61a1 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -33,6 +33,7 @@ from erpnext.controllers.item_variant import ( validate_item_variant_attributes, ) from erpnext.stock.doctype.item_default.item_default import ItemDefault +from erpnext.stock.utils import get_valuation_method class DuplicateReorderRows(frappe.ValidationError): @@ -153,6 +154,7 @@ class Item(Document): def onload(self): self.set_onload("stock_exists", self.stock_ledger_created()) self.set_onload("asset_naming_series", get_asset_naming_series()) + self.set_onload("current_valuation_method", get_valuation_method(self.name)) def autoname(self): if frappe.db.get_default("item_naming_by") == "Naming Series": diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 223789fc8a3..63597dd3e72 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -462,6 +462,7 @@ frappe.ui.form.on("Stock Entry", { docstatus: 1, purpose: "Material Transfer", add_to_transit: 1, + per_transferred: ["<", 100], }, }); }, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index efa263a7573..a23ac8342a0 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -503,17 +503,29 @@ class StockEntry(StockController): ).format(frappe.bold(self.company)) ) - elif ( - self.is_opening == "Yes" - and frappe.db.get_value("Account", d.expense_account, "report_type") == "Profit and Loss" - ): + acc_details = frappe.get_cached_value( + "Account", + d.expense_account, + ["account_type", "report_type"], + as_dict=True, + ) + + if self.is_opening == "Yes" and acc_details.report_type == "Profit and Loss": frappe.throw( _( - "Difference Account must be a Asset/Liability type account, since this Stock Entry is an Opening Entry" + "Difference Account must be a Asset/Liability type account (Temporary Opening), since this Stock Entry is an Opening Entry" ), OpeningEntryAccountError, ) + if acc_details.account_type == "Stock": + frappe.throw( + _( + "At row {0}: the Difference Account must not be a Stock type account, please change the Account Type for the account {1} or select a different account" + ).format(d.idx, get_link_to_form("Account", d.expense_account)), + OpeningEntryAccountError, + ) + def validate_warehouse(self): """perform various (sometimes conditional) validations on warehouse""" diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 6beab7ce48d..917aba9803e 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1314,7 +1314,7 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): # To deliver 100 qty we fall short of 11.0073 qty (11.007 with precision 3) # Stock up with 11.007 (balance in db becomes 99.9997, on UI it will show as 100) make_stock_entry(item_code=item_code, target=warehouse, qty=11.007, rate=100) - self.assertEqual(get_stock_balance(item_code, warehouse), 99.9997) + self.assertEqual(get_stock_balance(item_code, warehouse), 100.0) # See if delivery note goes through # Negative qty error should not be raised as 99.9997 is 100 with precision 3 (system precision) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 0c28afe07e4..76a540f3e92 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -5,11 +5,11 @@ import frappe from frappe import _, bold, json, msgprint from frappe.query_builder.functions import CombineDatetime, Sum -from frappe.utils import add_to_date, cint, cstr, flt +from frappe.utils import add_to_date, cint, cstr, flt, get_datetime import erpnext from erpnext.accounts.utils import get_company_default -from erpnext.controllers.stock_controller import StockController +from erpnext.controllers.stock_controller import StockController, create_repost_item_valuation_entry from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( @@ -186,9 +186,35 @@ class StockReconciliation(StockController): if not item.reconcile_all_serial_batch and item.serial_and_batch_bundle: bundle = self.get_bundle_for_specific_serial_batch(item) + if not bundle: + continue + item.current_serial_and_batch_bundle = bundle.name item.current_valuation_rate = abs(bundle.avg_rate) + if bundle.total_qty: + item.current_qty = abs(bundle.total_qty) + + if save: + if not item.current_qty: + frappe.throw( + _("Row # {0}: Please enter quantity for Item {1} as it is not zero.").format( + item.idx, item.item_code + ) + ) + + if self.docstatus == 1: + bundle.voucher_no = self.name + bundle.submit() + + item.db_set( + { + "current_serial_and_batch_bundle": item.current_serial_and_batch_bundle, + "current_qty": item.current_qty, + "current_valuation_rate": item.current_valuation_rate, + } + ) + if not item.valuation_rate: item.valuation_rate = item.current_valuation_rate continue @@ -333,20 +359,26 @@ class StockReconciliation(StockController): entry.batch_no, row.warehouse, row.item_code, + ignore_voucher_nos=[self.name], posting_date=self.posting_date, posting_time=self.posting_time, for_stock_levels=True, consider_negative_batches=True, + do_not_check_future_batches=True, ) + if not current_qty: + continue + total_current_qty += current_qty entry.qty = current_qty * -1 - reco_obj.save() + if total_current_qty: + reco_obj.save() - row.current_qty = total_current_qty + row.current_qty = total_current_qty - return reco_obj + return reco_obj def has_change_in_serial_batch(self, row) -> bool: bundles = {row.serial_and_batch_bundle: [], row.current_serial_and_batch_bundle: []} @@ -968,7 +1000,7 @@ class StockReconciliation(StockController): else: self._cancel() - def recalculate_current_qty(self, voucher_detail_no): + def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False): from erpnext.stock.stock_ledger import get_valuation_rate for row in self.items: @@ -1036,6 +1068,49 @@ class StockReconciliation(StockController): } ) + if ( + add_new_sle + and not frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0}, + "name", + ) + and not row.current_serial_and_batch_bundle + ): + self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True) + row.reload() + + self.add_missing_stock_ledger_entry(row, voucher_detail_no, sle_creation) + + def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation): + if row.current_qty == 0: + return + + new_sle = frappe.get_doc(self.get_sle_for_items(row)) + new_sle.actual_qty = row.current_qty * -1 + new_sle.valuation_rate = row.current_valuation_rate + new_sle.serial_and_batch_bundle = row.current_serial_and_batch_bundle + new_sle.submit() + + creation = add_to_date(sle_creation, seconds=-1) + new_sle.db_set("creation", creation) + + if not frappe.db.exists( + "Repost Item Valuation", + {"item": row.item_code, "warehouse": row.warehouse, "docstatus": 1, "status": "Queued"}, + ): + create_repost_item_valuation_entry( + { + "based_on": "Item and Warehouse", + "item_code": row.item_code, + "warehouse": row.warehouse, + "company": self.company, + "allow_negative_stock": 1, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + } + ) + def has_negative_stock_allowed(self): allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) if allow_negative_stock: @@ -1109,6 +1184,7 @@ class StockReconciliation(StockController): ignore_voucher_nos=[doc.voucher_no], for_stock_levels=True, consider_negative_batches=True, + do_not_check_future_batches=True, ) or 0 ) * -1 diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 77d2f7eaebb..1fcbd73b49a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1069,7 +1069,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): sr.reload() self.assertTrue(sr.items[0].serial_and_batch_bundle) - self.assertFalse(sr.items[0].current_serial_and_batch_bundle) + self.assertTrue(sr.items[0].current_serial_and_batch_bundle) def test_not_reconcile_all_batch(self): from erpnext.stock.doctype.batch.batch import get_batch_qty @@ -1446,6 +1446,74 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(sr.difference_amount, 100 * -1) self.assertTrue(sr.items[0].qty == 0) + def test_stock_reco_recalculate_qty_for_backdated_entry(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item( + "Test Batch Item Stock Reco Recalculate Qty", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-RRQ-.###", + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + sr = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=100, + use_serial_batch_fields=1, + ) + + sr.reload() + self.assertEqual(sr.items[0].current_qty, 0) + self.assertEqual(sr.items[0].current_valuation_rate, 0) + + batch_no = get_batch_from_bundle(sr.items[0].serial_and_batch_bundle) + stock_ledgers = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_no": sr.name, "is_cancelled": 0}, + pluck="name", + ) + + self.assertTrue(len(stock_ledgers) == 1) + + make_stock_entry( + item_code=item_code, + target=warehouse, + qty=10, + basic_rate=100, + use_serial_batch_fields=1, + batch_no=batch_no, + ) + + # Make backdated stock reconciliation entry + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=100, + use_serial_batch_fields=1, + batch_no=batch_no, + posting_date=add_days(nowdate(), -1), + ) + + stock_ledgers = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_no": sr.name, "is_cancelled": 0}, + pluck="name", + ) + + sr.reload() + self.assertEqual(sr.items[0].current_qty, 10) + self.assertEqual(sr.items[0].current_valuation_rate, 100) + + self.assertTrue(len(stock_ledgers) == 2) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.js b/erpnext/stock/doctype/stock_settings/stock_settings.js index 79638590f9b..76651bf69fe 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.js +++ b/erpnext/stock/doctype/stock_settings/stock_settings.js @@ -51,4 +51,30 @@ frappe.ui.form.on("Stock Settings", { } ); }, + auto_insert_price_list_rate_if_missing(frm) { + if (!frm.doc.auto_insert_price_list_rate_if_missing) return; + + frm.set_value( + "update_price_list_based_on", + cint(frappe.defaults.get_default("editable_price_list_rate")) ? "Price List Rate" : "Rate" + ); + }, + update_price_list_based_on(frm) { + if ( + frm.doc.update_price_list_based_on === "Price List Rate" && + !cint(frappe.defaults.get_default("editable_price_list_rate")) + ) { + const dialog = frappe.warn( + __("Incompatible Setting Detected"), + __( + "

Price List Rate has not been set as editable in Selling Settings. In this scenario, setting Update Price List Based On to Price List Rate will prevent auto-updation of Item Price.

Are you sure you want to continue?" + ) + ); + dialog.set_secondary_action(() => { + frm.set_value("update_price_list_based_on", "Rate"); + dialog.hide(); + }); + return; + } + }, }); diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index ea79b78132a..ec154c95136 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -16,6 +16,7 @@ "stock_uom", "price_list_defaults_section", "auto_insert_price_list_rate_if_missing", + "update_price_list_based_on", "column_break_12", "update_existing_price_list_rate", "conversion_factor_section", @@ -531,6 +532,15 @@ "fieldname": "allow_to_make_quality_inspection_after_purchase_or_delivery", "fieldtype": "Check", "label": "Allow to Make Quality Inspection after Purchase / Delivery" + }, + { + "default": "Rate", + "depends_on": "eval: doc.auto_insert_price_list_rate_if_missing", + "fieldname": "update_price_list_based_on", + "fieldtype": "Select", + "label": "Update Price List Based On", + "mandatory_depends_on": "eval: doc.auto_insert_price_list_rate_if_missing", + "options": "Rate\nPrice List Rate" } ], "icon": "icon-cog", @@ -538,7 +548,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-04-11 18:56:35.781929", + "modified": "2025-05-06 02:39:24.284587", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 82b62fb4062..1513f48eb9f 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -64,6 +64,7 @@ class StockSettings(Document): stock_frozen_upto_days: DF.Int stock_uom: DF.Link | None update_existing_price_list_rate: DF.Check + update_price_list_based_on: DF.Literal["Rate", "Price List Rate"] use_naming_series: DF.Check use_serial_batch_fields: DF.Check valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"] diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index ff87d159e36..46cd2f52c4d 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -911,8 +911,8 @@ def get_price_list_rate(args, item_doc, out=None): price_list_rate = get_price_list_rate_for(args, item_doc.variant_of) # insert in database - if price_list_rate is None or frappe.db.get_single_value( - "Stock Settings", "update_existing_price_list_rate" + if price_list_rate is None or frappe.get_cached_value( + "Stock Settings", "Stock Settings", "update_existing_price_list_rate" ): insert_item_price(args) @@ -946,54 +946,71 @@ def insert_item_price(args): ): return - if frappe.db.get_value("Price List", args.price_list, "currency", cache=True) == args.currency and cint( - frappe.db.get_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing") - ): - if frappe.has_permission("Item Price", "write"): - price_list_rate = ( - (flt(args.rate) + flt(args.discount_amount)) / args.get("conversion_factor") - if args.get("conversion_factor") - else (flt(args.rate) + flt(args.discount_amount)) - ) + stock_settings = frappe.get_cached_doc("Stock Settings") - item_price = frappe.db.get_value( - "Item Price", - { - "item_code": args.item_code, - "price_list": args.price_list, - "currency": args.currency, - "uom": args.stock_uom, - }, - ["name", "price_list_rate"], - as_dict=1, - ) - if item_price and item_price.name: - if item_price.price_list_rate != price_list_rate and frappe.db.get_single_value( - "Stock Settings", "update_existing_price_list_rate" - ): - frappe.db.set_value("Item Price", item_price.name, "price_list_rate", price_list_rate) - frappe.msgprint( - _("Item Price updated for {0} in Price List {1}").format( - args.item_code, args.price_list - ), - alert=True, - ) - else: - item_price = frappe.get_doc( - { - "doctype": "Item Price", - "price_list": args.price_list, - "item_code": args.item_code, - "currency": args.currency, - "price_list_rate": price_list_rate, - "uom": args.stock_uom, - } - ) - item_price.insert() - frappe.msgprint( - _("Item Price added for {0} in Price List {1}").format(args.item_code, args.price_list), - alert=True, - ) + if ( + not frappe.db.get_value("Price List", args.price_list, "currency", cache=True) == args.currency + or not stock_settings.auto_insert_price_list_rate_if_missing + or not frappe.has_permission("Item Price", "write") + ): + return + + item_price = frappe.db.get_value( + "Item Price", + { + "item_code": args.item_code, + "price_list": args.price_list, + "currency": args.currency, + "uom": args.stock_uom, + }, + ["name", "price_list_rate"], + as_dict=1, + ) + + update_based_on_price_list_rate = stock_settings.update_price_list_based_on == "Price List Rate" + + if item_price and item_price.name: + if not stock_settings.update_existing_price_list_rate: + return + + rate_to_consider = flt(args.price_list_rate) if update_based_on_price_list_rate else flt(args.rate) + price_list_rate = _get_stock_uom_rate(rate_to_consider, args) + + if not price_list_rate or item_price.price_list_rate == price_list_rate: + return + + frappe.db.set_value("Item Price", item_price.name, "price_list_rate", price_list_rate) + frappe.msgprint( + _("Item Price updated for {0} in Price List {1}").format(args.item_code, args.price_list), + alert=True, + ) + else: + rate_to_consider = ( + (flt(args.price_list_rate) or flt(args.rate)) + if update_based_on_price_list_rate + else flt(args.rate) + ) + price_list_rate = _get_stock_uom_rate(rate_to_consider, args) + + item_price = frappe.get_doc( + { + "doctype": "Item Price", + "price_list": args.price_list, + "item_code": args.item_code, + "currency": args.currency, + "price_list_rate": price_list_rate, + "uom": args.stock_uom, + } + ) + item_price.insert() + frappe.msgprint( + _("Item Price added for {0} in Price List {1}").format(args.item_code, args.price_list), + alert=True, + ) + + +def _get_stock_uom_rate(rate, args): + return rate / args.conversion_factor if args.conversion_factor else rate def get_item_price(args, item_code, ignore_party=False, force_batch_no=False) -> list[dict]: diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index e127960d6bb..bff764228f5 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -743,6 +743,9 @@ class BatchNoValuation(DeprecatedBatchNoValuation): if not self.sle.actual_qty: self.sle.actual_qty = self.get_actual_qty() + if not self.sle.actual_qty: + return 0.0 + return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty)) def get_actual_qty(self): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7e2d8bef79b..ebf4973b066 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -894,7 +894,7 @@ class update_entries_after: self.wh_data.prev_stock_value = self.wh_data.stock_value # update current sle - sle.qty_after_transaction = self.wh_data.qty_after_transaction + sle.qty_after_transaction = flt(self.wh_data.qty_after_transaction, self.flt_precision) sle.valuation_rate = self.wh_data.valuation_rate sle.stock_value = self.wh_data.stock_value sle.stock_queue = json.dumps(self.wh_data.stock_queue) @@ -962,7 +962,7 @@ class update_entries_after: def reset_actual_qty_for_stock_reco(self, sle): doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no) - doc.recalculate_current_qty(sle.voucher_detail_no) + doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0) if sle.actual_qty < 0: sle.actual_qty = ( diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 2aaf8a8adcd..c9fe457ef79 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -54,7 +54,7 @@ frappe.ui.form.on("Subcontracting Receipt", { from_date: frm.doc.posting_date, to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), company: frm.doc.company, - group_by: "Group by Voucher (Consolidated)", + categorize_by: "Categorize by Voucher (Consolidated)", show_cancelled_entries: frm.doc.docstatus === 2, }; frappe.set_route("query-report", "General Ledger");