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 $('
' + @@ -1232,7 +1589,7 @@ function parseAndRenderFormula($container, formula, isTableField) { if (!formula) return; // Регулярные выражения для поиска функций и операторов - const funcRegex = /(val|row|sum|univ)\(([^)]+)\)/g; + const funcRegex = /(val|row|sum|univ|report)\(([^)]+)\)/g; const operatorsRegex = /([+\-*/()%])/g; // Сохраняем текущую позицию @@ -1269,76 +1626,78 @@ function parseAndRenderFormula($container, formula, isTableField) { }); } -// Разбираем операторы в частях, которые не являются функциями -let finalParts = []; -formulaParts.forEach(part => { - if (part.type === 'function') { - finalParts.push(part); - } else if (part.type === 'text') { - let text = part.value; - lastIndex = 0; - - // Сбрасываем lastIndex для operatorsRegex - operatorsRegex.lastIndex = 0; - - // Разбираем операторы - while ((match = operatorsRegex.exec(text)) !== null) { - // Добавляем текст до оператора - if (match.index > lastIndex) { - const textBefore = text.substring(lastIndex, match.index).trim(); - if (textBefore) { + // Разбираем операторы в частях, которые не являются функциями + let finalParts = []; + formulaParts.forEach(part => { + if (part.type === 'function') { + finalParts.push(part); + } else if (part.type === 'text') { + let text = part.value; + lastIndex = 0; + + // Сбрасываем lastIndex для operatorsRegex + operatorsRegex.lastIndex = 0; + + // Разбираем операторы + while ((match = operatorsRegex.exec(text)) !== null) { + // Добавляем текст до оператора + if (match.index > lastIndex) { + const textBefore = text.substring(lastIndex, match.index).trim(); + if (textBefore) { + finalParts.push({ + type: 'text', + value: textBefore + }); + } + } + + // Добавляем оператор + finalParts.push({ + type: 'operator', + value: match[1] + }); + + lastIndex = match.index + match[1].length; + } + + // Добавляем оставшийся текст после последнего оператора + if (lastIndex < text.length) { + const textAfter = text.substring(lastIndex).trim(); + if (textAfter) { finalParts.push({ type: 'text', - value: textBefore + value: textAfter }); } } - - // Добавляем оператор - finalParts.push({ - type: 'operator', - value: match[1] - }); - - lastIndex = match.index + match[1].length; } - - // Добавляем оставшийся текст после последнего оператора - if (lastIndex < text.length) { - const textAfter = text.substring(lastIndex).trim(); - if (textAfter) { - finalParts.push({ - type: 'text', - value: textAfter - }); + }); + + // Рендерим части формулы + finalParts.forEach(part => { + if (part.type === 'function') { + // Определяем класс в зависимости от типа функции + let tokenClass = 'field'; + if (part.func === 'univ') { + tokenClass = 'field universal-field'; + } else if (part.func === 'report') { + tokenClass = 'field report-field'; } + + const $token = $(`${part.value}×`); + $container.append($token).append(' '); + } else if (part.type === 'operator') { + const $token = $(`${part.value}×`); + $container.append($token).append(' '); + } else if (part.type === 'text') { + $container.append(document.createTextNode(`${part.value} `)); } - } -}); + }); -// Рендерим части формулы -finalParts.forEach(part => { - if (part.type === 'function') { - // Определяем класс в зависимости от типа функции - let tokenClass = 'field'; - if (part.func === 'univ') { - tokenClass = 'field universal-field'; - } - - const $token = $(`${part.value}×`); - $container.append($token).append(' '); - } else if (part.type === 'operator') { - const $token = $(`${part.value}×`); - $container.append($token).append(' '); - } else if (part.type === 'text') { - $container.append(document.createTextNode(`${part.value} `)); - } -}); + // Добавляем обработчики для токенов + addTokenHandlers($container); -// Добавляем обработчики для токенов -addTokenHandlers($container); - -return formula; + return formula; } function addTokenHandlers($container) { @@ -1829,6 +2188,15 @@ function addTokenToFormula(token, type, $resultContainer, isTableField = false, const $token = $(`${formulaText}×`); $resultContainer.append($token).append(' '); } + } else if (type === 'report-field') { + // Обработка для полей отчетов + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + const field = reportFields[token]; + if (field) { + const formulaText = `report(${field.name})`; + const $token = $(`${formulaText}×`); + $resultContainer.append($token).append(' '); + } } // Переносим курсор в конец контейнера @@ -1947,6 +2315,43 @@ function populateFieldsPanel($modal, frm, grouped_fields, isTableField) { } } + if (frm.doc.report_fields_storage) { + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + + if (Object.keys(reportFields).length > 0) { + const $reportFieldGroup = $(` +
+
Поля отчетов
+
+
+ `); + + const $reportButtonsContainer = $reportFieldGroup.find('.field-buttons-container'); + + Object.keys(reportFields).forEach(fieldId => { + const field = reportFields[fieldId]; + + if (!field || !field.name) return; + + const $fieldBtn = $(` + + `); + + $reportButtonsContainer.append($fieldBtn); + }); + + $container.append($reportFieldGroup); + } + } + // Добавляем группу для полей строк табличных частей const $tableRowGroup = $(`
@@ -2116,9 +2521,19 @@ function updateDropMarker(e, $container) { } } +// Исправляем функцию handleDrop для корректной обработки полей отчетов function handleDrop(e, $container, isTableField, frm) { try { - const data = JSON.parse(e.originalEvent.dataTransfer.getData('text/plain')); + // Получаем данные из события перетаскивания + const dataTransfer = e.originalEvent.dataTransfer.getData('text/plain'); + + // Проверяем, что данные не пустые + if (!dataTransfer) { + console.error("Ошибка при перетаскивании: пустые данные"); + return; + } + + const data = JSON.parse(dataTransfer); const x = e.originalEvent.clientX; const y = e.originalEvent.clientY; @@ -2143,6 +2558,15 @@ function handleDrop(e, $container, isTableField, frm) { const formulaText = `univ(${field.name})`; $newElement = $(`${formulaText}×`); } + } else if (data.type === 'report-field') { + // Обработка поля отчета + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + const field = reportFields[data.fieldId]; + + if (field) { + const formulaText = `report(${field.name})`; + $newElement = $(`${formulaText}×`); + } } else if (data.type === 'field') { $newElement = createFieldToken(data, isTableField); } else if (data.type === 'operator') { @@ -2161,6 +2585,385 @@ function handleDrop(e, $container, isTableField, frm) { } } +// Добавляем поддержку полей отчетов в populateFieldsPanel +function populateFieldsPanel($modal, frm, grouped_fields, isTableField) { + const $container = $modal.find(".field-groups-container"); + $container.empty(); + + // Добавляем секцию предопределенных событий + const $predefinedEventsGroup = $(` +
+
Предопределенные события
+
+
+ `); + + const $predefinedButtonsContainer = $predefinedEventsGroup.find('.field-buttons-container'); + + // Добавляем кнопки для предопределенных событий + const predefinedEvents = [ + { name: "При открытии документа", id: "При открытии документа" } + ]; + + predefinedEvents.forEach(event => { + const $eventBtn = $(` + + `); + + $predefinedButtonsContainer.append($eventBtn); + }); + + // Добавляем группу предопределенных событий в начало контейнера + $container.prepend($predefinedEventsGroup); + + // Добавляем стандартные поля + Object.keys(grouped_fields).forEach(group => { + const $fieldGroup = $(` +
+
${group}
+
+
+ `); + + const $buttonsContainer = $fieldGroup.find('.field-buttons-container'); + + grouped_fields[group].forEach(field => { + // Определяем, является ли это поле таблицы или нет + const isTableGroupField = group !== "Основные поля"; + + // Создаем кнопку поля с правильными атрибутами + const btnClass = isTableGroupField ? "field-btn table-field-btn" : "field-btn"; + const $fieldBtn = $(` + + `); + + $buttonsContainer.append($fieldBtn); + }); + + $container.append($fieldGroup); + }); + + // Добавляем секцию универсальных полей + if (frm.doc.universal_fields_storage) { + const universalFields = JSON.parse(frm.doc.universal_fields_storage || "{}"); + + if (Object.keys(universalFields).length > 0) { + const $universalFieldGroup = $(` +
+
Универсальные поля
+
+
+ `); + + const $universalButtonsContainer = $universalFieldGroup.find('.field-buttons-container'); + + Object.keys(universalFields).forEach(fieldId => { + const field = universalFields[fieldId]; + + if (!field || !field.name) return; + + const $fieldBtn = $(` + + `); + + $universalButtonsContainer.append($fieldBtn); + }); + + $container.append($universalFieldGroup); + } + } + + // Добавляем секцию полей отчетов + if (frm.doc.report_fields_storage) { + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + + if (Object.keys(reportFields).length > 0) { + const $reportFieldGroup = $(` +
+
Поля отчетов
+
+
+ `); + + const $reportButtonsContainer = $reportFieldGroup.find('.field-buttons-container'); + + Object.keys(reportFields).forEach(fieldId => { + const field = reportFields[fieldId]; + + if (!field || !field.name) return; + + const $fieldBtn = $(` + + `); + + $reportButtonsContainer.append($fieldBtn); + }); + + $container.append($reportFieldGroup); + } + } + + // Добавляем группу для полей строк табличных частей + const $tableRowGroup = $(` +
+
Поля строк табличных частей
+
+ +
+
+ `); + + $container.append($tableRowGroup); + + // Добавляем обработчик для кнопки выбора поля строки табличной части + $tableRowGroup.find('.select-table-row-field').on('click', function() { + // Определяем целевой контейнер в зависимости от контекста + const targetContainer = $modal.hasClass('trigger-focus') ? + $modal.find(".trigger-result") : + $modal.find(".formula-result"); + + openTableRowFieldSelector(frm, targetContainer, $modal.hasClass('trigger-focus')); + }); +} + +// Обновляем функцию initDragAndDrop для поддержки полей отчетов +function initDragAndDrop($modal, $resultContainer, isTableField, frm) { + // Очищаем предыдущие обработчики + $modal.find('.field-btn, .operator-btn, .universal-field-btn, .report-field-btn').off('dragstart'); + $resultContainer.off('dragover dragleave drop'); + + // Настраиваем перетаскивание существующих токенов + setupTokenDragging($resultContainer); + + // Настраиваем перетаскивание полей из панели + $modal.find('.field-btn').off('dragstart').on('dragstart', function(e) { + const fieldname = $(this).data('fieldname'); + const label = $(this).data('label'); + const group = $(this).data('group'); + + // Проверяем принадлежность к группе + const isTableFieldGroup = group !== "Основные поля"; + const fieldData = { + type: 'field', + fieldname: fieldname, + label: label, + group: group, + isTableField: isTableFieldGroup + }; + + e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify(fieldData)); + }); + + // Настраиваем перетаскивание универсальных полей + $modal.find('.universal-field-btn').off('dragstart').on('dragstart', function(e) { + const fieldId = $(this).data('field-id'); + const fieldname = $(this).data('fieldname'); + const label = $(this).data('label'); + + e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({ + type: 'universal-field', + fieldId: fieldId, + fieldname: fieldname, + label: label + })); + }); + + // Настраиваем перетаскивание полей отчетов + $modal.find('.report-field-btn').off('dragstart').on('dragstart', function(e) { + const fieldId = $(this).data('field-id'); + const fieldname = $(this).data('fieldname'); + const label = $(this).data('label'); + + e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({ + type: 'report-field', + fieldId: fieldId, + fieldname: fieldname, + label: label + })); + }); + + // Настраиваем перетаскивание операторов + $modal.find('.operator-btn').off('dragstart').on('dragstart', function(e) { + const operator = $(this).data('operator'); + + e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({ + type: 'operator', + value: operator + })); + }); + + // Обработчики для целевого контейнера + $resultContainer.off('dragover').on('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + $(this).addClass('drag-hover'); + + updateDropMarker(e, $(this)); + }); + + $resultContainer.off('dragleave').on('dragleave', function(e) { + $(this).removeClass('drag-hover'); + $(this).find('.drop-marker').remove(); + }); + + $resultContainer.off('drop').on('drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + $(this).removeClass('drag-hover'); + $(this).find('.drop-marker').remove(); + + handleDrop(e, $(this), isTableField, frm); + }); +} + +// Обновляем функцию для обработки клика по кнопке поля отчета +function attachFormulaBuilderEvents(frm, fields, grouped_fields) { + // Сделаем поля ввода формул редактируемыми + $(frm.wrapper).find('.formula-input').prop('readonly', false); + + // Сначала удаляем существующие обработчики событий + $(frm.wrapper).off('click', '.open-formula-builder'); + + // Обработчик для кнопок открытия визуального редактора + $(frm.wrapper).on('click', '.open-formula-builder', function() { + const fieldname = $(this).data('fieldname'); + const isTableField = $(this).data('is-table') === true; + const currentFormula = $(frm.wrapper).find(`.formula-input[data-fieldname="${fieldname}"]`).val(); + + openFormulaBuilder(frm, fieldname, isTableField, currentFormula, fields, grouped_fields); + return false; + }); +} + +// Добавляем обработчики для кнопок полей отчетов в редакторе формул +function openFormulaBuilder(frm, targetFieldname, isTableField, currentFormula, fields, grouped_fields) { + const $modal = $("#formula_builder_modal"); + const $resultContainer = $modal.find(".formula-result"); + + // Очистка предыдущих обработчиков и данных + $modal.off('click', '.field-btn'); + $modal.off('click', '.universal-field-btn'); + $modal.off('click', '.report-field-btn'); + $modal.off('click', '.operator-btn'); + $modal.find('.apply-formula').off('click'); + $resultContainer.empty(); + $modal.find(".field-groups-container").empty(); + + // Заполняем текущую формулу, если она есть + if (currentFormula) { + parseAndRenderFormula($resultContainer, currentFormula, isTableField); + } + + // Заполняем панель с полями + populateFieldsPanel($modal, frm, grouped_fields, isTableField); + + // Сохраняем данные о текущем поле + $modal.data("target-field", targetFieldname); + $modal.data("is-table-field", isTableField); + + // Обработчики для кнопок операторов + $modal.find('.operator-btn').off('click').on('click', function() { + const operator = $(this).data('operator'); + addTokenToFormula(operator, 'operator', $resultContainer); + }); + + // Обработчик для кнопки применения формулы + $modal.find('.apply-formula').off('click').on('click', function() { + const formula = getFormulaText($resultContainer); + const $targetInput = $(frm.wrapper).find(`.formula-input[data-fieldname="${targetFieldname}"]`); + $targetInput.val(formula); + $modal.modal('hide'); + }); + + // Обработчики для кнопок полей - используем делегирование событий + $modal.off('click', '.field-btn').on('click', '.field-btn', function() { + const fieldname = $(this).data('fieldname'); + addTokenToFormula(fieldname, 'field', $resultContainer, isTableField, $(this).data('group')); + }); + + // Обработчики для кнопок универсальных полей + $modal.off('click', '.universal-field-btn').on('click', '.universal-field-btn', function() { + const fieldId = $(this).data('field-id'); + const universalFields = JSON.parse(frm.doc.universal_fields_storage || "{}"); + const field = universalFields[fieldId]; + + if (field) { + const formulaText = `univ(${field.name})`; + const $token = $(`${formulaText}×`); + $resultContainer.append($token).append(' '); + + // Добавляем обработчики для токенов + addTokenHandlers($resultContainer); + + // Переносим курсор в конец контейнера + setEndOfContentEditable($resultContainer[0]); + } + }); + + // Обработчики для кнопок полей отчетов + $modal.off('click', '.report-field-btn').on('click', '.report-field-btn', function() { + const fieldId = $(this).data('field-id'); + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + const field = reportFields[fieldId]; + + if (field) { + const formulaText = `report(${field.name})`; + const $token = $(`${formulaText}×`); + $resultContainer.append($token).append(' '); + + // Добавляем обработчики для токенов + addTokenHandlers($resultContainer); + + // Переносим курсор в конец контейнера + setEndOfContentEditable($resultContainer[0]); + } + }); + + // Показываем модальное окно + $modal.modal('show'); + + // Инициализируем перетаскивание + initDragAndDrop($modal, $resultContainer, isTableField, frm); + + // Настраиваем редактируемый контейнер + setupEditableContainer($resultContainer); + + // Фокусируемся на редактируемом поле + $resultContainer.focus(); +} + function findDropPosition(x, y, $container) { let position = { element: null, position: 'after' }; @@ -4137,10 +4940,66 @@ function editUniversalField(frm, fieldId) { // Функция для преобразования выражений формул function transform_expression(expression, for_child_table = false, table_name = null) { - let transformed = expression; + let needsAsync = false; + let reportCalls = []; + + // === Обработка report(...) === + const reportRegex = /report\(([^)]+)\)/g; + 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}__`; + + // Ищем соответствующее поле отчета по имени + const reportFields = JSON.parse(cur_frm.doc.report_fields_storage || "{}"); + const reportField = Object.values(reportFields).find(f => f.name === reportName); + + if (reportField) { + let filters = {}; + try { + filters = JSON.parse(reportField.filters_json || "{}"); + + // Заменяем компанию на специальный маркер если нет + if (filters.company && typeof filters.company === 'string' && !filters.company.startsWith('$')) { + filters.company = "$DefaultCompany"; + } + + // Заменяем даты на специальный маркер если подходят для этого + if (filters.from_date && typeof filters.from_date === 'string') { + filters.from_date = "$AyIlSpecial"; + } + if (filters.to_date && typeof filters.to_date === 'string') { + filters.to_date = "$AyIlSpecial"; + } + if (filters.date && typeof filters.date === 'string') { + filters.date = "$AyIlSpecial"; + } + } catch (e) { + console.error("Ошибка при обработке фильтров отчета:", e); + filters = {}; + } + + reportCalls.push({ + placeholder: placeholder, + reportName: reportField.report, + filters: filters, + fieldName: reportField.field_name, + aggregation: reportField.aggregation || 'sum' + }); + + // Заменяем вызов report() на плейсхолдер + transformed = transformed.replace(fullMatch, placeholder); + } + } + + // === Обработка universal полей === const universalFields = JSON.parse(cur_frm.doc.universal_fields_storage || "{}"); Object.keys(universalFields).forEach(fieldId => { const field = universalFields[fieldId]; @@ -4149,59 +5008,48 @@ function transform_expression(expression, for_child_table = false, table_name = const pattern = new RegExp(`univ\\(${field.name}\\)`, 'g'); transformed = transformed.replace(pattern, `(frm.doc.__universalFields && frm.doc.__universalFields["${fieldId}"] || 0)`); }); - - // Получаем список табличных частей из DOM или из предопределенного списка - const tablePrefixes = getTablePrefixes(); - - // Функция суммирования по табличной части - обрабатываем первой - tablePrefixes.forEach(tablePrefix => { - const pattern = new RegExp(`sum\\(${tablePrefix}_([^)]+)\\)`, 'g'); - transformed = transformed.replace(pattern, (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 (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 `row["${actualFieldName}"]`; - } - // Если поле не из текущей таблицы, используем frm.doc - return `frm.doc["${field}"]`; - }); - - // Обрабатываем row() как обычно для табличной части - transformed = transformed.replace(/row\(([^)]+)\)/g, (match, field) => { - return `row["${field}"]`; - }); - } else { - // Стандартная замена для обычных полей - transformed = transformed.replace(/val\(([^)]+)\)/g, (match, field) => { - return `frm.doc["${field}"]`; - }); - - // Предупреждение, если row() используется вне таблицы - transformed = transformed.replace(/row\(([^)]+)\)/g, (match, field) => { - return `0 /* ERROR: row() вне табличной части */`; - }); + + // === Обработка val() и row() === + const tablePrefixes = ["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)` + ); } - - // Обработка процентов - transformed = transformed.replace(/(frm\.doc\["[^"]+"\]|\d+|row\["[^"]+"\])\s*([+\-])\s*(\d+(\.\d+)?)%/g, (match, base, operator, percent) => { - return `${base} ${operator} ((${base} * ${percent}) / 100)`; - }); - return transformed; + + if (for_child_table) { + transformed = transformed.replace(/val\(([^)]+)\)/g, (m, f) => { + if (f.startsWith(table_name + '_')) { + return `(parseFloat(row["${f.slice(table_name.length + 1)}"]) || 0)`; + } + return `(parseFloat(frm.doc["${f}"]) || 0)`; + }); + + transformed = transformed.replace(/row\(([^)]+)\)/g, (m, f) => + `(parseFloat(row["${f}"]) || 0)` + ); + } else { + 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, op, percent) => `${base} ${op} ((${base} * ${percent}) / 100)` + ); + + return { transformed, needsAsync, reportCalls }; } // Вспомогательная функция для получения доступных префиксов таблиц @@ -6555,6 +7403,7 @@ function addTokenStyles() { line-height: 16px; font-size: 10px; cursor: pointer; + z-index: 100; } .formula-token:hover .token-remove, @@ -6584,6 +7433,19 @@ function addTokenStyles() { border-color: #b7d69d !important; } + /* Стили для полей отчетов */ + .report-field-btn, + .trigger-token.report-field, + .formula-token.report-field { + background-color: #f5e6ff !important; + border-color: #d9b3ff !important; + position: relative; + } + + .report-field-btn:hover { + background-color: #ebccff !important; + } + /* Стили для полей строк */ .row-field-btn { background-color: #fff3cd !important; @@ -6604,6 +7466,22 @@ function addTokenStyles() { white-space: pre-wrap; word-break: break-word; } + + /* Стили для фильтров отчетов */ + .filters-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .filter-row { + margin-bottom: 10px; + } + + .filter-label { + font-weight: 500; + margin-bottom: 3px; + } `; $('head').append(tokenStyles); @@ -6700,4 +7578,921 @@ function openTriggerFieldsEditorForTableRow(frm, $triggerContainer) { // Фокусируемся на редактируемом поле $modal.find(".trigger-result").focus(); -} \ No newline at end of file +} + +function addReportFieldsSection(wrapper) { + const reportSection = $('
'); + reportSection.append('

