diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 15badf105f8..25e62e20f3e 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -216,7 +216,7 @@ "description": "Payment Terms from orders will be fetched into the invoices as is", "fieldname": "automatically_fetch_payment_terms", "fieldtype": "Check", - "label": "Automatically Fetch Payment Terms from Order" + "label": "Automatically Fetch Payment Terms from Order/Quotation" }, { "description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ", @@ -307,7 +307,7 @@ }, { "default": "0", - "description": "Learn about Common Party", + "description": "Learn about Common Party", "fieldname": "enable_common_party_accounting", "fieldtype": "Check", "label": "Enable Common Party Accounting" @@ -671,7 +671,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-12-26 19:46:55.093717", + "modified": "2026-03-06 14:49:11.467716", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", @@ -701,4 +701,4 @@ "sort_order": "ASC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 4ff2a13eae2..36cbb321518 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -5,7 +5,9 @@ import frappe from frappe import _, msgprint from frappe.model.document import Document +from frappe.query_builder import Case from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Coalesce, Sum from frappe.utils import flt, fmt_money, get_link_to_form, getdate from pypika import Order @@ -136,65 +138,162 @@ def get_payment_entries_for_bank_clearance( ): entries = [] - condition = "" - pe_condition = "" + journal_entry = frappe.qb.DocType("Journal Entry") + journal_entry_account = frappe.qb.DocType("Journal Entry Account") + + journal_entry_query = ( + frappe.qb.from_(journal_entry_account) + .inner_join(journal_entry) + .on(journal_entry_account.parent == journal_entry.name) + .select( + ConstantColumn("Journal Entry").as_("payment_document"), + journal_entry.name.as_("payment_entry"), + journal_entry.cheque_no.as_("cheque_number"), + journal_entry.cheque_date, + Sum(journal_entry_account.debit_in_account_currency).as_("debit"), + Sum(journal_entry_account.credit_in_account_currency).as_("credit"), + journal_entry.posting_date, + journal_entry_account.against_account, + journal_entry.clearance_date, + journal_entry_account.account_currency, + ) + .where( + (journal_entry_account.account == account) + & (journal_entry.docstatus == 1) + & (journal_entry.posting_date >= from_date) + & (journal_entry.posting_date <= to_date) + & (journal_entry.is_opening == "No") + ) + ) + if not include_reconciled_entries: - condition = "and (clearance_date IS NULL or clearance_date='0000-00-00')" - pe_condition = "and (pe.clearance_date IS NULL or pe.clearance_date='0000-00-00')" + journal_entry_query = journal_entry_query.where( + (journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00") + ) - journal_entries = frappe.db.sql( - f""" - select - "Journal Entry" as payment_document, t1.name as payment_entry, - t1.cheque_no as cheque_number, t1.cheque_date, - sum(t2.debit_in_account_currency) as debit, sum(t2.credit_in_account_currency) as credit, - t1.posting_date, t2.against_account, t1.clearance_date, t2.account_currency - from - `tabJournal Entry` t1, `tabJournal Entry Account` t2 - where - t2.parent = t1.name and t2.account = %(account)s and t1.docstatus=1 - and t1.posting_date >= %(from)s and t1.posting_date <= %(to)s - and ifnull(t1.is_opening, 'No') = 'No' {condition} - group by t2.account, t1.name - order by t1.posting_date ASC, t1.name DESC - """, - {"account": account, "from": from_date, "to": to_date}, - as_dict=1, + journal_entries = ( + journal_entry_query.groupby(journal_entry_account.account, journal_entry.name) + .orderby(journal_entry.posting_date) + .orderby(journal_entry.name, order=Order.desc) + ).run(as_dict=True) + + pe = frappe.qb.DocType("Payment Entry") + company = frappe.qb.DocType("Company") + payment_entry_query = ( + frappe.qb.from_(pe) + .join(company) + .on(pe.company == company.name) + .select( + ConstantColumn("Payment Entry").as_("payment_document"), + pe.name.as_("payment_entry"), + pe.reference_no.as_("cheque_number"), + pe.reference_date.as_("cheque_date"), + ( + Case() + .when( + pe.paid_from == account, + ( + pe.paid_amount + + ( + Case() + .when( + (pe.payment_type == "Pay") + & (company.default_currency == pe.paid_from_account_currency), + pe.base_total_taxes_and_charges, + ) + .else_(pe.total_taxes_and_charges) + ) + ), + ) + .else_(0) + ).as_("credit"), + ( + Case() + .when(pe.paid_from == account, 0) + .else_( + pe.received_amount + + ( + Case() + .when( + company.default_currency == pe.paid_to_account_currency, + pe.base_total_taxes_and_charges, + ) + .else_(pe.total_taxes_and_charges) + ) + ) + ).as_("debit"), + pe.posting_date, + Coalesce(pe.party, Case().when(pe.paid_from == account, pe.paid_to).else_(pe.paid_from)).as_( + "against_account" + ), + pe.clearance_date, + ( + Case() + .when(pe.paid_to == account, pe.paid_to_account_currency) + .else_(pe.paid_from_account_currency) + ).as_("account_currency"), + ) + .where( + ((pe.paid_from == account) | (pe.paid_to == account)) + & (pe.docstatus == 1) + & (pe.posting_date >= from_date) + & (pe.posting_date <= to_date) + ) ) - payment_entries = frappe.db.sql( - f""" - select - "Payment Entry" as payment_document, pe.name as payment_entry, - pe.reference_no as cheque_number, pe.reference_date as cheque_date, - if(pe.paid_from=%(account)s, pe.paid_amount + if(pe.payment_type = 'Pay' and c.default_currency = pe.paid_from_account_currency, pe.base_total_taxes_and_charges, pe.total_taxes_and_charges) , 0) as credit, - if(pe.paid_from=%(account)s, 0, pe.received_amount + pe.total_taxes_and_charges) as debit, - pe.posting_date, ifnull(pe.party,if(pe.paid_from=%(account)s,pe.paid_to,pe.paid_from)) as against_account, pe.clearance_date, - if(pe.paid_to=%(account)s, pe.paid_to_account_currency, pe.paid_from_account_currency) as account_currency - from `tabPayment Entry` as pe - join `tabCompany` c on c.name = pe.company - where - (pe.paid_from=%(account)s or pe.paid_to=%(account)s) and pe.docstatus=1 - and pe.posting_date >= %(from)s and pe.posting_date <= %(to)s - {pe_condition} - order by - pe.posting_date ASC, pe.name DESC - """, - { - "account": account, - "from": from_date, - "to": to_date, - }, - as_dict=1, + if not include_reconciled_entries: + payment_entry_query = payment_entry_query.where( + (pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00") + ) + + payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run( + as_dict=True ) - pos_sales_invoices, pos_purchase_invoices = [], [] + acc = frappe.qb.DocType("Account") + + pi = frappe.qb.DocType("Purchase Invoice") + + paid_purchase_invoices_query = ( + frappe.qb.from_(pi) + .inner_join(acc) + .on(pi.cash_bank_account == acc.name) + .select( + ConstantColumn("Purchase Invoice").as_("payment_document"), + pi.name.as_("payment_entry"), + pi.paid_amount.as_("credit"), + pi.posting_date, + pi.supplier.as_("against_account"), + pi.bill_no.as_("cheque_number"), + pi.clearance_date, + acc.account_currency, + ConstantColumn(0).as_("debit"), + ) + .where( + (pi.docstatus == 1) + & (pi.is_paid == 1) + & (pi.cash_bank_account == account) + & (pi.posting_date >= from_date) + & (pi.posting_date <= to_date) + ) + ) + + if not include_reconciled_entries: + paid_purchase_invoices_query = paid_purchase_invoices_query.where( + (pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00") + ) + + paid_purchase_invoices = ( + paid_purchase_invoices_query.orderby(pi.posting_date).orderby(pi.name, order=Order.desc) + ).run(as_dict=True) + + pos_sales_invoices = [] + if include_pos_transactions: si_payment = frappe.qb.DocType("Sales Invoice Payment") si = frappe.qb.DocType("Sales Invoice") - acc = frappe.qb.DocType("Account") - pos_sales_invoices = ( + pos_sales_invoices_query = ( frappe.qb.from_(si_payment) .inner_join(si) .on(si_payment.parent == si.name) @@ -217,38 +316,22 @@ def get_payment_entries_for_bank_clearance( & (si.posting_date >= from_date) & (si.posting_date <= to_date) ) - .orderby(si.posting_date) - .orderby(si.name, order=Order.desc) - ).run(as_dict=True) + ) - pi = frappe.qb.DocType("Purchase Invoice") + if not include_reconciled_entries: + pos_sales_invoices_query = pos_sales_invoices_query.where( + (si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00") + ) - pos_purchase_invoices = ( - frappe.qb.from_(pi) - .inner_join(acc) - .on(pi.cash_bank_account == acc.name) - .select( - ConstantColumn("Purchase Invoice").as_("payment_document"), - pi.name.as_("payment_entry"), - pi.paid_amount.as_("credit"), - pi.posting_date, - pi.supplier.as_("against_account"), - pi.clearance_date, - acc.account_currency, - ConstantColumn(0).as_("debit"), - ) - .where( - (pi.docstatus == 1) - & (pi.cash_bank_account == account) - & (pi.posting_date >= from_date) - & (pi.posting_date <= to_date) - ) - .orderby(pi.posting_date) - .orderby(pi.name, order=Order.desc) + pos_sales_invoices = ( + pos_sales_invoices_query.orderby(si.posting_date).orderby(si.name, order=Order.desc) ).run(as_dict=True) entries = ( - list(payment_entries) + list(journal_entries) + list(pos_sales_invoices) + list(pos_purchase_invoices) + list(payment_entries) + + list(journal_entries) + + list(pos_sales_invoices) + + list(paid_purchase_invoices) ) return entries diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js index 7361cb2fc1c..a186f419108 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js @@ -2,6 +2,15 @@ // For license information, please see license.txt frappe.ui.form.on("Bank Statement Import", { + onload(frm) { + frm.set_query("bank_account", function (doc) { + return { + filters: { + company: doc.company, + }, + }; + }); + }, setup(frm) { frappe.realtime.on("data_import_refresh", ({ data_import }) => { frm.import_in_progress = false; diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 2ed9881772c..502a4f9e015 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -214,6 +214,8 @@ class JournalEntry(AccountsController): def on_cancel(self): # References for this Journal are removed on the `on_cancel` event in accounts_controller super().on_cancel() + + from_doc_events = getattr(self, "ignore_linked_doctypes", ()) self.ignore_linked_doctypes = ( "GL Entry", "Stock Ledger Entry", @@ -226,6 +228,10 @@ class JournalEntry(AccountsController): "Unreconcile Payment Entries", "Advance Payment Ledger Entry", ) + + if from_doc_events and from_doc_events != self.ignore_linked_doctypes: + self.ignore_linked_doctypes = self.ignore_linked_doctypes + from_doc_events + self.make_gl_entries(1) self.unlink_advance_entry_reference() self.unlink_asset_reference() @@ -263,6 +269,9 @@ class JournalEntry(AccountsController): frappe.throw(_("Journal Entry type should be set as Depreciation Entry for asset depreciation")) def validate_stock_accounts(self): + if not erpnext.is_perpetual_inventory_enabled(self.company): + return + stock_accounts = get_stock_accounts(self.company, accounts=self.accounts) for account in stock_accounts: account_bal, stock_bal, warehouse_list = get_stock_and_account_balance( diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 585ec41ffac..262dc89d44f 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2556,14 +2556,9 @@ def get_orders_to_be_billed( if not voucher_type: return [] - # Add cost center condition - doc = frappe.get_doc({"doctype": voucher_type}) - condition = "" - if doc and hasattr(doc, "cost_center") and doc.cost_center: - condition = " and cost_center='%s'" % cost_center - # dynamic dimension filters - active_dimensions = get_dimensions()[0] + condition = "" + active_dimensions = get_dimensions(True)[0] for dim in active_dimensions: if filters.get(dim.fieldname): condition += f" and {dim.fieldname}='{filters.get(dim.fieldname)}'" diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index e47b51ae028..735d6b02857 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -22,6 +22,7 @@ "reqd": 1 }, { + "allow_on_submit": 1, "fieldname": "cost_center", "fieldtype": "Link", "in_list_view": 1, @@ -59,7 +60,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-11-05 16:07:47.307971", + "modified": "2026-03-11 14:26:11.312950", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Deduction", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index efaeff12279..81c9ff09ccb 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -312,7 +312,7 @@ "fieldname": "posting_date", "fieldtype": "Date", "in_list_view": 1, - "label": "Date", + "label": "Posting Date", "oldfieldname": "posting_date", "oldfieldtype": "Date", "print_hide": 1, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 8f04f173c1f..f207b2079ab 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -373,7 +373,7 @@ "fieldtype": "Date", "hide_days": 1, "hide_seconds": 1, - "label": "Date", + "label": "Posting Date", "no_copy": 1, "oldfieldname": "posting_date", "oldfieldtype": "Date", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index cf4377bd0df..96eca4f52cd 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -804,11 +804,9 @@ class SalesInvoice(SellingController): if self.pos_profile: pos = frappe.get_doc("POS Profile", self.pos_profile) - if not self.get("payments") and not for_validate: - update_multi_mode_option(self, pos) - if pos: if not for_validate: + update_multi_mode_option(self, pos) self.tax_category = pos.get("tax_category") if not for_validate and not self.customer: @@ -2747,6 +2745,8 @@ def update_multi_mode_option(doc, pos_profile): payment.account = payment_mode.default_account payment.type = payment_mode.type + mop_refetched = bool(doc.payments) and not doc.is_created_using_pos + doc.set("payments", []) invalid_modes = [] mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")] @@ -2768,6 +2768,12 @@ def update_multi_mode_option(doc, pos_profile): msg = _("Please set default Cash or Bank account in Mode of Payments {}") frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account")) + if mop_refetched: + frappe.toast( + _("Payment methods refreshed. Please review before proceeding."), + indicator="orange", + ) + def get_all_mode_of_payments(doc): return frappe.db.sql( diff --git a/erpnext/accounts/print_format/point_of_sale/__init__.py b/erpnext/accounts/print_format/point_of_sale/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/accounts/print_format/point_of_sale/point_of_sale.json b/erpnext/accounts/print_format/point_of_sale/point_of_sale.json deleted file mode 100644 index c0c50cb4e26..00000000000 --- a/erpnext/accounts/print_format/point_of_sale/point_of_sale.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "align_labels_right": 0, - "creation": "2016-05-05 17:16:18.564460", - "custom_format": 1, - "disabled": 0, - "doc_type": "Sales Invoice", - "docstatus": 0, - "doctype": "Print Format", - "font": "Default", - "html": "\n\n

\n\t{{ company }}
\n\t{{ __(\"POS No : \") }} {{ offline_pos_name }}
\n

\n

\n\t{{ __(\"Customer\") }}: {{ customer }}
\n

\n\n

\n\t{{ __(\"Date\") }}: {{ dateutil.global_date_format(posting_date) }}
\n

\n\n
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t{% for item in items %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% endfor %}\n\t\n
{{ __(\"Item\") }}{{ __(\"Qty\") }}{{ __(\"Amount\") }}
\n\t\t\t\t{{ item.item_name }}\n\t\t\t{{ format_number(item.qty, null,precision(\"difference\")) }}
@ {{ format_currency(item.rate, currency) }}
{{ format_currency(item.amount, currency) }}
\n\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% for row in taxes %}\n\t\t{% if not row.included_in_print_rate %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% endif %}\n\t\t{% endfor %}\n\t\t{% if discount_amount %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% endif %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{{ __(\"Net Total\") }}\n\t\t\t\n\t\t\t\t{{ format_currency(total, currency) }}\n\t\t\t
\n\t\t\t\t{{ row.description }}\n\t\t\t\n\t\t\t\t{{ format_currency(row.tax_amount, currency) }}\n\t\t\t
\n\t\t\t\t{{ __(\"Discount\") }}\n\t\t\t\n\t\t\t\t{{ format_currency(discount_amount, currency) }}\n\t\t\t
\n\t\t\t\t{{ __(\"Grand Total\") }}\n\t\t\t\n\t\t\t\t{{ format_currency(grand_total, currency) }}\n\t\t\t
\n\t\t\t\t{{ __(\"Paid Amount\") }}\n\t\t\t\n\t\t\t\t{{ format_currency(paid_amount, currency) }}\n\t\t\t
\n\t\t\t\t{{ __(\"Qty Total\") }}\n\t\t\t\n\t\t\t\t{{ qty_total }}\n\t\t\t
\n\n\n
\n

{{ terms }}

\n

{{ __(\"Thank you, please visit again.\") }}

", - "idx": 0, - "line_breaks": 0, - "modified": "2019-09-05 17:20:30.726659", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Point of Sale", - "owner": "Administrator", - "print_format_builder": 0, - "print_format_type": "JS", - "raw_printing": 0, - "show_section_headings": 0, - "standard": "Yes" -} \ No newline at end of file diff --git a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py index ae675670446..5320de2b66c 100644 --- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py +++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py @@ -4,7 +4,10 @@ import frappe from frappe import _ -from frappe.utils import getdate, nowdate +from frappe.query_builder import Case +from frappe.query_builder.custom import ConstantColumn +from frappe.utils import getdate +from pypika import Order def execute(filters=None): @@ -48,17 +51,6 @@ def get_columns(): return columns -def get_conditions(filters): - conditions = "" - - if filters.get("from_date"): - conditions += " and posting_date>=%(from_date)s" - if filters.get("to_date"): - conditions += " and posting_date<=%(to_date)s" - - return conditions - - def get_entries(filters): entries = [] @@ -73,41 +65,90 @@ def get_entries(filters): return sorted( entries, - key=lambda k: k[2].strftime("%H%M%S") or getdate(nowdate()), + key=lambda k: getdate(k[2]), ) def get_entries_for_bank_clearance_summary(filters): entries = [] - conditions = get_conditions(filters) + je = frappe.qb.DocType("Journal Entry") + jea = frappe.qb.DocType("Journal Entry Account") - journal_entries = frappe.db.sql( - f"""SELECT - "Journal Entry", jv.name, jv.posting_date, jv.cheque_no, - jv.clearance_date, jvd.against_account, jvd.debit - jvd.credit - FROM - `tabJournal Entry Account` jvd, `tabJournal Entry` jv - WHERE - jvd.parent = jv.name and jv.docstatus=1 and jvd.account = %(account)s {conditions} - order by posting_date DESC, jv.name DESC""", - filters, - as_list=1, - ) + journal_entries = ( + frappe.qb.from_(jea) + .inner_join(je) + .on(jea.parent == je.name) + .select( + ConstantColumn("Journal Entry").as_("payment_document"), + je.name.as_("payment_entry"), + je.posting_date, + je.cheque_no, + je.clearance_date, + jea.against_account, + jea.debit_in_account_currency - jea.credit_in_account_currency, + ) + .where( + (jea.account == filters.account) + & (je.docstatus == 1) + & (je.posting_date >= filters.from_date) + & (je.posting_date <= filters.to_date) + & ((je.is_opening == "No") | (je.is_opening.isnull())) + ) + .orderby(je.posting_date, order=Order.desc) + .orderby(je.name, order=Order.desc) + ).run(as_list=True) - payment_entries = frappe.db.sql( - f"""SELECT - "Payment Entry", name, posting_date, reference_no, clearance_date, party, - if(paid_from=%(account)s, ((paid_amount * -1) - total_taxes_and_charges) , received_amount) - FROM - `tabPayment Entry` - WHERE - docstatus=1 and (paid_from = %(account)s or paid_to = %(account)s) {conditions} - order by posting_date DESC, name DESC""", - filters, - as_list=1, - ) + pe = frappe.qb.DocType("Payment Entry") + payment_entries = ( + frappe.qb.from_(pe) + .select( + ConstantColumn("Payment Entry").as_("payment_document"), + pe.name.as_("payment_entry"), + pe.posting_date, + pe.reference_no.as_("cheque_no"), + pe.clearance_date, + pe.party.as_("against_account"), + Case() + .when( + (pe.paid_from == filters.account), + ((pe.paid_amount * -1) - pe.total_taxes_and_charges), + ) + .else_(pe.received_amount), + ) + .where((pe.paid_from == filters.account) | (pe.paid_to == filters.account)) + .where( + (pe.docstatus == 1) + & (pe.posting_date >= filters.from_date) + & (pe.posting_date <= filters.to_date) + ) + .orderby(pe.posting_date, order=Order.desc) + .orderby(pe.name, order=Order.desc) + ).run(as_list=True) - entries = journal_entries + payment_entries + pi = frappe.qb.DocType("Purchase Invoice") + purchase_invoices = ( + frappe.qb.from_(pi) + .select( + ConstantColumn("Purchase Invoice").as_("payment_document"), + pi.name.as_("payment_entry"), + pi.posting_date, + pi.bill_no.as_("cheque_no"), + pi.clearance_date, + pi.supplier.as_("against_account"), + (pi.paid_amount * -1).as_("amount"), + ) + .where( + (pi.docstatus == 1) + & (pi.is_paid == 1) + & (pi.cash_bank_account == filters.account) + & (pi.posting_date >= filters.from_date) + & (pi.posting_date <= filters.to_date) + ) + .orderby(pi.posting_date, order=Order.desc) + .orderby(pi.name, order=Order.desc) + ).run(as_list=True) + + entries = journal_entries + payment_entries + purchase_invoices return entries diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py index bfc2f2d56ff..474e25c5474 100644 --- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py +++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py @@ -4,7 +4,11 @@ import frappe from frappe import _ +from frappe.query_builder import Case +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Coalesce, Sum from frappe.utils import flt, getdate +from pypika import Order from erpnext.accounts.utils import get_balance_on @@ -123,73 +127,143 @@ def get_entries_for_bank_reconciliation_statement(filters): payment_entries = get_payment_entries(filters) + purchase_invoices = get_purchase_invoices(filters) + pos_entries = [] if filters.include_pos_transactions: pos_entries = get_pos_entries(filters) - return list(journal_entries) + list(payment_entries) + list(pos_entries) + return list(journal_entries) + list(payment_entries) + list(pos_entries) + list(purchase_invoices) def get_journal_entries(filters): - return frappe.db.sql( - """ - select "Journal Entry" as payment_document, jv.posting_date, - jv.name as payment_entry, jvd.debit_in_account_currency as debit, - jvd.credit_in_account_currency as credit, jvd.against_account, - jv.cheque_no as reference_no, jv.cheque_date as ref_date, jv.clearance_date, jvd.account_currency - from - `tabJournal Entry Account` jvd, `tabJournal Entry` jv - where jvd.parent = jv.name and jv.docstatus=1 - and jvd.account = %(account)s and jv.posting_date <= %(report_date)s - and ifnull(jv.clearance_date, '4000-01-01') > %(report_date)s - and ifnull(jv.is_opening, 'No') = 'No' - and jv.company = %(company)s """, - filters, - as_dict=1, - ) + je = frappe.qb.DocType("Journal Entry") + jea = frappe.qb.DocType("Journal Entry Account") + return ( + frappe.qb.from_(jea) + .join(je) + .on(jea.parent == je.name) + .select( + ConstantColumn("Journal Entry").as_("payment_document"), + je.name.as_("payment_entry"), + je.posting_date, + jea.debit_in_account_currency.as_("debit"), + jea.credit_in_account_currency.as_("credit"), + jea.against_account, + je.cheque_no.as_("reference_no"), + je.cheque_date.as_("ref_date"), + je.clearance_date, + jea.account_currency, + ) + .where( + (je.docstatus == 1) + & (jea.account == filters.account) + & (je.posting_date <= filters.report_date) + & (je.clearance_date.isnull() | (je.clearance_date > filters.report_date)) + & (je.company == filters.company) + & ((je.is_opening.isnull()) | (je.is_opening == "No")) + ) + .orderby(je.posting_date) + .orderby(je.name, order=Order.desc) + ).run(as_dict=True) def get_payment_entries(filters): - return frappe.db.sql( - """ - select - "Payment Entry" as payment_document, name as payment_entry, - reference_no, reference_date as ref_date, - if(paid_to=%(account)s, received_amount_after_tax, 0) as debit, - if(paid_from=%(account)s, paid_amount_after_tax, 0) as credit, - posting_date, ifnull(party,if(paid_from=%(account)s,paid_to,paid_from)) as against_account, clearance_date, - if(paid_to=%(account)s, paid_to_account_currency, paid_from_account_currency) as account_currency - from `tabPayment Entry` - where - (paid_from=%(account)s or paid_to=%(account)s) and docstatus=1 - and posting_date <= %(report_date)s - and ifnull(clearance_date, '4000-01-01') > %(report_date)s - and company = %(company)s - """, - filters, - as_dict=1, - ) + pe = frappe.qb.DocType("Payment Entry") + return ( + frappe.qb.from_(pe) + .select( + ConstantColumn("Payment Entry").as_("payment_document"), + pe.name.as_("payment_entry"), + pe.reference_no.as_("reference_no"), + pe.reference_date.as_("ref_date"), + Case().when(pe.paid_to == filters.account, pe.received_amount_after_tax).else_(0).as_("debit"), + Case().when(pe.paid_from == filters.account, pe.paid_amount_after_tax).else_(0).as_("credit"), + pe.posting_date, + Coalesce( + pe.party, Case().when(pe.paid_from == filters.account, pe.paid_to).else_(pe.paid_from) + ).as_("against_account"), + pe.clearance_date, + ( + Case() + .when(pe.paid_to == filters.account, pe.paid_to_account_currency) + .else_(pe.paid_from_account_currency) + ).as_("account_currency"), + ) + .where( + (pe.docstatus == 1) + & ((pe.paid_from == filters.account) | (pe.paid_to == filters.account)) + & (pe.posting_date <= filters.report_date) + & (pe.clearance_date.isnull() | (pe.clearance_date > filters.report_date)) + & (pe.company == filters.company) + ) + .orderby(pe.posting_date) + .orderby(pe.name, order=Order.desc) + ).run(as_dict=True) + + +def get_purchase_invoices(filters): + pi = frappe.qb.DocType("Purchase Invoice") + acc = frappe.qb.DocType("Account") + return ( + frappe.qb.from_(pi) + .inner_join(acc) + .on(pi.cash_bank_account == acc.name) + .select( + ConstantColumn("Purchase Invoice").as_("payment_document"), + pi.name.as_("payment_entry"), + pi.bill_no.as_("reference_no"), + pi.posting_date.as_("ref_date"), + Case().when(pi.paid_amount < 0, pi.paid_amount * -1).else_(0).as_("debit"), + Case().when(pi.paid_amount > 0, pi.paid_amount).else_(0).as_("credit"), + pi.posting_date, + pi.supplier.as_("against_account"), + pi.clearance_date, + acc.account_currency, + ) + .where( + (pi.docstatus == 1) + & (pi.is_paid == 1) + & (pi.cash_bank_account == filters.account) + & (pi.posting_date <= filters.report_date) + & (pi.clearance_date.isnull() | (pi.clearance_date > filters.report_date)) + & (pi.company == filters.company) + ) + .orderby(pi.posting_date) + .orderby(pi.name, order=Order.desc) + ).run(as_dict=True) def get_pos_entries(filters): - return frappe.db.sql( - """ - select - "Sales Invoice Payment" as payment_document, sip.name as payment_entry, sip.amount as debit, - si.posting_date, si.debit_to as against_account, sip.clearance_date, - account.account_currency, 0 as credit - from `tabSales Invoice Payment` sip, `tabSales Invoice` si, `tabAccount` account - where - sip.account=%(account)s and si.docstatus=1 and sip.parent = si.name - and account.name = sip.account and si.posting_date <= %(report_date)s and - ifnull(sip.clearance_date, '4000-01-01') > %(report_date)s - and si.company = %(company)s - order by - si.posting_date ASC, si.name DESC - """, - filters, - as_dict=1, - ) + si = frappe.qb.DocType("Sales Invoice") + si_payment = frappe.qb.DocType("Sales Invoice Payment") + acc = frappe.qb.DocType("Account") + return ( + frappe.qb.from_(si_payment) + .join(si) + .on(si_payment.parent == si.name) + .join(acc) + .on(si_payment.account == acc.name) + .select( + ConstantColumn("Sales Invoice").as_("payment_document"), + si.name.as_("payment_entry"), + si_payment.amount.as_("debit"), + si.posting_date, + si.debit_to.as_("against_account"), + si_payment.clearance_date, + acc.account_currency, + ConstantColumn(0).as_("credit"), + ) + .where( + (si_payment.account == filters.account) + & (si.docstatus == 1) + & (si.posting_date <= filters.report_date) + & (si_payment.clearance_date.isnull() | (si_payment.clearance_date > filters.report_date)) + & (si.company == filters.company) + ) + .orderby(si.posting_date) + .orderby(si_payment.name, order=Order.desc) + ).run(as_dict=True) def get_amounts_not_reflected_in_system(filters): @@ -205,30 +279,66 @@ def get_amounts_not_reflected_in_system(filters): def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filters): - je_amount = frappe.db.sql( - """ - select sum(jvd.debit_in_account_currency - jvd.credit_in_account_currency) - from `tabJournal Entry Account` jvd, `tabJournal Entry` jv - where jvd.parent = jv.name and jv.docstatus=1 and jvd.account=%(account)s - and jv.posting_date > %(report_date)s and jv.clearance_date <= %(report_date)s - and ifnull(jv.is_opening, 'No') = 'No' """, - filters, + je = frappe.qb.DocType("Journal Entry") + jea = frappe.qb.DocType("Journal Entry Account") + + je_amount = ( + frappe.qb.from_(jea) + .inner_join(je) + .on(jea.parent == je.name) + .select( + Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("amount"), + ) + .where( + (je.docstatus == 1) + & (jea.account == filters.account) + & (je.posting_date > filters.report_date) + & (je.clearance_date <= filters.report_date) + & (je.company == filters.company) + & ((je.is_opening.isnull()) | (je.is_opening == "No")) + ) + .run(as_dict=True) ) + je_amount = flt(je_amount[0].amount) if je_amount else 0.0 - je_amount = flt(je_amount[0][0]) if je_amount else 0.0 - - pe_amount = frappe.db.sql( - """ - select sum(if(paid_from=%(account)s, paid_amount, received_amount)) - from `tabPayment Entry` - where (paid_from=%(account)s or paid_to=%(account)s) and docstatus=1 - and posting_date > %(report_date)s and clearance_date <= %(report_date)s""", - filters, + pe = frappe.qb.DocType("Payment Entry") + pe_amount = ( + frappe.qb.from_(pe) + .select( + Sum(Case().when(pe.paid_from == filters.account, pe.paid_amount).else_(pe.received_amount)).as_( + "amount" + ), + ) + .where( + ((pe.paid_from == filters.account) | (pe.paid_to == filters.account)) + & (pe.docstatus == 1) + & (pe.posting_date > filters.report_date) + & (pe.clearance_date <= filters.report_date) + & (pe.company == filters.company) + ) + .run(as_dict=True) ) + pe_amount = flt(pe_amount[0].amount) if pe_amount else 0.0 - pe_amount = flt(pe_amount[0][0]) if pe_amount else 0.0 + pi = frappe.qb.DocType("Purchase Invoice") + pi_amount = ( + frappe.qb.from_(pi) + .select( + Sum(pi.paid_amount).as_("amount"), + ) + .where( + (pi.docstatus == 1) + & (pi.is_paid == 1) + & (pi.cash_bank_account == filters.account) + & (pi.posting_date > filters.report_date) + & (pi.clearance_date <= filters.report_date) + & (pi.company == filters.company) + ) + ).run(as_dict=True) - return je_amount + pe_amount + pi_amount = flt(pi_amount[0].amount) if pi_amount else 0.0 + + return je_amount + pe_amount + pi_amount def get_balance_row(label, amount, account_currency): diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js index 84ffcaf4a36..21449be5e84 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js @@ -22,7 +22,7 @@ frappe.query_reports["Profit and Loss Statement"]["filters"].push( fieldname: "accumulated_values", label: __("Accumulated Values"), fieldtype: "Check", - default: 1, + default: 0, }, { fieldname: "include_default_book_entries", diff --git a/erpnext/accounts/report/purchase_invoice_trends/purchase_invoice_trends.json b/erpnext/accounts/report/purchase_invoice_trends/purchase_invoice_trends.json index 2080f51933a..37556b6b4c2 100644 --- a/erpnext/accounts/report/purchase_invoice_trends/purchase_invoice_trends.json +++ b/erpnext/accounts/report/purchase_invoice_trends/purchase_invoice_trends.json @@ -7,10 +7,10 @@ "docstatus": 0, "doctype": "Report", "filters": [], - "idx": 3, + "idx": 4, "is_standard": "Yes", - "letterhead": null, - "modified": "2025-11-05 11:55:49.950442", + "letter_head": null, + "modified": "2026-03-13 17:35:39.703838", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Trends", diff --git a/erpnext/accounts/report/sales_invoice_trends/sales_invoice_trends.json b/erpnext/accounts/report/sales_invoice_trends/sales_invoice_trends.json index 1ed34ff4c36..93aa6567f0c 100644 --- a/erpnext/accounts/report/sales_invoice_trends/sales_invoice_trends.json +++ b/erpnext/accounts/report/sales_invoice_trends/sales_invoice_trends.json @@ -9,8 +9,8 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "letterhead": null, - "modified": "2025-11-05 11:55:50.070651", + "letter_head": null, + "modified": "2026-03-13 17:36:13.725601", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Trends", diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index 9fb40938d59..d93c60b2cf4 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -5,6 +5,7 @@ import frappe from frappe import _ from frappe.utils import flt, getdate +from pypika import Tuple from erpnext.accounts.utils import get_currency_precision @@ -19,18 +20,14 @@ def execute(filters=None): validate_filters(filters) ( - tds_docs, tds_accounts, tax_category_map, - journal_entry_party_map, net_total_map, ) = get_tds_docs(filters) columns = get_columns(filters) - res = get_result( - filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map - ) + res = get_result(filters, tds_accounts, tax_category_map, net_total_map) return columns, res @@ -41,27 +38,23 @@ def validate_filters(filters): frappe.throw(_("From Date must be before To Date")) -def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map): - party_map = get_party_pan_map(filters.get("party_type")) +def get_result(filters, tds_accounts, tax_category_map, net_total_map): + party_names = {v.party for v in net_total_map.values() if v.party} + party_map = get_party_pan_map(filters.get("party_type"), party_names) tax_rate_map = get_tax_rate_map(filters) - gle_map = get_gle_map(tds_docs) + gle_map = get_gle_map(net_total_map) precision = get_currency_precision() - out = [] entries = {} - for name, details in gle_map.items(): + for (voucher_type, name), details in gle_map.items(): for entry in details: tax_amount, total_amount, grand_total, base_total, base_tax_withholding_net_total = 0, 0, 0, 0, 0 tax_withholding_category, rate = None, None bill_no, bill_date = "", "" - party = entry.party or entry.against posting_date = entry.posting_date - voucher_type = entry.voucher_type - if voucher_type == "Journal Entry": - party_list = journal_entry_party_map.get(name) - if party_list: - party = party_list[0] + values = net_total_map.get((voucher_type, name)) + party = values.party if values else (entry.party or entry.against) if entry.account in tds_accounts.keys(): tax_amount += entry.credit - entry.debit @@ -76,12 +69,13 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_ rate = get_tax_withholding_rates(tax_rate_map.get(tax_withholding_category, []), posting_date) - values = net_total_map.get((voucher_type, name)) - if values: if voucher_type == "Journal Entry" and tax_amount and rate: # back calculate total amount from rate and tax_amount - base_total = min(flt(tax_amount / (rate / 100), precision=precision), values[0]) + base_total = min( + flt(tax_amount / (rate / 100), precision=precision), + values.base_tax_withholding_net_total, + ) total_amount = grand_total = base_total base_tax_withholding_net_total = total_amount @@ -90,16 +84,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_ # back calculate total amount from rate and tax_amount total_amount = flt((tax_amount * 100) / rate, precision=precision) else: - total_amount = values[0] + total_amount = values.base_tax_withholding_net_total - grand_total = values[1] - base_total = values[2] + grand_total = values.grand_total + base_total = values.base_total base_tax_withholding_net_total = total_amount if voucher_type == "Purchase Invoice": - base_tax_withholding_net_total = values[0] - bill_no = values[3] - bill_date = values[4] + base_tax_withholding_net_total = values.base_tax_withholding_net_total + bill_no = values.bill_no + bill_date = values.bill_date else: total_amount += entry.credit @@ -147,14 +141,17 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_ else: entries[key] = row out = list(entries.values()) - out.sort(key=lambda x: (x["section_code"], x["transaction_date"])) + out.sort(key=lambda x: (x["section_code"], x["transaction_date"], x["ref_no"])) return out -def get_party_pan_map(party_type): +def get_party_pan_map(party_type, party_names): party_map = frappe._dict() + if not party_names: + return party_map + fields = ["name", "tax_withholding_category"] if party_type == "Supplier": fields += ["supplier_type", "supplier_name"] @@ -164,7 +161,7 @@ def get_party_pan_map(party_type): if frappe.db.has_column(party_type, "pan"): fields.append("pan") - party_details = frappe.db.get_all(party_type, fields=fields) + party_details = frappe.db.get_all(party_type, filters={"name": ("in", list(party_names))}, fields=fields) for party in party_details: party.party_type = party_type @@ -173,22 +170,33 @@ def get_party_pan_map(party_type): return party_map -def get_gle_map(documents): - # create gle_map of the form - # {"purchase_invoice": list of dict of all gle created for this invoice} +def get_gle_map(net_total_map): + if not net_total_map: + return {} + + gle = frappe.qb.DocType("GL Entry") + voucher_pairs = list(net_total_map.keys()) + + rows = ( + frappe.qb.from_(gle) + .select( + gle.credit, + gle.debit, + gle.account, + gle.voucher_no, + gle.posting_date, + gle.voucher_type, + gle.against, + gle.party, + gle.party_type, + ) + .where(gle.is_cancelled == 0) + .where(Tuple(gle.voucher_type, gle.voucher_no).isin(voucher_pairs)) + ).run(as_dict=True) + gle_map = {} - - gle = frappe.db.get_all( - "GL Entry", - {"voucher_no": ["in", documents], "is_cancelled": 0}, - ["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"], - ) - - for d in gle: - if d.voucher_no not in gle_map: - gle_map[d.voucher_no] = [d] - else: - gle_map[d.voucher_no].append(d) + for d in rows: + gle_map.setdefault((d.voucher_type, d.voucher_no), []).append(d) return gle_map @@ -308,14 +316,9 @@ def get_columns(filters): def get_tds_docs(filters): - tds_documents = [] - purchase_invoices = [] - sales_invoices = [] - payment_entries = [] - journal_entries = [] + vouchers = frappe._dict() tax_category_map = frappe._dict() net_total_map = frappe._dict() - journal_entry_party_map = frappe._dict() bank_accounts = frappe.get_all("Account", {"is_group": 0, "account_type": "Bank"}, pluck="name") _tds_accounts = frappe.get_all( @@ -334,35 +337,14 @@ def get_tds_docs(filters): tds_docs = get_tds_docs_query(filters, bank_accounts, list(tds_accounts.keys())).run(as_dict=True) for d in tds_docs: - if d.voucher_type == "Purchase Invoice": - purchase_invoices.append(d.voucher_no) - if d.voucher_type == "Sales Invoice": - sales_invoices.append(d.voucher_no) - elif d.voucher_type == "Payment Entry": - payment_entries.append(d.voucher_no) - elif d.voucher_type == "Journal Entry": - journal_entries.append(d.voucher_no) + vouchers.setdefault(d.voucher_type, set()).add(d.voucher_no) - tds_documents.append(d.voucher_no) - - if purchase_invoices: - get_doc_info(purchase_invoices, "Purchase Invoice", tax_category_map, net_total_map) - - if sales_invoices: - get_doc_info(sales_invoices, "Sales Invoice", tax_category_map, net_total_map) - - if payment_entries: - get_doc_info(payment_entries, "Payment Entry", tax_category_map, net_total_map) - - if journal_entries: - journal_entry_party_map = get_journal_entry_party_map(journal_entries) - get_doc_info(journal_entries, "Journal Entry", tax_category_map, net_total_map) + for voucher_type, docs in vouchers.items(): + get_doc_info(docs, voucher_type, tax_category_map, net_total_map, filters) return ( - tds_documents, tds_accounts, tax_category_map, - journal_entry_party_map, net_total_map, ) @@ -373,11 +355,16 @@ def get_tds_docs_query(filters, bank_accounts, tds_accounts): _("No {0} Accounts found for this company.").format(frappe.bold(_("Tax Withholding"))), title=_("Accounts Missing Error"), ) + + invoice_voucher = "Purchase Invoice" if filters.get("party_type") == "Supplier" else "Sales Invoice" + voucher_types = {"Payment Entry", "Journal Entry", invoice_voucher} + gle = frappe.qb.DocType("GL Entry") query = ( frappe.qb.from_(gle) .select("voucher_no", "voucher_type", "against", "party") .where(gle.is_cancelled == 0) + .where(gle.voucher_type.isin(voucher_types)) ) if filters.get("from_date"): @@ -403,25 +390,27 @@ def get_tds_docs_query(filters, bank_accounts, tds_accounts): return query -def get_journal_entry_party_map(journal_entries): +def get_journal_entry_party_map(journal_entries, party_type): journal_entry_party_map = {} for d in frappe.db.get_all( "Journal Entry Account", { "parent": ("in", journal_entries), - "party_type": ("in", ("Supplier", "Customer")), + "party_type": party_type, "party": ("is", "set"), }, ["parent", "party"], ): - if d.parent not in journal_entry_party_map: - journal_entry_party_map[d.parent] = [] - journal_entry_party_map[d.parent].append(d.party) + journal_entry_party_map.setdefault(d.parent, []).append(d.party) return journal_entry_party_map -def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None): +def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None, filters=None): + journal_entry_party_map = {} + party_type = filters.get("party_type") if filters else None + party = filters.get("party") if filters else None + common_fields = ["name"] fields_dict = { "Purchase Invoice": [ @@ -431,37 +420,81 @@ def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None): "base_total", "bill_no", "bill_date", + "supplier", ], - "Sales Invoice": ["base_net_total", "grand_total", "base_total"], + "Sales Invoice": ["base_net_total", "grand_total", "base_total", "customer"], "Payment Entry": [ "tax_withholding_category", "paid_amount", "paid_amount_after_tax", "base_paid_amount", + "party", + "party_type", ], "Journal Entry": ["tax_withholding_category", "total_debit"], } + party_field = { + "Purchase Invoice": "supplier", + "Sales Invoice": "customer", + "Payment Entry": "party", + } - entries = frappe.get_all( - doctype, filters={"name": ("in", vouchers)}, fields=common_fields + fields_dict[doctype] - ) + doc_filters = {"name": ("in", vouchers)} + + if party and party_field.get(doctype): + doc_filters[party_field[doctype]] = party + + if doctype == "Payment Entry": + doc_filters["party_type"] = party_type + + entries = frappe.get_all(doctype, filters=doc_filters, fields=common_fields + fields_dict[doctype]) + + if doctype == "Journal Entry": + journal_entry_party_map = get_journal_entry_party_map(vouchers, party_type=party_type) for entry in entries: tax_category_map[(doctype, entry.name)] = entry.tax_withholding_category + + value = frappe._dict( + party=None, + party_type=party_type, + base_tax_withholding_net_total=0, + grand_total=0, + base_total=0, + bill_no="", + bill_date="", + ) + if doctype == "Purchase Invoice": - value = [ - entry.base_tax_withholding_net_total, - entry.grand_total, - entry.base_total, - entry.bill_no, - entry.bill_date, - ] + value.party = entry.supplier + value.party_type = "Supplier" + value.base_tax_withholding_net_total = entry.base_tax_withholding_net_total + value.grand_total = entry.grand_total + value.base_total = entry.base_total + value.bill_no = entry.bill_no + value.bill_date = entry.bill_date elif doctype == "Sales Invoice": - value = [entry.base_net_total, entry.grand_total, entry.base_total] + value.party = entry.customer + value.party_type = "Customer" + value.base_tax_withholding_net_total = entry.base_net_total + value.grand_total = entry.grand_total + value.base_total = entry.base_total elif doctype == "Payment Entry": - value = [entry.paid_amount, entry.paid_amount_after_tax, entry.base_paid_amount] + value.party = entry.party + value.party_type = entry.party_type + value.base_tax_withholding_net_total = entry.paid_amount + value.grand_total = entry.paid_amount_after_tax + value.base_total = entry.base_paid_amount else: - value = [entry.total_debit] * 3 + party_list = journal_entry_party_map.get(entry.name, []) + if party and party in party_list: + value.party = party + elif party_list: + value.party = sorted(party_list)[0] + value.party_type = party_type + value.base_tax_withholding_net_total = entry.total_debit + value.grand_total = entry.total_debit + value.base_total = entry.total_debit net_total_map[(doctype, entry.name)] = value diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index 1b5292f6bde..cbceaeed092 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -20,16 +20,12 @@ def execute(filters=None): columns = get_columns(filters) ( - tds_docs, tds_accounts, tax_category_map, - journal_entry_party_map, - invoice_total_map, + net_total_map, ) = get_tds_docs(filters) - res = get_result( - filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_total_map - ) + res = get_result(filters, tds_accounts, tax_category_map, net_total_map) final_result = group_by_party_and_category(res, filters) return columns, final_result diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index f81b26b2c8d..a8802c1d67e 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -36,6 +36,7 @@ frappe.ui.form.on("Asset", { }, company: function (frm) { + frm.trigger("set_dynamic_labels"); erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); }, @@ -87,6 +88,7 @@ frappe.ui.form.on("Asset", { }, refresh: function (frm) { + frm.trigger("set_dynamic_labels"); frappe.ui.form.trigger("Asset", "is_existing_asset"); frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1); @@ -221,6 +223,10 @@ frappe.ui.form.on("Asset", { } }, + set_dynamic_labels: function (frm) { + frm.set_currency_labels(["gross_purchase_amount"], erpnext.get_currency(frm.doc.company)); + }, + set_depr_posting_failure_alert: function (frm) { const alert = `
diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index e797415ee3e..79a5099b730 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -229,7 +229,7 @@ "fieldtype": "Currency", "label": "Net Purchase Amount", "mandatory_depends_on": "eval:(!doc.is_composite_asset || doc.docstatus==1)", - "options": "Company:company:default_currency", + "options": "currency", "read_only_depends_on": "eval: doc.is_composite_asset" }, { @@ -597,7 +597,7 @@ "link_fieldname": "target_asset" } ], - "modified": "2025-12-23 16:01:10.195932", + "modified": "2026-03-13 12:15:25.734623", "modified_by": "Administrator", "module": "Assets", "name": "Asset", diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 646a7eee7ef..7f7d045ac92 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -131,7 +131,7 @@ frappe.ui.form.on("Asset Repair", { function () { frappe.route_options = { voucher_no: frm.doc.name, - from_date: frm.doc.posting_date, + from_date: moment(frm.doc.completion_date).format("YYYY-MM-DD"), to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), company: frm.doc.company, categorize_by: "", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 85095e66a57..e55ebb52b58 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -769,10 +769,6 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( items_on_form_rendered() { set_schedule_date(this.frm); } - - schedule_date() { - set_schedule_date(this.frm); - } }; // for backward compatibility: combine new and previous states 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 d9d4d7ea1cc..670231653cf 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -165,14 +165,10 @@ frappe.ui.form.on("Request for Quotation", { }, show_supplier_quotation_comparison(frm) { - const today = new Date(); - const oneMonthAgo = new Date(today); - oneMonthAgo.setMonth(today.getMonth() - 1); - frappe.route_options = { company: frm.doc.company, - from_date: moment(oneMonthAgo).format("YYYY-MM-DD"), - to_date: moment(today).format("YYYY-MM-DD"), + from_date: moment(frm.doc.transaction_date).format("YYYY-MM-DD"), + to_date: moment(new Date()).format("YYYY-MM-DD"), request_for_quotation: frm.doc.name, }; frappe.set_route("query-report", "Supplier Quotation Comparison"); diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.json b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.json index 0047d6ecbe5..e53b8e6d669 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.json +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.json @@ -9,8 +9,8 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "letterhead": null, - "modified": "2025-11-05 11:55:50.058154", + "letter_head": null, + "modified": "2026-03-13 17:36:05.561765", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Trends", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 008402eeb53..ec2b5caf9d2 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2502,13 +2502,14 @@ class AccountsController(TransactionBase): grand_total = self.get("rounded_total") or self.grand_total automatically_fetch_payment_terms = 0 - if self.doctype in ("Sales Invoice", "Purchase Invoice"): - base_grand_total = base_grand_total - flt(self.base_write_off_amount) - grand_total = grand_total - flt(self.write_off_amount) + if self.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"): po_or_so, doctype, fieldname = self.get_order_details() automatically_fetch_payment_terms = cint( frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") ) + if self.doctype != "Sales Order": + base_grand_total = base_grand_total - flt(self.base_write_off_amount) + grand_total = grand_total - flt(self.write_off_amount) if self.get("total_advance"): if party_account_currency == self.company_currency: @@ -2524,7 +2525,7 @@ class AccountsController(TransactionBase): if not self.get("payment_schedule"): if ( - self.doctype in ["Sales Invoice", "Purchase Invoice"] + self.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"] and automatically_fetch_payment_terms and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) ): @@ -2579,17 +2580,23 @@ class AccountsController(TransactionBase): self.ignore_default_payment_terms_template = 1 def get_order_details(self): + if not self.get("items"): + return None, None, None if self.doctype == "Sales Invoice": - po_or_so = self.get("items") and self.get("items")[0].get("sales_order") - po_or_so_doctype = "Sales Order" - po_or_so_doctype_name = "sales_order" - + prev_doc = self.get("items")[0].get("sales_order") + prev_doctype = "Sales Order" + prev_doctype_name = "sales_order" + elif self.doctype == "Purchase Invoice": + prev_doc = self.get("items")[0].get("purchase_order") + prev_doctype = "Purchase Order" + prev_doctype_name = "purchase_order" + elif self.doctype == "Sales Order": + prev_doc = self.get("items")[0].get("prevdoc_docname") + prev_doctype = "Quotation" + prev_doctype_name = "prevdoc_docname" else: - po_or_so = self.get("items") and self.get("items")[0].get("purchase_order") - po_or_so_doctype = "Purchase Order" - po_or_so_doctype_name = "purchase_order" - - return po_or_so, po_or_so_doctype, po_or_so_doctype_name + return None, None, None + return prev_doc, prev_doctype, prev_doctype_name def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype): if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname): @@ -2685,7 +2692,9 @@ class AccountsController(TransactionBase): for d in self.get("payment_schedule"): d.validate_from_to_dates("discount_date", "due_date") - if self.doctype == "Sales Order" and getdate(d.due_date) < getdate(self.transaction_date): + if self.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate( + self.transaction_date + ): frappe.throw( _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format( d.idx diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 5dace4af884..0ba090956ca 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -360,13 +360,13 @@ def copy_attributes_to_variant(item, variant): else: if item.variant_based_on == "Item Attribute": if variant.attributes: - attributes_description = item.description + " " + attributes_description = item.description or "" for d in variant.attributes: attributes_description += ( "
" + d.attribute + ": " + cstr(d.attribute_value) + "
" ) - if attributes_description not in variant.description: + if attributes_description not in (variant.description or ""): variant.description = attributes_description diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 12d5229c9f3..75fbd5d60a5 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -15,6 +15,7 @@ from frappe.utils import cint, nowdate, today, unique from pypika import Order import erpnext +from erpnext.accounts.utils import build_qb_match_conditions from erpnext.stock.get_item_details import _get_item_tax_template @@ -608,34 +609,37 @@ def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_income_account(doctype, txt, searchfield, start, page_len, filters): - from erpnext.controllers.queries import get_match_cond - # income account can be any Credit account, # but can also be a Asset account with account_type='Income Account' in special circumstances. # Hence the first condition is an "OR" + if not filters: filters = {} - doctype = "Account" - condition = "" + dt = "Account" + + acc = qb.DocType(dt) + condition = [ + (acc.report_type.eq("Profit and Loss") | acc.account_type.isin(["Income Account", "Temporary"])), + acc.is_group.eq(0), + acc.disabled.eq(0), + ] + if txt: + condition.append(acc.name.like(f"%{txt}%")) + if filters.get("company"): - condition += "and tabAccount.company = %(company)s" + condition.append(acc.company.eq(filters.get("company"))) - condition += " and tabAccount.disabled = %(disabled)s" + user_perms = build_qb_match_conditions(dt) + condition.extend(user_perms) - return frappe.db.sql( - f"""select tabAccount.name from `tabAccount` - where (tabAccount.report_type = "Profit and Loss" - or tabAccount.account_type in ("Income Account", "Temporary")) - and tabAccount.is_group=0 - and tabAccount.`{searchfield}` LIKE %(txt)s - {condition} {get_match_cond(doctype)} - order by idx desc, name""", - { - "txt": "%" + txt + "%", - "company": filters.get("company", ""), - "disabled": cint(filters.get("disabled", 0)), - }, + return ( + qb.from_(acc) + .select(acc.name) + .where(Criterion.all(condition)) + .orderby(acc.idx, order=Order.desc) + .orderby(acc.name) + .run() ) @@ -696,26 +700,38 @@ def get_filtered_dimensions(doctype, txt, searchfield, start, page_len, filters, @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_expense_account(doctype, txt, searchfield, start, page_len, filters): - from erpnext.controllers.queries import get_match_cond - if not filters: filters = {} - doctype = "Account" - condition = "" - if filters.get("company"): - condition += "and tabAccount.company = %(company)s" + dt = "Account" - return frappe.db.sql( - f"""select tabAccount.name from `tabAccount` - where (tabAccount.report_type = "Profit and Loss" - or tabAccount.account_type in ("Expense Account", "Fixed Asset", "Temporary", "Asset Received But Not Billed", "Capital Work in Progress")) - and tabAccount.is_group=0 - and tabAccount.disabled = 0 - and tabAccount.{searchfield} LIKE %(txt)s - {condition} {get_match_cond(doctype)}""", - {"company": filters.get("company", ""), "txt": "%" + txt + "%"}, - ) + acc = qb.DocType(dt) + condition = [ + ( + acc.report_type.eq("Profit and Loss") + | acc.account_type.isin( + [ + "Expense Account", + "Fixed Asset", + "Temporary", + "Asset Received But Not Billed", + "Capital Work in Progress", + ] + ) + ), + acc.is_group.eq(0), + acc.disabled.eq(0), + ] + if txt: + condition.append(acc.name.like(f"%{txt}%")) + + if filters.get("company"): + condition.append(acc.company.eq(filters.get("company"))) + + user_perms = build_qb_match_conditions(dt) + condition.extend(user_perms) + + return qb.from_(acc).select(acc.name).where(Criterion.all(condition)).run() @frappe.whitelist() diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index f1184851c20..15e595a3ca1 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -531,7 +531,6 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai target_doc.against_sales_order = source_doc.against_sales_order target_doc.against_sales_invoice = source_doc.against_sales_invoice target_doc.so_detail = source_doc.so_detail - target_doc.si_detail = source_doc.si_detail target_doc.expense_account = source_doc.expense_account target_doc.dn_detail = source_doc.name if default_warehouse_for_sales_return: diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index acdea69cf22..20c8a72290b 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -511,6 +511,9 @@ class SellingController(StockController): if self.doctype not in ("Delivery Note", "Sales Invoice"): return + if self.doctype == "Sales Invoice" and not self.update_stock and not self.is_internal_transfer(): + return + from erpnext.stock.serial_batch_bundle import get_batch_nos, get_serial_nos allow_at_arms_length_price = frappe.get_cached_value( diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 549493d4b92..ae60bcb1ca8 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -281,9 +281,8 @@ class JobCard(Document): # if key number reaches/crosses to production_capacity means capacity is full and overlap error generated # this will store last to_time of sequential job cards alloted_capacity = {1: time_logs[0]["to_time"]} - # flag for sequential Job card found - sequential_job_card_found = False for i in range(1, len(time_logs)): + sequential_job_card_found = False # scanning for all Existing keys for key in alloted_capacity.keys(): # if current Job Card from time is greater than last to_time in that key means these job card are sequential diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 351ba27a43e..d2ca6ff73fd 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1784,7 +1784,7 @@ def get_item_data(item_code): return { "bom_no": item_details.get("bom_no"), "stock_uom": item_details.get("stock_uom"), - # "description": item_details.get("description") + "description": item_details.get("description"), } @@ -1800,6 +1800,7 @@ def get_sub_assembly_items( skip_available_sub_assembly_item=False, ): data = get_bom_children(parent=bom_no) + precision = frappe.get_precision("Production Plan Sub Assembly Item", "qty") for d in data: if d.expandable: parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") @@ -1837,7 +1838,7 @@ def get_sub_assembly_items( "is_sub_contracted_item": d.is_sub_contracted_item, "bom_level": indent, "indent": indent, - "stock_qty": stock_qty, + "stock_qty": flt(stock_qty, precision), } ) ) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 326a8b37efc..510e69cd272 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -79,9 +79,6 @@ class Workstation(Document): self.total_working_hours += row.hours def validate_working_hours(self, row): - if not (row.start_time and row.end_time): - frappe.throw(_("Row #{0}: Start Time and End Time are required").format(row.idx)) - if get_time(row.start_time) >= get_time(row.end_time): frappe.throw(_("Row #{0}: Start Time must be before End Time").format(row.idx)) diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index c8a61a0ca79..37c83483c18 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -140,6 +140,7 @@ erpnext.buying = { this.toggle_subcontracting_fields(); super.refresh(); + this.prevent_past_schedule_dates(this.frm); } toggle_subcontracting_fields() { @@ -183,6 +184,28 @@ erpnext.buying = { erpnext.utils.set_letter_head(this.frm) } + schedule_date(doc, cdt, cdn) { + if (doc.schedule_date && !cdt.endsWith(" Item")) { + doc.items.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "schedule_date", doc.schedule_date); + }); + } + } + + transaction_date() { + super.transaction_date(); + this.frm.set_value("schedule_date", ""); + this.prevent_past_schedule_dates(this.frm); + } + + prevent_past_schedule_dates(frm) { + if (frm.doc.transaction_date && frm.fields_dict["schedule_date"]) { + frm.fields_dict["schedule_date"].datepicker?.update({ + minDate: new Date(frm.doc.transaction_date), + }); + } + } + supplier_address() { erpnext.utils.get_address_display(this.frm); erpnext.utils.set_taxes_from_address(this.frm, "supplier_address", "supplier_address", "supplier_address"); diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index ef727eec8d8..b0b1281aeff 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1052,6 +1052,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe if (this.frm.doc.transaction_date) { this.frm.transaction_date = this.frm.doc.transaction_date; frappe.ui.form.trigger(this.frm.doc.doctype, "currency"); + this.recalculate_terms(); } } diff --git a/erpnext/public/js/utils/landed_taxes_and_charges_common.js b/erpnext/public/js/utils/landed_taxes_and_charges_common.js index 7d801ca91e6..751d831a6f7 100644 --- a/erpnext/public/js/utils/landed_taxes_and_charges_common.js +++ b/erpnext/public/js/utils/landed_taxes_and_charges_common.js @@ -42,9 +42,9 @@ erpnext.landed_cost_taxes_and_charges = { if (row.account_currency == company_currency) { row.exchange_rate = 1; - frm.set_df_property("taxes", "hidden", 1, row.name, "exchange_rate"); + frm.set_df_property("taxes", "hidden", 1, frm.docname, "exchange_rate", cdn); } else if (!row.exchange_rate || row.exchange_rate == 1) { - frm.set_df_property("taxes", "hidden", 0, row.name, "exchange_rate"); + frm.set_df_property("taxes", "hidden", 0, frm.docname, "exchange_rate", cdn); frappe.call({ method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_exchange_rate", args: { diff --git a/erpnext/regional/italy/e-invoice.xml b/erpnext/regional/italy/e-invoice.xml index 7c436a2b449..ef1e94ff27b 100644 --- a/erpnext/regional/italy/e-invoice.xml +++ b/erpnext/regional/italy/e-invoice.xml @@ -18,25 +18,27 @@ {{ address.country_code }} {%- endmacro %} -{%- macro render_discount_or_margin(item) -%} -{%- if (item.discount_percentage and item.discount_percentage > 0.0) or item.margin_type %} +{%- macro render_discount_or_margin(item, tax_divisor) -%} +{%- if item.discount_percentage and item.discount_percentage > 0.0 %} - {%- if item.discount_percentage > 0.0 %} SC {{ format_float(item.discount_percentage) }} - {%- endif %} - {%- if item.margin_rate_or_amount > 0.0 -%} - MG - {%- if item.margin_type == "Percentage" -%} - {{ format_float(item.margin_rate_or_amount) }} - {%- elif item.margin_type == "Amount" -%} - {{ format_float(item.margin_rate_or_amount) }} - {%- endif -%} - {%- endif %} -{%- endif -%} +{%- endif %} +{%- if item.margin_rate_or_amount and item.margin_rate_or_amount > 0.0 %} + + MG + {%- if item.margin_type == "Percentage" -%} + {{ format_float(item.margin_rate_or_amount) }} + {%- elif item.margin_type == "Amount" -%} + {{ format_float(item.margin_rate_or_amount / tax_divisor) }} + {%- endif -%} + +{%- endif %} {%- endmacro -%} +{%- set has_inclusive_tax = doc.taxes | selectattr("included_in_print_rate") | list | length > 0 -%} + {%- for item in doc.e_invoice_items %} + {%- set tax_divisor = (1 + item.tax_rate / 100) if has_inclusive_tax and item.tax_rate else 1 %} {{ item.idx }} @@ -188,8 +191,9 @@ {{ html2text(item.description or '') or item.item_name }} {{ format_float(item.qty) }} {{ item.stock_uom }} - {{ format_float(item.net_rate or item.price_list_rate or item.rate, item_meta.get_field("rate").precision) }} - {{ render_discount_or_margin(item) }} + {%- set item_unit_net_price = (item.price_list_rate / tax_divisor) or (item.net_rate) or (item.rate / tax_divisor) %} + {{ format_float(item_unit_net_price, item_meta.get_field("rate").precision) }} + {{ render_discount_or_margin(item, tax_divisor) }} {{ format_float(item.net_amount, item_meta.get_field("amount").precision) }} {{ format_float(item.tax_rate, item_meta.get_field("tax_rate").precision) }} {%- if item.tax_exemption_reason %} diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 7a31854d259..b4e433ac805 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.model.mapper import get_mapped_doc -from frappe.utils import flt, getdate, nowdate +from frappe.utils import cint, flt, getdate, nowdate from erpnext.controllers.selling_controller import SellingController @@ -442,6 +442,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar child_filter = d.name in filtered_items if filtered_items else True return child_filter + automatically_fetch_payment_terms = cint( + frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") + ) + doclist = get_mapped_doc( "Quotation", source_name, @@ -449,6 +453,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar "Quotation": { "doctype": "Sales Order", "validation": {"docstatus": ["=", 1]}, + "field_no_map": ["payment_terms_template"], }, "Quotation Item": { "doctype": "Sales Order Item", @@ -458,13 +463,15 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, - "Payment Schedule": {"doctype": "Payment Schedule", "add_if_empty": True}, }, target_doc, set_missing_values, ignore_permissions=ignore_permissions, ) + if automatically_fetch_payment_terms: + doclist.set_payment_schedule() + return doclist diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 2d1da049653..4d4d485c71a 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -175,6 +175,10 @@ class TestQuotation(FrappeTestCase): self.assertTrue(quotation.payment_schedule) + @change_settings( + "Accounts Settings", + {"automatically_fetch_payment_terms": 1}, + ) def test_make_sales_order_terms_copied(self): from erpnext.selling.doctype.quotation.quotation import make_sales_order @@ -317,7 +321,11 @@ class TestQuotation(FrappeTestCase): @change_settings( "Accounts Settings", - {"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 0}, + { + "add_taxes_from_item_tax_template": 0, + "add_taxes_from_taxes_and_charges_template": 0, + "automatically_fetch_payment_terms": 1, + }, ) def test_make_sales_order_with_terms(self): from erpnext.selling.doctype.quotation.quotation import make_sales_order @@ -355,10 +363,13 @@ class TestQuotation(FrappeTestCase): sales_order.save() self.assertEqual(sales_order.payment_schedule[0].payment_amount, 8906.00) - self.assertEqual(sales_order.payment_schedule[0].due_date, getdate(quotation.transaction_date)) + self.assertEqual( + getdate(sales_order.payment_schedule[0].due_date), getdate(quotation.transaction_date) + ) self.assertEqual(sales_order.payment_schedule[1].payment_amount, 8906.00) self.assertEqual( - sales_order.payment_schedule[1].due_date, getdate(add_days(quotation.transaction_date, 30)) + getdate(sales_order.payment_schedule[1].due_date), + getdate(add_days(quotation.transaction_date, 30)), ) def test_valid_till_before_transaction_date(self): @@ -1058,6 +1069,56 @@ class TestQuotation(FrappeTestCase): quotation.reload() self.assertEqual(quotation.status, "Open") + @change_settings( + "Accounts Settings", + {"automatically_fetch_payment_terms": 1}, + ) + def test_make_sales_order_with_payment_terms(self): + from erpnext.selling.doctype.quotation.quotation import make_sales_order + + template = frappe.get_doc( + { + "doctype": "Payment Terms Template", + "template_name": "_Test Payment Terms Template for Quotation", + "terms": [ + { + "doctype": "Payment Terms Template Detail", + "invoice_portion": 50.00, + "credit_days_based_on": "Day(s) after invoice date", + "credit_days": 0, + }, + { + "doctype": "Payment Terms Template Detail", + "invoice_portion": 50.00, + "credit_days_based_on": "Day(s) after invoice date", + "credit_days": 10, + }, + ], + } + ).save() + + quotation = make_quotation(qty=10, rate=1000, do_not_submit=1) + quotation.transaction_date = add_days(nowdate(), -2) + quotation.valid_till = add_days(nowdate(), 10) + quotation.update({"payment_terms_template": template.name, "payment_schedule": []}) + quotation.save() + quotation.submit() + + self.assertEqual(quotation.payment_schedule[0].payment_amount, 5000) + self.assertEqual(quotation.payment_schedule[1].payment_amount, 5000) + self.assertEqual(quotation.payment_schedule[0].due_date, quotation.transaction_date) + self.assertEqual(quotation.payment_schedule[1].due_date, add_days(quotation.transaction_date, 10)) + + sales_order = make_sales_order(quotation.name) + sales_order.transaction_date = nowdate() + sales_order.delivery_date = nowdate() + sales_order.save() + + self.assertEqual(sales_order.payment_schedule[0].due_date, sales_order.transaction_date) + self.assertEqual(sales_order.payment_schedule[1].due_date, add_days(sales_order.transaction_date, 10)) + self.assertEqual(sales_order.payment_schedule[0].payment_amount, 5000) + self.assertEqual(sales_order.payment_schedule[1].payment_amount, 5000) + test_records = frappe.get_test_records("Quotation") diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index bbad2fe4fae..38334cc29bc 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -26,7 +26,7 @@ frappe.ui.form.on("Sales Order", { let color; if (!doc.qty && frm.doc.has_unit_price_items) { color = "yellow"; - } else if (doc.stock_qty <= doc.delivered_qty) { + } else if (doc.stock_qty <= doc.actual_qty) { color = "green"; } else { color = "orange"; diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 4bbdb20d311..e649b8e9383 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -160,6 +160,7 @@ "language", "additional_info_section", "is_internal_customer", + "ignore_default_payment_terms_template", "represents_company", "column_break_152", "source", @@ -1484,9 +1485,9 @@ }, { "default": "0", - "depends_on": "eval:doc.order_type == 'Maintenance';", "fieldname": "skip_delivery_note", "fieldtype": "Check", + "hidden": 1, "hide_days": 1, "hide_seconds": 1, "label": "Skip Delivery Note", @@ -1665,13 +1666,21 @@ "fieldtype": "Data", "is_virtual": 1, "label": "Last Scanned Warehouse" + }, + { + "default": "0", + "fieldname": "ignore_default_payment_terms_template", + "fieldtype": "Check", + "hidden": 1, + "label": "Ignore Default Payment Terms Template", + "read_only": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-02-06 11:06:16.092658", + "modified": "2026-03-06 15:33:49.059029", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", @@ -1750,4 +1759,4 @@ "title_field": "customer_name", "track_changes": 1, "track_seen": 1 -} +} \ No newline at end of file diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 7ceba32232f..dbd7f406432 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -66,7 +66,6 @@ class SalesOrder(SellingController): additional_discount_percentage: DF.Float address_display: DF.SmallText | None advance_paid: DF.Currency - advance_payment_status: DF.Literal["Not Requested", "Requested", "Partially Paid", "Fully Paid"] amended_from: DF.Link | None amount_eligible_for_commission: DF.Currency apply_discount_on: DF.Literal["", "Grand Total", "Net Total"] @@ -111,6 +110,7 @@ class SalesOrder(SellingController): grand_total: DF.Currency group_same_items: DF.Check has_unit_price_items: DF.Check + ignore_default_payment_terms_template: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -158,7 +158,6 @@ class SalesOrder(SellingController): "", "Draft", "On Hold", - "To Pay", "To Deliver and Bill", "To Bill", "To Deliver", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 4fffd1f801e..d7a2f9d5e26 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -57,6 +57,45 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): def tearDown(self): frappe.set_user("Administrator") + @change_settings( + "Stock Settings", + { + "auto_insert_price_list_rate_if_missing": 1, + "update_existing_price_list_rate": 1, + "update_price_list_based_on": "Rate", + }, + ) + def test_sales_order_expired_item_price(self): + price_list = "_Test Price List" + + item_1 = make_item("_Test Expired Item 1", {"is_stock_item": 1}) + + frappe.db.delete("Item Price", {"item_code": item_1.item_code}) + + item_price = frappe.new_doc("Item Price") + item_price.item_code = item_1.item_code + item_price.price_list = price_list + item_price.price_list_rate = 100 + item_price.valid_from = add_days(today(), -10) + item_price.valid_upto = add_days(today(), -5) + item_price.save() + + so = make_sales_order( + item_code=item_1.item_code, qty=1, rate=1000, selling_price_list=price_list, do_not_save=True + ) + so.save() + so.reload() + + self.assertEqual(frappe.db.get_value("Item Price", item_price.name, "price_list_rate"), 100) + self.assertEqual( + frappe.db.count("Item Price", {"item_code": item_1.item_code, "price_list": price_list}), + 2, + ) + all_item_prices = frappe.get_all( + "Item Price", filters={"item_code": item_1.item_code}, order_by="valid_from desc" + ) + self.assertEqual(frappe.db.get_value("Item Price", all_item_prices[0].name, "price_list_rate"), 1000) + def test_sales_order_skip_delivery_note(self): so = make_sales_order(do_not_submit=True) so.order_type = "Maintenance" diff --git a/erpnext/selling/report/quotation_trends/quotation_trends.json b/erpnext/selling/report/quotation_trends/quotation_trends.json index a4011db4041..8722bf61fd7 100644 --- a/erpnext/selling/report/quotation_trends/quotation_trends.json +++ b/erpnext/selling/report/quotation_trends/quotation_trends.json @@ -9,8 +9,8 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "letterhead": null, - "modified": "2025-11-05 11:55:50.127020", + "letter_head": null, + "modified": "2026-03-13 17:36:37.619715", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Trends", diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.json b/erpnext/selling/report/sales_order_trends/sales_order_trends.json index dedec06bcf9..26758f5ab3f 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.json +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.json @@ -9,8 +9,8 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "letterhead": null, - "modified": "2025-11-05 11:55:50.096303", + "letter_head": null, + "modified": "2026-03-13 17:36:21.440118", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Trends", diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 14724ead051..543a8a194b8 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -185,13 +185,11 @@ class Employee(NestedSet): throw(_("Please enter relieving date.")) def validate_for_enabled_user_id(self, enabled): - if not self.status == "Active": - return - if enabled is None: frappe.throw(_("User {0} does not exist").format(self.user_id)) - if enabled == 0: - frappe.throw(_("User {0} is disabled").format(self.user_id), EmployeeUserDisabledError) + + if self.status != "Active" and enabled or self.status == "Active" and enabled == 0: + frappe.set_value("User", self.user_id, "enabled", not enabled) def validate_duplicate_user_id(self): Employee = frappe.qb.DocType("Employee") diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 69443e3a608..6c30b087de8 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -211,6 +211,7 @@ class DeprecatedBatchNoValuation: .select( sle.batch_no, Sum(sle.actual_qty).as_("batch_qty"), + Sum(sle.stock_value_difference).as_("batch_value"), ) .where( (sle.item_code == self.sle.item_code) @@ -227,9 +228,24 @@ class DeprecatedBatchNoValuation: if self.sle.name: query = query.where(sle.name != self.sle.name) + # Moving Average items with no Use Batch wise Valuation but want to use batch wise valuation + moving_avg_item_non_batch_value = False + if valuation_method := self.get_valuation_method(self.sle.item_code): + if valuation_method == "Moving Average" and not frappe.db.get_single_value( + "Stock Settings", "do_not_use_batchwise_valuation" + ): + query = query.where(batch.use_batchwise_valuation == 0) + moving_avg_item_non_batch_value = True + batch_data = query.run(as_dict=True) for d in batch_data: self.available_qty[d.batch_no] += flt(d.batch_qty) + if moving_avg_item_non_batch_value: + self.non_batchwise_balance_qty[d.batch_no] += flt(d.batch_qty) + self.non_batchwise_balance_value[d.batch_no] += flt(d.batch_value) + + if moving_avg_item_non_batch_value: + return for d in batch_data: if self.available_qty.get(d.batch_no): @@ -327,9 +343,24 @@ class DeprecatedBatchNoValuation: query = query.where(bundle.voucher_type != "Pick List") + # Moving Average items with no Use Batch wise Valuation but want to use batch wise valuation + moving_avg_item_non_batch_value = False + if valuation_method := self.get_valuation_method(self.sle.item_code): + if valuation_method == "Moving Average" and not frappe.db.get_single_value( + "Stock Settings", "do_not_use_batchwise_valuation" + ): + query = query.where(batch.use_batchwise_valuation == 0) + moving_avg_item_non_batch_value = True + batch_data = query.run(as_dict=True) for d in batch_data: self.available_qty[d.batch_no] += flt(d.batch_qty) + if moving_avg_item_non_batch_value: + self.non_batchwise_balance_qty[d.batch_no] += flt(d.batch_qty) + self.non_batchwise_balance_value[d.batch_no] += flt(d.batch_value) + + if moving_avg_item_non_batch_value: + return if not self.last_sle: return @@ -337,3 +368,8 @@ class DeprecatedBatchNoValuation: for batch_no in self.available_qty: self.non_batchwise_balance_value[batch_no] = flt(self.last_sle.stock_value) self.non_batchwise_balance_qty[batch_no] = flt(self.last_sle.qty_after_transaction) + + def get_valuation_method(self, item_code): + from erpnext.stock.utils import get_valuation_method + + return get_valuation_method(item_code) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index b2dd23f80bd..f1fc54c751d 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _ from frappe.contacts.doctype.address.address import get_company_address +from frappe.contacts.doctype.contact.contact import get_default_contact from frappe.desk.notifications import clear_doctype_notifications from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values @@ -392,6 +393,9 @@ class DeliveryNote(SellingController): ) def validate_sales_invoice_references(self): + if self.is_return: + return + self._validate_dependent_item_fields( "against_sales_invoice", "si_detail", _("References to Sales Invoices are Incomplete") ) @@ -1169,18 +1173,24 @@ def make_shipment(source_name, target_doc=None): # As we are using session user details in the pickup_contact then pickup_contact_person will be session user target.pickup_contact_person = frappe.session.user - if source.contact_person: + contact_person = source.contact_person or get_default_contact("Customer", source.customer) + if contact_person: contact = frappe.db.get_value( - "Contact", source.contact_person, ["email_id", "phone", "mobile_no"], as_dict=1 + "Contact", contact_person, ["email_id", "phone", "mobile_no"], as_dict=1 ) - delivery_contact_display = f"{source.contact_display}" - if contact: + + delivery_contact_display = source.contact_display or contact_person or "" + if contact and not source.contact_display: if contact.email_id: delivery_contact_display += "
" + contact.email_id if contact.phone: delivery_contact_display += "
" + contact.phone if contact.mobile_no and not contact.phone: delivery_contact_display += "
" + contact.mobile_no + + target.delivery_contact_name = contact_person + if contact and contact.email_id and not target.delivery_contact_email: + target.delivery_contact_email = contact.email_id target.delivery_contact = delivery_contact_display if source.shipping_address_name: diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index ecd11a4fcd6..10c771d1415 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -151,6 +151,7 @@ "set_only_once": 1 }, { + "allow_in_quick_entry": 1, "bold": 1, "fieldname": "item_name", "fieldtype": "Data", @@ -339,6 +340,7 @@ }, { "depends_on": "is_stock_item", + "documentation_url": "https://docs.frappe.io/erpnext/change-valuation-method", "fieldname": "valuation_method", "fieldtype": "Select", "label": "Valuation Method", @@ -895,7 +897,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2025-12-15 20:08:35.634046", + "modified": "2026-03-17 20:39:05.218344", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 89dba460809..7642133fe3e 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -642,10 +642,6 @@ erpnext.buying.MaterialRequestController = class MaterialRequestController exten set_schedule_date(this.frm); } - schedule_date() { - set_schedule_date(this.frm); - } - qty(doc, cdt, cdn) { var row = frappe.get_doc(cdt, cdn); row.amount = flt(row.qty) * flt(row.rate); diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 65bec2d7ea3..d8412dd9dbf 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -339,11 +339,19 @@ def update_product_bundle_rate(parent_items_price, pi_row, item_row): def set_product_bundle_rate_amount(doc, parent_items_price): "Set cumulative rate and amount in bundle item." + rate_updated = False for item in doc.get("items"): bundle_rate = parent_items_price.get((item.item_code, item.name)) if bundle_rate and bundle_rate != item.rate: item.rate = bundle_rate item.amount = flt(bundle_rate * item.qty) + item.margin_rate_or_amount = 0 + item.discount_percentage = 0 + item.discount_amount = 0 + rate_updated = True + if rate_updated: + doc.calculate_taxes_and_totals() + doc.set_total_in_words() def on_doctype_update(): diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index b0936ec1381..b5618bda08e 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -1684,17 +1684,34 @@ def upload_csv_file(item_code, file_path): def get_serial_batch_from_csv(item_code, file_path): - if "private" in file_path: - file_path = frappe.get_site_path() + file_path - else: - file_path = frappe.get_site_path() + "/public" + file_path + from frappe.utils.csvutils import read_csv_content serial_nos = [] batch_nos = [] - with open(file_path) as f: - reader = csv.reader(f) - serial_nos, batch_nos = parse_csv_file_to_get_serial_batch(reader) + if not file_path: + return serial_nos, batch_nos + + try: + file = frappe.get_doc("File", {"file_url": file_path}) + except frappe.DoesNotExistError: + frappe.msgprint( + _("File '{0}' not found").format(frappe.bold(file_path)), + alert=True, + indicator="red", + raise_exception=FileNotFoundError, + ) + + if file.file_type != "CSV": + frappe.msgprint( + _("{0} is not a CSV file.").format(frappe.bold(file.file_name)), + alert=True, + indicator="red", + raise_exception=frappe.ValidationError, + ) + + csv_data = read_csv_content(file.get_content()) + serial_nos, batch_nos = parse_csv_file_to_get_serial_batch(csv_data) if serial_nos: make_serial_nos(item_code, serial_nos) @@ -2644,7 +2661,7 @@ def get_auto_batch_nos(kwargs): ) if kwargs.based_on == "Expiry": - available_batches = sorted(available_batches, key=lambda x: (x.expiry_date or getdate("9999-12-31"))) + available_batches = sorted(available_batches, key=lambda x: x.expiry_date or getdate("9999-12-31")) if not kwargs.get("do_not_check_future_batches") and available_batches and kwargs.get("posting_date"): filter_zero_near_batches(available_batches, kwargs) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 64563625297..51b939c343d 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -358,6 +358,136 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertFalse(json.loads(sle.stock_queue or "[]")) self.assertEqual(flt(sle.stock_value), 0.0) + def test_old_moving_avg_item_with_without_batchwise_valuation(self): + frappe.flags.ignore_serial_batch_bundle_validation = True + frappe.flags.use_serial_and_batch_fields = True + batch_item_code = "Old Batch Item Valuation 2" + make_item( + batch_item_code, + { + "has_batch_no": 1, + "batch_number_series": "TEST-OLD2-BAT-VAL-.#####", + "create_new_batch": 1, + "is_stock_item": 1, + "valuation_method": "Moving Average", + }, + ) + + non_batchwise_val_batches = [ + "TEST-OLD2-BAT-VAL-00001", + "TEST-OLD2-BAT-VAL-00002", + "TEST-OLD2-BAT-VAL-00003", + "TEST-OLD2-BAT-VAL-00004", + ] + + for batch_id in non_batchwise_val_batches: + if not frappe.db.exists("Batch", batch_id): + batch_doc = frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": batch_item_code, + "use_batchwise_valuation": 0, + } + ).insert(ignore_permissions=True) + + self.assertTrue(batch_doc.use_batchwise_valuation) + batch_doc.db_set( + { + "use_batchwise_valuation": 0, + "batch_qty": 20, + } + ) + + qty_after_transaction = 0 + balance_value = 0 + i = 0 + for batch_id in non_batchwise_val_batches: + i += 1 + qty = 20 + valuation = 100 * i + qty_after_transaction += qty + balance_value += qty * valuation + + doc = frappe.get_doc( + { + "doctype": "Stock Ledger Entry", + "posting_date": today(), + "posting_time": nowtime(), + "batch_no": batch_id, + "incoming_rate": valuation, + "qty_after_transaction": qty_after_transaction, + "stock_value_difference": valuation * qty, + "stock_value": balance_value, + "balance_value": balance_value, + "valuation_rate": balance_value / qty_after_transaction, + "actual_qty": qty, + "item_code": batch_item_code, + "warehouse": "_Test Warehouse - _TC", + } + ) + + doc.set_posting_datetime() + doc.flags.ignore_permissions = True + doc.flags.ignore_mandatory = True + doc.flags.ignore_links = True + doc.flags.ignore_validate = True + doc.submit() + doc.reload() + + frappe.flags.ignore_serial_batch_bundle_validation = False + frappe.flags.use_serial_and_batch_fields = False + + se = make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=30, + rate=355, + use_serial_batch_fields=True, + ) + + se = make_stock_entry( + item_code=batch_item_code, + source="_Test Warehouse - _TC", + qty=70, + use_serial_batch_fields=True, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": se.name}, + ["qty_after_transaction", "stock_value"], + as_dict=True, + ) + + self.assertEqual(flt(sle.stock_value), 14000.0) + self.assertEqual(flt(sle.qty_after_transaction), 40.0) + + se = make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=10, + rate=200, + use_serial_batch_fields=True, + ) + + se = make_stock_entry( + item_code=batch_item_code, + source="_Test Warehouse - _TC", + qty=50, + use_serial_batch_fields=True, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": se.name}, + ["qty_after_transaction", "stock_value"], + as_dict=True, + ) + + self.assertEqual(flt(sle.stock_value), 0.0) + self.assertEqual(flt(sle.qty_after_transaction), 0.0) + def test_old_serial_no_valuation(self): from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index c8b559a525d..00b5c9bd5a1 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -525,11 +525,11 @@ class StockReconciliation(StockController): return True rate_precision = item.precision("valuation_rate") - item_dict["rate"] = flt(item_dict.get("rate"), rate_precision) - item.valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None + rate = flt(item_dict.get("rate"), rate_precision) + valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None if ( (item.qty is None or item.qty == item_dict.get("qty")) - and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate")) + and (valuation_rate is None or valuation_rate == rate) and (not item.serial_no or (item.serial_no == item_dict.get("serial_nos"))) ): return False @@ -999,9 +999,9 @@ class StockReconciliation(StockController): def set_total_qty_and_amount(self): for d in self.get("items"): - d.amount = flt(d.qty, d.precision("qty")) * flt(d.valuation_rate, d.precision("valuation_rate")) - d.current_amount = flt(d.current_qty, d.precision("current_qty")) * flt( - d.current_valuation_rate, d.precision("current_valuation_rate") + d.amount = flt(flt(d.qty) * flt(d.valuation_rate), d.precision("amount")) + d.current_amount = flt( + flt(d.current_qty) * flt(d.current_valuation_rate), d.precision("current_amount") ) d.quantity_difference = flt(d.qty) - flt(d.current_qty) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 0ea1738b60e..69e626db1ba 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -110,6 +110,22 @@ class StockSettings(Document): self.validate_auto_insert_price_list_rate_if_missing() self.change_precision_for_for_sales() self.change_precision_for_purchase() + self.validate_do_not_use_batchwise_valuation() + + def validate_do_not_use_batchwise_valuation(self): + doc_before_save = self.get_doc_before_save() + if not doc_before_save: + return + + if not frappe.get_all("Serial and Batch Bundle", filters={"docstatus": 1}, limit=1, pluck="name"): + return + + if doc_before_save.do_not_use_batchwise_valuation and not self.do_not_use_batchwise_valuation: + frappe.throw( + _("Cannot disable {0} as it may lead to incorrect stock valuation.").format( + frappe.bold(_("Do Not Use Batchwise Valuation")) + ) + ) def validate_warehouses(self): warehouse_fields = ["default_warehouse", "sample_retention_warehouse"] diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 312b8e129f8..65d04cfd80c 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -10,7 +10,7 @@ from frappe.model import child_table_fields, default_fields from frappe.model.meta import get_field_precision from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import IfNull, Sum -from frappe.utils import add_days, add_months, cint, cstr, flt, getdate, parse_json +from frappe.utils import add_days, add_months, cint, cstr, flt, get_link_to_form, getdate, parse_json from erpnext import get_company_currency from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( @@ -972,16 +972,30 @@ def insert_item_price(args): ): return - item_price = frappe.db.get_value( + transaction_date = ( + getdate(args.get("posting_date") or args.get("transaction_date") or args.get("posting_datetime")) + or getdate() + ) + + item_prices = frappe.get_all( "Item Price", - { + filters={ "item_code": args.item_code, "price_list": args.price_list, "currency": args.currency, "uom": args.stock_uom, }, - ["name", "price_list_rate"], - as_dict=1, + fields=["name", "price_list_rate", "valid_from", "valid_upto"], + order_by="valid_from desc, creation desc", + ) + item_price = next( + ( + row + for row in item_prices + if (not row.valid_from or getdate(row.valid_from) <= transaction_date) + and (not row.valid_upto or getdate(row.valid_upto) >= transaction_date) + ), + item_prices[0] if item_prices else None, ) update_based_on_price_list_rate = stock_settings.update_price_list_based_on == "Price List Rate" @@ -996,11 +1010,35 @@ def insert_item_price(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, - ) + is_price_valid_for_transaction = ( + not item_price.valid_from or getdate(item_price.valid_from) <= transaction_date + ) and (not item_price.valid_upto or getdate(item_price.valid_upto) >= transaction_date) + if is_price_valid_for_transaction: + 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( + get_link_to_form("Item", args.item_code), args.price_list + ), + alert=True, + ) + else: + # if price is not valid for the transaction date, insert a new price list rate with updated price and future validity + + item_price = frappe.new_doc( + "Item Price", + item_code=args.item_code, + price_list_rate=price_list_rate, + currency=args.currency, + uom=args.stock_uom, + price_list=args.price_list, + ) + item_price.insert() + frappe.msgprint( + _("Item Price Added for {0} in Price List {1}").format( + get_link_to_form("Item", args.item_code), args.price_list + ), + alert=True, + ) else: rate_to_consider = ( (flt(args.price_list_rate) or flt(args.rate)) diff --git a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.json b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.json index cef82c5912d..b4da5b466c3 100644 --- a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.json +++ b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.json @@ -9,8 +9,8 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "letterhead": null, - "modified": "2025-11-05 11:55:50.114173", + "letter_head": null, + "modified": "2026-03-13 17:36:31.552712", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Trends", diff --git a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.json b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.json index 03c2a09f3bb..6743b359e30 100644 --- a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.json +++ b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.json @@ -9,8 +9,8 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "letterhead": null, - "modified": "2025-11-05 11:55:49.983683", + "letter_head": null, + "modified": "2026-03-13 17:35:57.060786", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Trends", diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 69c94afe30d..5134bd29116 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -919,7 +919,10 @@ class update_entries_after: if ( sle.is_adjustment_entry and flt(sle.qty_after_transaction, self.flt_precision) == 0 - and flt(sle.stock_value, self.currency_precision) != 0 + and ( + flt(sle.stock_value, self.currency_precision) != 0 + or flt(sle.stock_value_difference, self.currency_precision) == 0 + ) ): sle.stock_value_difference = ( get_stock_value_difference( diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index b6023b47e76..70ced430257 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -227,10 +227,14 @@ def set_status(name, status): def auto_close_tickets(): - """Auto-close replied support tickets after 7 days""" - auto_close_after_days = ( - frappe.db.get_value("Support Settings", "Support Settings", "close_issue_after_days") or 7 - ) + """ + Auto-close replied support tickets as defined on `close_issue_after_days` in Support Settings. + Disables the feature if `close_issue_after_days` is set to 0. + """ + auto_close_after_days = frappe.db.get_single_value("Support Settings", "close_issue_after_days") + + if not auto_close_after_days: + return table = frappe.qb.DocType("Issue") issues = ( diff --git a/erpnext/support/doctype/support_settings/support_settings.json b/erpnext/support/doctype/support_settings/support_settings.json index bf1daa16f86..cbc727309bb 100644 --- a/erpnext/support/doctype/support_settings/support_settings.json +++ b/erpnext/support/doctype/support_settings/support_settings.json @@ -37,9 +37,11 @@ }, { "default": "7", + "description": "Set this value to 0 to disable the feature.", "fieldname": "close_issue_after_days", "fieldtype": "Int", - "label": "Close Issue After Days" + "label": "Close Issue After Days", + "non_negative": 1 }, { "fieldname": "portal_sb", @@ -163,7 +165,7 @@ ], "issingle": 1, "links": [], - "modified": "2021-10-14 13:08:38.473616", + "modified": "2026-03-16 16:33:45.859541", "modified_by": "Administrator", "module": "Support", "name": "Support Settings", diff --git a/pyproject.toml b/pyproject.toml index fc872af4d61..f5bc11693a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,4 +72,5 @@ docstring-code-format = true [project.urls] Homepage = "https://frappe.io/erpnext" Repository = "https://github.com/frappe/erpnext.git" +Documentation = "https://docs.frappe.io/erpnext" "Bug Reports" = "https://github.com/frappe/erpnext/issues"