diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 061e01c4a4..0025819bd4 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -75,7 +75,13 @@ def get_report_result(report, filters): @frappe.read_only() def generate_report_result( - report, filters=None, user=None, custom_columns=None, is_tree=False, parent_field=None + report, + filters=None, + user=None, + custom_columns=None, + is_tree=False, + parent_field=None, + skip_total_calculation=False, ): user = user or frappe.session.user filters = filters or [] @@ -110,12 +116,13 @@ def generate_report_result( if result: result = get_filtered_data(report.ref_doctype, columns, result, user) - if cint(report.add_total_row) and result and not skip_total_row: + has_total_row = cint(report.add_total_row) and result and not skip_total_row + + if has_total_row and not skip_total_calculation: result = add_total_row(result, columns, is_tree=is_tree, parent_field=parent_field) if isinstance(filters, dict) and filters.get("translate_data"): - total_row = cint(report.add_total_row) and result and not skip_total_row - result = translate_report_data(result, total_row) + result = translate_report_data(result, has_total_row) return { "result": result, @@ -201,6 +208,7 @@ def run( parent_field=None, are_default_filters=True, js_filters=None, + skip_total_calculation=False, ): if not user: user = frappe.session.user @@ -217,8 +225,10 @@ def run( if sbool(are_default_filters) and report.get("custom_filters"): filters = report.custom_filters + is_prepared_report = report.prepared_report and not sbool(ignore_prepared_report) and not custom_columns + try: - if report.prepared_report and not sbool(ignore_prepared_report) and not custom_columns: + if is_prepared_report: if filters: if isinstance(filters, str): filters = json.loads(filters) @@ -228,7 +238,9 @@ def run( dn = "" result = get_prepared_report_result(report, filters, dn, user) else: - result = generate_report_result(report, filters, user, custom_columns, is_tree, parent_field) + result = generate_report_result( + report, filters, user, custom_columns, is_tree, parent_field, skip_total_calculation + ) add_data_to_monitor(report=report.reference_report or report.name) except Exception: frappe.log_error("Report Error") @@ -239,6 +251,8 @@ def run( if sbool(are_default_filters) and report.get("custom_filters"): result["custom_filters"] = report.custom_filters + result["is_prepared_report"] = is_prepared_report + return result @@ -367,13 +381,22 @@ def _export_query(form_params, csv_params, populate_response=True): custom_columns = frappe.parse_json(form_params.custom_columns or "[]") include_indentation = form_params.include_indentation include_filters = form_params.include_filters - visible_idx = form_params.visible_idx + visible_idx = form_params.visible_idx or [] + ignore_visible_idx = sbool(form_params.get("ignore_visible_idx")) + skip_all_rows_total = not ignore_visible_idx include_hidden_columns = form_params.include_hidden_columns if isinstance(visible_idx, str): visible_idx = json.loads(visible_idx) - data = run(report_name, form_params.filters, custom_columns=custom_columns, are_default_filters=False) + data = run( + report_name, + form_params.filters, + custom_columns=custom_columns, + are_default_filters=False, + skip_total_calculation=skip_all_rows_total, + ) + data = frappe._dict(data) data.filters = form_params.applied_filters @@ -384,13 +407,22 @@ def _export_query(form_params, csv_params, populate_response=True): ) return + # calculate total row only for visible rows + if skip_all_rows_total and cint(data.get("add_total_row")): + if data.get("is_prepared_report"): + data.result = data.result[:-1] # delete total row from result + + data["result"] = add_total_row(data.result, data.columns, visible_idx=visible_idx) + format_fields(data) + xlsx_data, column_widths, header_index = build_xlsx_data( data, visible_idx, include_indentation, include_filters=include_filters, include_hidden_columns=include_hidden_columns, + ignore_visible_idx=ignore_visible_idx, ) if file_format_type == "CSV": @@ -555,11 +587,31 @@ def build_xlsx_data( return result, column_widths, header_index -def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None): +def add_total_row( + result, + columns, + meta=None, + is_tree=False, + parent_field=None, + visible_idx: list[int] | None = None, + ignore_visible_idx: bool = False, +) -> list[dict | list[Any]]: total_row = [""] * len(columns) has_percent = [] - for i, col in enumerate(columns): + if not visible_idx or len(visible_idx) == len(result): + # It's not possible to have same length and different content. + ignore_visible_idx = True + visible_idx_set = set() + else: + # Note: converted for faster lookups + ignore_visible_idx = False + visible_idx_set = set(visible_idx) + + # all rows are dict or list/tuple, we can check the first row to decide the type + is_row_dict = isinstance(result[0], dict) if result else False + + for col_idx, col in enumerate(columns): fieldtype, options, fieldname = None, None, None if isinstance(col, str): if meta: @@ -582,10 +634,16 @@ def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None): fieldname = col.get("fieldname") options = col.get("options") - for row in result: - if i >= len(row): + for row_idx, row in enumerate(result): + # Skip rows not in visible_idx when filtering is enabled + if not ignore_visible_idx and row_idx not in visible_idx_set: continue - cell = row.get(fieldname) if isinstance(row, dict) else row[i] + + # Skip if column index is out of bounds for list/tuple rows + if not is_row_dict and col_idx >= len(row): + continue + + cell = row.get(fieldname) if is_row_dict else row[col_idx] if fieldtype is None: if isinstance(cell, int): fieldtype = "Int" @@ -593,21 +651,21 @@ def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None): fieldtype = "Float" if fieldtype in ["Currency", "Int", "Float", "Percent", "Duration"] and flt(cell): if not (is_tree and row.get(parent_field)): - total_row[i] = flt(total_row[i]) + flt(cell) + total_row[col_idx] = flt(total_row[col_idx]) + flt(cell) - if fieldtype == "Percent" and i not in has_percent: - has_percent.append(i) + if fieldtype == "Percent" and col_idx not in has_percent: + has_percent.append(col_idx) if fieldtype == "Time" and cell: - if not total_row[i]: - total_row[i] = timedelta(hours=0, minutes=0, seconds=0) - total_row[i] = total_row[i] + cell + if not total_row[col_idx]: + total_row[col_idx] = timedelta(hours=0, minutes=0, seconds=0) + total_row[col_idx] = total_row[col_idx] + cell if fieldtype == "Link" and options == "Currency": - total_row[i] = result[0].get(fieldname) if isinstance(result[0], dict) else result[0][i] + total_row[col_idx] = result[0].get(fieldname) if is_row_dict else result[0][col_idx] - for i in has_percent: - total_row[i] = flt(total_row[i]) / len(result) + for col_idx in has_percent: + total_row[col_idx] = flt(total_row[col_idx]) / len(result) first_col_fieldtype = None if isinstance(columns[0], str): @@ -971,7 +1029,7 @@ def validate_filters_permissions(report_name, filters=None, user=None, js_filter ) -def translate_report_data(data, total_row): +def translate_report_data(data, total_row: bool): for d in data[:-1] if total_row else data: for field, value in d.items(): if isinstance(value, str): diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index c1968985d7..999c917bb5 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -1768,10 +1768,16 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { filters.prepared_report_name = this.prepared_report_name; } - const visible_idx = this.datatable?.bodyRenderer.visibleRowIndices || []; - if (visible_idx.length + 1 === this.data?.length) { - visible_idx.push(visible_idx.length); + // excluding total row index + let visible_idx = this.datatable?.bodyRenderer.visibleRowIndices || []; + + if (this.raw_data.add_total_row) { + visible_idx.push(this.data.length - 1); // total row index } + + const ignore_visible_idx = visible_idx.length === this.data.length; + visible_idx = ignore_visible_idx ? [] : visible_idx; + const args = { cmd: "frappe.desk.query_report.export_query", report_name: this.report_name, @@ -1780,6 +1786,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { filters: filters, applied_filters: applied_filters, visible_idx, + ignore_visible_idx, csv_delimiter, csv_quoting, csv_decimal_sep,