From 30f878fc8284da0f124b83d805c5ec9d0069a80c Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Mon, 14 Apr 2025 22:51:18 +0400 Subject: [PATCH] Added report support, but have a lot of bugs --- formula_editor/api.py | 709 ++++- .../doctype/formula_editor/formula_editor.js | 2355 +++++++++++++++-- .../formula_editor/formula_editor.json | 8 +- .../doctype/report_simulator/__init__.py | 0 .../report_simulator/report_simulator.js | 231 ++ .../report_simulator/report_simulator.json | 61 + .../report_simulator/report_simulator.py | 1094 ++++++++ .../report_simulator/report_simulator_list.js | 124 + .../report_simulator/test_report_simulator.py | 30 + 9 files changed, 4330 insertions(+), 282 deletions(-) create mode 100644 formula_editor/formula_editor/doctype/report_simulator/__init__.py create mode 100644 formula_editor/formula_editor/doctype/report_simulator/report_simulator.js create mode 100644 formula_editor/formula_editor/doctype/report_simulator/report_simulator.json create mode 100644 formula_editor/formula_editor/doctype/report_simulator/report_simulator.py create mode 100644 formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js create mode 100644 formula_editor/formula_editor/doctype/report_simulator/test_report_simulator.py diff --git a/formula_editor/api.py b/formula_editor/api.py index 4b03239..c0829c2 100644 --- a/formula_editor/api.py +++ b/formula_editor/api.py @@ -1,4 +1,10 @@ import frappe +import json +import copy +from frappe.model.document import Document +from frappe.utils import getdate, nowdate, get_first_day, get_last_day, add_months, today +import re + @frappe.whitelist() def get_doctype_fields(doctype): meta = frappe.get_meta(doctype) @@ -77,4 +83,705 @@ def on_trash(doc, method=None): frappe.delete_doc("Client Script", script.name, force=True) frappe.db.commit() except Exception as e: - frappe.log_error(f"Не удалось удалить клиентский скрипт {script.name}: {str(e)}") \ No newline at end of file + frappe.log_error(f"Не удалось удалить клиентский скрипт {script.name}: {str(e)}") + +@frappe.whitelist() +def run_report_for_formula(report_name, filters, field_name=None): + """Выполняет отчет с указанными фильтрами и возвращает результат для формулы""" + try: + frappe.log_error(f"ОТЧЕТ API - начало запроса: {report_name}, поле: {field_name}", "Report API Debug") + frappe.log_error(f"ОТЧЕТ API - фильтры: {filters}", "Report API Debug") + + # Преобразуем фильтры, если они пришли в виде строки + if isinstance(filters, str): + filters = json.loads(filters) + + # Создаем новый словарь для обработанных фильтров + final_filters = {} + + # Копируем компанию и другие базовые фильтры (не даты) + for key, value in filters.items(): + if not ('from_date' in key or 'to_date' in key or 'from' == key or 'to' == key or 'period_start_date' == key or 'period_end_date' == key): + final_filters[key] = value + + # Проверяем и устанавливаем значение компании по умолчанию + if "company" not in final_filters or not final_filters["company"]: + final_filters["company"] = frappe.defaults.get_user_default("Company") or frappe.db.get_single_value("Global Defaults", "default_company") + frappe.log_error(f"ОТЧЕТ API - установлена компания по умолчанию: {final_filters['company']}", "Report API Debug") + + # Находим специальные операторы для дат + from_date_key = None + to_date_key = None + from_date_value = None + to_date_value = None + + # Ищем фильтры с операторами для дат + for key, value in filters.items(): + if 'from_date>' in key or 'from_date>=' in key: + from_date_key = key + from_date_value = value + elif 'to_date<' in key or 'to_date<=' in key: + to_date_key = key + to_date_value = value + + # Если нашли специальные операторы, используем их значения + if from_date_key and from_date_value: + if isinstance(from_date_value, str): + from_date_value = frappe.utils.getdate(from_date_value) + final_filters['from_date'] = from_date_value + frappe.log_error(f"ОТЧЕТ API - используем from_date из {from_date_key}: {final_filters['from_date']}", "Report API Debug") + + if to_date_key and to_date_value: + if isinstance(to_date_value, str): + to_date_value = frappe.utils.getdate(to_date_value) + final_filters['to_date'] = to_date_value + frappe.log_error(f"ОТЧЕТ API - используем to_date из {to_date_key}: {final_filters['to_date']}", "Report API Debug") + + # Если нет специальных операторов, проверяем обычные поля from_date и to_date + if 'from_date' not in final_filters and 'from_date' in filters: + if isinstance(filters['from_date'], str): + final_filters['from_date'] = frappe.utils.getdate(filters['from_date']) + else: + final_filters['from_date'] = filters['from_date'] + frappe.log_error(f"ОТЧЕТ API - используем стандартное поле from_date: {final_filters['from_date']}", "Report API Debug") + + if 'to_date' not in final_filters and 'to_date' in filters: + if isinstance(filters['to_date'], str): + final_filters['to_date'] = frappe.utils.getdate(filters['to_date']) + else: + final_filters['to_date'] = filters['to_date'] + frappe.log_error(f"ОТЧЕТ API - используем стандартное поле to_date: {final_filters['to_date']}", "Report API Debug") + + # Если нет дат, устанавливаем значения по умолчанию + if 'from_date' not in final_filters: + final_filters['from_date'] = frappe.utils.get_first_day(frappe.utils.nowdate()) + frappe.log_error(f"ОТЧЕТ API - устанавливаем from_date по умолчанию: {final_filters['from_date']}", "Report API Debug") + + if 'to_date' not in final_filters: + final_filters['to_date'] = frappe.utils.nowdate() + frappe.log_error(f"ОТЧЕТ API - устанавливаем to_date по умолчанию: {final_filters['to_date']}", "Report API Debug") + + # Специальная обработка для отчетов в зависимости от их типа + if report_name.lower() in ["general ledger", "general_ledger"]: + # Для General Ledger нужны поля from и to + final_filters['from'] = final_filters['from_date'] + final_filters['to'] = final_filters['to_date'] + elif report_name.lower() in ["cash flow", "cash_flow"]: + # Для Cash Flow нужны period_start_date и period_end_date + final_filters['period_start_date'] = final_filters['from_date'] + final_filters['period_end_date'] = final_filters['to_date'] + # Также добавляем filter_based_on со значением "Date Range" + final_filters['filter_based_on'] = "Date Range" + # и periodicity со значением "Monthly" + final_filters['periodicity'] = "Monthly" + + frappe.log_error(f"ОТЧЕТ API - финальные фильтры: {final_filters}", "Report API Debug") + + # Для указанных отчетов просто используем подготовленные фильтры + if report_name.lower() in ["general ledger", "general_ledger", "cash flow", "cash_flow"]: + standard_filters = final_filters + custom_filters = {} + else: + # Для остальных отчетов используем обычное разделение + standard_filters, custom_filters = generate_dynamic_filters(report_name, final_filters) + + frappe.log_error(f"ОТЧЕТ API - стандартные фильтры: {standard_filters}", "Report API Debug") + frappe.log_error(f"ОТЧЕТ API - пользовательские фильтры: {custom_filters}", "Report API Debug") + + # Выполняем отчет + from frappe.desk.query_report import run + frappe.log_error(f"ОТЧЕТ API - запуск отчета {report_name} с фильтрами {standard_filters}", "Report API Debug") + report_result = run(report_name, filters=standard_filters) + + # Проверяем на ошибки + if isinstance(report_result, dict) and report_result.get('message'): + frappe.log_error(f"ОТЧЕТ API - получена ошибка: {report_result.get('message')}", "Report API Debug") + return {"error": report_result.get('message')} + + # Получаем результаты + columns = report_result.get('columns', []) + data = report_result.get('result', []) + + frappe.log_error(f"ОТЧЕТ API - получено строк данных: {len(data)}", "Report API Debug") + + # Применяем пользовательские фильтры, если они есть + if custom_filters: + frappe.log_error(f"ОТЧЕТ API - применяем пользовательские фильтры", "Report API Debug") + data = apply_custom_filters(data, custom_filters, columns) + frappe.log_error(f"ОТЧЕТ API - после фильтрации строк: {len(data)}", "Report API Debug") + + # Если указано конкретное поле, возвращаем только его значение + if field_name: + frappe.log_error(f"ОТЧЕТ API - извлекаем поле: {field_name}", "Report API Debug") + result = extract_field_from_report(data, columns, field_name) + frappe.log_error(f"ОТЧЕТ API - результат извлечения: {result}", "Report API Debug") + return result + + # Иначе возвращаем полный результат + frappe.log_error("ОТЧЕТ API - возвращаем полный результат", "Report API Debug") + return { + "columns": columns, + "data": data + } + except Exception as e: + frappe.log_error(f"ОТЧЕТ API - ошибка: {str(e)}\n{frappe.get_traceback()}", "Report API Debug") + return {"error": str(e)} + +@frappe.whitelist() +def generate_dynamic_filters(report_name, user_filters): + """Обрабатывает фильтры для отчета, разделяя их на стандартные и пользовательские""" + + # Создаем копию пользовательских фильтров + processed_filters = copy.deepcopy(user_filters) + + # Преобразуем даты в datetime объекты + date_fields = [ + "from_date", "to_date", "date", + "period_start_date", "period_end_date", + "year_start_date", "year_end_date" + ] + + for field in date_fields: + if field in processed_filters and isinstance(processed_filters[field], str): + try: + processed_filters[field] = frappe.utils.getdate(processed_filters[field]) + except: + # Если не удаётся преобразовать, оставляем как есть + pass + + # ИСПРАВЛЕНИЕ: для отчета General Ledger всегда используем фильтры как стандартные + if report_name == "General Ledger": + return processed_filters, {} + + # Для всех остальных отчетов используем стандартный механизм + standard_filters = {} + custom_filters = {} + + try: + from frappe.desk.query_report import get_script + script_data = get_script(report_name) + report_filters = script_data.get('filters') or [] + + # Определяем, какие фильтры являются стандартными + standard_filter_names = [] + for f in report_filters: + if isinstance(f, dict): + standard_filter_names.append(f.get('fieldname')) + elif isinstance(f, list) and len(f) > 0: + standard_filter_names.append(f[0]) + + # Разделяем фильтры на стандартные и пользовательские + for key, value in processed_filters.items(): + if key in standard_filter_names: + standard_filters[key] = value + else: + # Проверяем "All" значения в фильтрах + if isinstance(value, str) and "All " in value: + # Игнорируем универсальные значения типа "All Item Groups" + field_base = key.replace('_group', '').replace('_category', '') + if value == f"All {field_base.title()}s" or value == f"All {key.title()}s": + continue + custom_filters[key] = value + except Exception as e: + frappe.log_error(f"Ошибка при получении JS-фильтров: {str(e)}", "Formula Editor") + # В случае ошибки, считаем все фильтры стандартными + standard_filters = processed_filters + custom_filters = {} + + return standard_filters, custom_filters + +@frappe.whitelist() +def apply_custom_filters(data, custom_filters, columns): + """Применяет пользовательские фильтры к результатам отчета""" + filtered_data = [] + + # Для каждой строки результата проверяем соответствие пользовательским фильтрам + for row in data: + match = True + + for filter_name, filter_value in custom_filters.items(): + # Проверяем, есть ли поле в строке + if isinstance(row, dict) and filter_name in row: + # Если значение фильтра не совпадает со значением в строке, исключаем строку + row_value = str(row[filter_name]).lower() if row[filter_name] is not None else "" + filter_str = str(filter_value).lower() + if filter_str != row_value: + match = False + break + elif not isinstance(row, dict): + # Для строк в виде списков ищем колонку по имени + column_idx = None + for i, col in enumerate(columns): + if isinstance(col, dict) and col.get('fieldname') == filter_name: + column_idx = i + break + elif isinstance(col, str) and filter_name in col.lower(): + column_idx = i + break + + if column_idx is not None and column_idx < len(row): + row_value = str(row[column_idx]).lower() if row[column_idx] is not None else "" + filter_str = str(filter_value).lower() + if filter_str != row_value: + match = False + break + + # Если строка соответствует всем фильтрам, добавляем её в результат + if match: + filtered_data.append(row) + + return filtered_data + +@frappe.whitelist() +def extract_field_from_report(data, columns, field_name): + """Извлекает значение конкретного поля из результатов отчета""" + + # Логгируем входящие параметры + frappe.log_error(f"ОТЧЕТ - field_name: {field_name}", "Report Debug") + frappe.log_error(f"ОТЧЕТ - columns: {columns[:10] if columns else 'None'}", "Report Debug") + frappe.log_error(f"ОТЧЕТ - data count: {len(data) if data else 0}", "Report Debug") + + if data and len(data) > 0: + frappe.log_error(f"ОТЧЕТ - first row type: {type(data[0])}", "Report Debug") + frappe.log_error(f"ОТЧЕТ - first row content: {data[0]}", "Report Debug") + + # Проверяем, есть ли данные + if not data: + frappe.log_error("ОТЧЕТ - No data returned", "Report Debug") + return 0 + + total = 0 + count = 0 + + # Определяем индекс колонки по имени поля + column_idx = None + for i, col in enumerate(columns): + frappe.log_error(f"ОТЧЕТ - checking column {i}: {col}", "Report Debug") + + if isinstance(col, dict): + if col.get('fieldname') == field_name or col.get('label') == field_name: + column_idx = i + frappe.log_error(f"ОТЧЕТ - found column by name/label at index {i}", "Report Debug") + break + elif isinstance(col, str) and (col == field_name or field_name.lower() in col.lower()): + column_idx = i + frappe.log_error(f"ОТЧЕТ - found column by string at index {i}: {col}", "Report Debug") + break + + frappe.log_error(f"ОТЧЕТ - column_idx: {column_idx}", "Report Debug") + + # Если колонка не найдена, ищем ее по любым подстрокам в имени + if column_idx is None: + for i, col in enumerate(columns): + col_name = "" + if isinstance(col, dict): + col_name = col.get('fieldname', '') or col.get('label', '') + elif isinstance(col, str): + col_name = col + + # Проверка на частичное совпадение + if field_name.lower() in col_name.lower() or "balance" in col_name.lower(): + column_idx = i + frappe.log_error(f"ОТЧЕТ - found column by partial match at index {i}: {col_name}", "Report Debug") + break + + # Если колонка найдена, суммируем значения + if column_idx is not None: + for idx, row in enumerate(data[:10]): # Логируем только первые 10 строк для производительности + frappe.log_error(f"ОТЧЕТ - processing row {idx}: {row}", "Report Debug") + + if isinstance(row, dict) and field_name in row: + value = row[field_name] + frappe.log_error(f"ОТЧЕТ - dict row value: {value}", "Report Debug") + elif not isinstance(row, dict) and column_idx < len(row): + value = row[column_idx] + frappe.log_error(f"ОТЧЕТ - list row value at index {column_idx}: {value}", "Report Debug") + else: + # Дополнительная проверка для словарей - может, поле есть с другим регистром + if isinstance(row, dict): + # Проходим по всем ключам и ищем соответствие без учета регистра + for key in row: + if key.lower() == field_name.lower() or "balance" in key.lower(): + value = row[key] + frappe.log_error(f"ОТЧЕТ - found value by case-insensitive key {key}: {value}", "Report Debug") + break + else: + frappe.log_error(f"ОТЧЕТ - no value found in row, available keys: {list(row.keys()) if isinstance(row, dict) else 'N/A'}", "Report Debug") + continue + else: + frappe.log_error(f"ОТЧЕТ - no value found in row", "Report Debug") + continue + + # Пытаемся преобразовать в число + try: + if value is not None: + num_value = float(value) + total += num_value + count += 1 + frappe.log_error(f"ОТЧЕТ - added value: {num_value}, total now: {total}", "Report Debug") + except (ValueError, TypeError): + frappe.log_error(f"ОТЧЕТ - could not convert value to float: {value}", "Report Debug") + pass + else: + # Если колонка не найдена, попробуем найти первое числовое поле как заглушку + frappe.log_error(f"ОТЧЕТ - column not found, trying to find any numeric field", "Report Debug") + for idx, row in enumerate(data[:5]): + if isinstance(row, dict): + for key, value in row.items(): + try: + # Проверяем, можно ли преобразовать в число + if value is not None: + num_value = float(value) + frappe.log_error(f"ОТЧЕТ - found numeric value in key {key}: {num_value}", "Report Debug") + total += num_value + count += 1 + break + except (ValueError, TypeError): + continue + elif isinstance(row, list): + for i, value in enumerate(row): + try: + if value is not None: + num_value = float(value) + frappe.log_error(f"ОТЧЕТ - found numeric value at index {i}: {num_value}", "Report Debug") + total += num_value + count += 1 + break + except (ValueError, TypeError): + continue + + frappe.log_error(f"ОТЧЕТ - final total: {total}, count: {count}", "Report Debug") + # Возвращаем сумму или 0, если не удалось найти + return total + +@frappe.whitelist() +def get_report_fields(report_name, filters=None): + """Получает поля из отчета для использования в Formula Editor""" + if not report_name: + frappe.throw("Не указано имя отчета") + + try: + # Если переданы фильтры, обрабатываем их + if filters and isinstance(filters, str): + try: + filters = json.loads(filters) + except json.JSONDecodeError: + frappe.throw(f"Ошибка при разборе JSON фильтров: {filters}") + + # Получаем метаданные отчета + report_doc = frappe.get_doc("Report", report_name) + + # Проверяем, что это Script Report + if report_doc.report_type != "Script Report": + frappe.throw("Выбранный отчет должен быть типа 'Script Report'") + + # Специальная обработка для отчета Cash Flow + if report_name.lower() in ["cash flow", "cash_flow"]: + # Предопределенные поля для отчета Cash Flow + return [ + {"fieldname": "total_cash", "label": "Total Cash", "fieldtype": "Currency"}, + {"fieldname": "net_cash", "label": "Net Cash", "fieldtype": "Currency"}, + {"fieldname": "opening_balance", "label": "Opening Balance", "fieldtype": "Currency"}, + {"fieldname": "closing_balance", "label": "Closing Balance", "fieldtype": "Currency"}, + {"fieldname": "inflow", "label": "Cash Inflow", "fieldtype": "Currency"}, + {"fieldname": "outflow", "label": "Cash Outflow", "fieldtype": "Currency"} + ] + + # Специальная обработка для отчета General Ledger + if report_name.lower() in ["general ledger", "general_ledger"]: + return [ + {"fieldname": "debit", "label": "Debit", "fieldtype": "Currency"}, + {"fieldname": "credit", "label": "Credit", "fieldtype": "Currency"}, + {"fieldname": "balance", "label": "Balance", "fieldtype": "Currency"}, + {"fieldname": "account", "label": "Account", "fieldtype": "Link"}, + {"fieldname": "party_type", "label": "Party Type", "fieldtype": "Link"}, + {"fieldname": "party", "label": "Party", "fieldtype": "Dynamic Link"}, + {"fieldname": "voucher_type", "label": "Voucher Type", "fieldtype": "Link"}, + {"fieldname": "voucher_no", "label": "Voucher No", "fieldtype": "Dynamic Link"}, + {"fieldname": "against_account", "label": "Against Account", "fieldtype": "Link"}, + {"fieldname": "debit_in_account_currency", "label": "Debit in Account Currency", "fieldtype": "Currency"}, + {"fieldname": "credit_in_account_currency", "label": "Credit in Account Currency", "fieldtype": "Currency"}, + {"fieldname": "balance_in_account_currency", "label": "Balance in Account Currency", "fieldtype": "Currency"} + ] + + # Для остальных отчетов - стандартная обработка + from frappe.desk.query_report import run + + # Добавляем фильтр компании, если его нет, но он требуется + if filters is None: + filters = {} + + if 'company' not in filters: + default_company = frappe.defaults.get_user_default("Company") or frappe.db.get_single_value("Global Defaults", "default_company") + if default_company: + filters['company'] = default_company + + # Добавляем фильтры дат, если их нет + if 'from_date' not in filters: + filters['from_date'] = frappe.utils.get_first_day(frappe.utils.nowdate()) + if 'to_date' not in filters: + filters['to_date'] = frappe.utils.nowdate() + + # Используем базовые фильтры или переданные пользователем + base_filters = filters or { + "from_date": frappe.utils.get_first_day(frappe.utils.nowdate()), + "to_date": frappe.utils.nowdate() + } + + # Преобразуем строки в объекты datetime.date + for key in ["from_date", "to_date"]: + if key in base_filters and isinstance(base_filters[key], str): + try: + base_filters[key] = getdate(base_filters[key]) + except Exception: + pass + + # Выполняем запрос отчета для получения колонок + try: + result = run(report_name, filters=base_filters) + except Exception as e: + frappe.throw(f"Ошибка при выполнении отчета: {str(e)}") + + + if not result: + frappe.throw("Отчет не вернул результатов") + + if not result.get('columns'): + frappe.throw("В результатах отчета отсутствуют колонки") + + # Получаем колонки отчета + columns = result.get('columns', []) + + fields = [] + + # Преобразуем колонки в удобный формат + for i, col in enumerate(columns): + field_data = {} + + if isinstance(col, dict): + field_data = { + "fieldname": col.get('fieldname') or f"column_{i}", + "label": col.get('label') or f"Column {i+1}", + "fieldtype": col.get('fieldtype') or "Data" + } + else: + # Для случая, когда колонка просто строка + field_data = { + "fieldname": f"column_{i}", + "label": col, + "fieldtype": "Data" + } + + # Добавляем только числовые поля и поля, которые могут быть преобразованы в числа + if field_data.get('fieldtype') in ['Int', 'Float', 'Currency', 'Percent'] or \ + any(numeric_term in field_data.get('label', '').lower() for numeric_term in ['amount', 'total', 'sum', 'qty', 'price', 'rate', 'number']): + fields.append(field_data) + + if not fields: + # Анализируем данные для поиска числовых полей, даже если они не отмечены как числовые + data = result.get('result', []) + if data and isinstance(data, list) and len(data) > 0: + # Если первая строка является словарем + if isinstance(data[0], dict): + for key, value in data[0].items(): + if isinstance(value, (int, float)) and key not in [f['fieldname'] for f in fields]: + fields.append({ + "fieldname": key, + "label": key.replace('_', ' ').title(), + "fieldtype": "Currency" if any(term in key.lower() for term in ['amount', 'total', 'balance']) else "Float" + }) + # Если первая строка является списком + elif isinstance(data[0], list) and len(data[0]) == len(columns): + for i, value in enumerate(data[0]): + if isinstance(value, (int, float)) and i < len(columns): + col_name = f"column_{i}" + col_label = columns[i] if isinstance(columns[i], str) else (columns[i].get('label') or f"Column {i+1}") + + if col_name not in [f['fieldname'] for f in fields]: + fields.append({ + "fieldname": col_name, + "label": col_label, + "fieldtype": "Currency" if any(term in col_label.lower() for term in ['amount', 'total', 'balance']) else "Float" + }) + + if not fields: + frappe.throw(f"В отчете не найдено числовых полей. Пожалуйста, выберите другой отчет или свяжитесь с администратором.") + + return fields + + except Exception as e: + frappe.throw(f"Ошибка при получении полей отчета: {str(e)}") + +@frappe.whitelist() +def get_report_filters_dynamic(report_name): + """Получить доступные фильтры для указанного отчета в формате для динамического создания полей""" + if not report_name: + return [] + + try: + # Получаем документ отчета + from frappe.desk.query_report import get_report_doc, get_script + report_doc = get_report_doc(report_name) + + # Проверяем, что отчет - script report + if report_doc.report_type != "Script Report": + frappe.throw("Выбранный отчет должен быть типа 'Script Report'") + + # Получаем фильтры из метаданных отчета + filters_meta = report_doc.get('filters') or [] + + # Преобразуем фильтры в формат, удобный для создания полей + formatted_filters = [] + + for filter_meta in filters_meta: + # Обработка фильтров в формате списка + if isinstance(filter_meta, list) and len(filter_meta) > 0: + filter_data = { + 'fieldname': filter_meta[0], + 'label': filter_meta[1] if len(filter_meta) > 1 else filter_meta[0], + 'fieldtype': filter_meta[2] if len(filter_meta) > 2 else 'Data', + 'options': filter_meta[3] if len(filter_meta) > 3 else None, + 'default': filter_meta[4] if len(filter_meta) > 4 else None, + 'reqd': filter_meta[5] if len(filter_meta) > 5 else 0 + } + formatted_filters.append(filter_data) + + # Обработка фильтров в формате словаря + elif isinstance(filter_meta, dict) and filter_meta.get('fieldname'): + filter_data = { + 'fieldname': filter_meta.get('fieldname'), + 'label': filter_meta.get('label') or filter_meta.get('fieldname'), + 'fieldtype': filter_meta.get('fieldtype') or 'Data', + 'options': filter_meta.get('options'), + 'default': filter_meta.get('default'), + 'reqd': filter_meta.get('reqd') or 0, + 'description': filter_meta.get('description') + } + formatted_filters.append(filter_data) + + # Если фильтров нет в метаданных, пробуем запустить отчет с минимальными фильтрами + if not formatted_filters: + # Пытаемся запустить отчет с минимальными фильтрами + # Базовые фильтры, которые часто требуются отчетам + base_filters = { + "from_date": frappe.utils.get_first_day(frappe.utils.nowdate()), + "to_date": frappe.utils.nowdate() + } + + # Используем безопасный способ выполнения отчета через API + from frappe.desk.query_report import run + try: + result = run(report_name, filters=base_filters) + + # Если отчет выполнился успешно, используем базовые фильтры + if isinstance(result, dict) and not result.get('message'): + # Добавляем базовые фильтры, которые использовались для успешного запуска + for key, value in base_filters.items(): + fieldtype = 'Date' if 'date' in key.lower() else 'Data' + + formatted_filters.append({ + 'fieldname': key, + 'label': key.replace('_', ' ').title(), + 'fieldtype': fieldtype, + 'default': value, + 'reqd': 1 # Считаем базовые фильтры обязательными + }) + except Exception as e: + frappe.log_error(f"Ошибка при выполнении отчета с базовыми фильтрами: {str(e)}", "Formula Editor") + + # Анализируем ошибку на предмет недостающих фильтров + error_msg = str(e) + missing_filter_names = extract_filter_names_from_error(error_msg) + + # Добавляем базовые фильтры + for key, value in base_filters.items(): + fieldtype = 'Date' if 'date' in key.lower() else 'Data' + + formatted_filters.append({ + 'fieldname': key, + 'label': key.replace('_', ' ').title(), + 'fieldtype': fieldtype, + 'default': value, + 'reqd': 1 if key in missing_filter_names else 0 + }) + + # Добавляем фильтры, упомянутые в сообщении об ошибке + for filter_name in missing_filter_names: + if not any(f['fieldname'] == filter_name for f in formatted_filters): + filter_data = { + 'fieldname': filter_name, + 'label': filter_name.replace('_', ' ').title(), + 'fieldtype': 'Data', + 'reqd': 1 + } + + # Предположения о типе поля на основе названия + if 'date' in filter_name.lower(): + filter_data['fieldtype'] = 'Date' + elif 'company' in filter_name.lower(): + filter_data['fieldtype'] = 'Link' + filter_data['options'] = 'Company' + elif 'item' == filter_name.lower(): + filter_data['fieldtype'] = 'Link' + filter_data['options'] = 'Item' + elif 'group' in filter_name.lower() and 'item' in filter_name.lower(): + filter_data['fieldtype'] = 'Link' + filter_data['options'] = 'Item Group' + + formatted_filters.append(filter_data) + + # Получаем дефолтную компанию + default_company = frappe.defaults.get_user_default("Company") or frappe.db.get_single_value("Global Defaults", "default_company") + + # Находим и обновляем фильтр компании или добавляем его, если отсутствует + company_filter = next((f for f in formatted_filters if f['fieldname'] == 'company'), None) + + if company_filter: + company_filter['default'] = default_company + company_filter['hidden'] = 1 # Скрываем фильтр компании всегда + else: + # Добавляем скрытый фильтр компании, если его нет + formatted_filters.append({ + 'fieldname': 'company', + 'label': 'Company', + 'fieldtype': 'Link', + 'options': 'Company', + 'default': default_company, + 'reqd': 1, + 'hidden': 1 # Скрываем фильтр компании всегда + }) + + return formatted_filters + + except Exception as e: + frappe.log_error(f"Ошибка при получении фильтров отчета: {str(e)}", "Formula Editor") + return [] + + +def extract_filter_names_from_error(error_msg): + """Извлечь имена отсутствующих фильтров из сообщения об ошибке""" + filter_names = set() + + # Типичные шаблоны ошибок в отчетах Frappe + import re + + # Например: "Mandatory filters required: company, from_date" + mandatory_pattern = re.search(r'Mandatory\s+filters\s+required\s*:\s*(.*)', error_msg, re.IGNORECASE) + if mandatory_pattern: + filter_list = mandatory_pattern.group(1).strip() + for filter_name in filter_list.split(','): + filter_name = filter_name.strip() + if filter_name: + filter_names.add(filter_name) + + # Например: "Filter 'company' is mandatory" + single_filter_pattern = re.findall(r'[Ff]ilter\s+[\'"]([^\'"]+)[\'"]', error_msg) + for filter_name in single_filter_pattern: + filter_names.add(filter_name) + + # Например: "Missing parameter: company" + missing_param_pattern = re.findall(r'[Mm]issing\s+parameter\s*:\s*([^\s,]+)', error_msg) + for filter_name in missing_param_pattern: + filter_names.add(filter_name) + + return list(filter_names) diff --git a/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js index ed01c9a..afefb4f 100644 --- a/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js +++ b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js @@ -5,7 +5,12 @@ frappe.ui.form.on('Formula Editor', { frm.set_value("universal_fields_storage", "{}"); } - addTokenStyles() + // Инициализация хранилища для полей отчетов + if (!frm.doc.report_fields_storage) { + frm.set_value("report_fields_storage", "{}"); + } + + addTokenStyles(); // Инициализация хранилища для формул строк табличных частей if (!frm.doc.table_row_formulas_storage) { @@ -118,52 +123,14 @@ function generate_client_script(frm) { let formula_data = JSON.parse(frm.doc.formula_storage || "{}"); let universal_fields = JSON.parse(frm.doc.universal_fields_storage || "{}"); let table_row_formulas = JSON.parse(frm.doc.table_row_formulas_storage || "{}"); + let report_fields = JSON.parse(frm.doc.report_fields_storage || "{}"); let main_script = `// Generated by Formula Editor\nfrappe.ui.form.on("${target_doctype}", {\n`; let functions = []; let table_child_scripts = {}; let table_doctypes = {}; - - // Получаем информацию о таблицах DocType - getTableDoctypes(target_doctype, table_doctypes); + let refreshHandlers = []; // Добавим это для хранения обработчиков refresh - // Проверяем содержимое table_row_formulas - - // Сбор всех табличных частей и их полей - let child_tables = {}; - collectChildTables(formula_data, child_tables); - - // Добавляем функцию для расчета универсальных полей - functions.push(generateUniversalFieldsFunction(universal_fields)); - - // Добавляем обработчики для ay и il - для расчета универсальных полей - main_script += ` "ay": async function(frm) { - // Вычисляем универсальные поля только когда меняется ay - await calculate_universal_fields(frm); - },\n`; - - main_script += ` "il": async function(frm) { - // Вычисляем универсальные поля только когда меняется il - await calculate_universal_fields(frm); - },\n`; - - // Добавляем функцию преобразования выражений - functions.push(generateTransformExpressionFunction(universal_fields)); - - // Добавляем функцию для работы с конкретными строками табличных частей - functions.push(generateTableRowAccessFunction()); - - // Создаем список всех полей, которые используют универсальные поля - let fieldsUsingUniversal = []; - - // Проверяем, какие поля используют универсальные поля - Object.keys(formula_data).forEach(fieldname => { - let field_info = formula_data[fieldname]; - if (field_info && field_info.formula && field_info.formula.includes("univ(")) { - fieldsUsingUniversal.push(fieldname); - } - }); - // Собираем информацию о табличных триггерах - ключ: table_field, значение: массив полей основного доктайпа const table_field_triggers = {}; @@ -188,7 +155,8 @@ function generate_client_script(frm) { // Сохраняем поле основного документа и признак асинхронности table_field_triggers[trigger].push({ main_field: fieldname, - uses_universal: field_info.formula.includes("univ(") + uses_universal: field_info.formula.includes("univ("), + uses_reports: field_info.formula.includes("report(") }); } } @@ -201,24 +169,157 @@ function generate_client_script(frm) { Object.keys(table_field_triggers).forEach(trigger => { tableTriggersSet.add(trigger); }); + + // Получаем информацию о таблицах DocType + getTableDoctypes(target_doctype, table_doctypes); - // Отдельно собираем все поля, которые используют предопределенное событие "При открытии документа" - const refreshHandlers = []; + // Сбор всех табличных частей и их полей + let child_tables = {}; + collectChildTables(formula_data, child_tables); + + // Добавляем функцию для обработки и выполнения формул + functions.push(` + // Функция для обработки и выполнения формул + async function processFormula(frm, formula) { + try { + // Проверяем наличие вызовов отчетов + if (formula.includes("report(")) { + // Извлекаем все вызовы report() из формулы + const reportRegex = /report\\(([^)]+)\\)/g;\n; + let match; + let processedFormula = formula; + + // Массив для хранения промисов запросов к отчетам + const reportPromises = []; + const reportPlaceholders = []; + + // Находим все вызовы report() и заменяем их на плейсхолдеры + while ((match = reportRegex.exec(formula)) !== null) { + const fullMatch = match[0]; // report(fieldName) + const fieldName = match[1]; // fieldName + + // Создаем уникальный плейсхолдер + const placeholder = \`__REPORT_PLACEHOLDER_\${reportPromises.length}__\`; + + // Ищем данные отчета в хранилище + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + const reportField = Object.values(reportFields).find(f => f.name === fieldName); + + if (reportField) { + // Добавляем промис запроса к отчету + reportPromises.push( + getReportFieldValue( + reportField.report, + JSON.parse(reportField.filters_json || "{}"), + reportField.field_name, + reportField.aggregation || 'sum' + ) + ); + + // Запоминаем соответствие плейсхолдера и вызова + reportPlaceholders.push({ + placeholder: placeholder, + original: fullMatch + }); + + // Заменяем вызов отчета на плейсхолдер + processedFormula = processedFormula.replace(fullMatch, placeholder); + } + } + + // Если нашли вызовы отчетов, ждем выполнения всех запросов + if (reportPromises.length > 0) { + const reportResults = await Promise.all(reportPromises); + + // Заменяем плейсхолдеры на результаты + for (let i = 0; i < reportPlaceholders.length; i++) { + processedFormula = processedFormula.replace( + reportPlaceholders[i].placeholder, + reportResults[i] !== undefined ? reportResults[i] : 0 + ); + } + + // Теперь формула не содержит вызовов report(), обрабатываем её обычным способом + let transformResult = transform_expression(processedFormula, false); + let result = eval(transformResult.transformed); + + // Возвращаем результат + return isNaN(result) ? 0 : result; + } + } + + // Стандартная обработка для формул без отчетов + let transformResult = transform_expression(formula, false); + let result = eval(transformResult.transformed); + return isNaN(result) ? 0 : result; + } catch (e) { + console.error("Error processing formula:", formula, e); + return 0; + } + }`); + + // Добавляем функцию для расчета универсальных полей + functions.push(generateUniversalFieldsFunction(universal_fields)); + + // Добавляем функцию для получения значений полей отчетов + if (Object.keys(report_fields).length > 0) { + functions.push(generateReportFieldsFunction(report_fields)); + } + + // Добавляем обработчики для ay и il - для расчета универсальных полей + main_script += ` "ay": async function(frm) { + // Вычисляем универсальные поля только когда меняется ay + await calculate_universal_fields(frm); + },\n`; + + main_script += ` "il": async function(frm) { + // Вычисляем универсальные поля только когда меняется il + await calculate_universal_fields(frm); + },\n`; + + // Добавляем функцию преобразования выражений + functions.push(generateTransformExpressionFunction(universal_fields, report_fields)); + + // Добавляем функцию для работы с конкретными строками табличных частей + functions.push(generateTableRowAccessFunction()); + + // Создаем список всех полей, которые используют универсальные поля или отчеты + let fieldsUsingUniversalOrReports = []; + + // Проверяем, какие поля используют универсальные поля или отчеты + Object.keys(formula_data).forEach(fieldname => { + let field_info = formula_data[fieldname]; + if (field_info && field_info.formula) { + if (field_info.formula.includes("univ(")) { + fieldsUsingUniversalOrReports.push({ + field: fieldname, + uses_universal: true, + uses_reports: field_info.formula.includes("report(") + }); + } else if (field_info.formula.includes("report(")) { + fieldsUsingUniversalOrReports.push({ + field: fieldname, + uses_universal: false, + uses_reports: true + }); + } + } + }); // Обрабатываем все формулы для основного документа Object.keys(formula_data).forEach(fieldname => { let field_info = formula_data[fieldname]; if (!field_info || typeof field_info !== "object" || !field_info.formula) { - // console.warn(`⚠️ Пропущено поле ${fieldname}, так как оно не содержит формулу.`); return; } // Создаем функцию расчета для поля let is_table_field = field_info.is_table_field; let formula = field_info.formula.trim(); - let uses_universal = formula.includes("univ("); + // Все функции расчета делаем асинхронными для единообразия, + // так как мы не можем точно определить все случаи использования report() if (is_table_field) { // Обработка полей табличных частей... let parts = fieldname.split('_'); @@ -230,20 +331,29 @@ function generate_client_script(frm) { doctype: table_doctypes[table_name] || `${target_doctype} Item`, calculations: [], fields: new Set(), - uses_universal: false + uses_universal: false, + uses_reports: false }; } + // Определяем, нужна ли асинхронная функция + let uses_universal = formula.includes("univ("); + let uses_reports = formula.includes("report("); + table_child_scripts[table_name].calculations.push({ field: field_name_in_table, formula: formula, - uses_universal: uses_universal + uses_universal: uses_universal, + uses_reports: uses_reports }); - // Помечаем таблицу, если хотя бы одно поле использует универсальные поля + // Помечаем таблицу, если хотя бы одно поле использует универсальные поля или отчеты if (uses_universal) { table_child_scripts[table_name].uses_universal = true; } + if (uses_reports) { + table_child_scripts[table_name].uses_reports = true; + } if (field_info.triggers) { field_info.triggers.split(',').map(t => t.trim()).forEach(trigger => { @@ -251,43 +361,42 @@ function generate_client_script(frm) { }); } } else { - // Для полей основного документа - // Если формула использует универсальные поля, функция должна быть асинхронной - if (uses_universal) { - functions.push(` -async function calculate_${fieldname}(frm) { -try { - // Сначала вычисляем универсальные поля - await calculate_universal_fields(frm); - - // Затем оцениваем формулу - let result = eval(transform_expression("${formula}", false)); - - // Проверка на NaN и преобразование в 0 - if (isNaN(result)) { - result = 0; - } - - frm.set_value("${fieldname}", result); -} catch (e) { -} -}`); - } else { - functions.push(` -function calculate_${fieldname}(frm) { -try { - let result = eval(transform_expression("${formula}", false)); - - // Проверка на NaN и преобразование в 0 - if (isNaN(result)) { - result = 0; - } - - frm.set_value("${fieldname}", result); -} catch (e) { -} -}`); - } + // Используем единый подход для всех формул через transform_expression + functions.push(` + async function calculate_${fieldname}(frm) { + try { + ${formula.includes("univ(") ? 'await calculate_universal_fields(frm);' : ''} + const { transformed, needsAsync, reportCalls } = transform_expression(${JSON.stringify(formula)}, false); + + // Обработка вызовов отчетов, если они есть + let finalExpression = transformed; + if (reportCalls && reportCalls.length > 0) { + // Собираем все результаты отчетов + const reportResults = []; + for (const call of reportCalls) { + const result = await getReportFieldValue( + frm, + call.reportName, + call.filters, + call.fieldName, + call.aggregation + ); + reportResults.push({ placeholder: call.placeholder, value: result }); + } + + // Заменяем плейсхолдеры на полученные значения + for (const result of reportResults) { + finalExpression = finalExpression.replace(result.placeholder, result.value); + } + } + + // Теперь можно безопасно вычислить выражение + const result = eval(finalExpression); + frm.set_value("${fieldname}", isNaN(result) ? 0 : result); + } catch (e) { + console.error("Error calculating ${fieldname}:", e); + } + }`); // Проверяем триггеры if (field_info.triggers) { @@ -300,7 +409,8 @@ try { // Добавляем в список обработчиков refresh refreshHandlers.push({ field: fieldname, - uses_universal: uses_universal + uses_universal: formula.includes("univ("), + uses_reports: formula.includes("report(") }); // Удаляем предопределенное событие из списка триггеров @@ -315,12 +425,8 @@ try { uniqueTriggers.forEach(trigger => { if (trigger) { - // Добавляем обработчик для обычного поля - if (uses_universal) { - main_script += ` "${trigger}": async function(frm) { await calculate_${fieldname}(frm); },\n`; - } else { - main_script += ` "${trigger}": function(frm) { calculate_${fieldname}(frm); },\n`; - } + // Добавляем обработчик для обычного поля (всегда асинхронный) + main_script += ` "${trigger}": async function(frm) { await calculate_${fieldname}(frm); },\n`; } }); } @@ -335,8 +441,15 @@ try { // Добавляем обработчик refresh, если были найдены формулы с триггером "При открытии документа" if (refreshHandlers.length > 0 || Object.keys(table_row_formulas).length > 0) { + // Определяем, нужна ли асинхронная функция для refresh + const needsAsync = refreshHandlers.some(h => h.uses_universal || h.uses_reports) || + Object.keys(table_row_formulas).some(id => { + const formula = table_row_formulas[id]; + return formula.formula.includes("univ(") || formula.formula.includes("report("); + }); + // Создаем обработчик refresh с вызовом всех нужных функций расчета - let refreshHandlerBody = ` "refresh": ${refreshHandlers.some(h => h.uses_universal) ? 'async ' : ''}function(frm) {\n`; + let refreshHandlerBody = ` "refresh": ${needsAsync ? 'async ' : ''}function(frm) {\n`; // Если есть формулы для строк табличных частей, инициализируем их if (Object.keys(table_row_formulas).length > 0) { @@ -344,17 +457,19 @@ try { } // Добавляем вызовы всех обработчиков - if (refreshHandlers.some(h => h.uses_universal)) { + const universalUsed = refreshHandlers.some(h => h.uses_universal); + if (universalUsed) { refreshHandlerBody += ` await calculate_universal_fields(frm);\n`; } - refreshHandlers.forEach(handler => { - if (handler.uses_universal) { + // Асинхронно вызываем функции расчета полей + for (const handler of refreshHandlers) { + if (handler.uses_universal || handler.uses_reports) { refreshHandlerBody += ` await calculate_${handler.field}(frm);\n`; } else { refreshHandlerBody += ` calculate_${handler.field}(frm);\n`; } - }); + } refreshHandlerBody += ' },\n'; @@ -379,6 +494,168 @@ try { update_or_create_client_script(target_doctype, final_script); } +function generateReportFieldsFunction(report_fields) { + return ` + // Функция для получения значения поля отчета + async function getReportFieldValue(frm, reportName, filters, fieldName, aggregation = 'sum') { + try { + // Обрабатываем фильтры с возможными ссылками на поля документа + const processedFilters = {}; + + // Используем for...in loop для перебора свойств объекта filters + for (const key in filters) { + let value = filters[key]; + + // Специальная обработка для $DefaultCompany + if (value === "$DefaultCompany") { + value = frm.doc.company || frappe.defaults.get_user_default("Company") || ""; + } + + // Специальная обработка для $AyIlSpecial + if (value === "$AyIlSpecial") { + // Проверяем, есть ли у документа поля ay и il + if (frm.doc["ay"] !== undefined && frm.doc["il"] !== undefined) { + // Обрабатываем специальные поля ay и il для получения диапазона дат + const dateRange = processAzerbayjanDateField({ + ay: frm.doc["ay"], + il: frm.doc["il"] + }); + + // Если удалось получить диапазон дат, заменяем фильтр только на нужные условия + if (dateRange) { + // Удаляем исходный фильтр + delete processedFilters[key]; + + // Проверяем, относится ли фильтр к начальной или конечной дате + if (key.includes('from_date')) { + // Для начальной даты используем только >= + processedFilters[\`\${key}>\`] = dateRange.start; + } else if (key.includes('to_date')) { + // Для конечной даты используем только <= + processedFilters[\`\${key}<\`] = dateRange.end; + } else { + // Для других полей используем оба условия как раньше + processedFilters[\`\${key}>\`] = dateRange.start; + processedFilters[\`\${key}<\`] = dateRange.end; + } + + // Переходим к следующему фильтру + continue; + } + } + } + + // Проверяем, является ли значение ссылкой на поле документа + if (typeof value === 'string' && value.startsWith('doc.')) { + const docField = value.substring(4); // Убираем 'doc.' + + // Проверяем, является ли это специальным полем ay или il + if (docField === 'ay' || docField === 'il') { + // Получаем значения ay и il из документа + if (frm.doc["ay"] !== undefined && frm.doc["il"] !== undefined) { + // Обрабатываем специальные поля ay и il для получения диапазона дат + const dateRange = processAzerbayjanDateField({ + ay: frm.doc["ay"], + il: frm.doc["il"] + }); + + // Если удалось получить диапазон дат, создаем правильные фильтры + if (dateRange) { + // Удаляем исходный фильтр, если он есть + if (processedFilters.hasOwnProperty(key)) { + delete processedFilters[key]; + } + + // Проверяем, относится ли фильтр к начальной или конечной дате + if (key.includes('from_date')) { + // Для начальной даты используем только >= + processedFilters[\`\${key}>=\`] = dateRange.start; + } else if (key.includes('to_date')) { + // Для конечной даты используем только <= + processedFilters[\`\${key}<=\`] = dateRange.end; + } else { + // Для других полей используем оба условия + processedFilters[\`\${key}>=\`] = dateRange.start; + processedFilters[\`\${key}<=\`] = dateRange.end; + } + + // Переходим к следующему фильтру + continue; + } + } + } else if (frm.doc[docField] !== undefined) { + // Берем значение из документа + value = frm.doc[docField]; + } else { + // Если поле не найдено, пропускаем фильтр + continue; + } + } + + // Добавляем обработанное значение в фильтры + processedFilters[key] = value; + } + + // Проверяем кеш для экономии запросов + if (!window.reportCache) window.reportCache = {}; + + const cacheKey = \`\${reportName}|\${JSON.stringify(processedFilters)}|\${fieldName}|\${aggregation}\`; + + // Если есть в кеше и кеш не устарел (5 минут) + const now = new Date().getTime(); + if (window.reportCache[cacheKey] && + window.reportCache[cacheKey].timestamp > now - 5 * 60 * 1000) { + return window.reportCache[cacheKey].value; + } + + console.log("DEBUG: Вызов getReportFieldValue для отчета:", reportName, "Фильтры:", processedFilters, "Поле:", fieldName); + + // Выполняем запрос к серверу с обработанными фильтрами + try { + const result = await frappe.call({ + method: "formula_editor.api.run_report_for_formula", + args: { + report_name: reportName, + filters: processedFilters, + field_name: fieldName + }, + freeze: false // Не блокируем интерфейс + }); + + // Проверяем результат + if (result && result.message) { + if (result.message.error) { + console.error(\`Ошибка при выполнении отчета \${reportName}:\`, result.message.error); + return 0; + } + + // Получаем числовое значение + let value = parseFloat(result.message); + if (isNaN(value)) value = 0; + + // Применяем агрегацию, если нужно + // На данный момент сервер всегда возвращает сумму, поэтому здесь ничего не делаем + + // Сохраняем в кеш + window.reportCache[cacheKey] = { + value: value, + timestamp: now + }; + + return value; + } + } catch (err) { + console.error(\`Ошибка вызова API для отчета \${reportName}:\`, err); + } + + return 0; + } catch (error) { + console.error(\`Ошибка при получении значения поля отчета:\`, error); + return 0; + } + }`; +} + // Новая функция для генерации скриптов дочерних таблиц с расширенным логированием function generate_child_table_script(table_child_scripts, table_doctypes, table_field_triggers, formula_data, functions) { let child_scripts = ''; @@ -562,10 +839,12 @@ function generateUniversalFieldsFunction(universal_fields) { if (dateRange) { // Добавляем два фильтра для диапазона дат - filters_${fieldId}.push(["${filter.field}", ">=", dateRange.start]); - filters_${fieldId}.push(["${filter.field}", "<=", dateRange.end]); + if (!filters_${fieldId}.some(f => f[0] === "${filter.field}" && f[1] === ">=")) + filters_${fieldId}.push(["${filter.field}", ">=", dateRange.start]); + if (!filters_${fieldId}.some(f => f[0] === "${filter.field}" && f[1] === "<")) + filters_${fieldId}.push(["${filter.field}", "<=", dateRange.end]); } else { - skipCalculation_${fieldId} = true; + skipCalculation_${fieldId} = true; } } else { skipCalculation_${fieldId} = true; @@ -655,8 +934,8 @@ function generateUniversalFieldsFunction(universal_fields) { const lastDay = new Date(year, monthNum, 0); // Форматируем даты в формате YYYY.MM.DD - const firstDayStr = \`\${year}.\${String(monthNum).padStart(2, '0')}.01\`; - const lastDayStr = \`\${year}.\${String(monthNum).padStart(2, '0')}.\${String(lastDay.getDate()).padStart(2, '0')}\`; + const firstDayStr = \`\${year}-\${String(monthNum).padStart(2, '0')}-01\`; + const lastDayStr = \`\${year}-\${String(monthNum).padStart(2, '0')}-\${String(lastDay.getDate()).padStart(2, '0')}\`; // Возвращаем объект с диапазоном дат return { @@ -701,78 +980,126 @@ function generateRefreshFunction(formula_data) { return refreshCode; } -function generateTransformExpressionFunction(universal_fields) { - // Получаем список табличных префиксов для включения в сгенерированный скрипт - const tablePrefixes = getTablePrefixes(); - const tablePrefixesJSON = JSON.stringify(tablePrefixes); - +// Модифицированный вариант функции для безопасного включения JSON в строку кода +function generateTransformExpressionFunction(universal_fields, report_fields) { return ` - // Функция для преобразования выражений формул function transform_expression(expression, for_child_table = false, table_name = null) { - let transformed = expression; + let needsAsync = false; + let reportCalls = []; + + // === Обработка report(...) === + const reportRegex = /report\\(([^)]+)\\)/g;\n + let match; - // Обработка универсальных полей + // Находим все вызовы report() в выражении + while ((match = reportRegex.exec(expression)) !== null) { + const reportName = match[1]; // Имя поля отчета + const fullMatch = match[0]; // Полный текст match - "report(xyz)" + needsAsync = true; + + // Создаем уникальный плейсхолдер + const placeholder = \`__REPORT_\${reportCalls.length}__\`; + + // Ищем соответствующее поле отчета по имени + ${Object.keys(report_fields).map(fieldId => { + const field = report_fields[fieldId]; + if (!field || !field.name) return ''; + + // Создаем безопасную строку для JSON-фильтров + let filtersJson = "{}"; + try { + const filtersObj = JSON.parse(field.filters_json || "{}"); + + // Заменяем значения компании и дат на маркеры + if (filtersObj.company && typeof filtersObj.company === 'string' && !filtersObj.company.startsWith('$')) { + filtersObj.company = "$DefaultCompany"; + } + + if (filtersObj.from_date && typeof filtersObj.from_date === 'string') { + filtersObj.from_date = "$AyIlSpecial"; + } + if (filtersObj.to_date && typeof filtersObj.to_date === 'string') { + filtersObj.to_date = "$AyIlSpecial"; + } + if (filtersObj.date && typeof filtersObj.date === 'string') { + filtersObj.date = "$AyIlSpecial"; + } + + // Конвертируем объект в строку JSON и экранируем спецсимволы + filtersJson = JSON.stringify(filtersObj); + } catch (e) { + filtersJson = "{}"; + } + + return ` + // Проверяем совпадение имени отчета + if (reportName === "${field.name}") { + reportCalls.push({ + placeholder: placeholder, + reportName: "${field.report}", + filters: JSON.parse(${JSON.stringify(filtersJson)}), + fieldName: "${field.field_name}", + aggregation: "${field.aggregation || 'sum'}" + }); + + // Заменяем вызов report() на плейсхолдер + transformed = transformed.replace(fullMatch, placeholder); + }`; + }).join('')} + } + + // === Обработка universal полей === ${Object.keys(universal_fields).map(fieldId => { const field = universal_fields[fieldId]; - if (!field || !field.name) { - return ''; - } - return `transformed = transformed.replace(/univ\\(${field.name}\\)/g, "(frm.doc.__universalFields && frm.doc.__universalFields['${fieldId}'] || 0)");`; - }).filter(code => code !== '').join('\n ')} - - // Список известных префиксов таблиц - const tablePrefixes = ${tablePrefixesJSON}; - - // Функция суммирования по табличной части обрабатывается первой - for (const tablePrefix of tablePrefixes) { - const sumPattern = new RegExp(\`sum\\\\(\${tablePrefix}_([^)]+)\\\\)\`, 'g'); - transformed = transformed.replace(sumPattern, (match, fieldName) => { - return \`(frm.doc["\${tablePrefix}"] && frm.doc["\${tablePrefix}"].reduce((sum, row) => sum + (parseFloat(row["\${fieldName}"]) || 0), 0) || 0)\`; - }); - - // Проверяем использование val() для табличных полей (должны использоваться sum()) - const valTablePattern = new RegExp(\`val\\\\(\${tablePrefix}_([^)]+)\\\\)\`, 'g'); - transformed = transformed.replace(valTablePattern, (match, fieldName) => { - return \`(frm.doc["\${tablePrefix}"] && frm.doc["\${tablePrefix}"].reduce((sum, row) => sum + (parseFloat(row["\${fieldName}"]) || 0), 0) || 0)\`; - }); + if (!field || !field.name) return ''; + return `transformed = transformed.replace(/univ\\\\(${field.name}\\\\)/g, "(frm.doc.__universalFields && frm.doc.__universalFields['${fieldId}'] || 0)");`; + }).filter(Boolean).join('\n')} + + // === Обработка val() и row() === + const tablePrefixes = ${JSON.stringify([ + "items", "taxes", "packed_items", "payments", "advances", "timesheets", + "payment_schedule", "sales_team", "aksiz_test_table" + ])}; + + for (const prefix of tablePrefixes) { + const sumPattern = new RegExp(\`sum\\\\(\${prefix}_([^)]+)\\\\)\`, 'g'); + transformed = transformed.replace(sumPattern, (m, f) => + \`(frm.doc["\${prefix}"] && frm.doc["\${prefix}"].reduce((s, r) => s + (parseFloat(r["\${f}"]) || 0), 0) || 0)\` + ); + + const valPattern = new RegExp(\`val\\\\(\${prefix}_([^)]+)\\\\)\`, 'g'); + transformed = transformed.replace(valPattern, (m, f) => + \`(frm.doc["\${prefix}"] && frm.doc["\${prefix}"].reduce((s, r) => s + (parseFloat(r["\${f}"]) || 0), 0) || 0)\` + ); } - - // Если мы трансформируем выражение для табличной части + if (for_child_table) { - // Заменяем val() на row[] для всех полей внутри табличной части - transformed = transformed.replace(/val\\(([^)]+)\\)/g, (match, field) => { - // Проверяем, если имя поля содержит префикс таблицы - if (field.startsWith(table_name + '_')) { - // Извлекаем имя поля без префикса - const actualFieldName = field.substring(table_name.length + 1); - return \`(parseFloat(row["\${actualFieldName}"]) || 0)\`; + transformed = transformed.replace(/val\\\\(([^)]+)\\\\)/g, (m, f) => { + if (f.startsWith(table_name + '_')) { + return \`(parseFloat(row["\${f.slice(table_name.length + 1)}"]) || 0)\`; } - // Если поле не из текущей таблицы, используем frm.doc - return \`(parseFloat(frm.doc["\${field}"]) || 0)\`; - }); - - // Обрабатываем row() как обычно для табличной части - transformed = transformed.replace(/row\\(([^)]+)\\)/g, (match, field) => { - return \`(parseFloat(row["\${field}"]) || 0)\`; + return \`(parseFloat(frm.doc["\${f}"]) || 0)\`; }); + + transformed = transformed.replace(/row\\\\(([^)]+)\\\\)/g, (m, f) => + \`(parseFloat(row["\${f}"]) || 0)\` + ); } else { - // Стандартная замена для обычных полей - transformed = transformed.replace(/val\\(([^)]+)\\)/g, (match, field) => { - return \`(parseFloat(frm.doc["\${field}"]) || 0)\`; - }); - - // Предупреждение, если row() используется вне таблицы - transformed = transformed.replace(/row\\(([^)]+)\\)/g, (match, field) => { - return \`0 /* ERROR: row() вне табличной части */\`; - }); + transformed = transformed.replace(/val\\\\(([^)]+)\\\\)/g, (m, f) => + \`(parseFloat(frm.doc["\${f}"]) || 0)\` + ); + + transformed = transformed.replace(/row\\\\(([^)]+)\\\\)/g, () => + \`0 /* ERROR: row() вне табличной части */\` + ); } - - // Обработка процентов - transformed = transformed.replace(/(frm\\.doc\\["[^"]+"\]|\\d+|row\\["[^"]+"\])\\s*([+\\-])\\s*(\\d+(\\.\\d+)?)%/g, (match, base, operator, percent) => { - return \`\${base} \${operator} ((\${base} * \${percent}) / 100)\`; - }); - return transformed; + + transformed = transformed.replace(/(frm\\\\.doc\\\\["[^"]+"]|\\\\d+|row\\\\["[^"]+"])\\\\s*([+\\\\-])\\\\s*(\\\\d+(\\\\.\\\\d+)?)%/g, + (match, base, op, percent) => \`\${base} \${op} ((\${base} * \${percent}) / 100)\` + ); + + return { transformed, needsAsync, reportCalls }; }`; } @@ -1034,6 +1361,9 @@ function generate_dynamic_fields(frm, doctype, callback) { // Добавляем секцию универсальных полей addUniversalFieldsSection(wrapper); + // Добавляем секцию полей отчетов + addReportFieldsSection(wrapper); + // Добавляем секцию формул для строк табличных частей addTableRowFormulasSection(wrapper); @@ -1061,6 +1391,10 @@ function generate_dynamic_fields(frm, doctype, callback) { initUniversalFields(frm); renderUniversalFields(frm); + // Инициализируем и отрисовываем поля отчетов + initReportFields(frm); + renderReportFields(frm); + // Инициализируем формулы для строк табличных частей initTableRowFormulas(frm); @@ -1081,10 +1415,33 @@ function createModals() { createFormulaBuilderModal(); createTriggerFieldsModal(); createUniversalFieldsModal(); + createReportFieldModal(); createTableRowFormulaModal(); createTableRowFieldSelectorModal(); } +function handleReportFieldInFormula(fieldName, $resultContainer) { + const reportFields = JSON.parse(cur_frm.doc.report_fields_storage || "{}"); + const field = Object.values(reportFields).find(f => f.name === fieldName); + + if (!field) return null; + + const formulaText = `report(${field.name})`; + const $token = $(`${formulaText} + × + `); + + $resultContainer.append($token).append(' '); + + // Добавляем обработчики для токенов + addTokenHandlers($resultContainer); + + return $token; +} + // Создание HTML для поля формулы function createFormulaFieldHtml(field, dataAttr) { return $('
${r.message}`
+ });
+ }
+ }
+ });
+ });
+
+ // Добавляем кнопку для открытия конструктора фильтров
+ frm.add_custom_button(__('Настроить фильтры'), function() {
+ show_filter_dialog(frm);
+ });
+ },
+
+ report: function(frm) {
+ // При выборе отчета загружаем его фильтры
+ if (frm.doc.report) {
+ load_report_filters(frm);
+ }
+ }
+});
+
+// Функция для загрузки фильтров отчета
+function load_report_filters(frm) {
+ frappe.call({
+ method: 'formula_editor.formula_editor.doctype.report_simulator.report_simulator.get_report_filters_dynamic',
+ args: {
+ report_name: frm.doc.report
+ },
+ freeze: true,
+ freeze_message: __('Загрузка фильтров отчета...'),
+ callback: function(r) {
+ if (r.message) {
+ frm.report_filters_meta = r.message;
+ // Устанавливаем пустой фильтр по умолчанию, если его нет
+ if (!frm.doc.filters_json) {
+ frm.set_value('filters_json', '{}');
+ }
+
+ // Показываем уведомление о количестве доступных фильтров
+ frappe.show_alert({
+ message: __(`Загружено ${r.message.length} фильтров для отчета`),
+ indicator: 'green'
+ }, 3);
+ } else {
+ frm.report_filters_meta = [];
+ // Показываем уведомление, если фильтры не найдены
+ frappe.show_alert({
+ message: __('Для этого отчета не найдены фильтры. Используйте формат JSON для настройки фильтров.'),
+ indicator: 'orange'
+ }, 5);
+ }
+ }
+ });
+}
+
+// Функция для отображения диалога с динамическими фильтрами
+function show_filter_dialog(frm) {
+ if (!frm.doc.report) {
+ frappe.throw(__('Пожалуйста, сначала выберите отчет'));
+ return;
+ }
+
+ // Если фильтры еще не загружены, загружаем их
+ if (!frm.report_filters_meta) {
+ load_report_filters(frm);
+ frappe.throw(__('Пожалуйста, подождите загрузки фильтров и попробуйте снова'));
+ return;
+ }
+
+ // Загружаем текущие значения фильтров
+ let current_filters = {};
+ try {
+ current_filters = JSON.parse(frm.doc.filters_json || '{}');
+ } catch (e) {
+ current_filters = {};
+ }
+
+ // Создаем поля для диалога на основе метаданных фильтров
+ let fields = [];
+
+ // Если есть метаданные фильтров, создаем динамические поля
+ if (frm.report_filters_meta && frm.report_filters_meta.length > 0) {
+ // Добавляем разделитель для динамических фильтров
+ fields.push({
+ fieldtype: 'Section Break',
+ label: __('Динамические фильтры')
+ });
+
+ // Создаем поля для каждого фильтра
+ frm.report_filters_meta.forEach(filter => {
+ // Преобразуем метаданные фильтра в поле диалога
+ let field = {
+ fieldname: filter.fieldname,
+ label: __(filter.label || filter.fieldname),
+ fieldtype: filter.fieldtype || 'Data',
+ reqd: filter.reqd || 0,
+ default: current_filters[filter.fieldname] || filter.default,
+ options: filter.options,
+ description: filter.description
+ };
+
+ // Для дат устанавливаем форматирование
+ if (field.fieldtype === 'Date' && field.default) {
+ // Преобразуем строку в объект Date, если это необходимо
+ if (typeof field.default === 'string') {
+ try {
+ field.default = frappe.datetime.str_to_obj(field.default);
+ } catch (e) {
+ // Если не удалось преобразовать, оставляем как есть
+ }
+ }
+ }
+
+ // Добавляем поле в список
+ fields.push(field);
+
+ // Добавляем разрыв колонки после каждого второго поля для компактности
+ if (fields.length % 2 === 0) {
+ fields.push({ fieldtype: 'Column Break' });
+ }
+ });
+
+ // Добавляем разделитель для ручного редактирования JSON
+ fields.push({
+ fieldtype: 'Section Break',
+ label: __('Ручное редактирование')
+ });
+ }
+
+ // Всегда добавляем JSON-редактор для расширенного редактирования
+ fields.push({
+ fieldtype: 'Code',
+ fieldname: 'filters_json',
+ label: __('Фильтры (JSON)'),
+ default: JSON.stringify(current_filters, null, 2),
+ options: 'JSON'
+ });
+
+ // Создаем диалог с созданными полями
+ let d = new frappe.ui.Dialog({
+ title: __('Фильтры отчета'),
+ fields: fields,
+ primary_action_label: __('Применить'),
+ primary_action(values) {
+ try {
+ // Если есть динамические фильтры, собираем их значения
+ let final_filters = {};
+
+ // Проверяем наличие метаданных фильтров
+ if (frm.report_filters_meta && frm.report_filters_meta.length > 0) {
+ // Собираем значения динамических фильтров
+ frm.report_filters_meta.forEach(filter => {
+ let value = values[filter.fieldname];
+
+ // Пропускаем пустые значения для необязательных полей
+ if (value !== undefined && value !== null && value !== '') {
+ final_filters[filter.fieldname] = value;
+ }
+ });
+ }
+
+ // Проверяем, был ли изменен JSON вручную
+ let manual_json = {};
+ try {
+ manual_json = JSON.parse(values.filters_json);
+
+ // Если JSON был изменен вручную, используем его вместо динамических фильтров
+ if (JSON.stringify(manual_json) !== JSON.stringify(current_filters)) {
+ final_filters = manual_json;
+ }
+ } catch (e) {
+ frappe.throw(__('Некорректный формат JSON'));
+ return;
+ }
+
+ // Устанавливаем итоговое значение фильтров
+ frm.set_value('filters_json', JSON.stringify(final_filters, null, 2));
+ d.hide();
+
+ // Показываем уведомление об успешном применении фильтров
+ frappe.show_alert({
+ message: __('Фильтры успешно применены'),
+ indicator: 'green'
+ }, 3);
+ } catch (e) {
+ frappe.throw(__(`Ошибка при применении фильтров: ${e.message}`));
+ }
+ }
+ });
+
+ // Добавляем обработчик событий для обновления JSON при изменении динамических полей
+ if (frm.report_filters_meta && frm.report_filters_meta.length > 0) {
+ frm.report_filters_meta.forEach(filter => {
+ d.fields_dict[filter.fieldname].df.change = function() {
+ update_json_from_fields(d);
+ };
+ });
+ }
+
+ // Функция для обновления JSON на основе значений полей
+ function update_json_from_fields(dialog) {
+ let updated_filters = {};
+
+ // Собираем значения всех динамических полей
+ frm.report_filters_meta.forEach(filter => {
+ let value = dialog.fields_dict[filter.fieldname].get_value();
+
+ // Добавляем только непустые значения
+ if (value !== undefined && value !== null && value !== '') {
+ updated_filters[filter.fieldname] = value;
+ }
+ });
+
+ // Обновляем JSON-поле
+ dialog.set_value('filters_json', JSON.stringify(updated_filters, null, 2));
+ }
+
+ d.show();
+}
\ No newline at end of file
diff --git a/formula_editor/formula_editor/doctype/report_simulator/report_simulator.json b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.json
new file mode 100644
index 0000000..0f6ba05
--- /dev/null
+++ b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.json
@@ -0,0 +1,61 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-04-08 16:25:04.515744",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "report",
+ "filters_json",
+ "result",
+ "is_script_report"
+ ],
+ "fields": [
+ {
+ "fieldname": "report",
+ "fieldtype": "Link",
+ "label": "Report",
+ "options": "Report"
+ },
+ {
+ "fieldname": "filters_json",
+ "fieldtype": "Code",
+ "label": "JSON"
+ },
+ {
+ "fieldname": "result",
+ "fieldtype": "Long Text",
+ "label": "Result"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_script_report",
+ "fieldtype": "Check",
+ "hidden": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-04-08 17:10:17.242850",
+ "modified_by": "Administrator",
+ "module": "Formula Editor",
+ "name": "Report Simulator",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/formula_editor/formula_editor/doctype/report_simulator/report_simulator.py b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.py
new file mode 100644
index 0000000..0caa8c7
--- /dev/null
+++ b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.py
@@ -0,0 +1,1094 @@
+import frappe
+import json
+import copy
+import re
+from frappe.model.document import Document
+from frappe.utils import getdate, nowdate, get_first_day, get_last_day, add_months, today, cint
+from datetime import date, datetime
+
+# Функция для сериализации дат в JSON
+def date_serializer(obj):
+ """Преобразует объекты date и datetime в строки для JSON."""
+ if isinstance(obj, (date, datetime)):
+ return obj.isoformat()
+ raise TypeError(f'Object of type {type(obj)} is not JSON serializable')
+
+class ReportSimulator(Document):
+
+ def validate(self):
+ # Проверяем, что выбранный отчет является script report
+ if self.report:
+ report_doc = frappe.get_doc("Report", self.report)
+ self.is_script_report = report_doc.report_type == "Script Report"
+ if not self.is_script_report:
+ frappe.throw("Выбранный отчет должен быть типа 'Script Report'")
+
+ def generate_default_value(self, filter_meta):
+ """Генерирует значение по умолчанию для фильтра в зависимости от его типа и имени"""
+ fieldtype = filter_meta.get('fieldtype', '').lower()
+ fieldname = filter_meta.get('fieldname', '').lower()
+
+ frappe.msgprint(f"Генерация значения по умолчанию для поля {fieldname} типа {fieldtype}", "Отладка")
+
+ # Используем более универсальный подход на основе анализа имен и типов полей
+ return self.guess_default_value(fieldname, fieldtype, filter_meta.get('options'))
+
+ def guess_default_value(self, fieldname, fieldtype=None, options=None):
+ """Пытается определить оптимальное значение по умолчанию для поля на основе его имени и типа"""
+ fieldname_lower = fieldname.lower()
+ fieldtype = fieldtype.lower() if fieldtype else ""
+
+ frappe.msgprint(f"Анализ поля {fieldname} типа {fieldtype} для определения значения по умолчанию", "Отладка")
+
+ # Определяем тип поля на основе имени, если не задан явно
+ detected_fieldtype = fieldtype
+ if not detected_fieldtype:
+ # Распознаем тип поля по имени
+ if any(date_keyword in fieldname_lower for date_keyword in ["date", "day", "month", "year"]):
+ detected_fieldtype = "date"
+ elif any(select_keyword in fieldname_lower for select_keyword in ["type", "status", "mode", "category"]):
+ detected_fieldtype = "select"
+ elif any(check_keyword in fieldname_lower for check_keyword in ["is_", "include", "show", "hide", "enable", "disable"]):
+ detected_fieldtype = "check"
+ elif any(number_keyword in fieldname_lower for number_keyword in ["amount", "count", "qty", "quantity", "value", "rate", "price"]):
+ detected_fieldtype = "float"
+ elif "company" in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Company"
+ elif "warehouse" in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Warehouse"
+ elif "item" in fieldname_lower and "group" not in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Item"
+ elif "item_group" in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Item Group"
+ elif "account" in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Account"
+ elif "customer" in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Customer"
+ elif "supplier" in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Supplier"
+ elif "fiscal_year" in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Fiscal Year"
+ elif "currency" in fieldname_lower:
+ detected_fieldtype = "link"
+ options = "Currency"
+ else:
+ detected_fieldtype = "data"
+
+ # Определяем значение по умолчанию на основе определенного типа поля
+ if "date" in detected_fieldtype:
+ if any(keyword in fieldname_lower for keyword in ["from", "start", "begin"]):
+ return get_first_day(nowdate())
+ elif any(keyword in fieldname_lower for keyword in ["to", "end", "finish"]):
+ return nowdate()
+ else:
+ return nowdate()
+
+ elif detected_fieldtype == "link":
+ if "company" in fieldname_lower and options == "Company":
+ # Получаем компанию через универсальный метод
+ return self.get_default_entity("Company", "company")
+
+ elif "fiscal_year" in fieldname_lower and options == "Fiscal Year":
+ # Получаем фискальный год через универсальный метод
+ return self.get_default_entity("Fiscal Year", "fiscal_year",
+ filters={"year_start_date": ["<=", nowdate()],
+ "year_end_date": [">=", nowdate()]})
+
+ elif options:
+ # Для любых других Link-полей пытаемся получить запись из справочника
+ return self.get_default_entity(options)
+
+ elif detected_fieldtype == "select":
+ # Для полей выбора пытаемся взять первое значение из опций
+ if options:
+ options_list = options.split('\n')
+ if options_list:
+ return options_list[0]
+ return ""
+
+ elif detected_fieldtype in ["check", "checkbox"]:
+ # Для чекбоксов возвращаем 0 или 1 в зависимости от имени
+ # Определим семантику поля - для полей типа "include_", "show_" обычно логично 1
+ if any(positive_prefix in fieldname_lower for positive_prefix in ["include_", "show_", "enable_", "with_"]):
+ return 1
+ return 0
+
+ elif detected_fieldtype in ["int", "float", "currency"]:
+ # Для числовых полей возвращаем 0, если не указано иное
+ return 0
+
+ # Если ничего не подошло, возвращаем пустую строку для строковых полей
+ return ""
+
+ def get_default_entity(self, doctype, cache_key=None, filters=None):
+ """Универсальный метод для получения значения по умолчанию из любого типа документов"""
+ # Используем кэш для ускорения работы
+ cache_key = cache_key or doctype.lower()
+ cache = frappe.cache()
+
+ # Проверяем, есть ли уже в кэше
+ cached_value = cache.get_value(f"report_simulator:default:{cache_key}")
+ if cached_value:
+ return cached_value
+
+ try:
+ # Проверяем, есть ли значение по умолчанию для пользователя
+ user_default = frappe.defaults.get_user_default(cache_key)
+ if user_default:
+ # Кэшируем и возвращаем
+ cache.set_value(f"report_simulator:default:{cache_key}", user_default, expires_in_sec=3600)
+ return user_default
+
+ # Если не задано для пользователя, получаем первую запись из справочника
+ base_filters = {"disabled": 0} if frappe.get_meta(doctype).has_field("disabled") else {}
+ if filters:
+ base_filters.update(filters)
+
+ records = frappe.get_all(doctype, filters=base_filters, limit=1, order_by="creation desc")
+ if records:
+ # Кэшируем и возвращаем
+ cache.set_value(f"report_simulator:default:{cache_key}", records[0].name, expires_in_sec=3600)
+ return records[0].name
+ except Exception as e:
+ frappe.msgprint(f"Ошибка при получении значения для {doctype}: {str(e)}", "Отладка")
+
+ # Если не нашли, возвращаем пустую строку
+ return ""
+
+ def generate_dynamic_filters(self, report_name, user_filters):
+ """Генерирует и согласует фильтры для отчета на основе анализа API, метаданных и обработки ошибок"""
+ frappe.msgprint("🛠 Генерация динамических фильтров...", title="ReportSimulator", indicator="blue")
+
+ # Создаем копию пользовательских фильтров для работы
+ processed_filters = copy.deepcopy(user_filters)
+
+ # Преобразуем строковые даты в объекты datetime для корректной работы
+ for key, value in processed_filters.items():
+ if isinstance(value, str) and self.is_potential_date_field(key):
+ try:
+ processed_filters[key] = getdate(value)
+ except Exception as e:
+ frappe.msgprint(f"⚠️ Ошибка преобразования даты {key}: {str(e)}", indicator="orange")
+
+ # Инициализируем контейнеры для фильтров
+ standard_filters = {} # Фильтры, определенные в отчете
+ custom_filters = {} # Пользовательские фильтры
+
+ # Получаем метаданные фильтров из отчета
+ report_filters, filter_fields = self.get_report_filter_metadata(report_name)
+
+ frappe.msgprint(f"📊 Получено {len(report_filters)} фильтров из метаданных", indicator="blue")
+
+ # Распределяем фильтры по категориям
+ known_fields = []
+ for f in report_filters:
+ if isinstance(f, dict):
+ known_fields.append(f.get("fieldname"))
+ elif isinstance(f, list):
+ known_fields.append(f[0])
+
+ # Делим фильтры на стандартные и пользовательские
+ for k, v in processed_filters.items():
+ if k in known_fields:
+ standard_filters[k] = v
+ else:
+ custom_filters[k] = v
+
+ # Идентифицируем и переносим критичные поля
+ standard_filters = self.identify_and_map_critical_fields(standard_filters, custom_filters)
+
+ # Добавляем обязательные фильтры, которых нет в пользовательском вводе
+ standard_filters = self.add_required_filters(report_filters, standard_filters)
+
+ # Анализируем возможные ошибки от пробного запуска для выявления дополнительных обязательных фильтров
+ standard_filters = self.analyze_report_requirements(report_name, standard_filters)
+
+ frappe.msgprint(f"✅ Стандартные фильтры:\n{json.dumps(standard_filters, default=date_serializer, indent=2, ensure_ascii=False)}", title="Финал", indicator="green")
+ return standard_filters, custom_filters
+
+ def is_potential_date_field(self, field_name):
+ """Определяет, может ли поле содержать дату на основе его имени"""
+ field_lower = field_name.lower()
+ date_indicators = ["date", "from", "to", "start", "end", "day", "period_start", "period_end"]
+ return any(indicator in field_lower for indicator in date_indicators)
+
+ def get_report_filter_metadata(self, report_name):
+ """Получает информацию о фильтрах отчета из разных источников"""
+ report_filters = []
+ filter_fields = []
+
+ # Метод 1: Получаем фильтры через API
+ try:
+ from frappe.desk.query_report import get_script
+ script_data = get_script(report_name)
+ report_filters = script_data.get('filters') or []
+
+ if report_filters:
+ frappe.msgprint(f"📊 Получено {len(report_filters)} фильтров через API", indicator="blue")
+ except Exception as e:
+ frappe.msgprint(f"⚠️ Не удалось получить фильтры через API: {str(e)}", indicator="orange")
+
+ # Метод 2: Прямой анализ отчета и его модуля
+ if not report_filters:
+ try:
+ # Получаем модуль отчета
+ report_doc = frappe.get_doc("Report", report_name)
+ if report_doc.module:
+ # Пытаемся найти и проанализировать модуль отчета
+ report_filters = self.analyze_report_module(report_name, report_doc.module)
+ if report_filters:
+ frappe.msgprint(f"📊 Получено {len(report_filters)} фильтров через анализ модуля", indicator="blue")
+ except Exception as e:
+ frappe.msgprint(f"⚠️ Не удалось проанализировать модуль отчета: {str(e)}", indicator="orange")
+
+ # Анализируем поля отчета для выявления потенциальных фильтров
+ filter_fields = self.extract_potential_filter_fields(report_name)
+
+ return report_filters, filter_fields
+
+ def extract_potential_filter_fields(self, report_name):
+ """Анализирует структуру отчета для определения полей, которые могут быть фильтрами"""
+ potential_fields = []
+
+ try:
+ # Пробуем запустить отчет с минимальными параметрами
+ minimal_filters = {"from_date": get_first_day(nowdate()), "to_date": nowdate()}
+ from frappe.desk.query_report import run
+
+ try:
+ result = run(report_name, filters=minimal_filters)
+
+ # Если успешно получили результат, анализируем его структуру
+ if isinstance(result, dict) and "columns" in result:
+ columns = result.get("columns", [])
+ for col in columns:
+ if isinstance(col, dict) and col.get("fieldname"):
+ field_info = {
+ "fieldname": col.get("fieldname"),
+ "label": col.get("label") or col.get("fieldname"),
+ "fieldtype": col.get("fieldtype", "Data")
+ }
+
+ # Если это поле связанного документа, его можно использовать как фильтр
+ if col.get("fieldtype") == "Link":
+ field_info["options"] = col.get("options")
+ potential_fields.append(field_info)
+
+ # Если поле имеет название, подходящее для фильтра
+ elif any(keyword in col.get("fieldname", "").lower() for keyword in
+ ["date", "code", "name", "id", "type", "status"]):
+ potential_fields.append(field_info)
+ except Exception:
+ pass # Если не удалось запустить отчет, пропускаем этот шаг
+
+ except Exception as e:
+ frappe.msgprint(f"⚠️ Ошибка при анализе полей отчета: {str(e)}", indicator="orange")
+
+ return potential_fields
+
+ def analyze_report_module(self, report_name, module_name):
+ """Анализирует модуль отчета для поиска потенциальных фильтров"""
+ filters = []
+
+ try:
+ # Пытаемся найти Python-файл отчета
+ module_path = frappe.get_module_path(module_name)
+ import os
+
+ # Возможные папки для отчетов
+ report_paths = [
+ os.path.join(module_path, "report", report_name),
+ os.path.join(module_path, "report", report_name.lower()),
+ os.path.join(module_path, "report", report_name.lower().replace(" ", "_")),
+ ]
+
+ for report_path in report_paths:
+ # Проверяем наличие Python-файла
+ py_file = os.path.join(report_path, f"{os.path.basename(report_path)}.py")
+ if os.path.exists(py_file):
+ # Анализируем Python-файл для поиска упоминаний о фильтрах
+ with open(py_file, 'r', encoding='utf-8') as f:
+ py_content = f.read()
+
+ # Ищем переменные, которые могут быть фильтрами
+ filters.extend(self.extract_filters_from_python(py_content))
+
+ # Ищем параметры функции execute
+ execute_params = re.findall(r'def\s+execute\s*\(\s*(?:filters|context|.*?filters.*?)\s*\)', py_content)
+ if execute_params:
+ # Ищем обращения к фильтрам в коде
+ filter_references = re.findall(r'filters\.get\([\'"]([^\'"]+)[\'"]\)|filters\[[\'"]([^\'"]+)[\'"]', py_content)
+ for ref in filter_references:
+ fieldname = ref[0] or ref[1]
+ if fieldname and not any(f.get("fieldname") == fieldname if isinstance(f, dict) else False for f in filters):
+ filters.append({"fieldname": fieldname, "label": fieldname.replace("_", " ").title()})
+ except Exception as e:
+ frappe.msgprint(f"⚠️ Ошибка при анализе модуля отчета: {str(e)}", indicator="orange")
+
+ return filters
+
+ def extract_filters_from_python(self, py_content):
+ """Извлекает информацию о фильтрах из Python-кода отчета"""
+ filters = []
+
+ # Ищем прямые упоминания о проверке обязательных фильтров
+ required_checks = re.findall(r'if\s+not\s+filters\.get\([\'"]([^\'"]+)[\'"]\)|if\s+not\s+filters\[[\'"]([^\'"]+)[\'"]', py_content)
+ for check in required_checks:
+ fieldname = check[0] or check[1]
+ if fieldname and not any(f.get("fieldname") == fieldname if isinstance(f, dict) else False for f in filters):
+ filters.append({
+ "fieldname": fieldname,
+ "label": fieldname.replace("_", " ").title(),
+ "reqd": 1
+ })
+
+ # Ищем упоминания о фильтрах из стандартных шаблонов
+ date_checks = re.findall(r'(from_date|to_date|start_date|end_date)', py_content, re.IGNORECASE)
+ for date_field in date_checks:
+ date_field = date_field.lower()
+ if not any(f.get("fieldname") == date_field if isinstance(f, dict) else False for f in filters):
+ filters.append({
+ "fieldname": date_field,
+ "label": date_field.replace("_", " ").title(),
+ "fieldtype": "Date"
+ })
+
+ return filters
+
+ def identify_and_map_critical_fields(self, standard_filters, custom_filters):
+ """Выявляет и переносит критичные поля из пользовательских фильтров в стандартные"""
+ # Создаем динамический маппинг полей на основе анализа имен
+ critical_fields_map = {}
+
+ # Для каждого типа критичного поля определяем возможные варианты имен
+ field_variants = {
+ "date_start": ["from_date", "from", "start_date", "period_start_date", "start", "begin_date"],
+ "date_end": ["to_date", "to", "end_date", "period_end_date", "end", "finish_date"],
+ "company": ["company", "companies"],
+ "fiscal_year": ["fiscal_year", "fiscal"],
+ "period": ["periodicity", "period", "interval"],
+ "warehouse": ["warehouse", "warehouses"],
+ "item": ["item_code", "item", "product"],
+ "item_group": ["item_group", "product_group"],
+ "customer": ["customer", "client", "party"],
+ "supplier": ["supplier", "vendor"],
+ "account": ["account", "gl_account", "ledger_account"]
+ }
+
+ # Дополнительная логика для переноса полей
+ for category, variants in field_variants.items():
+ primary_key = variants[0] # Основное имя поля
+
+ # Если основное поле не задано, ищем его возможные варианты
+ if primary_key not in standard_filters:
+ for variant in variants:
+ if variant in custom_filters:
+ standard_filters[primary_key] = custom_filters.pop(variant)
+ frappe.msgprint(f"📎 Перенесено поле {variant} -> {primary_key}", indicator="blue")
+ break
+
+ return standard_filters
+
+ def add_required_filters(self, report_filters, standard_filters):
+ """Добавляет обязательные фильтры, если они отсутствуют"""
+ # Добавляем все обязательные фильтры из метаданных
+ for f in report_filters:
+ if isinstance(f, dict) and f.get("fieldname"):
+ fieldname = f.get("fieldname")
+ # Если фильтр обязательный и отсутствует
+ if f.get("reqd") == 1 and fieldname not in standard_filters:
+ # Пытаемся использовать значение по умолчанию из метаданных
+ default_value = f.get("default")
+ if default_value is None:
+ # Если нет значения по умолчанию, генерируем его
+ default_value = self.generate_default_value(f)
+
+ if default_value is not None:
+ standard_filters[fieldname] = default_value
+ frappe.msgprint(f"📌 Добавлен обязательный фильтр {fieldname} = {default_value}", indicator="green")
+
+ # Обработка для фильтров в формате списка
+ elif isinstance(f, list) and len(f) > 0:
+ fieldname = f[0]
+ if fieldname not in standard_filters:
+ # Проверяем обязательность
+ reqd = f[5] if len(f) > 5 else 0
+ if reqd == 1:
+ # Используем значение по умолчанию или генерируем его
+ default_value = f[4] if len(f) > 4 else None
+ if default_value is None:
+ filter_meta = {
+ "fieldname": fieldname,
+ "fieldtype": f[2] if len(f) > 2 else "Data",
+ "options": f[3] if len(f) > 3 else None
+ }
+ default_value = self.generate_default_value(filter_meta)
+
+ if default_value is not None:
+ standard_filters[fieldname] = default_value
+ frappe.msgprint(f"📌 Добавлен обязательный фильтр {fieldname} = {default_value}", indicator="green")
+
+ return standard_filters
+
+ def analyze_report_requirements(self, report_name, standard_filters):
+ """Запускает отчет и анализирует ошибки для выявления обязательных фильтров"""
+ # Пробуем запустить отчет с текущими фильтрами
+ try:
+ from frappe.desk.query_report import run
+ run(report_name, filters=standard_filters)
+ # Если отчет выполнился без ошибок, значит все нужные фильтры есть
+ frappe.msgprint("✅ Отчет успешно запущен с текущими фильтрами", indicator="green")
+ return standard_filters
+ except Exception as e:
+ error_msg = str(e)
+ frappe.msgprint(f"⚠️ Ошибка при запуске отчета: {error_msg}", indicator="orange")
+
+ # Пытаемся извлечь имена отсутствующих фильтров из ошибки
+ missing_filters = extract_filter_names_from_error(error_msg)
+
+ if missing_filters:
+ frappe.msgprint(f"🔍 Обнаружены отсутствующие фильтры: {', '.join(missing_filters)}", indicator="blue")
+
+ # Добавляем отсутствующие фильтры
+ for filter_name in missing_filters:
+ if filter_name not in standard_filters:
+ # Интеллектуально определяем тип и значение фильтра
+ field_meta = {"fieldname": filter_name}
+
+ # Пытаемся определить тип поля
+ field_type = self.detect_field_type(filter_name)
+ if field_type:
+ field_meta["fieldtype"] = field_type
+
+ # Если это Link-поле, пытаемся определить doctype
+ if field_type == "Link":
+ options = self.detect_link_options(filter_name)
+ if options:
+ field_meta["options"] = options
+
+ # Генерируем значение по умолчанию
+ default_value = self.generate_default_value(field_meta)
+ standard_filters[filter_name] = default_value
+ frappe.msgprint(f"🔄 Добавлен обнаруженный фильтр {filter_name} = {default_value}", indicator="blue")
+
+ # Повторно пытаемся запустить отчет с обновленными фильтрами
+ return self.analyze_report_requirements(report_name, standard_filters)
+
+ return standard_filters
+
+ def detect_field_type(self, fieldname):
+ """Определяет тип поля на основе его имени"""
+ fieldname_lower = fieldname.lower()
+
+ # Определяем тип на основе имени поля
+ if any(date_term in fieldname_lower for date_term in ["date", "day", "month"]):
+ return "Date"
+ elif "company" in fieldname_lower:
+ return "Link"
+ elif "warehouse" in fieldname_lower:
+ return "Link"
+ elif "item" in fieldname_lower and "group" not in fieldname_lower:
+ return "Link"
+ elif "item_group" in fieldname_lower:
+ return "Link"
+ elif "account" in fieldname_lower:
+ return "Link"
+ elif "customer" in fieldname_lower:
+ return "Link"
+ elif "supplier" in fieldname_lower:
+ return "Link"
+ elif "fiscal_year" in fieldname_lower:
+ return "Link"
+ elif any(select_term in fieldname_lower for select_term in ["type", "status", "category"]):
+ return "Select"
+ elif any(check_term in fieldname_lower for check_term in ["is_", "include", "show", "enable"]):
+ return "Check"
+ elif any(number_term in fieldname_lower for number_term in ["amount", "qty", "price", "count"]):
+ return "Float"
+
+ # По умолчанию считаем поле текстовым
+ return "Data"
+
+ def detect_link_options(self, fieldname):
+ """Определяет doctype для Link-поля на основе его имени"""
+ fieldname_lower = fieldname.lower()
+
+ # Маппинг имен полей на doctypes
+ field_to_doctype = {
+ "company": "Company",
+ "warehouse": "Warehouse",
+ "item": "Item",
+ "item_code": "Item",
+ "item_group": "Item Group",
+ "customer": "Customer",
+ "supplier": "Supplier",
+ "account": "Account",
+ "fiscal_year": "Fiscal Year",
+ "territory": "Territory",
+ "cost_center": "Cost Center",
+ "project": "Project",
+ "brand": "Brand",
+ "employee": "Employee"
+ }
+
+ # Проверяем прямое соответствие
+ if fieldname_lower in field_to_doctype:
+ return field_to_doctype[fieldname_lower]
+
+ # Проверяем частичное соответствие
+ for key, doctype in field_to_doctype.items():
+ if key in fieldname_lower:
+ return doctype
+
+ return None
+
+ def ensure_required_filters(self, filters_meta, user_filters):
+ """Проверяет наличие обязательных фильтров и добавляет значения по умолчанию при необходимости"""
+ frappe.msgprint("Проверка обязательных фильтров...", "Отладка")
+ # Создаем копию пользовательских фильтров
+ updated_filters = copy.deepcopy(user_filters)
+
+ added_filters = []
+ for filter_meta in filters_meta:
+ # Проверяем, является ли фильтр обязательным
+ is_required = filter_meta.get('reqd') == 1
+ fieldname = filter_meta.get('fieldname')
+
+ # Если фильтр обязательный и не задан пользователем, добавляем значение по умолчанию
+ if is_required and (fieldname not in updated_filters or updated_filters[fieldname] is None or updated_filters[fieldname] == ''):
+ default_value = filter_meta.get('default')
+
+ # Если указано значение по умолчанию в метаданных, используем его
+ if default_value is not None:
+ updated_filters[fieldname] = default_value
+ added_filters.append(f"{fieldname} = {default_value} (из метаданных)")
+ # Иначе генерируем значение по умолчанию в зависимости от типа поля
+ else:
+ default_value = self.generate_default_value(filter_meta)
+ if default_value is not None:
+ updated_filters[fieldname] = default_value
+ added_filters.append(f"{fieldname} = {default_value} (сгенерировано)")
+
+ if added_filters:
+ frappe.msgprint(f"Добавлены обязательные фильтры: {', '.join(added_filters)}", "Отладка")
+ else:
+ frappe.msgprint("Не найдены отсутствующие обязательные фильтры", "Отладка")
+
+ return updated_filters
+
+ @frappe.whitelist()
+ def execute_report(self):
+ """Выполнить выбранный отчет с указанными фильтрами и вернуть результат"""
+ if not self.report:
+ frappe.throw("Пожалуйста, выберите отчет")
+
+ try:
+ # 📥 Считываем фильтры из JSON
+ user_filters = json.loads(self.filters_json or '{}')
+ frappe.msgprint(f"📥 Исходные фильтры:\n{json.dumps(user_filters, indent=2)}", title="Фильтры", indicator="blue")
+
+ # ✅ Генерируем стандартные и пользовательские фильтры (с добавлением обязательных)
+ standard_filters, custom_filters = self.generate_dynamic_filters(self.report, user_filters)
+
+ # 🐛 Для отладки выводим итоговые фильтры
+ frappe.msgprint(f"✅ Стандартные фильтры:\n{json.dumps(standard_filters, indent=2, default=str)}", title="Standard Filters", indicator="green")
+ frappe.msgprint(f"ℹ️ Пользовательские фильтры:\n{json.dumps(custom_filters, indent=2, default=str)}", title="Custom Filters", indicator="blue")
+
+ # 🧪 Выполняем отчет
+ from frappe.desk.query_report import run
+ report_result = run(self.report, filters=standard_filters)
+
+ if not report_result:
+ error_msg = "❌ Ошибка: отчет вернул пустой результат (None)"
+ frappe.msgprint(error_msg, indicator="red")
+ self.result = error_msg
+ self.save()
+ return error_msg
+
+ # 📊 Извлекаем результат
+ columns = report_result.get('columns', []) if isinstance(report_result, dict) else []
+ data = report_result.get('result', []) if isinstance(report_result, dict) else report_result
+
+ # 🧹 Применяем пользовательские фильтры (если есть)
+ if custom_filters and isinstance(data, list):
+ filtered_data = []
+ for row in data:
+ match = True
+ for k, v in custom_filters.items():
+ val = None
+ if isinstance(row, dict):
+ val = row.get(k)
+ elif isinstance(row, (list, tuple)) and columns:
+ for idx, col in enumerate(columns):
+ if isinstance(col, dict) and col.get("fieldname") == k:
+ val = row[idx]
+ break
+ if str(val).lower() != str(v).lower():
+ match = False
+ break
+ if match:
+ filtered_data.append(row)
+ data = filtered_data
+ frappe.msgprint(f"📉 Данных после фильтрации: {len(data)}", indicator="blue")
+
+ # 📝 Формируем результат
+ result_text = "Применённые фильтры:\n"
+ result_text += f"{json.dumps(standard_filters, indent=2, default=str)}\n\n"
+ result_text += "Данные:\n"
+ result_text += json.dumps(data, indent=2, default=str) if data else "[]"
+
+ self.result = result_text
+ self.save()
+ frappe.msgprint("✅ Отчет успешно выполнен и сохранён", indicator="green")
+ return result_text
+
+ except Exception as e:
+ import traceback
+ tb = traceback.format_exc()
+ error_msg = f"❌ Ошибка при выполнении отчета: {str(e)}\n\nТрассировка:\n{tb}"
+ frappe.msgprint(error_msg, title="Ошибка", indicator="red")
+ self.result = error_msg
+ self.save()
+ return error_msg
+
+
+@frappe.whitelist()
+def extract_filter_names_from_error(error_msg):
+ """Извлечь имена отсутствующих фильтров из сообщения об ошибке"""
+ filter_names = set()
+
+ frappe.msgprint(f"Анализ сообщения об ошибке: {error_msg}", "Отладка")
+
+ # Универсальные шаблоны для поиска упоминаний фильтров в сообщениях об ошибках
+ patterns = [
+ # "Mandatory filters required: company, from_date"
+ (r'[Mm]andatory\s+filters\s+required\s*:\s*(.*)', lambda m: [f.strip() for f in m.group(1).split(',')]),
+
+ # "Filter 'company' is mandatory"
+ (r'[Ff]ilter\s+[\'"]([^\'"]+)[\'"](?:\s+is\s+mandatory)', lambda m: [m.group(1)]),
+
+ # "Missing parameter: company"
+ (r'[Mm]issing\s+parameter\s*:\s*([^\s,]+)', lambda m: [m.group(1)]),
+
+ # "Both From Date and To Date are mandatory"
+ (r'[Bb]oth\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\s+and\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\s+are\s+mandatory',
+ lambda m: [m.group(1).lower().replace(' ', '_'), m.group(2).lower().replace(' ', '_')]),
+
+ # "From Date and To Date are mandatory"
+ (r'([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)(?:\s+(?:is|are)\s+mandatory)',
+ lambda m: [part.strip().lower().replace(' ', '_') for part in m.group(1).split(' and ')]),
+
+ # "Required Field company"
+ (r'[Rr]equired\s+[Ff]ield\s+([^\s,]+)', lambda m: [m.group(1)]),
+
+ # "from_date is required" or "Company is required"
+ (r'([a-zA-Z_][a-zA-Z0-9_]*|[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\s+is\s+required',
+ lambda m: [m.group(1).lower().replace(' ', '_')]),
+ ]
+
+ # Применяем все шаблоны
+ for pattern, extract_func in patterns:
+ matches = re.finditer(pattern, error_msg, re.IGNORECASE)
+ for match in matches:
+ field_names = extract_func(match)
+ for field in field_names:
+ if field:
+ filter_names.add(field)
+
+ # Специальная обработка для распространенных пар фильтров
+ common_filter_pairs = [
+ ("from_date", "to_date"),
+ ("start_date", "end_date"),
+ ("from", "to"),
+ ("period_start_date", "period_end_date")
+ ]
+
+ for first, second in common_filter_pairs:
+ if first.lower() in error_msg.lower() and second.lower() in error_msg.lower() and "mandatory" in error_msg.lower():
+ filter_names.add(first)
+ filter_names.add(second)
+
+ # Общие фильтры, которые могут распознаваться из контекста ошибки
+ common_filters = ["company", "fiscal_year", "warehouse", "item_code", "from_date", "to_date"]
+ for cf in common_filters:
+ if cf.lower() in error_msg.lower() and ("required" in error_msg.lower() or "mandatory" in error_msg.lower()):
+ filter_names.add(cf)
+
+ result = list(filter_names)
+ frappe.msgprint(f"Итоговый список отсутствующих фильтров: {result}", "Отладка")
+ return result
+
+@frappe.whitelist()
+def get_report_filters_dynamic(report_name):
+ """Получить доступные фильтры для указанного отчета в формате для динамического создания полей"""
+ frappe.msgprint(f"Запрос фильтров для отчета: {report_name}", "Отладка")
+
+ if not report_name:
+ return []
+
+ # Используем кэш для ускорения работы с часто запрашиваемыми отчетами
+ cache = frappe.cache()
+ cache_key = f"report_filters:{report_name}"
+
+ # Для целей отладки и разработки, временно отключим кэширование
+ # cached_filters = cache.get_value(cache_key)
+ cached_filters = None
+
+ if cached_filters:
+ frappe.msgprint(f"Используем кэшированные фильтры для {report_name}", "Отладка")
+ return cached_filters
+
+ try:
+ # Получаем документ отчета
+ from frappe.desk.query_report import get_report_doc
+ report_doc = get_report_doc(report_name)
+
+ # Проверяем, что отчет - script report
+ if report_doc.report_type != "Script Report":
+ frappe.throw("Выбранный отчет должен быть типа 'Script Report'")
+
+ formatted_filters = []
+
+ # Метод 1: Прямой доступ к JS-файлу отчета (самый надежный метод)
+ import os
+ from frappe.modules import get_module_path, scrub
+
+ # Определяем путь к JS-файлу отчета
+ module_path = get_module_path(report_doc.module)
+ js_file_path = os.path.join(
+ module_path,
+ "report",
+ scrub(report_name),
+ f"{scrub(report_name)}.js"
+ )
+
+ # Проверяем альтернативные пути, если основной не существует
+ if not os.path.exists(js_file_path):
+ alternative_paths = [
+ # В ERPNext модулях часто используется другая структура
+ os.path.join(frappe.get_app_path("erpnext"),
+ report_doc.module.lower(),
+ "report",
+ scrub(report_name),
+ f"{scrub(report_name)}.js"),
+
+ # Проверяем варианты с разными формами имени
+ os.path.join(module_path, "report", report_name, f"{report_name}.js"),
+ os.path.join(module_path, "report", report_name.lower(), f"{report_name.lower()}.js"),
+ os.path.join(module_path, "report", report_name.lower().replace(" ", "_"),
+ f"{report_name.lower().replace(' ', '_')}.js"),
+ ]
+
+ for path in alternative_paths:
+ if os.path.exists(path):
+ js_file_path = path
+ break
+
+ js_filters = []
+ if os.path.exists(js_file_path):
+ frappe.msgprint(f"Найден JS-файл отчета: {js_file_path}", "Отладка")
+
+ # Читаем содержимое файла
+ with open(js_file_path, 'r', encoding='utf-8') as f:
+ js_content = f.read()
+
+ # Извлекаем фильтры с помощью улучшенного парсера
+ js_filters = extract_filters_from_js_improved(js_content, report_name)
+
+ if js_filters:
+ frappe.msgprint(f"Извлечено {len(js_filters)} фильтров напрямую из JS-файла", "Отладка")
+
+ # Добавляем все фильтры из JS-файла
+ for filter_meta in js_filters:
+ if isinstance(filter_meta, dict) and filter_meta.get('fieldname'):
+ # Проверяем дубликаты
+ if not any(f.get('fieldname') == filter_meta.get('fieldname') for f in formatted_filters):
+ formatted_filters.append(filter_meta)
+
+ # Метод 2: Получаем фильтры из метаданных отчета
+ if not formatted_filters:
+ filters_meta = report_doc.get('filters') or []
+ if filters_meta:
+ frappe.msgprint(f"Получено {len(filters_meta)} фильтров из метаданных отчета", "Отладка")
+ for filter_meta in filters_meta:
+ if isinstance(filter_meta, dict) and filter_meta.get('fieldname'):
+ formatted_filters.append(filter_meta)
+
+ # Метод 3: Получаем фильтры из JS через API
+ if not formatted_filters:
+ try:
+ from frappe.desk.query_report import get_script
+ report_script = get_script(report_name)
+ js_filters = report_script.get('filters') or []
+
+ for filter_meta in js_filters:
+ # Обработка фильтров в формате словаря
+ if isinstance(filter_meta, dict) and filter_meta.get('fieldname'):
+ # Проверяем дубликаты
+ if not any(f.get('fieldname') == filter_meta.get('fieldname') for f in formatted_filters):
+ formatted_filters.append(filter_meta)
+
+ # Обработка фильтров в формате списка
+ elif isinstance(filter_meta, list) and len(filter_meta) > 0:
+ fieldname = filter_meta[0]
+ # Проверяем дубликаты
+ if not any(f.get('fieldname') == fieldname for f in formatted_filters):
+ filter_data = {
+ 'fieldname': fieldname,
+ 'label': filter_meta[1] if len(filter_meta) > 1 else fieldname,
+ 'fieldtype': filter_meta[2] if len(filter_meta) > 2 else 'Data',
+ 'options': filter_meta[3] if len(filter_meta) > 3 else None,
+ 'default': filter_meta[4] if len(filter_meta) > 4 else None,
+ 'reqd': filter_meta[5] if len(filter_meta) > 5 else 0
+ }
+ formatted_filters.append(filter_data)
+
+ frappe.msgprint(f"Получено {len(js_filters)} фильтров через API", "Отладка")
+ except Exception as e:
+ frappe.msgprint(f"Ошибка при получении JS фильтров через API: {str(e)}", "Отладка")
+
+ # Метод 4: Анализ Python-модуля отчета
+ if not formatted_filters:
+ try:
+ # Создаем экземпляр ReportSimulator для использования его методов
+ simulator = frappe.new_doc("ReportSimulator")
+ py_filters = simulator.analyze_report_module(report_name, report_doc.module)
+
+ for filter_meta in py_filters:
+ if isinstance(filter_meta, dict) and filter_meta.get('fieldname'):
+ # Проверяем дубликаты
+ if not any(f.get('fieldname') == filter_meta.get('fieldname') for f in formatted_filters):
+ formatted_filters.append(filter_meta)
+
+ frappe.msgprint(f"Получено {len(py_filters)} фильтров из анализа Python-модуля", "Отладка")
+ except Exception as e:
+ frappe.msgprint(f"Ошибка при анализе Python-модуля: {str(e)}", "Отладка")
+
+ # Метод 4: Автоматическое определение фильтров через пробный запуск
+ if not formatted_filters:
+ frappe.msgprint("Фильтры не найдены, пробуем определить через пробный запуск", "Отладка")
+
+ # Базовые фильтры для пробного запуска
+ base_filters = {
+ "from_date": frappe.utils.get_first_day(frappe.utils.nowdate()),
+ "to_date": frappe.utils.nowdate(),
+ "company": get_default_company()
+ }
+
+ # Пробуем запустить отчет
+ try:
+ from frappe.desk.query_report import run
+ result = run(report_name, filters=base_filters)
+
+ # Если успешно, добавляем использованные фильтры
+ for key, value in base_filters.items():
+ fieldtype = "Date" if "date" in key.lower() else "Data"
+ if "company" in key.lower():
+ fieldtype = "Link"
+ options = "Company"
+ else:
+ options = None
+
+ if not any(f.get('fieldname') == key for f in formatted_filters):
+ formatted_filters.append({
+ 'fieldname': key,
+ 'label': key.replace('_', ' ').title(),
+ 'fieldtype': fieldtype,
+ 'options': options,
+ 'default': value,
+ 'reqd': 1
+ })
+
+ # Анализируем колонки отчета
+ if isinstance(result, dict) and "columns" in result:
+ for col in result.get("columns", []):
+ if isinstance(col, dict) and col.get("fieldname") and col.get("fieldtype") == "Link":
+ if not any(f.get('fieldname') == col.get("fieldname") for f in formatted_filters):
+ formatted_filters.append({
+ 'fieldname': col.get("fieldname"),
+ 'label': col.get("label") or col.get("fieldname").replace('_', ' ').title(),
+ 'fieldtype': "Link",
+ 'options': col.get("options"),
+ 'reqd': 0
+ })
+
+ frappe.msgprint(f"Определено {len(formatted_filters)} фильтров через пробный запуск", "Отладка")
+ except Exception as e:
+ frappe.msgprint(f"Ошибка при пробном запуске: {str(e)}", "Отладка")
+
+ # Добавляем фильтры, о которых сообщила ошибка
+ missing_filters = extract_filter_names_from_error(str(e))
+
+ for filter_name in missing_filters:
+ if not any(f.get('fieldname') == filter_name for f in formatted_filters):
+ # Определяем тип фильтра
+ simulator = frappe.new_doc("ReportSimulator")
+ fieldtype = simulator.detect_field_type(filter_name)
+ options = simulator.detect_link_options(filter_name) if fieldtype == "Link" else None
+
+ formatted_filters.append({
+ 'fieldname': filter_name,
+ 'label': filter_name.replace('_', ' ').title(),
+ 'fieldtype': fieldtype,
+ 'options': options,
+ 'reqd': 1
+ })
+
+ frappe.msgprint(f"Добавлено {len(missing_filters)} фильтров из анализа ошибок", "Отладка")
+
+ # Метод 5: Использование известных схем отчетов на основе имени
+ if not formatted_filters or len(formatted_filters) < 3:
+ # Проверяем имя отчета и модуль для определения типа
+ report_type = None
+ report_name_lower = report_name.lower()
+
+ # Определяем тип отчета на основе имени
+ if "stock balance" in report_name_lower:
+ report_type = "stock_balance"
+ elif "ledger" in report_name_lower:
+ report_type = "ledger"
+ elif "trial balance" in report_name_lower:
+ report_type = "trial_balance"
+ elif "accounts receivable" in report_name_lower or "accounts payable" in report_name_lower:
+ report_type = "receivable_payable"
+ elif "balance sheet" in report_name_lower or "profit and loss" in report_name_lower:
+ report_type = "financial_statements"
+
+ # Применяем схему фильтров на основе типа отчета
+ if report_type == "stock_balance":
+ formatted_filters = [
+ {"fieldname": "company", "label": "Company", "fieldtype": "Link", "options": "Company", "reqd": 1},
+ {"fieldname": "from_date", "label": "From Date", "fieldtype": "Date", "reqd": 1},
+ {"fieldname": "to_date", "label": "To Date", "fieldtype": "Date", "reqd": 1},
+ {"fieldname": "item_group", "label": "Item Group", "fieldtype": "Link", "options": "Item Group"},
+ {"fieldname": "item_code", "label": "Item", "fieldtype": "Link", "options": "Item"},
+ {"fieldname": "warehouse", "label": "Warehouse", "fieldtype": "Link", "options": "Warehouse"},
+ {"fieldname": "warehouse_type", "label": "Warehouse Type", "fieldtype": "Link", "options": "Warehouse Type"},
+ {"fieldname": "include_uom", "label": "Include UOM", "fieldtype": "Link", "options": "UOM"},
+ {"fieldname": "show_variant_attributes", "label": "Show Variant Attributes", "fieldtype": "Check"},
+ {"fieldname": "show_stock_ageing_data", "label": "Show Stock Ageing Data", "fieldtype": "Check"},
+ {"fieldname": "ignore_closing_balance", "label": "Ignore Closing Balance", "fieldtype": "Check"},
+ {"fieldname": "include_zero_stock_items", "label": "Include Zero Stock Items", "fieldtype": "Check"},
+ {"fieldname": "show_dimension_wise_stock", "label": "Show Dimension Wise Stock", "fieldtype": "Check"}
+ ]
+ frappe.msgprint(f"Применена схема фильтров для отчета Stock Balance", "Отладка")
+ elif report_type == "ledger":
+ formatted_filters = [
+ {"fieldname": "company", "label": "Company", "fieldtype": "Link", "options": "Company", "reqd": 1},
+ {"fieldname": "from_date", "label": "From Date", "fieldtype": "Date", "reqd": 1},
+ {"fieldname": "to_date", "label": "To Date", "fieldtype": "Date", "reqd": 1},
+ {"fieldname": "account", "label": "Account", "fieldtype": "Link", "options": "Account"},
+ {"fieldname": "voucher_no", "label": "Voucher No", "fieldtype": "Data"},
+ {"fieldname": "group_by", "label": "Group by", "fieldtype": "Select", "options": "Voucher\nGroup by Account"}
+ ]
+ frappe.msgprint(f"Применена схема фильтров для отчета типа Ledger", "Отладка")
+ # Можно добавить другие схемы по необходимости
+
+ # Для отчетов без фильтров добавляем базовый набор
+ if not formatted_filters:
+ frappe.msgprint("Фильтры не обнаружены, добавляем базовый набор", "Отладка")
+
+ formatted_filters = [
+ {
+ 'fieldname': 'from_date',
+ 'label': 'From Date',
+ 'fieldtype': 'Date',
+ 'default': frappe.utils.get_first_day(frappe.utils.nowdate()),
+ 'reqd': 1
+ },
+ {
+ 'fieldname': 'to_date',
+ 'label': 'To Date',
+ 'fieldtype': 'Date',
+ 'default': frappe.utils.nowdate(),
+ 'reqd': 1
+ },
+ {
+ 'fieldname': 'company',
+ 'label': 'Company',
+ 'fieldtype': 'Link',
+ 'options': 'Company',
+ 'default': get_default_company(),
+ 'reqd': 1
+ }
+ ]
+
+ # Удаляем дубликаты по названию поля
+ unique_filters = {}
+ for f in formatted_filters:
+ fieldname = f.get('fieldname')
+ if fieldname and fieldname not in unique_filters:
+ unique_filters[fieldname] = f
+
+ formatted_filters = list(unique_filters.values())
+
+ # Удаляем поля 'get_query' из фильтров, так как они не могут быть сериализованы
+ for f in formatted_filters:
+ if 'get_query' in f:
+ del f['get_query']
+
+ # Кэшируем результат (временно отключено для отладки)
+ # cache.set_value(cache_key, formatted_filters, expires_in_sec=3600)
+
+ frappe.msgprint(f"Итоговое количество фильтров: {len(formatted_filters)}", "Отладка")
+ return formatted_filters
+
+ except Exception as e:
+ frappe.msgprint(f"Ошибка при получении фильтров отчета: {str(e)}", "Отладка")
+ import traceback
+ frappe.msgprint(f"Трассировка: {traceback.format_exc()}", "Трассировка ошибки")
+ return []
+
+def get_default_company():
+ """Получить компанию по умолчанию"""
+ # Сначала проверяем настройки пользователя
+ default_company = frappe.defaults.get_user_default("company")
+ if default_company:
+ return default_company
+
+ # Если не задано, берем первую компанию из списка
+ companies = frappe.get_all("Company", limit=1)
+ if companies:
+ return companies[0].name
+
+ # Если вообще нет компаний, вернем пустую строку
+ return ""
+
+def get_current_fiscal_year():
+ """Получить текущий фискальный год"""
+ try:
+ # Используем frappe.db для получения текущего фискального года
+ fiscal_year = frappe.db.sql("""
+ SELECT name FROM `tabFiscal Year`
+ WHERE %s BETWEEN year_start_date AND year_end_date
+ AND disabled = 0
+ """, today(), as_dict=0)
+
+ if fiscal_year and len(fiscal_year) > 0:
+ return fiscal_year[0][0]
+
+ # Если не найдено, получаем любой активный фискальный год
+ fiscal_years = frappe.get_all('Fiscal Year',
+ filters={'disabled': 0},
+ order_by='name desc',
+ limit=1)
+ if fiscal_years:
+ return fiscal_years[0].name
+ return ""
+ except:
+ # Получаем любой активный фискальный год
+ fiscal_years = frappe.get_all('Fiscal Year',
+ filters={'disabled': 0},
+ order_by='name desc',
+ limit=1)
+ if fiscal_years:
+ return fiscal_years[0].name
+ return ""
\ No newline at end of file
diff --git a/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js b/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js
new file mode 100644
index 0000000..d55a772
--- /dev/null
+++ b/formula_editor/formula_editor/doctype/report_simulator/report_simulator_list.js
@@ -0,0 +1,124 @@
+frappe.listview_settings['Report Simulator'] = {
+ onload: function(listview) {
+ listview.page.add_inner_button(__('Быстрая симуляция'), function() {
+ // Открываем диалог для быстрой симуляции отчета
+ let d = new frappe.ui.Dialog({
+ title: __('Быстрая симуляция отчета'),
+ fields: [
+ {
+ fieldtype: 'Link',
+ fieldname: 'report',
+ label: __('Отчет'),
+ options: 'Report',
+ reqd: 1,
+ get_query: function() {
+ return {
+ filters: {
+ report_type: 'Script Report'
+ }
+ };
+ }
+ },
+ {
+ fieldtype: 'Code',
+ fieldname: 'filters_json',
+ label: __('Фильтры (JSON)'),
+ default: '{}',
+ options: 'JSON'
+ }
+ ],
+ primary_action_label: __('Запустить'),
+ primary_action(values) {
+ if (!values.report) {
+ frappe.throw(__('Пожалуйста, выберите отчет'));
+ return;
+ }
+
+ // Проверяем формат JSON
+ try {
+ JSON.parse(values.filters_json);
+ } catch (e) {
+ frappe.throw(__('Некорректный формат JSON'));
+ return;
+ }
+
+ // Выполняем отчет
+ frappe.call({
+ method: 'formula_editor.formula_editor.doctype.report_simulator.report_simulator.run_report_simulation',
+ args: {
+ report_name: values.report,
+ filters_json: values.filters_json
+ },
+ freeze: true,
+ freeze_message: __('Выполнение отчета...'),
+ callback: function(r) {
+ if (r.message) {
+ // Форматируем результат для отображения
+ let columns = r.message.columns;
+ let data = r.message.data;
+
+ // Создаем HTML-таблицу для отображения результата
+ let html = '| ${col.label || col.fieldname} | `; + }); + html += '
|---|
| ${value} | `; + }); + html += '