diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json index 38ee277c5d6..312c3832f54 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json @@ -22,12 +22,12 @@ "account_type": "Cash" }, "Petty Cash Fund": { - "account_number": "1200", + "account_number": "1110", "is_group": 1, "root_type": "Asset", "account_type": "Cash", "Petty Cash Fund": { - "account_number": "1201", + "account_number": "1111", "is_group": 0, "root_type": "Asset", "account_type": "Cash" @@ -35,10 +35,16 @@ } }, "Bank Accounts": { - "account_number": "1102", + "account_number": "1200", "is_group": 1, "root_type": "Asset", - "account_type": "Bank" + "account_type": "Bank", + "Cash in Bank - Checking Account": { + "account_number": "1201", + "is_group": 0, + "root_type": "Asset", + "account_type": "Bank" + } }, "Advances to Officers & Employees": { "account_number": "1290", @@ -104,25 +110,20 @@ "account_number": "1511", "is_group": 0, "root_type": "Asset" - }, - "Factory Overhead Variance": { - "account_number": "1512", - "is_group": 0, - "root_type": "Asset" } }, "Finished Goods": { - "account_number": "1520", + "account_number": "1540", "is_group": 1, "root_type": "Asset", "Finished Goods Inventory": { - "account_number": "1531", + "account_number": "1541", "is_group": 0, "root_type": "Asset", "account_type": "Stock" }, "Inventory in Transit": { - "account_number": "1532", + "account_number": "1542", "is_group": 0, "root_type": "Asset", "account_type": "Stock Adjustment" @@ -268,7 +269,7 @@ "root_type": "Asset" } }, - "System Development": { + "Intangible Assets": { "account_number": "1940", "is_group": 1, "root_type": "Asset", @@ -277,6 +278,17 @@ "is_group": 0, "root_type": "Asset" } + }, + "Accumulated Amortization - Intangible Assets": { + "account_number": "1950", + "is_group": 1, + "root_type": "Asset", + "Accum Amortization - System Development": { + "account_number": "1951", + "is_group": 0, + "root_type": "Asset", + "account_type": "Accumulated Depreciation" + } } } }, @@ -562,6 +574,28 @@ "is_group": 0, "root_type": "Income" } + }, + "Exchange Gain": { + "account_number": "6030", + "is_group": 1, + "root_type": "Income", + "Exchange Gain - Detail": { + "account_number": "6031", + "is_group": 0, + "root_type": "Income", + "account_type": "Indirect Income" + } + }, + "Gain on Asset Disposal": { + "account_number": "6040", + "is_group": 1, + "root_type": "Income", + "Gain on Asset Disposal - Detail": { + "account_number": "6041", + "is_group": 0, + "root_type": "Income", + "account_type": "Indirect Income" + } } } }, @@ -574,7 +608,7 @@ "is_group": 1, "root_type": "Expense", "Cost of Goods Sold": { - "account_number": "5010", + "account_number": "5002", "is_group": 0, "root_type": "Expense", "account_type": "Cost of Goods Sold" @@ -827,20 +861,61 @@ "root_type": "Expense" } }, - "Stock Adjustment": { + "Other Expenses": { "account_number": "5200", + "is_group": 1, + "root_type": "Expense", + "Bank Charges": { + "account_number": "5201", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Interest Expenses Bank": { + "account_number": "5202", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Write Off": { + "account_number": "5203", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Exchange Loss": { + "account_number": "5204", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + }, + "Loss on Asset Disposal": { + "account_number": "5205", + "is_group": 0, + "root_type": "Expense", + "account_type": "Indirect Expense" + } + }, + "Provision For Income Tax": { + "account_number": "5300", + "is_group": 0, + "root_type": "Expense", + "account_type": "Tax" + }, + "Stock Adjustment": { + "account_number": "5400", "is_group": 0, "root_type": "Expense", "account_type": "Stock Adjustment" }, "Round Off": { - "account_number": "5300", + "account_number": "5500", "is_group": 0, "root_type": "Expense", "account_type": "Round Off" }, "Expenses Included In Valuation": { - "account_number": "5400", + "account_number": "5600", "is_group": 0, "root_type": "Expense", "account_type": "Expenses Included In Valuation" diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 4934a0788c1..facb15f15f7 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -417,11 +417,12 @@ class JournalEntry(AccountsController): def update_journal_entry_link_on_depr_schedule(self, asset, je_row): depr_schedule = get_depr_schedule(asset.name, "Active", self.finance_book) + precision = je_row.precision("debit") for d in depr_schedule or []: if ( d.schedule_date == self.posting_date and not d.journal_entry - and d.depreciation_amount == flt(je_row.debit) + and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision) ): frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.name) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 9293e7fb0b8..c6e8ff410c7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2814,9 +2814,7 @@ def get_reference_details( exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date) else: exchange_rate = 1 - outstanding_amount, total_amount = get_outstanding_on_journal_entry( - reference_name, party_type, party - ) + outstanding_amount, total_amount = get_outstanding_on_journal_entry(reference_name, party_type, party) elif reference_doctype == "Payment Entry": if reverse_payment_details := frappe.db.get_all( diff --git a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json index 28b60e313c4..fbb83c7151f 100644 --- a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json +++ b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t
\n\t\t\t\t\t{{ doc.doctype }}\n\t\t\t\t\t{{ doc.name }}\n\t\t\t\t
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") if doc.get(\"company\") else None %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") if doc.get(\"company\") else None %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") if doc.get(\"company\") else None %}\n\n\t\t\t\t
\n\t\t\t\t\t{{ doc.doctype }}\n\t\t\t\t\t{{ doc.name }}\n\t\t\t\t
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", "creation": "2026-05-15 15:21:48.255627", "custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead", - "modified": "2026-06-24 17:49:52.350750", + "modified": "2026-07-12 21:11:44.765083", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead", diff --git a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json index 67c03298195..dd9035197a2 100644 --- a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json +++ b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", "creation": "2026-05-15 15:21:48.373815", "custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead - Grey", - "modified": "2026-06-24 18:23:05.120521", + "modified": "2026-07-12 22:03:24.525672", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead - Grey", diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 40aa222cbb0..5caee4f5c1f 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.json +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-04-22 16:16:03", "default_print_format": "Accounts Payable Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "Payment Ledger Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-05-22 14:35:14.716933", + "modified": "2026-07-01 13:37:41.185347", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Payable", @@ -33,5 +40,6 @@ "role": "Auditor" } ], + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json index b6e7820f91c..ef9b6df88d4 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-04-16 11:31:13", "default_print_format": "Accounts Receivable Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "Payment Ledger Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 5, "is_standard": "Yes", - "modified": "2026-05-22 14:34:57.666402", + "modified": "2026-07-01 13:37:44.167999", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Receivable", @@ -27,5 +34,6 @@ "role": "Accounts User" } ], + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.json b/erpnext/accounts/report/balance_sheet/balance_sheet.json index 4c1d4b64030..75277f72ac7 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.json +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-14 05:24:20.385279", "default_print_format": "Balance Sheet Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-05-22 14:35:28.187799", + "modified": "2026-06-22 13:38:25.236839", "modified_by": "Administrator", "module": "Accounts", "name": "Balance Sheet", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index a8531e58acb..1090b7f4b9c 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -4,18 +4,27 @@ import frappe from frappe import _ -from frappe.utils import cint, flt +from frappe.utils import add_days, cint, flt from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( FinancialReportEngine, get_xlsx_styles, #! DO NOT REMOVE - hook for styling ) from erpnext.accounts.report.financial_statements import ( + accumulate_values_into_parents, + add_total_row, + calculate_values, compute_growth_view_data, + filter_accounts, + filter_out_zero_value_rows, + get_accounting_entries, + get_accounts, + get_appropriate_currency, get_columns, get_data, get_filtered_list_for_consolidated_report, get_period_list, + prepare_data, ) @@ -266,3 +275,196 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency): chart["currency"] = currency return chart + + +def execute_snapshot_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + filters.period_start_date = period_list[0]["year_start_date"] + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + + asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list) + liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list) + equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list) + + provisional_profit_loss, total_credit = get_provisional_profit_loss( + asset, liability, equity, period_list, filters.company, currency + ) + message, opening_balance = check_opening_balance(asset, liability, equity) + + data = [] + data.extend(asset or []) + data.extend(liability or []) + data.extend(equity or []) + if opening_balance and round(opening_balance, 2) != 0: + unclosed = { + "account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "warn_if_negative": True, + "currency": currency, + } + for period in period_list: + unclosed[period.key] = opening_balance + if provisional_profit_loss: + provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance + unclosed["total"] = opening_balance + data.append(unclosed) + + if provisional_profit_loss: + data.append(provisional_profit_loss) + if total_credit: + data.append(total_credit) + + columns = get_columns( + filters.periodicity, period_list, filters.accumulated_values, company=filters.company + ) + chart = get_chart_data(filters, period_list, asset, liability, equity, currency) + report_summary, primitive_summary = get_report_summary( + period_list, asset, liability, equity, provisional_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + return columns, data, message, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + year_start_date = period_list[0]["year_start_date"] + last_to_date = period_list[-1]["to_date"] + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + opening_from_date = None + ignore_opening_entries = False + + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + if not ignore_closing_balances: + last_pcv_list = frappe.db.get_all( + "Period Closing Voucher", + filters={ + "docstatus": 1, + "company": company, + "period_end_date": ("<", filters.get("period_start_date") or year_start_date), + }, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + if last_pcv_list: + last_pcv = last_pcv_list[0] + pcv_entries = get_accounting_entries( + "Account Closing Balance", + None, + last_to_date, + filters, + root_type=root_type, + ignore_closing_entries=False, + period_closing_voucher=last_pcv.name, + ) + if filters.get("presentation_currency"): + convert_to_presentation_currency(pcv_entries, get_currency(filters)) + for entry in pcv_entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + opening_from_date = add_days(last_pcv.period_end_date, 1) + ignore_opening_entries = True + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + ] + base_params = [company, *leaf_accounts] + if ignore_opening_entries and not ignore_is_opening: + base_conds.append("is_opening = 'No'") + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + # Opening GL entries from DuckDB (entries before year_start_date) + open_conds = [*base_conds, "posting_date < ?"] + open_params = [*base_params, year_start_date] + if opening_from_date: + open_conds = [*open_conds, "posting_date >= ?"] + open_params = [*open_params, opening_from_date] + + opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(opening_entries, get_currency(filters)) + synthetic_open_date = add_days(year_start_date, -1) + for entry in opening_entries: + entry.posting_date = synthetic_open_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) + + # Period GL entries from DuckDB (one aggregated query per period) + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index fb7f8adac70..ba6c9369044 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -428,6 +428,11 @@ def build_comparison_chart_data(filters, columns, data): if not fieldname: continue + # skip the dimension column ("budget_against"), it only matches the + # "budget_" prefix by coincidence and would shift the actual values by one + if fieldname == "budget_against": + continue + if fieldname.startswith("budget_"): budget_fields.append(fieldname) elif fieldname.startswith("actual_"): @@ -439,7 +444,7 @@ def build_comparison_chart_data(filters, columns, data): labels = [ col["label"].replace("Budget", "").strip() for col in columns - if col.get("fieldname", "").startswith("budget_") + if col.get("fieldname", "").startswith("budget_") and col.get("fieldname") != "budget_against" ] budget_values = [0] * len(budget_fields) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index 8dac581eae3..a702e606edd 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-12-06 13:22:23", "default_print_format": "General Ledger Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-05-22 14:34:35.246000", + "modified": "2026-07-01 13:36:06.682661", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 8670a4fd175..76d4029fae1 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -818,3 +818,288 @@ def get_columns(filters): columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}]) return columns + + +def execute_snapshot_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if conn := get_latest_sync("GL Entry"): + return _execute_with_duckdb_conn(filters, conn) + + frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + +def _execute_with_duckdb_conn(filters, conn): + if not filters: + return [], [] + + account_details = {} + + if filters.get("print_in_account_currency") and not filters.get("account"): + frappe.throw(_("Select an account to print in account currency")) + + for acc in frappe.get_all("Account", fields=["name", "is_group"]): + account_details.setdefault(acc.name, acc) + + if filters.get("party"): + filters.party = frappe.parse_json(filters.get("party")) + + validate_filters(filters, account_details) + validate_party(filters) + filters = set_account_currency(filters) + columns = get_columns(filters) + res = get_result_duckdb(filters, account_details, conn) + return columns, res + + +def get_result_duckdb(filters, account_details, conn): + accounting_dimensions = [] + if filters.get("include_dimensions"): + accounting_dimensions = get_accounting_dimensions() + + gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn) + data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries) + return get_result_as_list(data, filters) + + +def get_gl_entries_duckdb(filters, accounting_dimensions, conn): + currency_map = get_currency(filters) + + col_names = [ + "gl_entry", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + select_exprs = [ + "name", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + + if filters.get("show_remarks"): + remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length") + if remarks_length: + select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})") + else: + select_exprs.append("remarks") + col_names.append("remarks") + + if filters.get("add_values_in_transaction_currency"): + select_exprs += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + col_names += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + + if accounting_dimensions: + select_exprs += accounting_dimensions + col_names += accounting_dimensions + + order_by = "posting_date, account, creation" + if filters.get("include_dimensions"): + order_by = "posting_date, creation" + if filters.get("categorize_by") == "Categorize by Voucher": + order_by = "posting_date, voucher_type, voucher_no" + if filters.get("categorize_by") == "Categorize by Account": + order_by = "account, posting_date, creation" + + if filters.get("include_default_book_entries"): + filters["company_fb"] = frappe.get_cached_value( + "Company", filters.get("company"), "default_finance_book" + ) + + conditions, params = _build_gl_conditions_duckdb(filters) + select_clause = ", ".join(select_exprs) + sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}' + + rows = conn.execute(sql, params).fetchall() + gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows] + + party_name_map = get_party_name_map() + for gl_entry in gl_entries: + if gl_entry.party_type and gl_entry.party: + gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party) + + if filters.get("presentation_currency"): + return convert_to_presentation_currency(gl_entries, currency_map, filters) + return gl_entries + + +def _build_gl_conditions_duckdb(filters): + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + conditions = ["company = ?"] + params = [filters.company] + + if filters.get("account"): + filters.account = get_accounts_with_children(filters.account) + if filters.account: + conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})") + params.extend(filters.account) + + if filters.get("cost_center"): + filters.cost_center = get_cost_centers_with_children(filters.cost_center) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})") + params.extend(filters.cost_center) + + if filters.get("voucher_no"): + conditions.append("voucher_no = ?") + params.append(filters.voucher_no) + + if filters.get("against_voucher_no"): + conditions.append("against_voucher = ?") + params.append(filters.against_voucher_no) + + if filters.get("ignore_err"): + err_journals = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]), + }, + pluck="name", + ) + if err_journals: + filters.update({"voucher_no_not_in": err_journals}) + + if filters.get("ignore_cr_dr_notes"): + system_generated = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Credit Note", "Debit Note"]), + "is_system_generated": 1, + }, + pluck="name", + ) + if system_generated: + vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated + filters.update({"voucher_no_not_in": vouchers_to_ignore}) + + if filters.get("voucher_no_not_in"): + vouchers = filters.voucher_no_not_in + conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})") + params.extend(vouchers) + + if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"): + conditions.append("party_type IN ('Customer', 'Supplier')") + + if filters.get("party_type"): + conditions.append("party_type = ?") + params.append(filters.party_type) + + if filters.get("party"): + conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})") + params.extend(filters.party) + + # from_date: skip when filtering by account/party to allow opening balance calc in Python + if filters.get("disable_opening_balance_calculation"): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + elif not ( + filters.get("account") + or filters.get("party") + or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"] + ): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + + if not ignore_is_opening: + conditions.append("(posting_date <= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date <= ?") + params.append(filters.to_date) + + if filters.get("project"): + conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})") + params.extend(filters.project) + + company_fb = filters.get("company_fb") or frappe.get_cached_value( + "Company", filters.company, "default_finance_book" + ) + if filters.get("include_default_book_entries"): + if filters.get("finance_book"): + if company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_vals = [cstr(filters.finance_book), ""] + else: + fb_vals = [cstr(company_fb), ""] + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)") + params.extend(fb_vals) + else: + if filters.get("finance_book"): + conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)") + params.append(cstr(filters.finance_book)) + else: + conditions.append("(finance_book IN ('') OR finance_book IS NULL)") + + if not filters.get("show_cancelled_entries"): + conditions.append("is_cancelled = 0") + + accounting_dimensions_list = get_accounting_dimensions(as_list=False) + if accounting_dimensions_list: + for dimension in accounting_dimensions_list: + if not dimension.disabled and dimension.document_type != "Finance Book": + if filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, filters.get(dimension.fieldname) + ) + vals = ( + filters[dimension.fieldname] + if isinstance(filters[dimension.fieldname], list) + else [filters[dimension.fieldname]] + ) + conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) + + return conditions, params diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json index 5abd51e2a30..5ddd3af7aa4 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-18 11:43:33.173207", "default_print_format": "P&L Statement Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 2, "is_standard": "Yes", - "modified": "2026-05-22 14:36:04.544347", + "modified": "2026-07-01 13:36:14.934965", "modified_by": "Administrator", "module": "Accounts", "name": "Profit and Loss Statement", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 9ce6cd77e5b..25eca6f4c79 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -11,12 +11,20 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine get_xlsx_styles, #! DO NOT REMOVE - hook for styling ) from erpnext.accounts.report.financial_statements import ( + accumulate_values_into_parents, + add_total_row, + calculate_values, compute_growth_view_data, compute_margin_view_data, + filter_accounts, + filter_out_zero_value_rows, + get_accounts, + get_appropriate_currency, get_columns, get_data, get_filtered_list_for_consolidated_report, get_period_list, + prepare_data, ) @@ -197,3 +205,125 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur chart["currency"] = currency return chart + + +def execute_snapshot_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw( + _("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")) + ) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + + income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list) + expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list) + + net_profit_loss = get_net_profit_loss( + income, expense, period_list, filters.company, filters.presentation_currency + ) + + data = [] + data.extend(income or []) + data.extend(expense or []) + if net_profit_loss: + data.append(net_profit_loss) + + columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company) + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency) + + report_summary, primitive_summary = get_report_summary( + period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + if filters.get("selected_view") == "Margin": + compute_margin_view_data(data, period_list, filters.accumulated_values) + + return columns, data, None, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + "voucher_type != 'Period Closing Voucher'", + ] + base_params = [company, *leaf_accounts] + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index b6c121bd5fd..5a8bd5c006e 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-22 11:41:23.743564", "default_print_format": "Trial Balance Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], - "idx": 2, + "generate_csv": 0, + "idx": 4, "is_standard": "Yes", - "modified": "2026-05-22 14:35:44.889062", + "modified": "2026-07-01 17:32:21.801141", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "snapshot_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 186a9eb71f0..8fb564ac47f 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -571,3 +571,215 @@ def hide_group_accounts(data): d.update(indent=0) non_group_accounts_data.append(d) return non_group_accounts_data + + +def execute_snapshot_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if conn := get_latest_sync("GL Entry"): + validate_filters(filters) + columns = get_columns() + data = get_data_duckdb(filters, conn) + return columns, data + else: + frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + +def get_data_duckdb(filters, conn): + # accounts and all metadata via frappe.db — only GL Entry comes from DuckDB + accounts = frappe.db.sql( + """select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt + from `tabAccount` where company=%s order by lft""", + filters.company, + as_dict=True, + ) + if not accounts: + return None + + company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company) + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + + gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening) + opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening) + + calculate_values( + accounts, + gl_entries_by_account, + opening_balances, + filters.get("show_net_values"), + ignore_is_opening=ignore_is_opening, + ) + accumulate_values_into_parents(accounts, accounts_by_name) + + data = prepare_data(accounts, filters, parent_children_map, company_currency) + return filter_out_zero_value_rows( + data, parent_children_map, show_zero_values=filters.get("show_zero_values") + ) + + +def _extra_gl_conditions(filters): + """Returns (conditions, params) for optional shared GL Entry filters.""" + conditions, params = [], [] + + if filters.get("cost_center"): + cc = get_cost_centers_with_children(filters.get("cost_center")) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})") + params.extend(cc) + + if filters.get("project"): + proj = filters.project if isinstance(filters.project, list) else [filters.project] + conditions.append(f"project IN ({', '.join(['?'] * len(proj))})") + params.extend(proj) + + if frappe.db.count("Finance Book"): + company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book") + if filters.get("include_default_book_entries"): + if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""] + else: + fb_list = [cstr(filters.get("finance_book")), ""] + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)") + params.extend(fb_list) + + for dim in get_accounting_dimensions(as_list=False): + if filters.get(dim.fieldname): + if frappe.get_cached_value("DocType", dim.document_type, "is_tree"): + filters[dim.fieldname] = get_dimension_with_children( + dim.document_type, filters.get(dim.fieldname) + ) + vals = ( + filters[dim.fieldname] + if isinstance(filters[dim.fieldname], list) + else [filters[dim.fieldname]] + ) + conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) + + return conditions, params + + +def _fetch_gl_rows_duckdb(conn, conditions, params): + cols = [ + "account", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + "account_currency", + ] + sql = f"""SELECT account, SUM(debit), SUM(credit), + SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency + FROM "tabGL Entry" WHERE {" AND ".join(conditions)} + GROUP BY account, account_currency""" + return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()] + + +def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): + conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"] + params = [filters.company, filters.from_date, filters.to_date] + + if not ignore_is_opening: + conditions.append("is_opening = 'No'") + if not flt(filters.get("with_period_closing_entry_for_current_period")): + conditions.append("voucher_type != 'Period Closing Voucher'") + + extra_cond, extra_params = _extra_gl_conditions(filters) + conditions.extend(extra_cond) + params.extend(extra_params) + + entries = _fetch_gl_rows_duckdb(conn, conditions, params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(entries, get_currency(filters)) + + gl_entries_by_account = {} + for entry in entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + return gl_entries_by_account + + +def get_opening_balances_duckdb(conn, filters, ignore_is_opening): + bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening) + pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening) + bs.update(pl) + return bs + + +def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening): + accounting_dimensions = get_accounting_dimensions(as_list=False) + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + last_pcv = "" + + if not ignore_closing_balances: + last_pcv = frappe.db.get_all( + "Period Closing Voucher", + filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)}, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + + if last_pcv: + # Account Closing Balance fetched via frappe (not GL Entry) + gle = get_opening_balance( + "Account Closing Balance", + filters, + report_type, + accounting_dimensions, + period_closing_voucher=last_pcv[0].name, + ignore_is_opening=ignore_is_opening, + ) + if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)): + start_date = add_days(last_pcv[0].period_end_date, 1) + gle += _get_gl_entry_opening_duckdb( + conn, filters, report_type, ignore_is_opening, start_date=start_date + ) + else: + gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening) + + opening = frappe._dict() + for d in gle: + opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0}) + opening[d.account]["opening_debit"] += flt(d.debit) + opening[d.account]["opening_credit"] += flt(d.credit) + return opening + + +def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None): + accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name") + if not accounts: + return [] + + conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"] + params = [filters.company, *accounts] + + if start_date: + conditions.append("posting_date >= ? AND posting_date < ?") + params.extend([start_date, filters.from_date]) + if not ignore_is_opening: + conditions.append("is_opening = 'No'") + elif not ignore_is_opening: + conditions.append("(posting_date < ? OR is_opening = 'Yes')") + params.append(filters.from_date) + else: + conditions.append("posting_date < ?") + params.append(filters.from_date) + + if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss": + conditions.append("posting_date >= ?") + params.append(filters.year_start_date) + + if not flt(filters.get("with_period_closing_entry_for_opening")): + conditions.append("voucher_type != 'Period Closing Voucher'") + + extra_cond, extra_params = _extra_gl_conditions(filters) + conditions.extend(extra_cond) + params.extend(extra_params) + + gle = _fetch_gl_rows_duckdb(conn, conditions, params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(gle, get_currency(filters)) + return gle diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index c5ac2d1cba2..37aa943f447 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1408,13 +1408,11 @@ def get_account_balances(accounts, company, finance_book=None, include_default_f def get_account_balances_coa(company: str, include_default_fb_balances: bool = False): company_currency = frappe.get_cached_value("Company", company, "default_currency") - Account = DocType("Account") - account_list = ( - frappe.qb.from_(Account) - .select(Account.name, Account.parent_account, Account.account_currency) - .where(Account.company == company) - .orderby(Account.lft) - .run(as_dict=True) + account_list = frappe.get_list( + "Account", + fields=["name", "parent_account", "account_currency"], + filters={"company": company}, + order_by="lft", ) account_balances_cc = {account.get("name"): 0 for account in account_list} @@ -1424,9 +1422,8 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F GLEntry = DocType("GL Entry") precision = get_currency_precision() get_ledger_balances_query = ( - frappe.qb.from_(GLEntry) + frappe.get_query(GLEntry, fields=[GLEntry.account], ignore_permissions=False) .select( - GLEntry.account, (Sum(Round(GLEntry.debit, precision)) - Sum(Round(GLEntry.credit, precision))).as_("balance"), ( Sum(Round(GLEntry.debit_in_account_currency, precision)) @@ -1436,7 +1433,7 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F .groupby(GLEntry.account) ) - condition_list = [GLEntry.company == company, GLEntry.is_cancelled == 0] + conditions = [GLEntry.company == company, GLEntry.is_cancelled == 0] default_finance_book = None @@ -1444,12 +1441,9 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F default_finance_book = frappe.get_cached_value("Company", company, "default_finance_book") if default_finance_book: - condition_list.append( - (GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull()) - ) + conditions.append((GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull())) - for condition in condition_list: - get_ledger_balances_query = get_ledger_balances_query.where(condition) + get_ledger_balances_query = get_ledger_balances_query.where(Criterion.all(conditions)) ledger_balances = get_ledger_balances_query.run(as_dict=True) diff --git a/erpnext/assets/dashboard_fixtures.py b/erpnext/assets/dashboard_fixtures.py deleted file mode 100644 index 0fd6c019f36..00000000000 --- a/erpnext/assets/dashboard_fixtures.py +++ /dev/null @@ -1,190 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -import json - -import frappe -from frappe import _ -from frappe.utils import get_date_str, nowdate - -from erpnext.accounts.dashboard_fixtures import _get_fiscal_year -from erpnext.buying.dashboard_fixtures import get_company_for_dashboards - - -def get_data(): - fiscal_year = _get_fiscal_year(nowdate()) - - if not fiscal_year: - return frappe._dict() - - year_start_date = get_date_str(fiscal_year.get("year_start_date")) - year_end_date = get_date_str(fiscal_year.get("year_end_date")) - - return frappe._dict( - { - "dashboards": get_dashboards(), - "charts": get_charts(fiscal_year, year_start_date, year_end_date), - "number_cards": get_number_cards(fiscal_year, year_start_date, year_end_date), - } - ) - - -def get_dashboards(): - return [ - { - "name": "Asset", - "dashboard_name": "Asset", - "charts": [ - {"chart": "Asset Value Analytics", "width": "Full"}, - {"chart": "Category-wise Asset Value", "width": "Half"}, - {"chart": "Location-wise Asset Value", "width": "Half"}, - ], - "cards": [ - {"card": "Total Assets"}, - {"card": "New Assets (This Year)"}, - {"card": "Asset Value"}, - ], - } - ] - - -def get_charts(fiscal_year, year_start_date, year_end_date): - company = get_company_for_dashboards() - return [ - { - "name": "Asset Value Analytics", - "chart_name": _("Asset Value Analytics"), - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "is_custom": 1, - "group_by_type": "Count", - "number_of_groups": 0, - "is_public": 0, - "timespan": "Last Year", - "time_interval": "Yearly", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "filter_based_on": "Fiscal Year", - "from_fiscal_year": fiscal_year.get("name"), - "to_fiscal_year": fiscal_year.get("name"), - "period_start_date": year_start_date, - "period_end_date": year_end_date, - "date_based_on": "Purchase Date", - "group_by": "--Select a group--", - } - ), - "type": "Bar", - "custom_options": json.dumps( - { - "type": "bar", - "barOptions": {"stacked": 1}, - "axisOptions": {"shortenYAxisNumbers": 1}, - "tooltipOptions": {}, - } - ), - "doctype": "Dashboard Chart", - "y_axis": [], - }, - { - "name": "Category-wise Asset Value", - "chart_name": _("Category-wise Asset Value"), - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "x_field": "asset_category", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "group_by": "Asset Category", - "asset_type": ["!=", "Existing Asset"], - } - ), - "type": "Donut", - "doctype": "Dashboard Chart", - "y_axis": [ - { - "parent": "Category-wise Asset Value", - "parentfield": "y_axis", - "parenttype": "Dashboard Chart", - "y_field": "asset_value", - "doctype": "Dashboard Chart Field", - } - ], - "custom_options": json.dumps( - {"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}} - ), - }, - { - "name": "Location-wise Asset Value", - "chart_name": "Location-wise Asset Value", - "chart_type": "Report", - "report_name": "Fixed Asset Register", - "x_field": "location", - "timeseries": 0, - "filters_json": json.dumps( - { - "company": company, - "status": "In Location", - "group_by": "Location", - "asset_type": ["!=", "Existing Asset"], - } - ), - "type": "Donut", - "doctype": "Dashboard Chart", - "y_axis": [ - { - "parent": "Location-wise Asset Value", - "parentfield": "y_axis", - "parenttype": "Dashboard Chart", - "y_field": "asset_value", - "doctype": "Dashboard Chart Field", - } - ], - "custom_options": json.dumps( - {"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}} - ), - }, - ] - - -def get_number_cards(fiscal_year, year_start_date, year_end_date): - return [ - { - "name": "Total Assets", - "label": _("Total Assets"), - "function": "Count", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": "[]", - "doctype": "Number Card", - }, - { - "name": "New Assets (This Year)", - "label": _("New Assets (This Year)"), - "function": "Count", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": json.dumps([["Asset", "creation", "between", [year_start_date, year_end_date]]]), - "doctype": "Number Card", - }, - { - "name": "Asset Value", - "label": _("Asset Value"), - "function": "Sum", - "aggregate_function_based_on": "value_after_depreciation", - "document_type": "Asset", - "is_public": 1, - "show_percentage_stats": 1, - "stats_time_interval": "Monthly", - "filters_json": "[]", - "doctype": "Number Card", - }, - ] diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 65f43f36a1f..1a4ae3625f0 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -147,7 +147,15 @@ frappe.ui.form.on("Asset", { __("Actions") ); } - + if (frm.doc.status === "Fully Depreciated") { + frm.add_custom_button( + __("Asset Repair"), + function () { + frm.trigger("create_asset_repair"); + }, + __("Actions") + ); + } frm.add_custom_button( __("Split Asset"), function () { diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 424bf9bac87..1b808cedcc1 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -1391,6 +1391,47 @@ class TestDepreciationBasics(AssetSetup): self.assertFalse(depr_schedule[1].journal_entry) self.assertFalse(depr_schedule[2].journal_entry) + def test_depr_schedule_link_matches_at_currency_precision(self): + """A Depreciation Schedule row whose amount carries more decimals than the + company currency (e.g. 25701.202 vs a JE debit of 25701.20) must still be + matched and stamped with the Journal Entry. Comparing at exact float + equality left the link NULL, so the scheduler treated the row as unposted + and created a duplicate Journal Entry on every run. Regression test for + JournalEntry.update_journal_entry_link_on_depr_schedule().""" + from unittest.mock import MagicMock, patch + + from erpnext.accounts.doctype.journal_entry import journal_entry as journal_entry_module + + posting_date = getdate("2021-06-01") + je = frappe.new_doc("Journal Entry") + je.name = "JE-DEPR-TEST" + je.finance_book = None + je.posting_date = posting_date + + # JE debit is stored at company currency precision (2 dp)... + je_row = MagicMock() + je_row.debit = 25701.20 + je_row.precision.return_value = 2 + + # ...while the schedule row amount carries a third decimal. + schedule_row = frappe._dict( + name="DS-ROW-1", + schedule_date=posting_date, + journal_entry=None, + depreciation_amount=25701.202, + ) + asset = frappe._dict(name="ASSET-TEST") + + with ( + patch.object(journal_entry_module, "get_depr_schedule", return_value=[schedule_row]), + patch.object(frappe.db, "set_value") as mock_set_value, + ): + je.update_journal_entry_link_on_depr_schedule(asset, je_row) + + mock_set_value.assert_called_once_with( + "Depreciation Schedule", "DS-ROW-1", "journal_entry", "JE-DEPR-TEST" + ) + def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self): """Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account.""" diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 4d9ef28ceae..2920ff7e381 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -84,6 +84,15 @@ frappe.ui.form.on("Asset Repair", { }; }; } + if (frm.doc.asset) { + frappe.db.get_value("Asset", frm.doc.asset, "status").then(({ message }) => { + frm.set_df_property( + "capitalize_repair_cost", + "read_only", + message && message.status === "Fully Depreciated" + ); + }); + } }, show_general_ledger: function (frm) { diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index 4fc9a31b875..a1081ecb188 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -130,7 +130,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Asset", - "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]", + "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]", "options": "Asset", "reqd": 1 }, @@ -275,7 +275,7 @@ "link_fieldname": "asset_repair" } ], - "modified": "2026-02-06 14:57:54.257572", + "modified": "2026-06-20 15:43:54.943335", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair", diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 476b0187bf1..4fc8981d200 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -70,12 +70,15 @@ class AssetRepair(AccountsController): self.check_repair_status() def validate_asset(self): - if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"): + if self.asset_doc.status in ("Sold", "Scrapped"): frappe.throw( _("Asset {0} is in {1} status and cannot be repaired.").format( get_link_to_form("Asset", self.asset), self.asset_doc.status ) ) + if self.asset_doc.get_status() == "Fully Depreciated": + self.capitalize_repair_cost = 0 + self.increase_in_asset_life = 0 def validate_dates(self): if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)): diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index c5975b1a35e..72ae897b745 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -270,6 +270,7 @@ class PurchaseOrder(BuyingController): "ref_dn_field": "material_request_item", "compare_fields": mri_compare_fields, "is_child_table": True, + "allow_duplicate_prev_row_id": True, }, } ) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 65462439c34..d614d8b6356 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -492,6 +492,7 @@ scheduler_events = { ], "weekly": [ "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly", + "erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries", ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 8551d5e04ff..f4b3622c9ca 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -457,6 +457,49 @@ class TestJobCard(ERPNextTestSuite): job_card.reload() self.assertEqual(job_card.transferred_qty, 0.0) + def test_work_order_transferred_qty_with_multiple_job_cards(self): + create_bom_with_multiple_operations() + work_order = make_wo_with_transfer_against_jc() + self.generate_required_stock(work_order) + + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": work_order.name}, + pluck="name", + order_by="sequence_id", + ) + completed_qty = (4, 3) + + for job_card_name, qty in zip(job_cards, completed_qty, strict=True): + job_card = frappe.get_doc("Job Card", job_card_name) + job_card.for_quantity = qty + job_card.save() + + transfer_entry = make_stock_entry_from_jc(job_card.name) + transfer_entry.fg_completed_qty = qty + transfer_entry.get_items() + transfer_entry.submit() + + job_card.reload() + job_card.append( + "time_logs", + { + "from_time": now(), + "to_time": add_to_date(now(), hours=1), + "completed_qty": qty, + }, + ) + job_card.submit() + + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty)) + + # Refreshing required items must not replace the Job Card roll-up with the sum + # of FG quantities from Material Transfer Stock Entries (4 + 3). + work_order.update_required_items() + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty)) + def test_job_card_material_transfer_correctness(self): """ 1. Test if only current Job Card Items are pulled in a Stock Entry against a Job Card diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 2454341dc39..488700b4208 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1806,6 +1806,10 @@ class WorkOrder(Document): def recompute_material_transferred_for_manufacturing(self, transferred_items): """Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty.""" + # Job Card transfers use the minimum completed quantity across operations. + if self.operations and self.transfer_material_against == "Job Card": + return + # When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the # SUM(fg_completed_qty) approach so excess-transfer tracking works correctly. sum_fg_completed_qty = self.get_transferred_or_manufactured_qty( diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index dd585041d71..9f344a6c576 100644 --- a/erpnext/public/js/utils/barcode_scanner.js +++ b/erpnext/public/js/utils/barcode_scanner.js @@ -15,6 +15,11 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { this.warehouse_field = opts.warehouse_field || "warehouse"; // field name on row which defines max quantity to be scanned e.g. picklist this.max_qty_field = opts.max_qty_field; + // row fields that, if set, mean max_qty_field is a real demand qty (e.g. from a + // linked Sales Order) that scanning must not exceed. Rows with none of these set + // have no real demand qty, so max_qty_field is just an arbitrary default and + // shouldn't cap further scans. + this.demand_ref_fields = opts.demand_ref_fields || []; // scanner won't add a new row if this flag is set. this.dont_allow_new_row = opts.dont_allow_new_row; // scanner will ask user to type the quantity instead of incrementing by 1 @@ -390,6 +395,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { } async set_barcode_uom(row, uom) { + // e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental + // barcode uom must not overwrite the row's own uom. + if (this.max_qty_field) return; if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) { await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom); } @@ -454,8 +462,9 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { const matching_row = (row) => { const item_match = row.item_code == item_code; const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no; - const uom_match = !uom || row[this.uom_field] == uom; - const qty_in_limit = flt(row[this.qty_field]) < flt(row[this.max_qty_field]); + const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom; + const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]); + const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]); const item_scanned = row.has_item_scanned; let warehouse_match = true; diff --git a/erpnext/public/js/utils/sales_common.js b/erpnext/public/js/utils/sales_common.js index d1d5d77f285..72a8d25af3b 100644 --- a/erpnext/public/js/utils/sales_common.js +++ b/erpnext/public/js/utils/sales_common.js @@ -284,19 +284,17 @@ erpnext.sales_common = { set_actual_qty(doc, cdt, cdn) { let row = locals[cdt][cdn]; - let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order"]; + let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order", "Quotation"]; if (row.item_code && row.warehouse && sales_doctypes.includes(doc.doctype)) { - frappe.call({ + return this.frm.call({ method: "erpnext.stock.get_item_details.get_bin_details", + child: row, args: { item_code: row.item_code, warehouse: row.warehouse, - }, - callback(r) { - if (r.message) { - frappe.model.set_value(cdt, cdn, "actual_qty", r.message.actual_qty); - } + company: doc.company, + include_child_warehouses: true, }, }); } diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py index 127517a1e3f..d3d056f3862 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py @@ -30,7 +30,7 @@ class TermsandConditions(Document): def validate(self): if self.terms: - validate_template(self.terms) + validate_template(self.terms, restrict_globals=True) if not cint(self.buying) and not cint(self.selling) and not cint(self.hr) and not cint(self.disabled): throw(_("At least one of the Applicable Modules should be selected")) @@ -40,7 +40,10 @@ def get_terms_and_conditions(template_name, doc): if isinstance(doc, str): doc = json.loads(doc) - terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name) + tnc = frappe.get_cached_doc("Terms and Conditions", template_name) + tnc.check_permission() - if terms_and_conditions.terms: - return frappe.render_template(terms_and_conditions.terms, doc) + if not tnc.terms: + return + + return frappe.render_template(tnc.terms, doc, restrict_globals=1) diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index bcb194bd23c..e50f8bc4390 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -288,7 +288,8 @@ frappe.ui.form.on("Pick List", { items_table_name: "locations", qty_field: "picked_qty", max_qty_field: "qty", - dont_allow_new_row: true, + demand_ref_fields: ["sales_order_item", "material_request_item", "product_bundle_item"], + dont_allow_new_row: !frm.doc.pick_manually, prompt_qty: frm.doc.prompt_qty, serial_no_field: "not_supported", // doesn't make sense for picklist without a separate field. }; 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 db902d1b3f0..c33c2cfd36e 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 @@ -2584,22 +2584,24 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos): def get_bundle_wise_serial_nos(data, kwargs): bundle_wise_serial_nos = defaultdict(list) - bundles = [d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle] + bundles = list({d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle}) if not bundles: return bundle_wise_serial_nos - filters = {"parent": ("in", bundles), "docstatus": 1, "serial_no": ("is", "set")} - - if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"): - filters["serial_no"] = ("in", kwargs.get("serial_nos")) - - bundle_data = frappe.get_all( - "Serial and Batch Entry", - fields=["serial_no", "parent"], - filters=filters, + sabe = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sabe) + .select(sabe.serial_no, sabe.parent) + .where(sabe.parent.isin(bundles)) + .where(sabe.docstatus == 1) + .where(sabe.serial_no.isnotnull()) + .where(sabe.serial_no != "") ) - for d in bundle_data: + if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"): + query = query.where(sabe.serial_no.isin(kwargs.get("serial_nos"))) + + for d in query.run(as_dict=True): if d.parent: bundle_wise_serial_nos[d.parent].append(d.serial_no) 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 e0b8cb750ac..a566ab8216e 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 @@ -10,8 +10,12 @@ from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( add_serial_batch_ledgers, combine_datetime, + get_available_batches_qty, + get_qty_based_available_batches, + get_type_of_transaction, make_batch_nos, make_serial_nos, + parse_serial_nos, ) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.tests.utils import ERPNextTestSuite @@ -1467,3 +1471,133 @@ def make_serial_batch_bundle(kwargs): return sb.make_serial_and_batch_bundle() return sb + + +class TestSerialandBatchBundleLogic(ERPNextTestSuite): + """Pure helpers and in-memory document validations, covering branches the + integration suite doesn't reach (no stock-ledger / serial / batch fixtures).""" + + def test_parse_serial_nos_splits_and_trims(self): + self.assertEqual(parse_serial_nos("SN1\nSN2"), ["SN1", "SN2"]) + self.assertEqual(parse_serial_nos("SN1, SN2 , SN3"), ["SN1", "SN2", "SN3"]) + # blanks are dropped and an existing list is returned unchanged + self.assertEqual(parse_serial_nos("SN1,,\n , SN2"), ["SN1", "SN2"]) + self.assertEqual(parse_serial_nos(["SN1", "SN2"]), ["SN1", "SN2"]) + + def test_get_qty_based_available_batches_allocates_across_batches(self): + batches = [ + frappe._dict(batch_no="B1", qty=10, warehouse="W"), + frappe._dict(batch_no="B2", qty=5, warehouse="W"), + ] + # 12 consumes B1 fully then 2 from B2 + result = get_qty_based_available_batches(batches, 12) + self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 10), ("B2", 2)]) + # 8 is satisfied by B1 alone; B2 is not touched + result = get_qty_based_available_batches(batches, 8) + self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 8)]) + + def test_get_available_batches_qty_aggregates_by_batch(self): + batches = [ + frappe._dict(batch_no="B1", qty=10), + frappe._dict(batch_no="B2", qty=5), + frappe._dict(batch_no="B1", qty=3), + ] + agg = get_available_batches_qty(batches) + self.assertEqual(agg["B1"], 13) + self.assertEqual(agg["B2"], 5) + + def test_get_type_of_transaction_derives_direction(self): + def se(**kw): + return get_type_of_transaction(frappe._dict(doctype="Stock Entry"), frappe._dict(**kw)) + + self.assertEqual(se(s_warehouse="W"), "Outward") # issuing from a source warehouse + self.assertEqual(se(), "Inward") # only a target warehouse + self.assertEqual( + get_type_of_transaction(frappe._dict(doctype="Purchase Receipt"), frappe._dict()), "Inward" + ) + self.assertEqual( + get_type_of_transaction(frappe._dict(doctype="Stock Reconciliation"), frappe._dict()), "Inward" + ) + # a purchase return reverses the direction to Outward + self.assertEqual( + get_type_of_transaction(frappe._dict(doctype="Purchase Receipt", is_return=1), frappe._dict()), + "Outward", + ) + + def test_duplicate_serial_no_in_entries_is_rejected(self): + doc = frappe.new_doc("Serial and Batch Bundle") + doc.append("entries", {"serial_no": "SN1"}) + doc.append("entries", {"serial_no": "SN1"}) + self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no) + + def test_duplicate_batch_no_in_entries_is_rejected(self): + doc = frappe.new_doc("Serial and Batch Bundle") + doc.append("entries", {"batch_no": "B1"}) + doc.append("entries", {"batch_no": "B1"}) + self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no) + + def test_voucher_no_is_mandatory(self): + doc = frappe.new_doc("Serial and Batch Bundle") + self.assertRaises(frappe.ValidationError, doc.validate_serial_and_batch_data) + + def test_validate_docstatus_rejects_unsubmitted_entries(self): + doc = frappe.new_doc("Serial and Batch Bundle") + doc.append("entries", {"qty": 1}) # a fresh row has docstatus 0 + self.assertRaises(frappe.ValidationError, doc.validate_docstatus) + + def test_calculate_total_qty_normalizes_and_signs(self): + inward = frappe.new_doc("Serial and Batch Bundle") + inward.type_of_transaction = "Inward" + inward.append("entries", {"qty": 5}) + inward.append("entries", {"qty": 3}) + inward.calculate_total_qty(save=False) + self.assertEqual(inward.total_qty, 8) + + # Outward flips the sign + outward = frappe.new_doc("Serial and Batch Bundle") + outward.type_of_transaction = "Outward" + outward.append("entries", {"qty": 5}) + outward.calculate_total_qty(save=False) + self.assertEqual(outward.total_qty, -5) + + # a serialized bundle normalizes each row qty to 1 + serialized = frappe.new_doc("Serial and Batch Bundle") + serialized.has_serial_no = 1 + serialized.type_of_transaction = "Inward" + serialized.append("entries", {"qty": 5}) + serialized.calculate_total_qty(save=False) + self.assertEqual(serialized.total_qty, 1) + + def test_get_bundle_wise_serial_nos(self): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_bundle_wise_serial_nos, + ) + + item_code = make_item(properties={"has_serial_no": 1, "serial_no_series": "TEST-BWSN-.#####"}).name + + bundles = [] + for _ in range(2): + se = make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=3, + rate=100, + ) + bundles.append(se.items[0].serial_and_batch_bundle) + + data = [frappe._dict(serial_and_batch_bundle=bundle) for bundle in bundles] + + self.assertEqual(get_bundle_wise_serial_nos([], {}), {}) + + bundle_wise_serial_nos = get_bundle_wise_serial_nos(data, {}) + for bundle in bundles: + self.assertEqual(sorted(bundle_wise_serial_nos[bundle]), get_serial_nos_from_bundle(bundle)) + + # check_serial_nos must restrict the result to the requested serial nos + serial_no = get_serial_nos_from_bundle(bundles[0])[0] + bundle_wise_serial_nos = get_bundle_wise_serial_nos( + data, {"check_serial_nos": True, "serial_nos": [serial_no]} + ) + + self.assertNotIn(bundles[1], bundle_wise_serial_nos) + self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no]) diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json index ed48522d770..eaab9db4786 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json @@ -22,7 +22,9 @@ "column_break_itvd", "enable_separate_reposting_for_gl", "errors_notification_section", - "notify_reposting_error_to_role" + "notify_reposting_error_to_role", + "auto_reposting_section", + "repost_incorrect_valuation_entries" ], "fields": [ { @@ -113,12 +115,24 @@ "fieldname": "do_not_fetch_incoming_rate_from_serial_no", "fieldtype": "Check", "label": "Do not fetch incoming rate from Serial No" + }, + { + "fieldname": "auto_reposting_section", + "fieldtype": "Section Break", + "label": "Auto Reposting of Incorrect Valuation" + }, + { + "default": "0", + "description": "If enabled, a weekly scheduler scans the Stock Ledger Variance for item-warehouses with incorrect valuation in the current financial year and auto-creates Item & Warehouse based reposts to fix them.", + "fieldname": "repost_incorrect_valuation_entries", + "fieldtype": "Check", + "label": "Auto Repost Incorrect Valuation Entries (Weekly)" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-05-15 12:59:34.392491", + "modified": "2026-07-01 14:41:51.499245", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reposting Settings", diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index 9164f8498cb..f703a694dad 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -4,7 +4,16 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import add_to_date, get_datetime, get_time_str, time_diff_in_hours +from frappe.utils import ( + add_to_date, + get_datetime, + get_link_to_form, + get_time_str, + getdate, + time_diff_in_hours, + today, +) +from frappe.utils.user import get_users_with_role class StockRepostingSettings(Document): @@ -27,6 +36,7 @@ class StockRepostingSettings(Document): ] no_of_parallel_reposting: DF.Int notify_reposting_error_to_role: DF.Link | None + repost_incorrect_valuation_entries: DF.Check start_time: DF.Time | None # end: auto-generated types @@ -117,3 +127,205 @@ def create_repost_item_valuation(item_code, warehouse, posting_date): "status": "Queued", } ).submit() + + +def repost_incorrect_valuation_entries(): + """Weekly scheduler entry point. + + When `repost_incorrect_valuation_entries` is enabled in Stock Reposting Settings, scan each + company's Stock Ledger Variance and Stock and Account Value Comparison reports for incorrect stock + valuation in the current financial year and auto-create reposts to correct them. Journal Entries are + never reposted, and warehouses pointing at a non-'Stock' account are reported to System Managers + instead. Disabled by default; does nothing unless explicitly turned on.""" + if not frappe.db.get_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries"): + return + + for company in frappe.get_all("Company", pluck="name"): + # The Stock Ledger Variance scan runs the invariant check for every item-warehouse, so process + # each company as its own long-running background job rather than blocking the weekly scheduler. + frappe.enqueue( + repost_incorrect_valuation_entries_for_company, + queue="long", + job_id=f"repost_incorrect_valuation::{company}", + deduplicate=True, + company=company, + ) + + +def repost_incorrect_valuation_entries_for_company(company): + """Detect and repost incorrect stock valuation for a single company, limited to the current + financial year, using two reports: + + 1. Stock Ledger Variance - item-warehouses whose ledger valuation is internally inconsistent + (typically a wrong previous-SLE pick). Fixed with an Item & Warehouse repost. + 2. Stock and Account Value Comparison - vouchers whose stock value does not match the accounting + ledger. Reposted via the report's own logic (Journal Entries are excluded - ERPNext does not + repost them). If a voucher's warehouse points at an account that is not of type 'Stock', + reposting can never clear the difference, so System Managers are notified instead.""" + from erpnext.accounts.utils import get_fiscal_year + + fy_start_date = get_fiscal_year(today(), company=company)[1] + + _repost_stock_ledger_variance(company, fy_start_date) + _repost_stock_account_value_comparison(company, fy_start_date) + + +def _repost_stock_ledger_variance(company, fy_start_date): + from erpnext.stock.report.stock_ledger_variance.stock_ledger_variance import ( + get_data as get_stock_ledger_variance, + ) + + created = [] + for row in get_stock_ledger_variance({"company": company}) or []: + row = frappe._dict(row) + + # Only correct issues that originate in the current financial year. + if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date): + continue + + # Avoid piling up duplicate reposts week over week for the same item-warehouse. + if has_pending_valuation_repost(company, row.item_code, row.warehouse): + continue + + create_repost_item_valuation(row.item_code, row.warehouse, row.posting_date) + created.append(row) + + if created: + frappe.logger("stock_reposting").info( + f"Auto-reposted {len(created)} incorrect-valuation item-warehouse(s) for {company}: " + + ", ".join(f"{d.item_code} @ {d.warehouse} from {d.posting_date}" for d in created) + ) + + return created + + +def _repost_stock_account_value_comparison(company, fy_start_date): + import erpnext + + # Stock vs accounting values only exist under perpetual inventory. + if not erpnext.is_perpetual_inventory_enabled(company): + return + + from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + create_reposting_entries, + ) + from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + get_data as get_value_comparison, + ) + + to_repost = [] + misconfigured = [] # (voucher_type, voucher_no, warehouse, account) + + # Scope the report's DB scan to the current financial year (see get_data) instead of loading every + # voucher ever posted and filtering in Python afterwards. + comparison_filters = frappe._dict(company=company, from_date=fy_start_date, as_on_date=today()) + for row in get_value_comparison(comparison_filters) or []: + row = frappe._dict(row) + + # ERPNext does not repost Journal Entries (GL-only postings have no stock ledger to repost). + if row.voucher_type == "Journal Entry": + continue + + # Only correct issues that originate in the current financial year. + if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date): + continue + + # If a warehouse on this voucher is mapped to an account that is not of type 'Stock', reposting + # can never reconcile stock vs accounting value - flag it for a human instead of reposting. + # Only flag accounts with a concrete, non-'Stock' type. An unset/blank account_type is treated as + # "unknown" - reposting may well reconcile it - so we don't skip the voucher or email a false alarm. + wrong_accounts = [ + (warehouse, account) + for warehouse, account, account_type in get_voucher_warehouse_accounts(row.voucher_no, company) + if account_type and account_type != "Stock" + ] + if wrong_accounts: + misconfigured.extend( + (row.voucher_type, row.voucher_no, warehouse, account) + for warehouse, account in wrong_accounts + ) + continue + + to_repost.append(row) + + if to_repost: + # create_reposting_entries reposts Purchase Receipt/Invoice transaction-wise and everything else + # item-warehouse-wise, and de-duplicates against existing reposts. + create_reposting_entries(to_repost, company) + frappe.logger("stock_reposting").info( + f"Auto-reposted {len(to_repost)} stock/account value mismatch voucher(s) for {company}." + ) + + if misconfigured: + notify_incorrect_stock_account(company, misconfigured) + + +def get_voucher_warehouse_accounts(voucher_no, company): + """Return (warehouse, account, account_type) for each distinct warehouse the voucher posted stock + into, so the caller can verify the account is a 'Stock' asset account.""" + warehouses = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_no": voucher_no, "is_cancelled": 0}, + pluck="warehouse", + distinct=True, + ) + + rows = [] + for warehouse in {w for w in warehouses if w}: + account = frappe.get_cached_value("Warehouse", warehouse, "account") or frappe.get_cached_value( + "Company", company, "default_inventory_account" + ) + account_type = frappe.get_cached_value("Account", account, "account_type") if account else None + rows.append((warehouse, account, account_type)) + + return rows + + +def notify_incorrect_stock_account(company, misconfigured): + """Email System Managers about warehouse accounts that are not of type 'Stock', which keep stock + and accounting values from reconciling even after reposting.""" + recipients = get_users_with_role("System Manager") + if not recipients: + return + + items = "".join( + "
  • {} {} → {}: {}
  • ".format( + voucher_type, + get_link_to_form(voucher_type, voucher_no), + warehouse, + account or _("No account set"), + ) + for voucher_type, voucher_no, warehouse, account in misconfigured + ) + + subject = _("Incorrect Stock Asset Account in {0}").format(company) + message = ( + _("Stock and accounting values could not be reconciled by reposting for {0}.").format( + frappe.bold(company) + ) + + "

    " + + _( + "The warehouse account(s) below are not of type 'Stock'. Please set a correct Stock asset account on the warehouse (Account Type must be 'Stock'):" + ) + + f"" + ) + + frappe.sendmail(recipients=recipients, subject=subject, message=message) + + +def has_pending_valuation_repost(company, item_code, warehouse): + """True if an Item & Warehouse repost for this item-warehouse is already queued or running, so the + weekly job does not stack duplicate reposts.""" + return bool( + frappe.db.exists( + "Repost Item Valuation", + { + "company": company, + "item_code": item_code, + "warehouse": warehouse, + "based_on": "Item and Warehouse", + "status": ("in", ["Queued", "In Progress"]), + "docstatus": 1, + }, + ) + ) diff --git a/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py index ff89ad34ab6..00f0b67984e 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py @@ -1,14 +1,137 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import unittest +from unittest.mock import patch import frappe +from frappe.utils import add_days, getdate, today +from erpnext.accounts.utils import get_fiscal_year +from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import get_recipients +from erpnext.stock.doctype.stock_reposting_settings import stock_reposting_settings as srs from erpnext.tests.utils import ERPNextTestSuite +TEST_COMPANY = "_Test Company" +TEST_WAREHOUSE = "_Test Warehouse - _TC" + class TestStockRepostingSettings(ERPNextTestSuite): + def tearDown(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0) + super().tearDown() + + def test_auto_repost_disabled_does_nothing(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0) + with patch("frappe.enqueue") as enqueue: + srs.repost_incorrect_valuation_entries() + enqueue.assert_not_called() + + def test_auto_repost_enabled_enqueues_per_company(self): + frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 1) + with patch("frappe.enqueue") as enqueue: + srs.repost_incorrect_valuation_entries() + self.assertTrue(enqueue.called) + # one job per company + self.assertEqual(enqueue.call_count, frappe.db.count("Company")) + + def test_reposts_only_current_financial_year_entries(self): + item = make_item().name + fy_start_date = get_fiscal_year(today(), company=TEST_COMPANY)[1] + + current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()} + prior_fy_row = { + "item_code": item, + "warehouse": TEST_WAREHOUSE, + "posting_date": add_days(fy_start_date, -1), + } + + calls = [] + variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data" + with ( + patch.object( + srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d))) + ), + patch(variance_path, return_value=[current_fy_row, prior_fy_row]), + ): + srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY) + + # Only the current-FY entry is reposted; the prior-FY one is ignored. + self.assertEqual(calls, [(item, TEST_WAREHOUSE, str(today()))]) + + def test_skips_when_repost_already_pending(self): + item = make_item().name + current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()} + + calls = [] + variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data" + with ( + patch.object(srs, "has_pending_valuation_repost", return_value=True), + patch.object( + srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d))) + ), + patch(variance_path, return_value=[current_fy_row]), + ): + srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY) + + self.assertEqual(calls, []) + + def test_value_comparison_excludes_je_and_flags_wrong_account(self): + fy_start = getdate(today()) + + rows = [ + # correct stock account, current FY -> reposted + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-OK", "posting_date": today()}, + # Journal Entry -> never reposted + {"voucher_type": "Journal Entry", "voucher_no": "JE-1", "posting_date": today()}, + # warehouse account not of type "Stock" -> notify, not reposted + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-BADACC", "posting_date": today()}, + # warehouse account with an unset account_type -> unknown, repost (not a false alarm) + {"voucher_type": "Purchase Receipt", "voucher_no": "PR-NOTYPE", "posting_date": today()}, + # prior financial year -> ignored + { + "voucher_type": "Purchase Receipt", + "voucher_no": "PR-OLD", + "posting_date": add_days(fy_start, -1), + }, + ] + accounts = { + "PR-OK": [("WH-A", "Stock A - _TC", "Stock")], + "PR-BADACC": [("WH-B", "Debtors - _TC", "Receivable")], + "PR-NOTYPE": [("WH-C", "Unclassified - _TC", None)], + "PR-OLD": [("WH-A", "Stock A - _TC", "Stock")], + } + + reposted = {} + sent = [] + comparison = ( + "erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison" + ) + with ( + patch("erpnext.is_perpetual_inventory_enabled", return_value=True), + patch(f"{comparison}.get_data", return_value=rows), + patch( + f"{comparison}.create_reposting_entries", + side_effect=lambda r, c: reposted.update(rows=r, company=c), + ), + patch.object( + srs, "get_voucher_warehouse_accounts", side_effect=lambda vno, c: accounts.get(vno, []) + ), + patch.object(srs, "get_users_with_role", return_value=["sysmgr@test.com"]), + patch("frappe.sendmail", side_effect=lambda **kw: sent.append(kw)), + ): + srs._repost_stock_account_value_comparison(TEST_COMPANY, fy_start) + + # Current-FY, non-Journal-Entry vouchers are reposted: the correct-account one and the one whose + # account_type is unset (unknown is treated as "proceed", not "wrong account"). + self.assertEqual([r["voucher_no"] for r in reposted["rows"]], ["PR-OK", "PR-NOTYPE"]) + # Only the concrete wrong-account voucher triggers a System Manager notification. + self.assertEqual(len(sent), 1) + self.assertIn("PR-BADACC", sent[0]["message"]) + self.assertNotIn("PR-NOTYPE", sent[0]["message"]) + + +class TestStockRepostingSettingsNotification(ERPNextTestSuite): def test_notify_reposting_error_to_role(self): role = "Notify Reposting Role" diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index ca8d49fe8ef..6fa410e50e3 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -1316,6 +1316,7 @@ class StockReservation: "voucher_type": entry.voucher_type or to_doctype, "voucher_no": entry.voucher_no, "voucher_detail_no": entry.voucher_detail_no, + "stock_uom": entry.stock_uom, "serial_nos": [], "sre_names": defaultdict(float), "batches": defaultdict(float), @@ -1373,6 +1374,7 @@ class StockReservation: sre.voucher_qty = entry.required_qty sre.item_code = entry.item_code sre.warehouse = entry.warehouse + sre.stock_uom = entry.stock_uom sre.reserved_qty = min(sre.available_qty, entry.qty) sre.has_serial_no = frappe.get_value("Item", sre.item_code, "has_serial_no") sre.has_batch_no = frappe.get_value("Item", sre.item_code, "has_batch_no") diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 05c61bbb4d5..7cbe369c720 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -307,10 +307,9 @@ def update_bin_details(ctx: ItemDetailsCtx, out: ItemDetails, doc): out.update(get_bin_details(ctx.item_code, ctx.from_warehouse)) elif out.get("warehouse"): - company = ctx.company if (doc and doc.get("doctype") == "Purchase Order") else None - - # calculate company_total_stock only for po - bin_details = get_bin_details(ctx.item_code, out.warehouse, company, include_child_warehouses=True) + bin_details = get_bin_details( + ctx.item_code, out.warehouse, ctx.company, include_child_warehouses=True + ) out.update(bin_details) diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index 0e23561fca1..011d117e2b2 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -33,6 +33,11 @@ def get_data(report_filters): "posting_date": ("<=", report_filters.as_on_date), } + # Optional lower bound: lets callers (e.g. the weekly auto-repost job) scope the scan to the current + # fiscal year in the query itself instead of loading every voucher ever posted and filtering later. + if report_filters.get("from_date"): + filters["posting_date"] = ("between", [report_filters.from_date, report_filters.as_on_date]) + get_currency_precision() or 2 stock_ledger_entries = get_stock_ledger_data(report_filters, filters) voucher_wise_gl_data = get_gl_data(report_filters, filters) @@ -240,19 +245,26 @@ def repost_based_on_transaction(rows, company=None, entries=None): continue duplicate_vouchers.add(voucher_key) - doc = frappe.get_doc( - { - "doctype": "Repost Item Valuation", - "based_on": "Transaction", - "status": "Queued", - "voucher_type": row.get("voucher_type"), - "voucher_no": row.get("voucher_no"), - "posting_date": row.get("posting_date"), - "posting_time": row.get("posting_time"), - "company": company, - "allow_nagative_stock": 1, - "recalculate_valuation_rate": 1, - } - ).submit() + # Isolate each submit in a savepoint: an already-queued repost raises DuplicateEntryError, and on + # PostgreSQL a failed insert aborts the whole transaction, killing the rest of the loop (and the + # silent weekly job). Rolling back to the savepoint keeps prior/next reposts intact. + frappe.db.savepoint("repost_based_on_transaction") + try: + doc = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Transaction", + "status": "Queued", + "voucher_type": row.get("voucher_type"), + "voucher_no": row.get("voucher_no"), + "posting_date": row.get("posting_date"), + "posting_time": row.get("posting_time"), + "company": company, + "allow_nagative_stock": 1, + "recalculate_valuation_rate": 1, + } + ).submit() - entries.append(get_link_to_form("Repost Item Valuation", doc.name)) + entries.append(get_link_to_form("Repost Item Valuation", doc.name)) + except frappe.DuplicateEntryError: + frappe.db.rollback(save_point="repost_based_on_transaction") diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index fdc563064ec..99d94008221 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -27,6 +27,40 @@ class TestGetItemDetail(ERPNextTestSuite): details = get_item_details(args) self.assertEqual(details.get("price_list_rate"), 100) + def test_bin_details_for_selling_doctypes(self): + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + item_code = make_item(properties={"is_stock_item": 1}).name + + make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse - _TC", qty=100, rate=100) + make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse 1 - _TC", qty=50, rate=100) + + args = frappe._dict( + { + "item_code": item_code, + "warehouse": "_Test Warehouse - _TC", + "company": "_Test Company", + "customer": "_Test Customer", + "currency": "INR", + "conversion_rate": 1.0, + "price_list": "_Test Price List", + "price_list_currency": "INR", + "plc_conversion_rate": 1.0, + "transaction_date": None, + "name": None, + "ignore_pricing_rule": 1, + "qty": 1, + } + ) + + for doctype in ("Sales Order", "Quotation", "Sales Invoice", "Delivery Note", "Purchase Order"): + with self.subTest(doctype=doctype): + details = get_item_details(args.copy().update({"doctype": doctype})) + + self.assertEqual(details.get("actual_qty"), 100) + self.assertEqual(details.get("company_total_stock"), 150) + # making this test in get_item_details test file as feat/fix is present in that method def test_fetch_price_from_list_rate_on_doc_save(self): # create item diff --git a/pyproject.toml b/pyproject.toml index 094dcf03ccb..afd54dac13f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,11 +39,10 @@ build-backend = "flit_core.buildapi" max_module_depth = 1 skip_namespaces = [ "erpnext.deprecation_dumpster", - "erpnext.assets.dashboard_fixtures", # https://github.com/frappe/erpnext/issues/44418 ] [tool.bench.frappe-dependencies] -frappe = ">=16.0.0,<17.0.0" +frappe = ">=16.21.0,<17.0.0" [tool.bench.assets] build_dir = "./banking"