diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml index b8e88d1b5f3..cfef2e05f5b 100644 --- a/.github/workflows/server-tests-mariadb.yml +++ b/.github/workflows/server-tests-mariadb.yml @@ -41,6 +41,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 env: + TZ: 'Asia/Kolkata' NODE_ENV: "production" WITH_COVERAGE: ${{ github.event_name != 'pull_request' }} @@ -56,6 +57,7 @@ jobs: mysql: image: mariadb:10.6 env: + TZ: 'Asia/Kolkata' MARIADB_ROOT_PASSWORD: 'root' ports: - 3306:3306 diff --git a/erpnext/accounts/doctype/account/account.js b/erpnext/accounts/doctype/account/account.js index ff44723afee..ae5d0e35523 100644 --- a/erpnext/accounts/doctype/account/account.js +++ b/erpnext/accounts/doctype/account/account.js @@ -5,8 +5,7 @@ frappe.ui.form.on("Account", { setup: function (frm) { frm.add_fetch("parent_account", "report_type", "report_type"); frm.add_fetch("parent_account", "root_type", "root_type"); - }, - onload: function (frm) { + frm.set_query("parent_account", function (doc) { return { filters: { @@ -15,7 +14,18 @@ frappe.ui.form.on("Account", { }, }; }); + + frm.set_query("account_category", function () { + if (!frm.doc.root_type) return; + + return { + filters: { + root_type: ["in", [frm.doc.root_type, ""]], + }, + }; + }); }, + refresh: function (frm) { frm.toggle_display("account_name", frm.is_new()); @@ -58,12 +68,20 @@ frappe.ui.form.on("Account", { } } }, + account_type: function (frm) { if (frm.doc.is_group == 0) { frm.toggle_display(["tax_rate"], frm.doc.account_type == "Tax"); frm.toggle_display("warehouse", frm.doc.account_type == "Stock"); } }, + + root_type: function (frm) { + if (frm.doc.account_category) { + frm.set_value("account_category", ""); + } + }, + add_toolbar_buttons: function (frm) { frm.add_custom_button( __("Chart of Accounts"), diff --git a/erpnext/accounts/doctype/account_category/account_category.json b/erpnext/accounts/doctype/account_category/account_category.json index cc8f4103f21..694ac06b082 100644 --- a/erpnext/accounts/doctype/account_category/account_category.json +++ b/erpnext/accounts/doctype/account_category/account_category.json @@ -7,6 +7,8 @@ "engine": "InnoDB", "field_order": [ "account_category_name", + "root_type", + "column_break_qluu", "description" ], "fields": [ @@ -14,6 +16,7 @@ "fieldname": "account_category_name", "fieldtype": "Data", "in_list_view": 1, + "in_standard_filter": 1, "label": "Account Category Name", "reqd": 1, "unique": 1 @@ -22,6 +25,18 @@ "fieldname": "description", "fieldtype": "Small Text", "label": "Description" + }, + { + "fieldname": "column_break_qluu", + "fieldtype": "Column Break" + }, + { + "fieldname": "root_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Root Type", + "options": "\nAsset\nLiability\nIncome\nExpense\nEquity" } ], "grid_page_length": 50, @@ -32,7 +47,7 @@ "link_fieldname": "account_category" } ], - "modified": "2026-02-23 01:19:49.589393", + "modified": "2026-03-05 06:49:34.430723", "modified_by": "Administrator", "module": "Accounts", "name": "Account Category", @@ -69,7 +84,7 @@ } ], "row_format": "Dynamic", - "search_fields": "account_category_name, description", + "search_fields": "account_category_name, root_type", "sort_field": "creation", "sort_order": "DESC", "states": [] diff --git a/erpnext/accounts/doctype/account_category/account_category.py b/erpnext/accounts/doctype/account_category/account_category.py index 8be84d0f8e2..f11ac18969e 100644 --- a/erpnext/accounts/doctype/account_category/account_category.py +++ b/erpnext/accounts/doctype/account_category/account_category.py @@ -21,6 +21,7 @@ class AccountCategory(Document): account_category_name: DF.Data description: DF.SmallText | None + root_type: DF.Literal["", "Asset", "Liability", "Income", "Expense", "Equity"] # end: auto-generated types def after_rename(self, old_name, new_name, merge): diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 0807f07d8d9..7d0b80c8283 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -16,6 +16,7 @@ "invoicing_features_section", "check_supplier_invoice_uniqueness", "automatically_fetch_payment_terms", + "enable_subscription", "column_break_17", "enable_common_party_accounting", "allow_multi_currency_invoices_against_single_party_account", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 693d0918d20..fa36f1de183 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -77,6 +77,7 @@ class AccountsSettings(Document): enable_immutable_ledger: DF.Check enable_loyalty_point_program: DF.Check enable_party_matching: DF.Check + enable_subscription: DF.Check exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"] fetch_payment_schedule_in_payment_request: DF.Check fetch_valuation_rate_for_internal_transaction: DF.Check @@ -142,6 +143,10 @@ class AccountsSettings(Document): toggle_loyalty_point_program_section(not self.enable_loyalty_point_program) clear_cache = True + if old_doc.enable_subscription != self.enable_subscription: + toggle_subscription_sections(not self.enable_subscription) + clear_cache = True + if clear_cache: frappe.clear_cache() @@ -234,6 +239,12 @@ def toggle_loyalty_point_program_section(hide): create_property_setter_for_hiding_field(doctype, "loyalty_points_redemption", hide) +def toggle_subscription_sections(hide): + subscription_doctypes = frappe.get_hooks("subscription_doctypes") + for doctype in subscription_doctypes: + create_property_setter_for_hiding_field(doctype, "subscription_section", hide) + + def create_property_setter_for_hiding_field(doctype, field_name, hide): make_property_setter( doctype, diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py index b5bd3a00a9f..af45c1f3f8e 100644 --- a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py @@ -6,7 +6,7 @@ import json import math from abc import ABC, abstractmethod from dataclasses import dataclass, field -from functools import reduce +from functools import cache, reduce from typing import Any, Union import frappe @@ -15,6 +15,7 @@ from frappe.database.operator_map import OPERATOR_MAP from frappe.query_builder import Case from frappe.query_builder.functions import Sum from frappe.utils import cstr, date_diff, flt, getdate +from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder from pypika.terms import Bracket, LiteralValue from erpnext import get_company_currency @@ -38,6 +39,9 @@ from erpnext.accounts.report.financial_statements import ( ) from erpnext.accounts.utils import get_children, get_currency_precision +DEFAULT_BULLET_PREFIX = "• " +SEGMENT_PREFIX = "seg_" + # ============================================================================ # DATA MODELS # ============================================================================ @@ -141,7 +145,7 @@ class SegmentData: @property def id(self) -> str: - return f"seg_{self.index}" + return f"{SEGMENT_PREFIX}{self.index}" @dataclass @@ -222,14 +226,38 @@ class FinancialReportEngine: return context.get_result() def _validate_filters(self, filters: dict[str, Any]) -> None: - required_filters = ["report_template", "period_start_date", "period_end_date"] + filter_labels = { + "report_template": _("Report Template"), + "filter_based_on": _("Filter Based On"), + "period_start_date": _("Start Date"), + "period_end_date": _("End Date"), + "from_fiscal_year": _("Start Year"), + "to_fiscal_year": _("End Year"), + } + + required_filters_by_basis = { + "Date Range": ("period_start_date", "period_end_date"), + "Fiscal Year": ("from_fiscal_year", "to_fiscal_year"), + } + + required_filters = ["report_template", "filter_based_on"] + required_filters.extend(required_filters_by_basis.get(filters.get("filter_based_on"), ())) for filter_key in required_filters: if not filters.get(filter_key): - frappe.throw(_("Missing required filter: {0}").format(filter_key)) + frappe.throw( + title=_("Missing Required Filter"), + msg=_("Missing required filter: {0}").format( + frappe.bold(filter_labels.get(filter_key, filter_key)) + ), + ) if filters.get("presentation_currency"): - frappe.msgprint(_("Currency filters are currently unsupported in Custom Financial Report.")) + frappe.msgprint( + title=_("Unsupported Feature"), + msg=_("Currency filters are currently unsupported in Custom Financial Report."), + indicator="orange", + ) # Margin view is dependent on first row being an income account. Hence not supported. # Way to implement this would be using calculated rows with formulas. @@ -464,6 +492,7 @@ class FinancialQueryBuilder: self.periods = periods self.company = filters.get("company") self.account_meta = {} # {name: {account_name, account_number}} + self.ignore_opening_entries = False def fetch_account_balances(self, accounts: list[dict]) -> dict[str, AccountData]: """ @@ -501,6 +530,8 @@ class FinancialQueryBuilder: """ Return opening balances for *all accounts* defaulting to zero. """ + self.ignore_opening_entries = False + if frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance"): return self._get_opening_balances_from_gl(accounts) @@ -520,9 +551,9 @@ class FinancialQueryBuilder: if last_closing_voucher: closing_voucher = last_closing_voucher[0] closing_data = self._get_closing_balances(accounts, closing_voucher.name) + self.ignore_opening_entries = True # Else it will double count - if sum(closing_data.values()) != 0.0: - return self._rebase_closing_balances(closing_data, closing_voucher.period_end_date) + return self._rebase_closing_balances(closing_data, closing_voucher.period_end_date) return self._get_opening_balances_from_gl(accounts) @@ -616,7 +647,12 @@ class FinancialQueryBuilder: .groupby(gl_table.account) ) - if not frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting"): + ignore_is_opening = frappe.get_single_value( + "Accounts Settings", "ignore_is_opening_check_for_reporting" + ) + if self.ignore_opening_entries and not ignore_is_opening: + # This filter here applies to all accounts (BS & PL) + # However, in legacy query, this filter only applies to BS accounts query = query.where(gl_table.is_opening == "No") # Add period-specific columns @@ -680,11 +716,18 @@ class FinancialQueryBuilder: account_data.unaccumulate_values() def _apply_standard_filters(self, query, table, doctype: str = "GL Entry"): - if self.filters.get("ignore_closing_entries"): - if doctype == "GL Entry": - query = query.where(table.voucher_type != "Period Closing Voucher") - else: - query = query.where(table.is_period_closing_voucher_entry == 0) + # Exclude PCV-generated entries except those posted to a closing-account-head + # so BS retained earnings survive while P&L reversal entries are filtered out + pcv = frappe.qb.DocType("Period Closing Voucher") + closing_heads = frappe.qb.from_(pcv).select(pcv.closing_account_head).where(pcv.docstatus == 1) + + if doctype == "GL Entry": + is_pcv = table.voucher_type == "Period Closing Voucher" + else: + # Account Closing Balance + is_pcv = table.is_period_closing_voucher_entry == 1 + + query = query.where(~is_pcv | table.account.isin(closing_heads)) if self.filters.get("project"): projects = self.filters.get("project") @@ -1392,7 +1435,8 @@ class FormattingEngine: condition=lambda rd: getattr(rd.row, "italic_text", False), format_properties={"italic": True} ), FormattingRule( - condition=lambda rd: rd.is_detail_row, format_properties={"is_detail": True, "prefix": "• "} + condition=lambda rd: rd.is_detail_row, + format_properties={"is_detail": True, "prefix": DEFAULT_BULLET_PREFIX}, ), FormattingRule( condition=lambda rd: getattr(rd.row, "warn_if_negative", False), @@ -1838,3 +1882,124 @@ class GrowthViewTransformer: return 0.0 else: return flt(((current_value - previous_value) / abs(previous_value)) * 100, 2) + + +# ============================================================================ +# XLSX EXPORT STYLING +# ============================================================================ + + +def get_xlsx_styles(metadata: XLSXMetadata) -> dict | None: + """ + Generate XLSX styles for financial report templates. + + NOTE: Currently only custom report generated with "Report Template" filter will have styles applied. + """ + # skip styling + if not metadata.filters.get("report_template"): + return + + builder = XLSXStyleBuilder(metadata, default_styling=False) + builder.apply_default_styles(currency_formatting=False) + + # currency is fixed for all columns (only if report template filter is applied) + currency = get_company_currency(metadata.filters.get("company")) + + styles = { + "bold": builder.register_style({"bold": True}), + "italic": builder.register_style({"italic": True}), + "warning": builder.register_style({"font_color": "#dc3545"}), # text-danger + } + + fieldtype_formats = { + "Int": builder.register_style({"num_format": "General"}), + "Float": builder.register_style({"num_format": builder.get_number_format("Float")}), + "Percent": builder.register_style({"num_format": builder.get_number_format("Percent")}), + "Currency": builder.register_style({"num_format": builder.get_number_format("Currency", currency)}), + } + + # quick access for hot loop + style_cell = builder.style_cell + + @cache + def get_color_style(color: str) -> int: + return builder.register_style({"font_color": color}) + + @cache + def get_prefix_style(prefix: str) -> int: + prefix = f"{prefix or DEFAULT_BULLET_PREFIX}@" + + return builder.register_style({"num_format": prefix}) + + @cache + def get_indent_style(indent: int) -> int: + return builder.register_style({"align": "left", "indent": indent}) + + # column level styling of currency columns + for col_idx, col in metadata.column_map.items(): + if col.get("fieldtype") != "Currency": + continue + + builder.style_column(col_idx, fieldtype_formats["Currency"]) + + # cell level styling + for row_idx, row in metadata.row_map.items(): + # skip total row + if metadata.has_total_row and row_idx == builder.last_row_index: + continue + + is_segmented = (row.get("_segment_info", {}).get("total_segments", 1) or 1) > 1 + segment_values = row.get("segment_values", {}) or {} + + for col_idx, col in metadata.column_map.items(): + fieldname = col.get("fieldname") + is_account = fieldname == "account" + + # determine formatting bucket + if is_segmented and fieldname.startswith(SEGMENT_PREFIX): + formatting = row.copy() + + _, seg_idx, seg_fieldname = fieldname.split("_", 2) + is_account = seg_fieldname == "account" + formatting.update(segment_values.get(f"{SEGMENT_PREFIX}{seg_idx}", {}) or {}) + else: + formatting = row # default formatting bucket. + + if not is_account and formatting.get("is_blank_line"): + continue + + col_fieldtype = col.get("fieldtype") + cell_fieldtype = formatting.get("fieldtype") or col_fieldtype + cell_value = row.get(fieldname) + + if cell_value in (None, ""): + continue + + # account column and other fieldtype styling + if is_account: + if formatting.get("is_detail") or (prefix := formatting.get("prefix")): + style_cell(row_idx, col_idx, get_prefix_style(prefix)) + + # custom indentation (different segment might have different indentation levels) + if is_segmented and (indent := formatting.get("indent")) and indent > 0: + style_cell(row_idx, col_idx, get_indent_style(indent)) + else: + if col_fieldtype != cell_fieldtype and cell_fieldtype in fieldtype_formats: + style_cell(row_idx, col_idx, fieldtype_formats[cell_fieldtype]) + + # text styles + for style_key in ("bold", "italic"): + if formatting.get(style_key): + style_cell(row_idx, col_idx, styles[style_key]) + + # color styles + if ( + formatting.get("warn_if_negative") + and cell_fieldtype in frappe.model.numeric_fieldtypes + and flt(cell_value) < 0 + ): + style_cell(row_idx, col_idx, styles["warning"]) + elif color := formatting.get("color"): + style_cell(row_idx, col_idx, get_color_style(color)) + + return builder.result diff --git a/erpnext/accounts/doctype/financial_report_template/test_financial_report_engine.py b/erpnext/accounts/doctype/financial_report_template/test_financial_report_engine.py index ef6f2785184..a90a5d32aad 100644 --- a/erpnext/accounts/doctype/financial_report_template/test_financial_report_engine.py +++ b/erpnext/accounts/doctype/financial_report_template/test_financial_report_engine.py @@ -10,6 +10,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine DependencyResolver, FilterExpressionParser, FinancialQueryBuilder, + FinancialReportEngine, FormulaCalculator, PeriodValue, ) @@ -1952,6 +1953,159 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase): jv_2023.cancel() + def test_opening_entries_roll_into_opening_after_period_closing(self): + """ + Sequence: + 1. is_opening JV of 3000 in current year (FY 2024) + 2. is_opening JV of 5000 in next year (FY 2025) + 3. Period Closing Voucher for previous year (FY 2023) + + Expected (BS report for FY 2024): + opening of FY 2024 = 3000 + 5000 = 8000 + (all is_opening entries roll into opening irrespective of fiscal year, + on top of the PCV carry-forward — here PCV closing for cash is 0). + """ + company = "_Test Company" + cash_account = "_Test Cash - _TC" + # Opening JVs cannot post against P&L accounts; use a Balance Sheet offset. + opening_offset_account = "Temporary Opening - _TC" + + pcv = None + jv_current_year = None + jv_next_year = None + original_pcv_setting = frappe.db.get_single_value( + "Accounts Settings", "use_legacy_controller_for_pcv" + ) + + try: + # Step 1: opening JV in current year (FY 2024) — must be posted before PCV + # exists, else `validate_against_pcv` rejects it. + jv_current_year = make_journal_entry( + account1=cash_account, + account2=opening_offset_account, + amount=3000, + posting_date="2024-06-15", + company=company, + save=False, + ) + jv_current_year.is_opening = "Yes" + jv_current_year.insert() + jv_current_year.submit() + + # Step 2: opening JV in next year (FY 2025) + jv_next_year = make_journal_entry( + account1=cash_account, + account2=opening_offset_account, + amount=5000, + posting_date="2025-06-15", + company=company, + save=False, + ) + jv_next_year.is_opening = "Yes" + jv_next_year.insert() + jv_next_year.submit() + + # Step 3: book Period Closing Voucher for previous year (FY 2023) + closing_account = frappe.db.get_value( + "Account", + { + "company": company, + "root_type": "Liability", + "is_group": 0, + "account_type": ["not in", ["Payable", "Receivable"]], + }, + "name", + ) + fy_2023 = get_fiscal_year("2023-06-15", company=company) + + frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1) + + pcv = frappe.get_doc( + { + "doctype": "Period Closing Voucher", + "transaction_date": "2023-12-31", + "period_start_date": fy_2023[1], + "period_end_date": fy_2023[2], + "company": company, + "fiscal_year": fy_2023[0], + "cost_center": "_Test Cost Center - _TC", + "closing_account_head": closing_account, + "remarks": "Test Period Closing", + } + ) + pcv.insert() + pcv.submit() + pcv.reload() + + # Run BS report for FY 2024 + filters = { + "company": company, + "from_fiscal_year": "2024", + "to_fiscal_year": "2024", + "period_start_date": "2024-01-01", + "period_end_date": "2024-12-31", + "filter_based_on": "Date Range", + "periodicity": "Yearly", + "ignore_closing_entries": True, + } + + periods = [{"key": "2024", "from_date": "2024-01-01", "to_date": "2024-12-31"}] + + query_builder = FinancialQueryBuilder(filters, periods) + accounts = [ + frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}), + frappe._dict( + { + "name": opening_offset_account, + "account_name": "Temporary Opening", + "account_number": "1900", + } + ), + ] + + balances_data = query_builder.fetch_account_balances(accounts) + cash_data = balances_data.get(cash_account) + offset_data = balances_data.get(opening_offset_account) + self.assertIsNotNone(cash_data, "Cash account should exist in results") + self.assertIsNotNone(offset_data, "Offset account should exist in results") + + year_2024_cash = cash_data.get_period("2024") + year_2024_offset = offset_data.get_period("2024") + self.assertIsNotNone(year_2024_cash, "FY 2024 period should exist for cash") + self.assertIsNotNone(year_2024_offset, "FY 2024 period should exist for offset") + + # All is_opening JVs (current + next year) roll into FY 2024 opening + self.assertEqual( + year_2024_cash.opening, + 8000.0, + "FY 2024 cash opening must combine is_opening JVs from current and next year", + ) + self.assertEqual( + year_2024_offset.opening, + -8000.0, + "FY 2024 offset opening must combine is_opening JVs from current and next year", + ) + self.assertEqual( + year_2024_cash.movement, 0.0, "Opening JVs must not be counted as period movement" + ) + self.assertEqual(year_2024_cash.closing, 8000.0, "Closing = opening when no non-opening movement") + + finally: + frappe.db.set_single_value( + "Accounts Settings", "use_legacy_controller_for_pcv", original_pcv_setting or 0 + ) + + if pcv: + pcv.reload() + if pcv.docstatus == 1: + pcv.cancel() + + if jv_next_year and jv_next_year.docstatus == 1: + jv_next_year.cancel() + + if jv_current_year and jv_current_year.docstatus == 1: + jv_current_year.cancel() + def test_account_with_gl_entries_but_no_prior_closing_balance(self): company = "_Test Company" cash_account = "_Test Cash - _TC" @@ -2025,3 +2179,210 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase): finally: jv.cancel() + + def test_pl_pcv_exclusion_and_growth_view_year_over_year(self): + """ + Sequence: + 1. Expense JV 2000 in FY 2024, PCV for FY 2024 + → assert FY 2024 movement = 2000 via FinancialQueryBuilder + 2. Expense JV 3000 in FY 2025, PCV for FY 2025 + 3. Run FinancialReportEngine with selected_view="Growth" + → assert col_2024 = 2000 (raw), col_2025 = 50.0 (% growth) + """ + company = "_Test Company" + expense_account = "Administrative Expenses - _TC" + bank_account = "_Test Bank - _TC" + + template = None + pcv_2024 = None + pcv_2025 = None + jv_2024 = None + jv_2025 = None + original_pcv_setting = frappe.db.get_single_value( + "Accounts Settings", "use_legacy_controller_for_pcv" + ) + + try: + closing_account = frappe.db.get_value( + "Account", + { + "company": company, + "root_type": "Liability", + "is_group": 0, + "account_type": ["not in", ["Payable", "Receivable"]], + }, + "name", + ) + + frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1) + + accounts = [ + frappe._dict( + { + "name": expense_account, + "account_name": "Administrative Expenses", + "account_number": "5001", + } + ), + ] + + # --- Step 1: FY 2024 expense + PCV, assert PCV reversal excluded --- + jv_2024 = make_journal_entry( + account1=expense_account, + account2=bank_account, + amount=2000, + posting_date="2024-06-15", + company=company, + submit=True, + ) + fy_2024 = get_fiscal_year("2024-06-15", company=company) + pcv_2024 = frappe.get_doc( + { + "doctype": "Period Closing Voucher", + "transaction_date": "2024-12-31", + "period_start_date": fy_2024[1], + "period_end_date": fy_2024[2], + "company": company, + "fiscal_year": fy_2024[0], + "cost_center": "_Test Cost Center - _TC", + "closing_account_head": closing_account, + "remarks": "Test PCV FY 2024", + } + ) + pcv_2024.insert() + pcv_2024.submit() + pcv_2024.reload() + + builder_2024 = FinancialQueryBuilder( + { + "company": company, + "from_fiscal_year": "2024", + "to_fiscal_year": "2024", + "period_start_date": "2024-01-01", + "period_end_date": "2024-12-31", + "filter_based_on": "Date Range", + "periodicity": "Yearly", + }, + [{"key": "2024", "from_date": "2024-01-01", "to_date": "2024-12-31"}], + ) + data_2024 = builder_2024.fetch_account_balances(accounts) + expense_2024 = data_2024.get(expense_account) + self.assertIsNotNone(expense_2024, "Expense account must appear in FY 2024 results") + year_2024 = expense_2024.get_period("2024") + self.assertEqual( + year_2024.movement, + 2000.0, + "FY 2024 expense movement must equal real expense (PCV reversal excluded)", + ) + + # --- Step 2: FY 2025 expense + PCV --- + jv_2025 = make_journal_entry( + account1=expense_account, + account2=bank_account, + amount=3000, + posting_date="2025-06-15", + company=company, + submit=True, + ) + fy_2025 = get_fiscal_year("2025-06-15", company=company) + pcv_2025 = frappe.get_doc( + { + "doctype": "Period Closing Voucher", + "transaction_date": "2025-12-31", + "period_start_date": fy_2025[1], + "period_end_date": fy_2025[2], + "company": company, + "fiscal_year": fy_2025[0], + "cost_center": "_Test Cost Center - _TC", + "closing_account_head": closing_account, + "remarks": "Test PCV FY 2025", + } + ) + pcv_2025.insert() + pcv_2025.submit() + pcv_2025.reload() + + # --- Step 3: full pipeline with Growth view across both years --- + template_name = f"Test Growth Template {frappe.generate_hash()[:8]}" + template = frappe.get_doc( + { + "doctype": "Financial Report Template", + "template_name": template_name, + "report_type": "Profit and Loss Statement", + "rows": [ + { + "reference_code": "EXP_ADMIN", + "display_name": "Administrative Expenses", + "indentation_level": 0, + "data_source": "Account Data", + "balance_type": "Closing Balance", + "calculation_formula": f'["name", "=", "{expense_account}"]', + }, + ], + } + ) + template.insert() + + filters = frappe._dict( + { + "company": company, + "report_template": template_name, + "from_fiscal_year": fy_2024[0], + "to_fiscal_year": fy_2025[0], + "period_start_date": "2024-01-01", + "period_end_date": "2025-12-31", + "filter_based_on": "Date Range", + "periodicity": "Yearly", + "accumulated_values": 0, + "selected_view": "Growth", + } + ) + + _columns, formatted_data, _msg, _chart = FinancialReportEngine().execute(filters) + + expense_row = next( + (row for row in formatted_data if row.get("account_name") == "Administrative Expenses"), + None, + ) + self.assertIsNotNone(expense_row, "Administrative Expenses row must appear in growth view") + + period_keys = expense_row.get("_segment_info", {}).get("period_keys", []) + self.assertEqual(len(period_keys), 2, "Yearly view must yield exactly two periods") + first_period_key, second_period_key = period_keys + + # First column: raw absolute value (FY 2024 expense) + self.assertEqual( + flt(expense_row[first_period_key]), + 2000.0, + "First column in growth view must keep raw FY 2024 expense value", + ) + # Second column: ((3000 - 2000) / 2000) * 100 = 50.0 + self.assertEqual( + flt(expense_row[second_period_key]), + 50.0, + "Second column must be % growth FY 2024 → FY 2025", + ) + + finally: + frappe.db.set_single_value( + "Accounts Settings", "use_legacy_controller_for_pcv", original_pcv_setting or 0 + ) + + if pcv_2025: + pcv_2025.reload() + if pcv_2025.docstatus == 1: + pcv_2025.cancel() + + if jv_2025 and jv_2025.docstatus == 1: + jv_2025.cancel() + + if pcv_2024: + pcv_2024.reload() + if pcv_2024.docstatus == 1: + pcv_2024.cancel() + + if jv_2024 and jv_2024.docstatus == 1: + jv_2024.cancel() + + if template and frappe.db.exists("Financial Report Template", template.name): + frappe.delete_doc("Financial Report Template", template.name, force=1) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 0eaefb5f618..00831ce7711 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -39,7 +39,7 @@ "clearance_date", "column_break_oizh", "user_remark", - "subscription_section", + "auto_repeat_section", "auto_repeat", "tax_withholding_tab", "section_tax_withholding_entry", @@ -477,11 +477,6 @@ "options": "Stock Entry", "read_only": 1 }, - { - "fieldname": "subscription_section", - "fieldtype": "Section Break", - "label": "Subscription" - }, { "allow_on_submit": 1, "fieldname": "auto_repeat", diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index afa508781d8..5500e1b3e07 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -89,6 +89,7 @@ "remarks", "base_in_words", "is_opening", + "title", "column_break_16", "letter_head", "print_heading", @@ -96,10 +97,9 @@ "bank_account_no", "payment_order", "in_words", - "subscription_section", - "auto_repeat", "amended_from", - "title" + "auto_repeat_section", + "auto_repeat" ], "fields": [ { @@ -503,11 +503,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "subscription_section", - "fieldtype": "Section Break", - "label": "Subscription Section" - }, { "allow_on_submit": 1, "fieldname": "auto_repeat", @@ -781,6 +776,11 @@ "fieldname": "override_tax_withholding_entries", "fieldtype": "Check", "label": "Edit Tax Withholding Entries" + }, + { + "fieldname": "auto_repeat_section", + "fieldtype": "Section Break", + "label": "Auto Repeat" } ], "grid_page_length": 50, diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index d60f77120b2..a582d31044b 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2306,22 +2306,20 @@ def get_outstanding_reference_documents(args, validate=False): # Get positive outstanding sales /purchase invoices condition = "" if args.get("voucher_type") and args.get("voucher_no"): - condition = " and voucher_type={} and voucher_no={}".format( - frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"]) - ) + condition = f" and voucher_type={frappe.db.escape(args['voucher_type'])} and voucher_no={frappe.db.escape(args['voucher_no'])}" common_filter.append(ple.voucher_type == args["voucher_type"]) common_filter.append(ple.voucher_no == args["voucher_no"]) # Add cost center condition if args.get("cost_center"): - condition += " and cost_center='%s'" % args.get("cost_center") + condition += f" and cost_center={frappe.db.escape(args.get('cost_center'))}" accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center")) # dynamic dimension filters active_dimensions = get_dimensions()[0] for dim in active_dimensions: if args.get(dim.fieldname): - condition += f" and {dim.fieldname}='{args.get(dim.fieldname)}'" + condition += f" and {dim.fieldname}={frappe.db.escape(args.get(dim.fieldname))}" accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname)) date_fields_dict = { @@ -2331,17 +2329,15 @@ def get_outstanding_reference_documents(args, validate=False): for fieldname, date_fields in date_fields_dict.items(): if args.get(date_fields[0]) and args.get(date_fields[1]): - condition += " and {} between '{}' and '{}'".format( - fieldname, args.get(date_fields[0]), args.get(date_fields[1]) - ) + condition += f" and {fieldname} between {frappe.db.escape(args.get(date_fields[0]))} and {frappe.db.escape(args.get(date_fields[1]))}" posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) elif args.get(date_fields[0]): # if only from date is supplied - condition += f" and {fieldname} >= '{args.get(date_fields[0])}'" + condition += f" and {fieldname} >= {frappe.db.escape(args.get(date_fields[0]))}" posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0]))) elif args.get(date_fields[1]): # if only to date is supplied - condition += f" and {fieldname} <= '{args.get(date_fields[1])}'" + condition += f" and {fieldname} <= {frappe.db.escape(args.get(date_fields[1]))}" posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1]))) if args.get("company"): @@ -2561,7 +2557,7 @@ def get_orders_to_be_billed( active_dimensions = get_dimensions(True)[0] for dim in active_dimensions: if filters.get(dim.fieldname): - condition += f" and {dim.fieldname}='{filters.get(dim.fieldname)}'" + condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}" if party_account_currency == company_currency: grand_total_field = "base_grand_total" diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index f6a23ddf450..759a6f0cfa2 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -195,6 +195,30 @@ class TestPaymentEntry(ERPNextTestSuite): outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) self.assertEqual(outstanding_amount, 100) + def test_reference_outstanding_amount_on_advance_pull(self): + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + + so = make_sales_order(qty=1, rate=1000) + pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") + pe.paid_amount = pe.received_amount = 500 + pe.references[0].allocated_amount = 500 + pe.insert() + pe.submit() + + so.reload() + self.assertEqual(so.advance_paid, 500) + + si = make_sales_invoice(so.name) + si.allocate_advances_automatically = 1 + si.save() + self.assertEqual(si.get("advances")[0].allocated_amount, 500) + self.assertEqual(si.get("advances")[0].reference_name, pe.name) + si.submit() + + pe.load_from_db() + self.assertEqual(pe.references[0].reference_name, si.name) + self.assertEqual(pe.references[0].outstanding_amount, si.outstanding_amount) + def test_payment_entry_against_pi(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json index 8fcf1f2f41f..17d8b74aa14 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.json +++ b/erpnext/accounts/doctype/payment_request/payment_request.json @@ -183,7 +183,7 @@ "depends_on": "eval:doc.is_a_subscription", "fieldname": "subscription_section", "fieldtype": "Section Break", - "label": "Subscription Section" + "label": "Subscription" }, { "fieldname": "subscription_plans", @@ -478,7 +478,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-01-13 12:53:00.963274", + "modified": "2026-02-27 19:11:03.308896", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Request", diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js index 57b05d19d83..7433f18c5ac 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js @@ -46,8 +46,8 @@ frappe.ui.form.on("Period Closing Voucher", { function () { frappe.route_options = { voucher_no: frm.doc.name, - from_date: frm.doc.posting_date, - to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), + from_date: frm.doc.period_start_date, + to_date: frm.doc.period_end_date, company: frm.doc.company, categorize_by: "", show_cancelled_entries: frm.doc.docstatus === 2, diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index 1fcff98a467..3258a8cbe8a 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -18,9 +18,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite): frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1) def test_closing_entry(self): - frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'") - frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'") - company = create_company() cost_center = create_cost_center("Test Cost Center 1") @@ -70,9 +67,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite): self.assertEqual(pcv_gle, expected_gle) def test_cost_center_wise_posting(self): - frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'") - frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'") - company = create_company() surplus_account = create_account() @@ -136,9 +130,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite): ) def test_period_closing_with_finance_book_entries(self): - frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'") - frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'") - company = create_company() surplus_account = create_account() cost_center = create_cost_center("Test Cost Center 1") @@ -190,9 +181,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite): self.assertSequenceEqual(pcv_gle, expected_gle) def test_gl_entries_restrictions(self): - frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'") - frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'") - company = create_company() cost_center = create_cost_center("Test Cost Center 1") @@ -213,10 +201,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite): self.assertRaises(frappe.ValidationError, jv1.submit) def test_closing_balance_with_dimensions_and_test_reposting_entry(self): - frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'") - frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'") - frappe.db.sql("delete from `tabAccount Closing Balance` where company='Test PCV Company'") - company = create_company() cost_center1 = create_cost_center("Test Cost Center 1") cost_center2 = create_cost_center("Test Cost Center 2") diff --git a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py index 05e24d16a3a..1e402d59ae0 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py @@ -201,7 +201,6 @@ class TestPOSClosingEntry(ERPNextTestSuite): ) from erpnext.stock.doctype.batch.batch import get_batch_qty - frappe.db.sql("delete from `tabPOS Invoice`") item_doc = make_item( "_Test Item With Batch FOR POS Merge Test", properties={ diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index ff57c811c58..248b577d57d 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -26,6 +26,8 @@ "due_date", "amended_from", "return_against", + "section_break_abck", + "title", "accounting_dimensions_section", "project", "dimension_col_break", @@ -187,7 +189,7 @@ "subscription_section", "from_date", "to_date", - "column_break_140", + "auto_repeat_section", "auto_repeat", "update_auto_repeat_reference", "against_income_account" @@ -1462,7 +1464,7 @@ { "fieldname": "subscription_section", "fieldtype": "Section Break", - "label": "Subscription Section" + "label": "Subscription" }, { "allow_on_submit": 1, @@ -1480,10 +1482,6 @@ "no_copy": 1, "print_hide": 1 }, - { - "fieldname": "column_break_140", - "fieldtype": "Column Break" - }, { "allow_on_submit": 1, "fieldname": "auto_repeat", @@ -1619,12 +1617,29 @@ { "fieldname": "column_break_bhao", "fieldtype": "Column Break" + }, + { + "fieldname": "auto_repeat_section", + "fieldtype": "Section Break", + "label": "Auto Repeat" + }, + { + "fieldname": "section_break_abck", + "fieldtype": "Section Break" + }, + { + "allow_on_submit": 1, + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "no_copy": 1, + "print_hide": 1 } ], "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2026-02-10 14:23:07.181782", + "modified": "2026-04-28 06:06:14.283612", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 503c19c7ff8..a5437409bcd 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -172,6 +172,7 @@ class POSInvoice(SalesInvoice): terms: DF.TextEditor | None territory: DF.Link | None timesheets: DF.Table[SalesInvoiceTimesheet] + title: DF.Data | None to_date: DF.Date | None total: DF.Currency total_advance: DF.Currency diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 8cf4d0cb90f..e306e03db34 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -37,7 +37,6 @@ class POSInvoiceTestMixin(ERPNextTestSuite): frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0) frappe.db.set_single_value("POS Settings", "invoice_type", "POS Invoice") make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=800, basic_rate=100) - frappe.db.sql("delete from `tabTax Rule`") mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft") set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank - _TC") diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_merge.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_merge.py index 5c490d303be..2ab8486b06a 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_merge.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice_merge.py @@ -34,7 +34,6 @@ class TestPOSInvoiceMerging(POSInvoiceTestMixin): consolidate_pos_invoices, ) - frappe.db.sql("delete from `tabPOS Invoice`") test_user, pos_profile = init_user_and_profile() pos_inv = create_pos_invoice(rate=300, additional_discount_percentage=10, do_not_submit=1) pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 270}) @@ -64,7 +63,6 @@ class TestPOSInvoiceMerging(POSInvoiceTestMixin): consolidate_pos_invoices, ) - frappe.db.sql("delete from `tabPOS Invoice`") test_user, pos_profile = init_user_and_profile() pos_inv = create_pos_invoice(rate=300, do_not_submit=1) pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300}) @@ -123,7 +121,7 @@ class TestPOSInvoiceMerging(POSInvoiceTestMixin): item = "Test Selling Price Validation" make_item(item, {"is_stock_item": 1}) make_purchase_receipt(item_code=item, warehouse="_Test Warehouse - _TC", qty=1, rate=300) - frappe.db.sql("delete from `tabPOS Invoice`") + test_user, pos_profile = init_user_and_profile() pos_inv = create_pos_invoice(item=item, rate=300, do_not_submit=1) pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300}) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 6ff1ed85d55..3e5550611ac 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -17,7 +17,6 @@ from erpnext.tests.utils import ERPNextTestSuite class TestPricingRule(ERPNextTestSuite): def setUp(self): - delete_existing_pricing_rules() setup_pricing_rule_data() self.enterClassContext(self.change_settings("Selling Settings", validate_selling_price=0)) @@ -1586,16 +1585,6 @@ def setup_pricing_rule_data(): ).insert() -def delete_existing_pricing_rules(): - for doctype in [ - "Pricing Rule", - "Pricing Rule Item Code", - "Pricing Rule Item Group", - "Pricing Rule Brand", - ]: - frappe.db.sql(f"delete from `tab{doctype}`") - - def make_item_price(item, price_list_name, item_price): frappe.get_doc( { diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index adb7dad6726..4f8fe3a79ca 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -27,6 +27,8 @@ "update_billed_amount_in_purchase_receipt", "apply_tds", "amended_from", + "section_break_hzux", + "title", "supplier_invoice_details", "bill_no", "column_break_15", @@ -180,11 +182,12 @@ "unrealized_profit_loss_account", "subscription_section", "subscription", - "auto_repeat", - "update_auto_repeat_reference", "column_break_114", "from_date", "to_date", + "automation_section", + "auto_repeat", + "update_auto_repeat_reference", "printing_settings", "letter_head", "group_same_items", @@ -1675,6 +1678,24 @@ "fieldname": "totals_section", "fieldtype": "Section Break", "label": "Totals" + }, + { + "collapsible": 1, + "fieldname": "automation_section", + "fieldtype": "Section Break", + "label": "Automation" + }, + { + "fieldname": "section_break_hzux", + "fieldtype": "Section Break" + }, + { + "allow_on_submit": 1, + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "no_copy": 1, + "print_hide": 1 } ], "grid_page_length": 50, @@ -1682,7 +1703,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2026-03-25 11:45:38.696888", + "modified": "2026-04-28 07:15:31.062404", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index c790f86633a..a7ef9f1414d 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -203,6 +203,7 @@ class PurchaseInvoice(BuyingController): taxes_and_charges_deducted: DF.Currency tc_name: DF.Link | None terms: DF.TextEditor | None + title: DF.Data | None to_date: DF.Date | None total: DF.Currency total_advance: DF.Currency diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 50734582dad..9baa366c27f 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -33,6 +33,8 @@ "is_created_using_pos", "pos_closing_entry", "has_subcontracted", + "section_break_qllv", + "title", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -91,9 +93,9 @@ "column_break_xjag", "base_rounding_adjustment", "base_rounded_total", - "section_break_vacb", + "section_break_pxwz", "total_advance", - "column_break_rdks", + "column_break_iaso", "outstanding_amount", "section_tax_withholding_entry", "tax_withholding_group", @@ -199,6 +201,7 @@ "unrealized_profit_loss_account", "against_income_account", "commission_section", + "column_break_rdiw", "sales_partner", "amount_eligible_for_commission", "column_break10", @@ -214,12 +217,14 @@ "language", "subscription_section", "subscription", - "from_date", - "auto_repeat", "column_break_140", + "from_date", "to_date", + "automation_section", + "auto_repeat", "update_auto_repeat_reference", "utm_analytics_section", + "column_break_rdke", "utm_source", "utm_medium", "column_break_ixxw", @@ -2304,14 +2309,6 @@ "options": "fa fa-group", "print_hide": 1 }, - { - "fieldname": "section_break_vacb", - "fieldtype": "Section Break" - }, - { - "fieldname": "column_break_rdks", - "fieldtype": "Column Break" - }, { "fieldname": "column_break_ixxw", "fieldtype": "Column Break" @@ -2321,6 +2318,40 @@ "fieldname": "utm_analytics_section", "fieldtype": "Section Break", "label": "UTM Analytics" + }, + { + "collapsible": 1, + "fieldname": "automation_section", + "fieldtype": "Section Break", + "label": "Automation" + }, + { + "fieldname": "section_break_pxwz", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_rdke", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_rdiw", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_iaso", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_qllv", + "fieldtype": "Section Break" + }, + { + "allow_on_submit": 1, + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "no_copy": 1, + "print_hide": 1 } ], "grid_page_length": 50, @@ -2334,7 +2365,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2026-03-09 17:15:30.931929", + "modified": "2026-04-28 13:08:19.849783", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 0477c172587..ef64a3ef79b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -225,6 +225,7 @@ class SalesInvoice(SellingController): terms: DF.TextEditor | None territory: DF.Link | None timesheets: DF.Table[SalesInvoiceTimesheet] + title: DF.Data | None to_date: DF.Date | None total: DF.Currency total_advance: DF.Currency diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 03d8b2e7eb5..cf22b186b97 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2025,10 +2025,6 @@ class TestSalesInvoice(ERPNextTestSuite): ) def test_multiple_uom_in_selling(self): - frappe.db.sql( - """delete from `tabItem Price` - where price_list='_Test Price List' and item_code='_Test Item'""" - ) item_price = frappe.new_doc("Item Price") item_price.price_list = "_Test Price List" item_price.item_code = "_Test Item" diff --git a/erpnext/accounts/doctype/share_transfer/test_share_transfer.py b/erpnext/accounts/doctype/share_transfer/test_share_transfer.py index a227a9f3059..3c1a395f93a 100644 --- a/erpnext/accounts/doctype/share_transfer/test_share_transfer.py +++ b/erpnext/accounts/doctype/share_transfer/test_share_transfer.py @@ -10,8 +10,6 @@ from erpnext.tests.utils import ERPNextTestSuite class TestShareTransfer(ERPNextTestSuite): def setUp(self): - frappe.db.sql("delete from `tabShare Transfer`") - frappe.db.sql("delete from `tabShare Balance`") share_transfers = [ { "doctype": "Share Transfer", diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index 2d0450107bc..13697084cbf 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -5,8 +5,7 @@ import datetime from unittest.mock import patch import frappe -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields -from frappe.utils import add_days, add_months, today +from frappe.utils import add_days, add_months, getdate, today from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.utils import get_fiscal_year @@ -1922,7 +1921,6 @@ class TestTaxWithholdingCategory(ERPNextTestSuite): def set_previous_fy_and_tax_category(self): test_company = "_Test Company" - category = "Cumulative Threshold TDS" def add_company_to_fy(fy, company): if not [x.company for x in fy.companies if x.company == company]: @@ -1948,20 +1946,6 @@ class TestTaxWithholdingCategory(ERPNextTestSuite): ) self.prev_fy.save() - # setup tax withholding category for previous fiscal year - cat = frappe.get_doc("Tax Withholding Category", category) - cat.append( - "rates", - { - "from_date": self.prev_fy.year_start_date, - "to_date": self.prev_fy.year_end_date, - "tax_withholding_rate": 10, - "single_threshold": 0, - "cumulative_threshold": 30000, - }, - ) - cat.save() - def test_tds_across_fiscal_year(self): """ Advance TDS on previous fiscal year should be properly allocated on Invoices in upcoming fiscal year @@ -1972,6 +1956,14 @@ class TestTaxWithholdingCategory(ERPNextTestSuite): supplier = "Test TDS Supplier" # Cumulative threshold 30000 and tax rate 10% category = "Cumulative Threshold TDS" + create_tax_withholding_category( + category_name=category, + rate=10, + from_date=self.prev_fy.year_start_date, + to_date=self.prev_fy.year_end_date, + account="TDS - _TC", + cumulative_threshold=30000, + ) frappe.db.set_value( "Supplier", supplier, @@ -2043,6 +2035,158 @@ class TestTaxWithholdingCategory(ERPNextTestSuite): self.assertEqual(pi2.taxes, []) self.assertEqual(payment.taxes[0].tax_amount, 6000) + def test_threshold_resets_in_new_fiscal_year(self): + """ + Threshold entries from a previous FY must not carry over into the new FY. + """ + self.set_previous_fy_and_tax_category() + invoices = [] + supplier = "Test TDS Supplier" + category = "Cumulative Threshold TDS" + create_tax_withholding_category( + category_name=category, + rate=10, + from_date=self.prev_fy.year_start_date, + to_date=self.prev_fy.year_end_date, + account="TDS - _TC", + cumulative_threshold=30000, + ) + self.setup_party_with_category("Supplier", supplier, category) + prev_fy_date = add_days(self.prev_fy.year_end_date, -10) + + # Previous FY: 3 invoices to cross the 30000 cumulative threshold + for _ in range(3): + pi = create_purchase_invoice(supplier=supplier, posting_date=prev_fy_date, set_posting_time=True) + pi.submit() + invoices.append(pi) + + # Third invoice crosses the threshold - 3000 TDS deducted across all three + self.validate_tax_deduction(invoices[-1], 3000) + + # Current FY: 10000 invoice - must be Under Withheld, threshold resets + pi_curr = create_purchase_invoice(supplier=supplier) + pi_curr.submit() + invoices.append(pi_curr) + self.validate_tax_deduction(pi_curr, 0) + + self.validate_tax_withholding_entries( + "Purchase Invoice", + pi_curr.name, + [ + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + taxable_doctype="Purchase Invoice", + taxable_name=pi_curr.name, + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=0.0, + status="Under Withheld", + withholding_doctype=None, + withholding_name=None, + under_withheld_reason=None, + ) + ], + ) + self.cleanup_invoices(invoices) + + def test_tax_on_excess_threshold_resets_in_new_fiscal_year(self): + """ + For tax-on-excess categories, unused threshold must reset each FY. + """ + self.set_previous_fy_and_tax_category() + invoices = [] + supplier = "Test TDS Supplier3" + category = "New TDS Category" + create_tax_withholding_category( + category_name=category, + rate=10, + from_date=self.prev_fy.year_start_date, + to_date=self.prev_fy.year_end_date, + account="TDS - _TC", + cumulative_threshold=30000, + tax_on_excess_amount=1, + round_off_tax_amount=1, + ) + self.setup_party_with_category("Supplier", supplier, category) + prev_fy_date = add_days(self.prev_fy.year_end_date, -10) + + for _ in range(2): + pi = create_purchase_invoice(supplier=supplier, posting_date=prev_fy_date, set_posting_time=True) + pi.submit() + invoices.append(pi) + + pi3 = create_purchase_invoice( + supplier=supplier, rate=20000, posting_date=prev_fy_date, set_posting_time=True + ) + pi3.submit() + invoices.append(pi3) + + self.validate_tax_deduction(pi3, 1000) + self.validate_tax_withholding_entries( + "Purchase Invoice", + pi3.name, + [ + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + taxable_doctype="Purchase Invoice", + taxable_name=pi3.name, + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=0.0, + status="Settled", + withholding_doctype="Purchase Invoice", + withholding_name=pi3.name, + under_withheld_reason="Threshold Exemption", + ), + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + taxable_doctype="Purchase Invoice", + taxable_name=pi3.name, + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=1000.0, + status="Settled", + withholding_doctype="Purchase Invoice", + withholding_name=pi3.name, + under_withheld_reason=None, + ), + ], + ) + + # no excess, so no TDS + pi_curr = create_purchase_invoice(supplier=supplier, rate=30000) + pi_curr.submit() + invoices.append(pi_curr) + self.validate_tax_deduction(pi_curr, 0) + + self.validate_tax_withholding_entries( + "Purchase Invoice", + pi_curr.name, + [ + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + taxable_doctype="Purchase Invoice", + taxable_name=pi_curr.name, + tax_rate=10.0, + taxable_amount=30000.0, + withholding_amount=0.0, + status="Settled", + withholding_doctype="Purchase Invoice", + withholding_name=pi_curr.name, + under_withheld_reason="Threshold Exemption", + ), + ], + ) + self.cleanup_invoices(invoices) + @ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1}) def test_tds_payment_entry_cancellation(self): """ @@ -3997,7 +4141,7 @@ def create_tax_withholding_category( tax_deduction_basis="Net Total", ): if not frappe.db.exists("Tax Withholding Category", category_name): - frappe.get_doc( + doc = frappe.get_doc( { "doctype": "Tax Withholding Category", "name": category_name, @@ -4018,6 +4162,22 @@ def create_tax_withholding_category( "accounts": [{"company": "_Test Company", "account": account}], } ).insert() + else: + doc = frappe.get_doc("Tax Withholding Category", category_name) + if not any(getdate(r.from_date) == getdate(from_date) for r in doc.rates): + doc.append( + "rates", + { + "from_date": from_date, + "to_date": to_date, + "tax_withholding_rate": rate, + "single_threshold": single_threshold, + "cumulative_threshold": cumulative_threshold, + }, + ) + doc.save() + + return doc def create_lower_deduction_certificate( diff --git a/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py b/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py index 8f8ee7898af..941d9da08f7 100644 --- a/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py +++ b/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py @@ -640,6 +640,7 @@ class TaxWithholdingController: .where(entry.tax_withholding_category == category.name) .where(entry.company == self.doc.company) .where(entry.docstatus == 1) + .where(entry.taxable_date.between(category.from_date, category.to_date)) .groupby(entry.status) ) diff --git a/erpnext/accounts/financial_report_template/account_categories.json b/erpnext/accounts/financial_report_template/account_categories.json index f9af2698f10..37eb7baf3cc 100644 --- a/erpnext/accounts/financial_report_template/account_categories.json +++ b/erpnext/accounts/financial_report_template/account_categories.json @@ -1,118 +1,147 @@ [ { "account_category_name": "Cash and Cash Equivalents", + "root_type": "Asset", "description": "Cash on hand, demand deposits, and short-term highly liquid investments readily convertible to cash with original maturities of three months or less. Examples: Cash in hand, bank current accounts, money market funds, treasury bills \u22643 months." }, { "account_category_name": "Cost of Goods Sold", + "root_type": "Expense", "description": "Direct costs attributable to cost of goods sold. Examples: Raw materials, stock in trade." }, { "account_category_name": "Current Tax Liabilities", + "root_type": "Liability", "description": "Income tax obligations for current and prior periods. Examples: Provision for income tax, advance tax paid, tax deducted at source." }, { "account_category_name": "Finance Costs", + "root_type": "Expense", "description": "Interest and financing-related expenses. Examples: Interest on borrowings, bank charges, lease interest, foreign exchange losses." }, { "account_category_name": "Intangible Assets", + "root_type": "Asset", "description": "Identifiable non-monetary assets without physical substance. Examples: Software, patents, trademarks, licenses, development costs." }, { "account_category_name": "Investment Income", + "root_type": "Income", "description": "Returns generated from financial investments and cash management. Examples: Interest income, dividend income, rental income, fair value gains." }, { "account_category_name": "Long-term Borrowings", + "root_type": "Liability", "description": "Interest-bearing debt obligations with maturity beyond one year. Examples: Term loans, bonds, debentures, mortgages." }, { "account_category_name": "Long-term Investments", + "root_type": "Asset", "description": "Investments held for strategic purposes or extended periods. Examples: Equity investments, bonds, associates, joint ventures, deposits." }, { "account_category_name": "Long-term Provisions", + "root_type": "Liability", "description": "Present obligations beyond one year with uncertain timing/amount. Examples: Asset retirement obligations, environmental remediation, legal settlements." }, { "account_category_name": "Operating Expenses", + "root_type": "Expense", "description": "Costs incurred in ordinary business operations excluding direct costs. Examples: Selling expenses, administrative costs, marketing, utilities, rent." }, { "account_category_name": "Other Current Assets", + "root_type": "Asset", "description": "Current assets not classified elsewhere including prepaid expenses and advances. Examples: Prepaid insurance, prepaid rent, advance to suppliers, security deposits recoverable within one year." }, { "account_category_name": "Other Current Liabilities", + "root_type": "Liability", "description": "Short-term obligations not classified elsewhere. Examples: Accrued expenses, statutory liabilities, employee payables." }, { "account_category_name": "Other Direct Costs", + "root_type": "Expense", "description": "Direct costs excluding cost of goods sold. Examples: Direct labor, manufacturing overhead, freight inward." }, { "account_category_name": "Other Non-current Assets", + "root_type": "Asset", "description": "Long-term assets not classified elsewhere. Examples: Security deposits, long-term prepayments, advances for capital goods." }, { "account_category_name": "Other Non-current Liabilities", + "root_type": "Liability", "description": "Long-term obligations not classified elsewhere. Examples: Long-term deposits, deferred income, government grants." }, { "account_category_name": "Other Operating Income", + "root_type": "Income", "description": "Incidental income related to business operations but not core revenue. Examples: Scrap sales, government grants, insurance claims, foreign exchange gains." }, { "account_category_name": "Other Payables", + "root_type": "Liability", "description": "Non-trade payables and obligations to parties other than suppliers. Examples: Employee payables, accrued expenses, customer advances, security deposits received." }, { "account_category_name": "Other Receivables", + "root_type": "Asset", "description": "Non-trade amounts due to the entity excluding financing arrangements. Examples: Employee advances, insurance claims, tax refunds, deposits recoverable." }, { "account_category_name": "Reserves and Surplus", + "root_type": "Equity", "description": "Accumulated profits and other reserves created from profits or share premium. Examples: General reserves, retained earnings, statutory reserves, share premium." }, { "account_category_name": "Revenue from Operations", + "root_type": "Income", "description": "Income from primary business activities in ordinary course. Examples: Sales of goods, service revenue, commission income, royalty income." }, { "account_category_name": "Share Capital", + "root_type": "Equity", "description": "Nominal value of issued and paid-up equity shares. Examples: Common stock, ordinary shares, preference shares." }, { "account_category_name": "Short-term Borrowings", + "root_type": "Liability", "description": "Interest-bearing debt obligations due within one year. Examples: Bank overdrafts, short-term loans, current portion of long-term debt." }, { "account_category_name": "Short-term Investments", + "root_type": "Asset", "description": "Financial instruments held for short-term investment purposes, readily convertible to cash. Examples: Marketable securities, fixed deposits >3 months, mutual funds." }, { "account_category_name": "Short-term Provisions", + "root_type": "Liability", "description": "Present obligations due within one year with uncertain timing or amount. Examples: Warranty provisions, legal claims, restructuring costs." }, { "account_category_name": "Stock Assets", + "root_type": "Asset", "description": "Inventory and stock-related assets including raw materials, work in progress, finished goods, and stock in trade. Examples: Raw materials, finished goods, trading merchandise, consumables." }, { "account_category_name": "Tangible Assets", + "root_type": "Asset", "description": "Physical assets used in business operations including property, plant, and equipment. Examples: Land, buildings, machinery, equipment, vehicles, furniture, capital work in progress." }, { "account_category_name": "Tax Expense", + "root_type": "Expense", "description": "Current and deferred income tax obligations. Examples: Current tax provision, deferred tax expense, withholding taxes." }, { "account_category_name": "Trade Payables", + "root_type": "Liability", "description": "Amounts owed to suppliers. Examples: Supplier invoices, accrued purchases, bills payable." }, { "account_category_name": "Trade Receivables", + "root_type": "Asset", "description": "Amounts due from customers for goods sold or services provided in ordinary course of business. Examples: Accounts receivable, notes receivable from customers, unbilled revenue." } -] \ No newline at end of file +] diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 7c5dcedaf74..de7f5941fca 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -36,7 +36,8 @@ def make_gl_entries( ): if gl_map: if ( - not cint(frappe.get_single_value("Accounts Settings", "use_legacy_budget_controller")) + not cancel + and not cint(frappe.get_single_value("Accounts Settings", "use_legacy_budget_controller")) and gl_map[0].voucher_type != "Period Closing Voucher" ): bud_val = BudgetValidation(gl_map=gl_map) diff --git a/erpnext/accounts/report/account_balance/test_account_balance.py b/erpnext/accounts/report/account_balance/test_account_balance.py index f166421b0de..49d81f0c625 100644 --- a/erpnext/accounts/report/account_balance/test_account_balance.py +++ b/erpnext/accounts/report/account_balance/test_account_balance.py @@ -10,9 +10,6 @@ from erpnext.tests.utils import ERPNextTestSuite class TestAccountBalance(ERPNextTestSuite): def test_account_balance(self): - frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 2'") - frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 2'") - filters = { "company": "_Test Company 2", "report_date": getdate(), diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index 97a903133da..a8531e58acb 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -8,6 +8,7 @@ from frappe.utils import 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 ( compute_growth_view_data, diff --git a/erpnext/accounts/report/balance_sheet/test_balance_sheet.py b/erpnext/accounts/report/balance_sheet/test_balance_sheet.py index dce9d8ab0fa..683aeecffbd 100644 --- a/erpnext/accounts/report/balance_sheet/test_balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/test_balance_sheet.py @@ -13,9 +13,6 @@ COMPANY_SHORT_NAME = "_TC6" class TestBalanceSheet(ERPNextTestSuite): def test_balance_sheet(self): - frappe.db.sql(f"delete from `tabJournal Entry` where company='{COMPANY}'") - frappe.db.sql(f"delete from `tabGL Entry` where company='{COMPANY}'") - create_account("VAT Liabilities", f"Duties and Taxes - {COMPANY_SHORT_NAME}", COMPANY) create_account("Advance VAT Paid", f"Duties and Taxes - {COMPANY_SHORT_NAME}", COMPANY) create_account("My Bank", f"Bank Accounts - {COMPANY_SHORT_NAME}", COMPANY) diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index 462d34b874f..a62867ba91c 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -12,6 +12,7 @@ from pypika import Order 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 ( get_columns, diff --git a/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.py b/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.py index dc506071f01..eeb5a336a8e 100644 --- a/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.py +++ b/erpnext/accounts/report/custom_financial_statement/custom_financial_statement.py @@ -3,6 +3,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( FinancialReportEngine, + get_xlsx_styles, #! DO NOT REMOVE - hook for styling ) 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 74290ec21b4..9ce6cd77e5b 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 @@ -8,6 +8,7 @@ from frappe.utils import 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 ( compute_growth_view_data, diff --git a/erpnext/accounts/report/purchase_register/test_purchase_register.py b/erpnext/accounts/report/purchase_register/test_purchase_register.py index e4ce5ffcfe3..9e0e2002f60 100644 --- a/erpnext/accounts/report/purchase_register/test_purchase_register.py +++ b/erpnext/accounts/report/purchase_register/test_purchase_register.py @@ -11,9 +11,6 @@ from erpnext.tests.utils import ERPNextTestSuite class TestPurchaseRegister(ERPNextTestSuite): def test_purchase_register(self): - frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") - frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") - filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today()) pi = make_purchase_invoice() @@ -28,9 +25,6 @@ class TestPurchaseRegister(ERPNextTestSuite): self.assertEqual(first_row.grand_total, 1100) def test_purchase_register_ignores_tax_rows_from_other_doctype(self): - frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") - frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") - filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today()) pi = make_purchase_invoice() @@ -74,9 +68,6 @@ class TestPurchaseRegister(ERPNextTestSuite): self.assertEqual(first_row.grand_total, 1100) def test_purchase_register_ledger_view(self): - frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") - frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") - filters = frappe._dict( company="_Test Company 6", from_date=add_months(today(), -1), diff --git a/erpnext/accounts/report/trial_balance/test_trial_balance.py b/erpnext/accounts/report/trial_balance/test_trial_balance.py index c37f9d5a46a..ec8f48c2413 100644 --- a/erpnext/accounts/report/trial_balance/test_trial_balance.py +++ b/erpnext/accounts/report/trial_balance/test_trial_balance.py @@ -42,9 +42,6 @@ class TestTrialBalance(ERPNextTestSuite): """ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice - frappe.db.sql("delete from `tabSales Invoice` where company='Trial Balance Company'") - frappe.db.sql("delete from `tabGL Entry` where company='Trial Balance Company'") - branch1 = frappe.new_doc("Branch") branch1.branch = "Location 1" branch1.insert(ignore_if_duplicate=True) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index fe68c4018aa..c1e8a831b2c 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -546,7 +546,7 @@ def reconcile_against_document( skip_ref_details_update_for_pe=skip_ref_details_update_for_pe, dimensions_dict=dimensions_dict, ) - if referenced_row.get("outstanding_amount"): + if referenced_row.get("outstanding_amount") and entry.get("outstanding_amount") is None: referenced_row.outstanding_amount -= flt(entry.allocated_amount) reposting_rows.append(referenced_row) diff --git a/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py index e37ac4c2bf3..e297fc94634 100644 --- a/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py @@ -26,7 +26,6 @@ class TestAssetCapitalization(ERPNextTestSuite): def setUp(self): set_depreciation_settings_in_company() create_asset_capitalization_data() - frappe.db.sql("delete from `tabTax Rule`") def test_capitalization_with_perpetual_inventory(self): company = "_Test Company with perpetual inventory" diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 260dc52ceac..ee1f13e4b3f 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -23,6 +23,8 @@ "is_subcontracted", "has_unit_price_items", "supplier_warehouse", + "section_break_ahub", + "title", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -147,7 +149,7 @@ "column_break_86", "select_print_heading", "language", - "subscription_section", + "auto_repeat_section", "from_date", "to_date", "column_break_97", @@ -1013,12 +1015,6 @@ "label": "Print Language", "print_hide": 1 }, - { - "collapsible": 1, - "fieldname": "subscription_section", - "fieldtype": "Section Break", - "label": "Auto Repeat" - }, { "allow_on_submit": 1, "fieldname": "from_date", @@ -1309,6 +1305,24 @@ "fieldtype": "Time", "label": "Time", "mandatory_depends_on": "is_internal_supplier" + }, + { + "collapsible": 1, + "fieldname": "auto_repeat_section", + "fieldtype": "Section Break", + "label": "Auto Repeat" + }, + { + "fieldname": "section_break_ahub", + "fieldtype": "Section Break" + }, + { + "allow_on_submit": 1, + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "no_copy": 1, + "print_hide": 1 } ], "grid_page_length": 50, @@ -1316,7 +1330,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-03-25 11:46:18.748951", + "modified": "2026-04-28 06:11:46.904768", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 2cb285c14f3..48ed761829e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -159,6 +159,7 @@ class PurchaseOrder(BuyingController): taxes_and_charges_deducted: DF.Currency tc_name: DF.Link | None terms: DF.TextEditor | None + title: DF.Data | None to_date: DF.Date | None total: DF.Currency total_net_weight: DF.Float diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index de8b4d28547..988ffcb9eb3 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -16,6 +16,8 @@ "status", "has_unit_price_items", "amended_from", + "section_break_mhyw", + "title", "suppliers_section", "suppliers", "items_section", @@ -371,6 +373,18 @@ "fieldtype": "Text Editor", "label": "Shipping Address Details", "read_only": 1 + }, + { + "fieldname": "section_break_mhyw", + "fieldtype": "Section Break" + }, + { + "allow_on_submit": 1, + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "no_copy": 1, + "print_hide": 1 } ], "grid_page_length": 50, @@ -378,7 +392,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-03-19 15:27:56.730649", + "modified": "2026-04-28 06:18:05.661710", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", @@ -448,5 +462,6 @@ "show_name_in_global_search": 1, "sort_field": "creation", "sort_order": "DESC", - "states": [] + "states": [], + "title_field": "company" } diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 03254c30f6e..c99d0f00606 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -63,6 +63,7 @@ class RequestforQuotation(BuyingController): suppliers: DF.Table[RequestforQuotationSupplier] tc_name: DF.Link | None terms: DF.TextEditor | None + title: DF.Data | None transaction_date: DF.Date use_html: DF.Check vendor: DF.Link | None diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 1328d8aebdc..ec0b33611b3 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -9,7 +9,6 @@ "engine": "InnoDB", "field_order": [ "supplier_section", - "title", "naming_series", "supplier", "supplier_name", @@ -21,6 +20,8 @@ "quotation_number", "has_unit_price_items", "amended_from", + "section_break_kumc", + "title", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -111,7 +112,7 @@ "column_break_85", "select_print_heading", "language", - "subscription_section", + "auto_repeat_section", "auto_repeat", "update_auto_repeat_reference", "more_info", @@ -127,10 +128,9 @@ "options": "fa fa-user" }, { - "default": "{supplier_name}", + "allow_on_submit": 1, "fieldname": "title", "fieldtype": "Data", - "hidden": 1, "label": "Title", "no_copy": 1, "print_hide": 1, @@ -736,11 +736,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "subscription_section", - "fieldtype": "Section Break", - "label": "Auto Repeat" - }, { "fieldname": "auto_repeat", "fieldtype": "Link", @@ -940,6 +935,15 @@ "no_copy": 1, "options": "Item Wise Tax Detail", "print_hide": 1 + }, + { + "fieldname": "auto_repeat_section", + "fieldtype": "Section Break", + "label": "Auto Repeat" + }, + { + "fieldname": "section_break_kumc", + "fieldtype": "Section Break" } ], "grid_page_length": 50, @@ -948,7 +952,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-01-29 21:23:13.778468", + "modified": "2026-04-28 06:23:52.813948", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", @@ -1016,5 +1020,5 @@ "sort_order": "DESC", "states": [], "timeline_field": "supplier", - "title_field": "title" + "title_field": "supplier_name" } diff --git a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py index a1d1dfc4709..92da4dd318d 100644 --- a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py @@ -13,7 +13,6 @@ class TestSupplierScorecard(ERPNextTestSuite): self.assertEqual(doc.name, valid_scorecard[0].get("supplier")) def test_criteria_weight(self): - delete_test_scorecards() my_doc = make_supplier_scorecard() for d in my_doc.criteria: d.weight = 0 @@ -33,26 +32,6 @@ def make_supplier_scorecard(): return my_doc -def delete_test_scorecards(): - my_doc = make_supplier_scorecard() - if frappe.db.exists("Supplier Scorecard", my_doc.name): - # Delete all the periods, then delete the scorecard - frappe.db.sql( - """delete from `tabSupplier Scorecard Period` where scorecard = %(scorecard)s""", - {"scorecard": my_doc.name}, - ) - frappe.db.sql( - """delete from `tabSupplier Scorecard Scoring Criteria` where parenttype = 'Supplier Scorecard Period'""" - ) - frappe.db.sql( - """delete from `tabSupplier Scorecard Scoring Standing` where parenttype = 'Supplier Scorecard Period'""" - ) - frappe.db.sql( - """delete from `tabSupplier Scorecard Scoring Variable` where parenttype = 'Supplier Scorecard Period'""" - ) - frappe.delete_doc(my_doc.doctype, my_doc.name) - - valid_scorecard = [ { "standings": [ diff --git a/erpnext/buying/doctype/supplier_scorecard_criteria/test_supplier_scorecard_criteria.py b/erpnext/buying/doctype/supplier_scorecard_criteria/test_supplier_scorecard_criteria.py index 951d33ed901..0d2a547c634 100644 --- a/erpnext/buying/doctype/supplier_scorecard_criteria/test_supplier_scorecard_criteria.py +++ b/erpnext/buying/doctype/supplier_scorecard_criteria/test_supplier_scorecard_criteria.py @@ -9,41 +9,15 @@ from erpnext.tests.utils import ERPNextTestSuite class TestSupplierScorecardCriteria(ERPNextTestSuite): def test_variables_exist(self): - delete_test_scorecards() for d in test_good_criteria: frappe.get_doc(d).insert() self.assertRaises(frappe.ValidationError, frappe.get_doc(test_bad_criteria[0]).insert) def test_formula_validate(self): - delete_test_scorecards() self.assertRaises(frappe.ValidationError, frappe.get_doc(test_bad_criteria[1]).insert) -def delete_test_scorecards(): - # Delete all the periods so we can delete all the criteria - frappe.db.sql("""delete from `tabSupplier Scorecard Period`""") - frappe.db.sql( - """delete from `tabSupplier Scorecard Scoring Criteria` where parenttype = 'Supplier Scorecard Period'""" - ) - frappe.db.sql( - """delete from `tabSupplier Scorecard Scoring Standing` where parenttype = 'Supplier Scorecard Period'""" - ) - frappe.db.sql( - """delete from `tabSupplier Scorecard Scoring Variable` where parenttype = 'Supplier Scorecard Period'""" - ) - - for d in test_good_criteria: - if frappe.db.exists("Supplier Scorecard Criteria", d.get("name")): - # Delete all the periods, then delete the scorecard - frappe.delete_doc(d.get("doctype"), d.get("name")) - - for d in test_bad_criteria: - if frappe.db.exists("Supplier Scorecard Criteria", d.get("name")): - # Delete all the periods, then delete the scorecard - frappe.delete_doc(d.get("doctype"), d.get("name")) - - test_good_criteria = [ { "name": "Delivery", diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 5627dffea95..0f74ea12348 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -461,7 +461,17 @@ class BuyingController(SubcontractingController): get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 ) - net_rate = item.qty * item.base_net_rate + net_rate = ( + flt( + (item.base_net_amount / item.received_qty) * item.qty, + item.precision("base_net_amount"), + ) + if item.received_qty + and frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + else item.base_net_amount + ) if item.sales_incoming_rate: # for internal transfer net_rate = item.qty * item.sales_incoming_rate diff --git a/erpnext/crm/doctype/contract/test_contract.py b/erpnext/crm/doctype/contract/test_contract.py index f6aa0ef0979..79993d6f87c 100644 --- a/erpnext/crm/doctype/contract/test_contract.py +++ b/erpnext/crm/doctype/contract/test_contract.py @@ -10,7 +10,6 @@ from erpnext.tests.utils import ERPNextTestSuite class TestContract(ERPNextTestSuite): def setUp(self): - frappe.db.sql("delete from `tabContract`") self.contract_doc = get_contract() def test_validate_start_date_before_end_date(self): diff --git a/erpnext/crm/doctype/lead/test_lead.py b/erpnext/crm/doctype/lead/test_lead.py index 5da10ff9ea2..e85efc94faa 100644 --- a/erpnext/crm/doctype/lead/test_lead.py +++ b/erpnext/crm/doctype/lead/test_lead.py @@ -87,9 +87,6 @@ class TestLead(ERPNextTestSuite): self.assertEqual(len(address_1.get("links")), 1) def test_prospect_creation_from_lead(self): - frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") - frappe.db.sql("delete from `tabProspect` where name='Prospect Company'") - lead = make_lead( first_name="Rahul", last_name="Tripathi", @@ -109,9 +106,6 @@ class TestLead(ERPNextTestSuite): self.assertEqual(event.event_participants[1].reference_docname, prospect) def test_opportunity_from_lead(self): - frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") - frappe.db.sql("delete from `tabOpportunity` where party_name='Rahul Tripathi'") - lead = make_lead( first_name="Rahul", last_name="Tripathi", @@ -139,9 +133,6 @@ class TestLead(ERPNextTestSuite): ) def test_copy_events_from_lead_to_prospect(self): - frappe.db.sql("delete from `tabLead` where lead_name='Rahul Tripathi'") - frappe.db.sql("delete from `tabProspect` where name='Prospect Company'") - lead = make_lead( first_name="Rahul", last_name="Tripathi", diff --git a/erpnext/edi/doctype/code_list/code_list_import.js b/erpnext/edi/doctype/code_list/code_list_import.js index 4a33f3e2fe6..917e815fc97 100644 --- a/erpnext/edi/doctype/code_list/code_list_import.js +++ b/erpnext/edi/doctype/code_list/code_list_import.js @@ -10,6 +10,7 @@ erpnext.edi.import_genericode = function (listview_or_form) { method: "erpnext.edi.doctype.code_list.code_list_import.import_genericode", doctype: doctype, docname: docname, + allow_web_link: false, allow_toggle_private: false, allow_take_photo: false, on_success: function (_file_doc, r) { diff --git a/erpnext/edi/doctype/code_list/code_list_import.py b/erpnext/edi/doctype/code_list/code_list_import.py index 71cb7d0f82d..0f6a51fc993 100644 --- a/erpnext/edi/doctype/code_list/code_list_import.py +++ b/erpnext/edi/doctype/code_list/code_list_import.py @@ -1,48 +1,106 @@ import json +from urllib.parse import urlsplit import frappe import requests from frappe import _ from frappe.utils import escape_html +from frappe.utils.file_manager import save_file from lxml import etree -URL_PREFIXES = ("http://", "https://") +GENERICODE_FETCH_TIMEOUT = 15 +LOCAL_FILE_PREFIXES = ("/files/", "/private/files/") + + +class RemoteGenericodeUrlNotAllowedError(Exception): + pass + + +class CodeListSelectionMismatchError(Exception): + pass @frappe.whitelist() def import_genericode(): - doctype = frappe.form_dict.doctype - docname = frappe.form_dict.docname - content = frappe.local.uploaded_file - - # recover the content, if it's a link - if (file_url := frappe.local.uploaded_file_url) and file_url.startswith(URL_PREFIXES): - try: - # If it's a URL, fetch the content and make it a local file (for durable audit) - response = requests.get(frappe.local.uploaded_file_url) - response.raise_for_status() - frappe.local.uploaded_file = content = response.content - frappe.local.uploaded_filename = frappe.local.uploaded_file_url.split("/")[-1] - frappe.local.uploaded_file_url = None - except Exception as e: - frappe.throw(f"
{e!s}
", title=_("Fetching Error")) - - if file_url := frappe.local.uploaded_file_url: - file_path = frappe.utils.file_manager.get_file_path(file_url) - with open(file_path.encode(), mode="rb") as f: - content = f.read() - - # Parse the xml content - parser = etree.XMLParser( - remove_blank_text=True, - resolve_entities=False, - load_dtd=False, - no_network=True, - ) try: - root = etree.fromstring(content, parser=parser) - except Exception as e: - frappe.throw(f"
{e!s}
", title=_("Parsing Error")) + content, file_name = get_uploaded_genericode_file() + + return import_genericode_content( + doctype="Code List", + docname=frappe.form_dict.docname, + content=content, + file_name=file_name, + ) + except RemoteGenericodeUrlNotAllowedError: + frappe.throw( + _("Importing Code Lists from remote URLs is not allowed."), + title=_("Invalid Upload"), + ) + except CodeListSelectionMismatchError: + frappe.throw(_("The uploaded file does not match the selected Code List.")) + except etree.XMLSyntaxError: + frappe.throw( + _("The uploaded file could not be parsed as a genericode XML document."), + title=_("Parsing Error"), + ) + + +def import_genericode_from_url( + url: str, + doctype: str = "Code List", + docname: str | None = None, +): + """Import a Code List from a trusted backend URL.""" + content = fetch_genericode_from_url(url) + file_name = urlsplit(url).path.rsplit("/", 1)[-1] or "genericode.xml" + + return import_genericode_content( + doctype=doctype, + docname=docname, + content=content, + file_name=file_name, + ) + + +def get_uploaded_genericode_file() -> tuple[bytes, str | None]: + uploaded_data = frappe.local.uploaded_file + file_name = frappe.local.uploaded_filename + if uploaded_data and file_name: + return uploaded_data, file_name + + file_url = frappe.local.uploaded_file_url + if not file_url: + raise frappe.ValidationError(_("No file uploaded or URL provided.")) + + if not is_local_file_url(file_url): + raise RemoteGenericodeUrlNotAllowedError + + file_doc = frappe.get_doc("File", {"file_url": file_url}) + file_doc.check_permission("read") + return file_doc.get_content(encodings=()), file_name + + +def is_local_file_url(file_url: str | None) -> bool: + if not file_url: + return False + + parsed = urlsplit(file_url.strip()) + return not parsed.scheme and not parsed.netloc and parsed.path.startswith(LOCAL_FILE_PREFIXES) + + +def fetch_genericode_from_url(url: str) -> bytes: + response = requests.get(url, timeout=GENERICODE_FETCH_TIMEOUT) + response.raise_for_status() + return response.content + + +def import_genericode_content( + doctype: str, + docname: str | None, + content: bytes, + file_name: str | None, +): + root = parse_genericode_content(content) # Extract the name (CanonicalVersionUri) from the parsed XML name = root.find(".//CanonicalVersionUri").text @@ -51,7 +109,7 @@ def import_genericode(): if frappe.db.exists(doctype, docname): code_list = frappe.get_doc(doctype, docname) if code_list.name != name: - frappe.throw(_("The uploaded file does not match the selected Code List.")) + raise CodeListSelectionMismatchError else: # Create a new Code List document with the extracted name code_list = frappe.new_doc(doctype) @@ -60,19 +118,13 @@ def import_genericode(): code_list.from_genericode(root) code_list.save() - # Attach the file and provide a recoverable identifier - file_doc = frappe.get_doc( - { - "doctype": "File", - "attached_to_doctype": "Code List", - "attached_to_name": code_list.name, - "folder": frappe.db.get_value("File", {"is_attachments_folder": 1}), - "file_name": frappe.local.uploaded_filename, - "file_url": frappe.local.uploaded_file_url, - "is_private": 1, - "content": content, - } - ).save() + file_doc = save_file( + fname=file_name, + content=content, + dt=doctype, + dn=code_list.name, + is_private=1, + ) # Get available columns and example values columns, example_values, filterable_columns = get_genericode_columns_and_examples(root) @@ -87,6 +139,16 @@ def import_genericode(): } +def parse_genericode_content(content: bytes): + parser = etree.XMLParser( + remove_blank_text=True, + resolve_entities=False, + load_dtd=False, + no_network=True, + ) + return etree.fromstring(content, parser=parser) + + @frappe.whitelist() def process_genericode_import( code_list_name: str, diff --git a/erpnext/edi/doctype/code_list/test_code_list_import.py b/erpnext/edi/doctype/code_list/test_code_list_import.py new file mode 100644 index 00000000000..949544bd633 --- /dev/null +++ b/erpnext/edi/doctype/code_list/test_code_list_import.py @@ -0,0 +1,200 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from unittest.mock import Mock, patch + +import frappe +import requests + +from erpnext.edi.doctype.code_list import code_list_import +from erpnext.tests.utils import ERPNextTestSuite + +SAMPLE_GENERICODE = b""" + + + Test Code List + 1.0 + test-code-list + Code list for tests + + Test Agency + TEST + + https://example.com/codelists/test.xml + + test-code-list-v1 + + + + + + + + A + Alpha + Group 1 + + + B + Beta + Group 2 + + + C + Gamma + Group 1 + + + +""" + + +class TestCodeListImport(ERPNextTestSuite): + def test_import_genericode_rejects_remote_file_url(self): + self.set_upload_context( + file_name="trusted.xml", + file_url="https://example.com/codelists/trusted.xml", + ) + + with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get: + with self.assertRaisesRegex( + frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed." + ): + code_list_import.import_genericode() + + mock_get.assert_not_called() + + def test_import_genericode_rejects_file_scheme_url(self): + self.set_upload_context( + file_name="trusted.xml", + file_url="file:///tmp/trusted.xml", + ) + + with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get: + with self.assertRaisesRegex( + frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed." + ): + code_list_import.import_genericode() + + mock_get.assert_not_called() + + def test_import_genericode_from_trusted_url(self): + response = Mock() + response.content = SAMPLE_GENERICODE + response.raise_for_status.return_value = None + + with patch( + "erpnext.edi.doctype.code_list.code_list_import.requests.get", + return_value=response, + ) as mock_get: + import_result = code_list_import.import_genericode_from_url( + "https://example.com/codelists/trusted.xml" + ) + + self.assert_import_response(import_result) + mock_get.assert_called_once_with( + "https://example.com/codelists/trusted.xml", + timeout=code_list_import.GENERICODE_FETCH_TIMEOUT, + ) + + file_doc = frappe.get_doc("File", import_result["file"]) + self.assertEqual(file_doc.get_content(encodings=()), SAMPLE_GENERICODE) + self.assertFalse(file_doc.file_url.startswith("https://")) + + def test_import_genericode_from_trusted_url_propagates_fetch_errors(self): + with patch( + "erpnext.edi.doctype.code_list.code_list_import.requests.get", + side_effect=requests.Timeout, + ): + with self.assertRaises(requests.Timeout): + code_list_import.import_genericode_from_url("https://example.com/codelists/trusted.xml") + + def test_import_genericode_from_uploaded_file_returns_metadata(self): + self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml") + + import_result = code_list_import.import_genericode() + + self.assert_import_response(import_result) + + file_doc = frappe.get_doc("File", import_result["file"]) + self.assertEqual(file_doc.get_content(encodings=()), SAMPLE_GENERICODE) + + def test_process_genericode_import_reads_file_doc_content(self): + self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml") + + import_result = code_list_import.import_genericode() + count = code_list_import.process_genericode_import( + code_list_name=import_result["code_list"], + file_name=import_result["file"], + code_column="code", + title_column="name", + ) + + self.assertEqual(count, 3) + self.assertEqual(frappe.db.count("Common Code", {"code_list": import_result["code_list"]}), 3) + self.assertEqual( + frappe.db.get_value( + "Common Code", + {"code_list": import_result["code_list"], "common_code": "A"}, + "title", + ), + "Alpha", + ) + + def test_import_genericode_from_local_file_url(self): + source_file = frappe.get_doc( + { + "doctype": "File", + "file_name": "library_genericode.xml", + "content": SAMPLE_GENERICODE, + "is_private": 1, + } + ).insert() + self.set_upload_context(file_name=source_file.file_name, file_url=source_file.file_url) + + import_result = code_list_import.import_genericode() + + self.assert_import_response(import_result) + + def set_upload_context( + self, + content: bytes | None = None, + file_name: str = "genericode.xml", + file_url: str | None = None, + docname: str | None = None, + ): + attrs = ("form_dict", "uploaded_file", "uploaded_file_url", "uploaded_filename") + originals = {attr: getattr(frappe.local, attr, None) for attr in attrs} + + frappe.local.form_dict = frappe._dict(doctype="Code List", docname=docname) + frappe.local.uploaded_file = content + frappe.local.uploaded_file_url = file_url + frappe.local.uploaded_filename = file_name + + def restore(): + for attr, value in originals.items(): + setattr(frappe.local, attr, value) + + self.addCleanup(restore) + + def assert_import_response(self, import_result): + self.assertEqual( + set(import_result), + { + "code_list", + "code_list_title", + "file", + "columns", + "example_values", + "filterable_columns", + }, + ) + self.assertEqual(import_result["code_list"], "test-code-list-v1") + self.assertEqual(import_result["code_list_title"], "Test Code List") + self.assertEqual(import_result["columns"], ["code", "name", "category"]) + self.assertEqual(import_result["example_values"]["code"], ["A", "B", "C"]) + self.assertEqual(import_result["example_values"]["name"], ["Alpha", "Beta", "Gamma"]) + self.assertEqual(import_result["example_values"]["category"], ["Group 1", "Group 2", "Group 1"]) + self.assertCountEqual(import_result["filterable_columns"]["category"], ["Group 1", "Group 2"]) + self.assertTrue(frappe.db.exists("Code List", import_result["code_list"])) + self.assertTrue(frappe.db.exists("File", import_result["file"])) diff --git a/erpnext/edi/doctype/common_code/common_code.py b/erpnext/edi/doctype/common_code/common_code.py index d1fd88350be..57e5e32f33a 100644 --- a/erpnext/edi/doctype/common_code/common_code.py +++ b/erpnext/edi/doctype/common_code/common_code.py @@ -9,6 +9,8 @@ from frappe.model.document import Document from frappe.utils.data import get_link_to_form from lxml import etree +from erpnext.edi.doctype.code_list.code_list_import import parse_genericode_content + class CommonCode(Document): # begin: auto-generated types @@ -86,15 +88,15 @@ def simple_hash(input_string, length=6): def import_genericode(code_list: str, file_name: str, column_map: dict, filters: dict | None = None): """Import genericode file and create Common Code entries""" - file_path = frappe.utils.file_manager.get_file_path(file_name) - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser=parser) - root = tree.getroot() + file_doc = frappe.get_doc("File", file_name) + file_doc.check_permission("read") + root = parse_genericode_content(file_doc.get_content(encodings=())) # Construct the XPath expression xpath_expr = ".//SimpleCodeList/Row" filter_conditions = [ - f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" for column_ref, value in filters.items() + f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" + for column_ref, value in (filters or {}).items() ] if filter_conditions: xpath_expr += "[" + " and ".join(filter_conditions) + "]" @@ -102,7 +104,7 @@ def import_genericode(code_list: str, file_name: str, column_map: dict, filters: elements = root.xpath(xpath_expr) total_elements = len(elements) for i, xml_element in enumerate(elements, start=1): - common_code: "CommonCode" = frappe.new_doc("Common Code") + common_code: CommonCode = frappe.new_doc("Common Code") common_code.code_list = code_list common_code.from_genericode(column_map, xml_element) common_code.save() diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 2fd4be3a510..a32c033c9ec 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -581,6 +581,8 @@ accounting_dimension_doctypes = [ "Advance Taxes and Charges", ] +subscription_doctypes = ["Sales Invoice", "Purchase Invoice", "Payment Request", "POS Invoice"] + get_matching_queries = ( "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_matching_queries" ) diff --git a/erpnext/locale/main.pot b/erpnext/locale/main.pot index bacf12f631e..0ab380aea3e 100644 --- a/erpnext/locale/main.pot +++ b/erpnext/locale/main.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: ERPNext VERSION\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" -"POT-Creation-Date: 2026-04-19 09:47+0000\n" -"PO-Revision-Date: 2026-04-19 09:47+0000\n" +"POT-Creation-Date: 2026-04-26 09:48+0000\n" +"PO-Revision-Date: 2026-04-26 09:48+0000\n" "Last-Translator: hello@frappe.io\n" "Language-Team: hello@frappe.io\n" "MIME-Version: 1.0\n" @@ -106,7 +106,7 @@ msgstr "" msgid "\"Is Fixed Asset\" cannot be unchecked, as Asset record exists against the item" msgstr "" -#: erpnext/public/js/utils/serial_no_batch_selector.js:262 +#: erpnext/public/js/utils/serial_no_batch_selector.js:273 msgid "\"SN-01::10\" for \"SN-01\" to \"SN-10\"" msgstr "" @@ -267,7 +267,7 @@ msgstr "" msgid "% of materials delivered against this Sales Order" msgstr "" -#: erpnext/controllers/accounts_controller.py:2381 +#: erpnext/controllers/accounts_controller.py:2384 msgid "'Account' in the Accounting section of Customer {0}" msgstr "" @@ -275,7 +275,7 @@ msgstr "" msgid "'Allow Multiple Sales Orders Against a Customer's Purchase Order'" msgstr "" -#: erpnext/controllers/trends.py:56 +#: erpnext/controllers/trends.py:62 msgid "'Based On' and 'Group By' can not be same" msgstr "" @@ -283,11 +283,11 @@ msgstr "" msgid "'Days Since Last Order' must be greater than or equal to zero" msgstr "" -#: erpnext/controllers/accounts_controller.py:2386 +#: erpnext/controllers/accounts_controller.py:2389 msgid "'Default {0} Account' in Company {1}" msgstr "" -#: erpnext/accounts/doctype/journal_entry/journal_entry.py:1229 +#: erpnext/accounts/doctype/journal_entry/journal_entry.py:1230 msgid "'Entries' cannot be empty" msgstr "" @@ -602,8 +602,8 @@ msgstr "" msgid "90 Above" msgstr "" -#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1330 -#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1331 +#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1338 +#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1339 msgid "<0" msgstr "" @@ -768,7 +768,7 @@ msgstr "" msgid "
  • Clearance date must be after cheque date for row(s): {0}
  • " msgstr "" -#: erpnext/controllers/accounts_controller.py:2264 +#: erpnext/controllers/accounts_controller.py:2267 msgid "
  • Item {0} in row(s) {1} billed more than {2}
  • " msgstr "" @@ -785,7 +785,7 @@ msgstr "" msgid "
  • {}
  • " msgstr "" -#: erpnext/controllers/accounts_controller.py:2261 +#: erpnext/controllers/accounts_controller.py:2264 msgid "

    Cannot overbill for the following Items:

    " msgstr "" @@ -822,7 +822,7 @@ msgstr "" msgid "

    Please correct the following row(s):