Поля отчетов

'); + reportSection.append('
'); + wrapper.append(reportSection); +} + +function initReportFields(frm) { + // Если хранилище полей отчетов не существует, создаем его + if (!frm.doc.report_fields_storage) { + frm.set_value("report_fields_storage", "{}"); + } + + // Инициализируем обработчики событий + attachReportFieldsEvents(frm); + + // Отображаем существующие поля отчетов + renderReportFields(frm); +} + +function attachReportFieldsEvents(frm) { + // Сначала удаляем существующие обработчики событий + $(frm.wrapper).off('click', '.add-report-field'); + $(frm.wrapper).off('click', '.edit-report-field'); + $(frm.wrapper).off('click', '.delete-report-field'); + + // Обработчик нажатия на кнопку "Добавить поле" + $(frm.wrapper).on('click', '.add-report-field', function() { + openReportFieldModal(frm); + }); + + // Обработчик для клика по кнопке редактирования + $(frm.wrapper).on('click', '.edit-report-field', function(e) { + e.stopPropagation(); + const fieldId = $(this).data('field-id'); + editReportField(frm, fieldId); + }); + + // Обработчик для клика по кнопке удаления + $(frm.wrapper).on('click', '.delete-report-field', function(e) { + e.stopPropagation(); + const fieldId = $(this).data('field-id'); + deleteReportField(frm, fieldId); + }); +} + +function createReportFieldModal() { + if ($("#report_fields_modal").length) { + return; + } + + const modalHtml = ` + `; + + $('body').append(modalHtml); +} + +function openReportFieldModal(frm, fieldId = null) { + // Создаем/обновляем модальное окно + createReportFieldModal(); + + // Получаем значения для редактирования (если указан fieldId) + let fieldValues = {}; + if (fieldId) { + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + fieldValues = reportFields[fieldId] || {}; + } + + // Создаем диалог с формой для настройки поля отчета + const d = new frappe.ui.Dialog({ + title: fieldId ? 'Редактирование поля отчета' : 'Создание поля отчета', + fields: [ + { + label: 'Название поля', + fieldname: 'name', + fieldtype: 'Data', + reqd: 1, + default: fieldValues.name || '' + }, + { + label: 'Отчет', + fieldname: 'report', + fieldtype: 'Link', + options: 'Report', + reqd: 1, + default: fieldValues.report || '', + filters: { + "report_type": "Script Report" + }, + onchange: function() { + const selectedReport = d.get_value('report'); + + if (selectedReport) { + // Загружаем фильтры отчета + frappe.call({ + method: "formula_editor.api.get_report_filters_dynamic", + args: { + report_name: selectedReport + }, + callback: function(r) { + if (r.message) { + // Сохраняем фильтры для использования + d.report_filters = r.message; + + // Обновляем поле фильтров + if (d.fields_dict.filters_json) { + let current_filters = {}; + try { + current_filters = JSON.parse(d.get_value('filters_json') || '{}'); + } catch (e) { + current_filters = {}; + } + + // Показываем секцию фильтров (используя set_df_property) + d.set_df_property('filters_section', 'hidden', 0); + d.set_df_property('filters_helper', 'hidden', 0); + + // Генерируем HTML для фильтров + const filtersHtml = generateFiltersHtml(r.message, current_filters); + d.fields_dict.filters_container.$wrapper.html(filtersHtml); + + // Инициализируем обработчики для фильтров + initFilterControls(d, current_filters); + } + + // Загружаем поля отчета + loadReportFields(d, selectedReport); + } else { + // Скрываем секцию фильтров + d.set_df_property('filters_section', 'hidden', 1); + d.set_df_property('filters_helper', 'hidden', 1); + d.fields_dict.filters_container.$wrapper.html(''); + + // Очищаем поле выбора полей + d.fields_dict.field_name.df.options = []; + d.fields_dict.field_name.refresh(); + } + } + }); + } else { + // Скрываем секцию фильтров + d.set_df_property('filters_section', 'hidden', 1); + d.set_df_property('filters_helper', 'hidden', 1); + d.fields_dict.filters_container.$wrapper.html(''); + + // Очищаем поле выбора полей + d.fields_dict.field_name.df.options = []; + d.fields_dict.field_name.refresh(); + } + } + }, + { + label: 'Фильтры отчета', + fieldname: 'filters_section', + fieldtype: 'Section Break', + hidden: 1 + }, + { + fieldname: 'filters_helper', + fieldtype: 'HTML', + options: '
Настройте фильтры для отчета. Эти фильтры будут использоваться при каждом вычислении формулы.
', + hidden: 1 + }, + { + fieldname: 'filters_container', + fieldtype: 'HTML', + options: '
' + }, + { + fieldname: 'filters_json', + fieldtype: 'Code', + label: 'Фильтры (JSON)', + hidden: 1, + options: 'JSON', + default: fieldValues.filters_json || '{}' + }, + { + label: 'Поле отчета', + fieldname: 'field_section', + fieldtype: 'Section Break' + }, + { + label: 'Поле отчета', + fieldname: 'field_name', + fieldtype: 'Select', + options: [], + reqd: 1, + default: fieldValues.field_name || '' + }, + { + label: 'Агрегация', + fieldname: 'aggregation', + fieldtype: 'Select', + options: [ + {label: 'Сумма', value: 'sum'} + ], + default: fieldValues.aggregation || 'sum' + } + ], + primary_action_label: fieldId ? 'Обновить' : 'Создать', + primary_action: function() { + const values = d.get_values(); + + // Получаем JSON фильтров + values.filters_json = d.get_value('filters_json'); + + // Проверяем, что все обязательные поля заполнены + if (!values.name || !values.report || !values.field_name) { + frappe.msgprint({ + title: __('Внимание'), + message: __('Заполните все обязательные поля'), + indicator: 'orange' + }); + return; + } + + // Сохраняем данные + saveReportField(frm, values, fieldId); + + d.hide(); + } + }); + + // Показываем диалог + d.show(); + + // Если редактируем существующее поле, загружаем данные + if (fieldId && fieldValues.report) { + setTimeout(() => { + d.fields_dict.report.df.onchange.call(d.fields_dict.report); + }, 200); + } +} + +function generateFiltersHtml(filters, currentValues) { + let html = '
'; + + filters.forEach(filter => { + const fieldId = `filter_${filter.fieldname}`; + const currentValue = currentValues[filter.fieldname] || filter.default; + + html += ` +
+
${filter.label || filter.fieldname}
+
+
`; + }); + + html += '
'; + return html; +} + +function initFilterControls(d, currentValues) { + if (!d.report_filters) { + frappe.throw("Отсутствует информация о фильтрах отчета"); + return; + } + + // Инициализируем объект для хранения контролов + if (!d.filter_controls) d.filter_controls = {}; + + d.report_filters.forEach(filter => { + // Скрываем поле company всегда и предустанавливаем $DefaultCompany + if (filter.fieldname === 'company') { + // Для скрытых полей просто сохраняем специальное значение + d.filter_controls[filter.fieldname] = { + get_value: function() { + return "$DefaultCompany"; + }, + set_value: function() {}, // пустая функция + df: { + fieldname: filter.fieldname, + hidden: true, + __is_hidden_field: true + } + }; + + // Обновляем фильтры + updateFiltersJson(d); + return; + } + + const fieldId = `filter_${filter.fieldname}`; + // Используем jQuery для поиска элемента + const $container = $(document.getElementById(fieldId)); + + if (!$container.length) { + frappe.show_alert({ + message: `Не найден контейнер для фильтра ${filter.fieldname}`, + indicator: 'orange' + }); + return; + } + + const currentValue = currentValues[filter.fieldname] !== undefined ? + currentValues[filter.fieldname] : + filter.default; + + // Создаем контрол в зависимости от типа поля + let control; + + switch(filter.fieldtype) { + case 'Link': + control = new frappe.ui.form.ControlLink({ + parent: $container, + df: { + fieldname: filter.fieldname, + label: filter.label || filter.fieldname, + fieldtype: 'Link', + options: filter.options, + reqd: filter.reqd || 0, + default: currentValue + }, + render_input: true + }); + break; + + case 'Select': + control = new frappe.ui.form.ControlSelect({ + parent: $container, + df: { + fieldname: filter.fieldname, + label: filter.label || filter.fieldname, + fieldtype: 'Select', + options: filter.options, + reqd: filter.reqd || 0, + default: currentValue + }, + render_input: true + }); + break; + + case 'Date': + // Получаем target_doctype из основной формы + const target_doctype = cur_frm.doc.target_doctype; + + if (target_doctype) { + // Загружаем поля целевого DocType + frappe.model.with_doctype(target_doctype, function() { + const targetMeta = frappe.get_meta(target_doctype); + if (targetMeta) { + // Фильтруем поля даты и специальные поля ay/il + const targetDateFields = targetMeta.fields.filter(field => + field.fieldtype === 'Date' || + field.fieldname === 'ay' || + field.fieldname === 'il' + ); + + // Проверяем, есть ли специальные поля ay и il одновременно + const hasAyField = targetDateFields.some(field => field.fieldname === 'ay'); + const hasIlField = targetDateFields.some(field => field.fieldname === 'il'); + + if (targetDateFields.length > 0) { + // Создаем селект с опциями для полей даты + let selectHtml = ''; + + $container.html(selectHtml); + + // Добавляем обработчик для опции "Указать дату..." + $container.find('.filter-value').on('change', function() { + const selectedValue = $(this).val(); + + if (selectedValue === 'custom') { + $container.html(''); + } + else if (selectedValue === '$AyIlSpecial') { + // Для специального значения $AyIlSpecial + updateFiltersJson(d); + } + }); + + // Создаем контрол на основе Select + control = { + get_value: function() { + return $container.find('.filter-value').val() || ""; + }, + set_value: function(v) { + $container.find('.filter-value').val(v); + }, + df: { + fieldname: filter.fieldname, + fieldtype: 'Select' + } + }; + + // Сохраняем контрол + d.filter_controls[filter.fieldname] = control; + + // Если есть текущее значение, пытаемся его установить + if (currentValue) { + try { + control.set_value(currentValue); + } catch(e) {} + } + + // Добавляем событие изменения для обновления JSON + $container.find('.filter-value').on('change', function() { + updateFiltersJson(d); + }); + } else { + // Если нет полей даты, создаем обычное поле даты + createDefaultDateControl(); + } + } else { + // Если не удалось получить метаданные, создаем обычное поле даты + createDefaultDateControl(); + } + }); + } else { + // Если target_doctype не указан, создаем обычное поле даты + createDefaultDateControl(); + } + + // Функция для создания стандартного контрола даты + function createDefaultDateControl() { + control = new frappe.ui.form.ControlDate({ + parent: $container, + df: { + fieldname: filter.fieldname, + label: filter.label || filter.fieldname, + fieldtype: 'Date', + reqd: filter.reqd || 0, + default: currentValue, + change: function() { + updateFiltersJson(d); + } + }, + render_input: true + }); + + // Сохраняем контрол + d.filter_controls[filter.fieldname] = control; + } + break; + + case 'Datetime': + control = new frappe.ui.form.ControlDatetime({ + parent: $container, + df: { + fieldname: filter.fieldname, + label: filter.label || filter.fieldname, + fieldtype: 'Datetime', + reqd: filter.reqd || 0, + default: currentValue + }, + render_input: true + }); + break; + + case 'Check': + control = new frappe.ui.form.ControlCheck({ + parent: $container, + df: { + fieldname: filter.fieldname, + label: filter.label || filter.fieldname, + fieldtype: 'Check', + reqd: filter.reqd || 0, + default: currentValue + }, + render_input: true + }); + break; + + default: + control = new frappe.ui.form.ControlData({ + parent: $container, + df: { + fieldname: filter.fieldname, + label: filter.label || filter.fieldname, + fieldtype: 'Data', + reqd: filter.reqd || 0, + default: currentValue + }, + render_input: true + }); + } + + // Обработчик изменения значения фильтра (для не-дата полей) + if (control && filter.fieldtype !== 'Date') { + control.df.change = function() { + updateFiltersJson(d); + }; + + // Сохраняем контрол + d.filter_controls[filter.fieldname] = control; + } + }); + + // Функция для обновления JSON фильтров + function updateFiltersJson(d) { + try { + // Собираем все значения фильтров + const values = {}; + Object.keys(d.filter_controls || {}).forEach(fieldname => { + const ctrl = d.filter_controls[fieldname]; + if (ctrl) { + try { + const value = ctrl.get_value(); + if (value !== undefined && value !== null && value !== '') { + values[fieldname] = value; + } + } catch (e) { + frappe.show_alert({ + message: `Ошибка при получении значения фильтра ${fieldname}: ${e.message}`, + indicator: 'red' + }); + } + } + }); + + // Обновляем JSON фильтров + if (Object.keys(values).length > 0) { + d.set_value('filters_json', JSON.stringify(values, null, 2)); + } + } catch (e) { + frappe.show_alert({ + message: `Ошибка при обновлении фильтров: ${e.message}`, + indicator: 'red' + }); + } + } +} + +function loadReportFields(d, reportName) { + // Сначала проверяем, все ли обязательные фильтры заполнены + const filtersJson = d.get_value('filters_json') || '{}'; + let filters = {}; + + try { + filters = JSON.parse(filtersJson); + } catch (e) { + frappe.throw(`Ошибка при разборе JSON фильтров: ${e.message}`); + return; + } + + // Показываем какие фильтры мы пытаемся использовать + frappe.show_alert({ + message: `Загружаем поля отчета '${reportName}' с фильтрами: ${JSON.stringify(filters)}`, + indicator: 'blue' + }); + + // Загружаем информацию о фильтрах отчета, если ее еще нет + if (!d.report_filters) { + frappe.call({ + method: "formula_editor.api.get_report_filters_dynamic", + args: { + report_name: reportName + }, + callback: function(r) { + if (r.message) { + d.report_filters = r.message; + + // Проверяем обязательные фильтры + checkRequiredFilters(); + } else { + // Если нет фильтров, пробуем загрузить поля + frappe.show_alert({ + message: `Для отчета '${reportName}' не найдены фильтры. Пробуем загрузить поля напрямую.`, + indicator: 'orange' + }); + fetchReportFields(); + } + } + }); + } else { + // Если информация о фильтрах уже есть, проверяем обязательные фильтры + checkRequiredFilters(); + } + + // Функция для проверки обязательных фильтров + function checkRequiredFilters() { + // Получаем список обязательных фильтров + const requiredFilters = d.report_filters.filter(f => f.reqd); + + // Показываем список обязательных фильтров + if (requiredFilters.length > 0) { + const requiredFilterNames = requiredFilters.map(f => f.label || f.fieldname).join(", "); + frappe.show_alert({ + message: `Для отчета требуются обязательные фильтры: ${requiredFilterNames}`, + indicator: 'blue' + }); + } + + // Проверяем, какие обязательные фильтры отсутствуют + const missingRequiredFilters = requiredFilters.filter(f => { + const isMissing = !(f.fieldname in filters) || + filters[f.fieldname] === undefined || + filters[f.fieldname] === null || + filters[f.fieldname] === ""; + return isMissing; + }); + + // Если есть отсутствующие обязательные фильтры даты, пробуем заполнить их значениями по умолчанию + const missingDateFilters = missingRequiredFilters.filter(f => + f.fieldtype === 'Date' && ( + f.fieldname.toLowerCase().includes('date') || + f.fieldname.toLowerCase().includes('from') || + f.fieldname.toLowerCase().includes('to') + ) + ); + + if (missingDateFilters.length > 0) { + // Заполняем значения по умолчанию для дат + const today = frappe.datetime.get_today(); + const lastMonth = frappe.datetime.add_months(today, -1); + + missingDateFilters.forEach(filter => { + // Определяем, какой тип фильтра даты + const isFromFilter = filter.fieldname.toLowerCase().includes('from'); + const isToFilter = filter.fieldname.toLowerCase().includes('to'); + + // Устанавливаем значения по умолчанию + if (isFromFilter) { + filters[filter.fieldname] = lastMonth; + } else if (isToFilter || filter.fieldname.toLowerCase().includes('date')) { + filters[filter.fieldname] = today; + } + + // Обновляем контрол фильтра, если он существует + if (d.filter_controls && d.filter_controls[filter.fieldname]) { + try { + d.filter_controls[filter.fieldname].set_value(filters[filter.fieldname]); + } catch(e) { + frappe.show_alert({ + message: `Ошибка при обновлении контрола фильтра ${filter.fieldname}: ${e.message}`, + indicator: 'red' + }); + } + } + }); + + // Обновляем JSON фильтров + d.set_value('filters_json', JSON.stringify(filters, null, 2)); + + // Показываем уведомление о заполнении фильтров + frappe.show_alert({ + message: `Добавлены значения по умолчанию для обязательных фильтров даты: ${missingDateFilters.map(f => f.fieldname).join(", ")}`, + indicator: 'blue' + }); + } + + // Проверяем оставшиеся обязательные фильтры после автозаполнения дат + const remainingMissingFilters = missingRequiredFilters.filter(f => { + // Проверяем, не был ли фильтр заполнен автоматически + const isMissing = !(f.fieldname in filters) || + filters[f.fieldname] === undefined || + filters[f.fieldname] === null || + filters[f.fieldname] === ""; + return isMissing; + }); + + if (remainingMissingFilters.length > 0) { + // Если остались обязательные фильтры, показываем предупреждение + const missingFieldNames = remainingMissingFilters.map(f => f.label || f.fieldname).join(", "); + + frappe.throw(`Для отчета "${reportName}" необходимо заполнить следующие обязательные фильтры: ${missingFieldNames}`); + + // Очищаем опции селекта полей + d.set_df_property('field_name', 'options', []); + d.fields_dict.field_name.refresh(); + } else { + // Если все обязательные фильтры заполнены, загружаем поля отчета + fetchReportFields(); + } + } + + // Функция для загрузки полей отчета + function fetchReportFields() { + frappe.show_alert({ + message: `Запрашиваем поля отчета с фильтрами: ${JSON.stringify(filters)}`, + indicator: 'blue' + }); + + frappe.call({ + method: "formula_editor.api.get_report_fields", + args: { + report_name: reportName, + filters: filters + }, + callback: function(r) { + if (r.exc) { + // Если произошла ошибка на сервере + frappe.throw(`Ошибка при загрузке полей отчета: ${r.exc}`); + return; + } + + if (r.message && r.message.length) { + const fields = r.message.map(field => ({ + label: field.label || field.fieldname, + value: field.fieldname + })); + + frappe.show_alert({ + message: `Найдено ${fields.length} полей отчета`, + indicator: 'green' + }); + + // Обновляем опции селекта + d.set_df_property('field_name', 'options', fields); + d.fields_dict.field_name.refresh(); + + // Если есть сохраненное значение, выбираем его + if (d.get_value('field_name')) { + d.set_value('field_name', d.get_value('field_name')); + } + } else { + // Если полей нет, очищаем селект + d.set_df_property('field_name', 'options', []); + d.fields_dict.field_name.refresh(); + + frappe.throw(`В отчете '${reportName}' не найдено подходящих числовых полей. Пожалуйста, выберите другой отчет.`); + } + } + }); + } +} + +function saveReportField(frm, values, fieldId = null) { + // Используем имя поля в качестве идентификатора или указанный ID + const newFieldId = fieldId || frappe.utils.get_random(10); + + try { + let reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + + // Проверка на дублирование имени поля + const existingField = Object.keys(reportFields).find(key => + key !== newFieldId && reportFields[key].name === values.name + ); + + if (existingField) { + frappe.msgprint({ + title: __('Внимание'), + message: __('Поле с таким именем уже существует.'), + indicator: 'orange' + }); + return; + } + + // Обработка JSON фильтров для замены прямых значений компании на специальный маркер + if (values.filters_json) { + try { + let filters = JSON.parse(values.filters_json); + + // Заменяем конкретную компанию на специальный маркер + if (filters.company) { + filters.company = "$DefaultCompany"; + } + + // Проверяем даты и заменяем на маркеры для Ay/Il где нужно + for (const key in filters) { + if (typeof filters[key] === 'string' && filters[key].startsWith('doc.ay')) { + // Заменяем на специальный маркер для дат с месяцем/годом + filters[key] = "$AyIlSpecial"; + } + } + + // Обновляем JSON фильтров + values.filters_json = JSON.stringify(filters); + } catch (e) { + console.error("Ошибка при обработке JSON фильтров:", e); + } + } + + // Сохраняем информацию о поле отчета + reportFields[newFieldId] = { + name: values.name, + report: values.report, + filters_json: values.filters_json, + field_name: values.field_name, + aggregation: values.aggregation + }; + + frm.set_value("report_fields_storage", JSON.stringify(reportFields)); + + frappe.show_alert({ + message: __('Поле отчета сохранено'), + indicator: 'green' + }); + + renderReportFields(frm); + } catch (error) { + frappe.msgprint({ + title: __('Ошибка'), + message: __('Не удалось сохранить поле отчета.'), + indicator: 'red' + }); + } +} + +function renderReportFields(frm) { + const $container = $(frm.wrapper).find('.report-fields-list'); + $container.empty(); + + try { + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + + if (Object.keys(reportFields).length === 0) { + $container.append('
Нет полей отчетов. Нажмите "Добавить поле отчета" для создания.
'); + return; + } + + Object.keys(reportFields).forEach(fieldId => { + const field = reportFields[fieldId]; + + const $fieldButton = $(` +
+ +
+ + +
+
+ `); + + // Показываем/скрываем кнопки действий при наведении + $fieldButton.hover( + function() { $(this).find('.field-actions').show(); }, + function() { $(this).find('.field-actions').hide(); } + ); + + $container.append($fieldButton); + }); + + // Инициализируем drag-n-drop для полей отчетов + $container.find('.report-field-btn').each(function() { + $(this).attr('draggable', 'true'); + + $(this).off('dragstart').on('dragstart', function(e) { + const fieldId = $(this).data('field-id'); + const reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + const field = reportFields[fieldId]; + + e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({ + type: 'report-field', + fieldId: fieldId, + name: field.name + })); + }); + }); + } catch (error) { + $container.append('
Ошибка при загрузке полей отчетов.
'); + } +} + +function editReportField(frm, fieldId) { + openReportFieldModal(frm, fieldId); +} + +function deleteReportField(frm, fieldId) { + frappe.confirm( + 'Вы уверены, что хотите удалить это поле отчета?', + function() { + try { + let reportFields = JSON.parse(frm.doc.report_fields_storage || "{}"); + delete reportFields[fieldId]; + frm.set_value("report_fields_storage", JSON.stringify(reportFields)); + + // Перерисовываем список полей + renderReportFields(frm); + + frappe.show_alert({ + message: __('Поле отчета удалено'), + indicator: 'green' + }); + } catch (error) { + frappe.msgprint({ + title: __('Ошибка'), + message: __('Не удалось удалить поле отчета.'), + indicator: 'red' + }); + } + } + ); +} diff --git a/formula_editor/formula_editor/doctype/formula_editor/formula_editor.json b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.json index c970c1b..93e137a 100644 --- a/formula_editor/formula_editor/doctype/formula_editor/formula_editor.json +++ b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.json @@ -9,6 +9,7 @@ "formula_storage", "universal_fields_storage", "table_row_formulas_storage", + "report_fields_storage", "formula_fields", "test" ], @@ -42,11 +43,16 @@ "fieldname": "table_row_formulas_storage", "fieldtype": "Long Text", "label": "table_row_formulas_storage" + }, + { + "fieldname": "report_fields_storage", + "fieldtype": "Long Text", + "label": "report_fields_storage" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-04-03 16:25:00.731153", + "modified": "2025-04-08 13:22:43.700556", "modified_by": "Administrator", "module": "Formula Editor", "name": "Formula Editor", diff --git a/formula_editor/formula_editor/doctype/report_simulator/__init__.py b/formula_editor/formula_editor/doctype/report_simulator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js new file mode 100644 index 0000000..b1ca8ac --- /dev/null +++ b/formula_editor/formula_editor/doctype/report_simulator/report_simulator.js @@ -0,0 +1,231 @@ +frappe.ui.form.on('Report Simulator', { + refresh: function(frm) { + // Добавляем кнопку для выполнения отчета + frm.add_custom_button(__('Выполнить отчет'), function() { + frm.call({ + doc: frm.doc, + method: 'execute_report', + callback: function(r) { + if (r.message) { + frappe.msgprint({ + title: __('Результат отчета'), + indicator: 'green', + message: `
${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 = '
'; + html += ''; + + // Заголовок таблицы + html += ''; + columns.forEach(col => { + html += ``; + }); + html += ''; + + // Тело таблицы + html += ''; + data.forEach(row => { + html += ''; + columns.forEach(col => { + let value = row[col.fieldname]; + + // Форматируем значение, если это нужно + if (value === null || value === undefined) { + value = ''; + } else if (typeof value === 'object') { + value = JSON.stringify(value); + } + + html += ``; + }); + html += ''; + }); + html += ''; + html += '
${col.label || col.fieldname}
${value}
'; + html += '
'; + + // Отображаем результат + frappe.msgprint({ + title: __('Результат отчета'), + indicator: 'green', + message: html + }); + + // Сохраняем симуляцию для истории + frappe.db.insert({ + doctype: 'Report Simulator', + report: values.report, + filters_json: values.filters_json, + result: JSON.stringify(r.message, null, 2) + }).then(doc => { + frappe.show_alert({ + message: __('Симуляция сохранена'), + indicator: 'green' + }, 3); + listview.refresh(); + }); + } + } + }); + + d.hide(); + } + }); + + d.show(); + }); + } +}; \ No newline at end of file diff --git a/formula_editor/formula_editor/doctype/report_simulator/test_report_simulator.py b/formula_editor/formula_editor/doctype/report_simulator/test_report_simulator.py new file mode 100644 index 0000000..a7ad296 --- /dev/null +++ b/formula_editor/formula_editor/doctype/report_simulator/test_report_simulator.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestReportSimulator(UnitTestCase): + """ + Unit tests for ReportSimulator. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestReportSimulator(IntegrationTestCase): + """ + Integration tests for ReportSimulator. + Use this class for testing interactions between multiple components. + """ + + pass