From a1849cdd4c47f42bf035734359c8493959b1fd84 Mon Sep 17 00:00:00 2001
From: Ali <010109ali@gmail.com>
Date: Thu, 27 Feb 2025 19:25:36 +0400
Subject: [PATCH] Added app formula editor
---
formula_editor/api.py | 24 +
.../formula_editor/doctype/__init__.py | 0
.../doctype/formula_editor/__init__.py | 0
.../doctype/formula_editor/formula_editor.js | 1247 +++++++++++++++++
.../formula_editor/formula_editor.json | 59 +
.../doctype/formula_editor/formula_editor.py | 9 +
.../formula_editor/test_formula_editor.py | 30 +
7 files changed, 1369 insertions(+)
create mode 100644 formula_editor/api.py
create mode 100644 formula_editor/formula_editor/doctype/__init__.py
create mode 100644 formula_editor/formula_editor/doctype/formula_editor/__init__.py
create mode 100644 formula_editor/formula_editor/doctype/formula_editor/formula_editor.js
create mode 100644 formula_editor/formula_editor/doctype/formula_editor/formula_editor.json
create mode 100644 formula_editor/formula_editor/doctype/formula_editor/formula_editor.py
create mode 100644 formula_editor/formula_editor/doctype/formula_editor/test_formula_editor.py
diff --git a/formula_editor/api.py b/formula_editor/api.py
new file mode 100644
index 0000000..5558f56
--- /dev/null
+++ b/formula_editor/api.py
@@ -0,0 +1,24 @@
+import frappe
+
+@frappe.whitelist()
+def get_doctype_fields(doctype):
+ meta = frappe.get_meta(doctype)
+ fields = []
+
+ # Получаем все int-поля
+ for field in meta.fields:
+ if field.fieldtype in ["Int", "Float", "Currency"]:
+ fields.append({"fieldname": field.fieldname, "label": field.label})
+
+ # Проверяем таблицы внутри DocType (например, `items` в `Sales Invoice`)
+ for child in meta.fields:
+ if child.fieldtype == "Table":
+ child_meta = frappe.get_meta(child.options)
+ for sub_field in child_meta.fields:
+ if sub_field.fieldtype in ["Int", "Float", "Currency"]:
+ fields.append({
+ "fieldname": f"{child.fieldname}_{sub_field.fieldname}",
+ "label": f"{child.label} → {sub_field.label}"
+ })
+
+ return fields
diff --git a/formula_editor/formula_editor/doctype/__init__.py b/formula_editor/formula_editor/doctype/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/formula_editor/formula_editor/doctype/formula_editor/__init__.py b/formula_editor/formula_editor/doctype/formula_editor/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js
new file mode 100644
index 0000000..0bb94c9
--- /dev/null
+++ b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.js
@@ -0,0 +1,1247 @@
+frappe.ui.form.on('Formula Editor', {
+ onload_post_render: function(frm) {
+ if (!frm.is_initialized) {
+ frm.is_initialized = true;
+ console.log("Initializing Formula Editor");
+
+ if (frm.doc.target_doctype) {
+ generate_dynamic_fields(frm, frm.doc.target_doctype, function() {
+ if (frm.doc.formula_storage) {
+ restore_formulas(frm);
+ }
+ });
+ }
+ }
+ },
+
+ target_doctype: function(frm) {
+ generate_dynamic_fields(frm, frm.doc.target_doctype, function() {
+ restore_formulas(frm);
+ });
+ },
+
+ before_save: function(frm) {
+ console.log("📝 `before_save` вызван. Проверяем сохраненные формулы...");
+ save_formulas(frm);
+
+ console.log("🔄 `formula_storage` перед сохранением документа:", frm.doc.formula_storage);
+ generate_client_script(frm);
+ }
+});
+
+function restore_formulas(frm) {
+ let stored_formulas = JSON.parse(frm.doc.formula_storage || "{}");
+ console.log("🔄 Восстанавливаем формулы:", stored_formulas);
+
+ $(frm.fields_dict.formula_fields.wrapper).find('.formula-input, .trigger-input').each(function () {
+ let fieldname = $(this).attr("data-fieldname");
+
+ if (!fieldname) return;
+
+ let isTriggerField = fieldname.endsWith("_triggers");
+ let baseFieldname = isTriggerField ? fieldname.replace("_triggers", "") : fieldname;
+
+ if (stored_formulas[baseFieldname] !== undefined) {
+ let value = isTriggerField ? stored_formulas[baseFieldname].triggers : stored_formulas[baseFieldname].formula;
+
+ if (typeof value !== "string") {
+ console.warn(`⚠️ Поле "${fieldname}" имеет некорректное значение:`, value);
+ return;
+ }
+
+ console.log(`✔️ Восстанавливаем "${fieldname}" -> "${value}"`);
+ $(this).val(value);
+ }
+ });
+}
+
+function save_formulas(frm) {
+ let formula_data = {};
+
+ console.log("🔄 Начинаем сохранение формул и триггеров...");
+
+ $(frm.fields_dict.formula_fields.wrapper).find('.formula-input').each(function () {
+ let fieldname = $(this).attr("data-fieldname");
+ let formula = $(this).val();
+ let is_table_field = $(this).attr("data-is-table-field") === "true";
+
+ // Ищем триггерное поле
+ let triggers = $(frm.fields_dict.formula_fields.wrapper)
+ .find(`[data-fieldname="${fieldname}_triggers"] input`).val();
+
+ console.log(`📌 Обрабатываем поле: "${fieldname}"`);
+ console.log(` 🔹 Формула: "${formula}"`);
+ console.log(` 🔹 Триггеры: "${triggers}"`);
+ console.log(` 🔹 Табличное поле: "${is_table_field}"`);
+
+ formula_data[fieldname] = {
+ formula: formula || "",
+ triggers: triggers || "",
+ is_table_field: is_table_field
+ };
+ });
+
+ console.log("✅ Итоговый JSON formula_storage:", formula_data);
+ frm.set_value("formula_storage", JSON.stringify(formula_data));
+}
+
+function generate_client_script(frm) {
+ if (!frm.doc.target_doctype) {
+ console.error("❌ Target Doctype отсутствует!");
+ return;
+ }
+
+ let target_doctype = frm.doc.target_doctype;
+ let formula_data = JSON.parse(frm.doc.formula_storage || "{}");
+
+ let main_script = `frappe.ui.form.on("${target_doctype}", {\n`;
+ let functions = [];
+ let table_child_scripts = {};
+
+ // Справочник соответствия таблиц и их DocType
+ let table_doctypes = {};
+
+ // Сначала соберем информацию о табличных полях и их DocType
+ frappe.call({
+ method: "frappe.client.get",
+ args: {
+ doctype: "DocType",
+ name: target_doctype
+ },
+ async: false, // Синхронный вызов для гарантии получения данных
+ callback: function(r) {
+ if (r.message && r.message.child_tables) {
+ r.message.child_tables.forEach(table => {
+ if (table.options) {
+ // Сохраняем соответствие имени поля таблицы в родительском DocType и DocType самой таблицы
+ table_doctypes[table.fieldname] = table.options;
+ }
+ });
+ }
+ }
+ });
+
+ // Если данные о таблицах не получены, пробуем альтернативный способ через meta
+ if (Object.keys(table_doctypes).length === 0) {
+ const meta = frappe.get_meta(target_doctype);
+ if (meta && meta.fields) {
+ meta.fields.forEach(field => {
+ if (field.fieldtype === 'Table' && field.options) {
+ table_doctypes[field.fieldname] = field.options;
+ }
+ });
+ }
+ }
+
+ console.log("📊 Соответствие таблиц и DocType:", table_doctypes);
+
+ // Сбор всех табличных частей и их полей
+ let child_tables = {};
+
+ Object.keys(formula_data).forEach(fieldname => {
+ let field_info = formula_data[fieldname];
+
+ if (field_info && field_info.is_table_field) {
+ // Извлекаем имя табличной части из fieldname (формат: tablename_fieldname)
+ let parts = fieldname.split('_');
+ if (parts.length >= 2) {
+ let table_name = parts[0];
+ if (!child_tables[table_name]) {
+ child_tables[table_name] = [];
+ }
+ child_tables[table_name].push(fieldname);
+ }
+ }
+ });
+
+ 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 formula = field_info.formula.trim();
+ let triggers = field_info.triggers ? field_info.triggers.split(',').map(t => t.trim()) : [];
+ let is_table_field = field_info.is_table_field;
+
+ function transform_expression(expression, for_child_table = false, table_name = null) {
+ console.log("🔄 Исходная формула:", expression, "для табличной части:", for_child_table, "таблица:", table_name);
+
+ let transformed = expression;
+
+ // Если мы трансформируем выражение для табличной части
+ if (for_child_table) {
+ // Заменяем val() на row() для всех полей внутри табличной части
+ transformed = transformed.replace(/val\((\w+)\)/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\((\w+)\)/g, (match, field) => {
+ return `row["${field}"]`;
+ });
+ } else {
+ // Стандартная замена для обычных полей
+ transformed = transformed.replace(/val\((\w+)\)/g, (match, field) => {
+ return `frm.doc["${field}"]`;
+ });
+
+ // Предупреждение, если row() используется вне таблицы
+ transformed = transformed.replace(/row\((\w+)\)/g, (match, field) => {
+ console.warn("⚠️ Функция row() используется вне контекста табличной части");
+ return `0 /* ERROR: row() вне табличной части */`;
+ });
+ }
+
+ // Функция суммирования по табличной части применяется в обоих случаях
+ transformed = transformed.replace(/sum\((\w+)_(\w+)\)/g, (match, tbl, fld) => {
+ return `(frm.doc["${tbl}"].reduce((sum, row) => sum + (row["${fld}"] || 0), 0))`;
+ });
+
+ // Обработка процентов
+ transformed = transformed.replace(/(frm\.doc\["\w+"\]|\d+|row\["\w+"\])\s*([+\-])\s*(\d+(\.\d+)?)%/g, (match, base, operator, percent) => {
+ console.log("✅ Обнаружен процент в выражении:", match);
+ return `${base} ${operator} ((${base} * ${percent}) / 100)`;
+ });
+
+ console.log("🔄 Трансформированная формула:", transformed);
+ return transformed;
+ }
+
+ let modified_formula = transform_expression(formula, is_table_field);
+
+ if (is_table_field) {
+ // Определяем имя табличной части из fieldname
+ let parts = fieldname.split('_');
+ let table_name = parts[0];
+ let field_name_in_table = parts.slice(1).join('_');
+
+ // Создаем или дополняем обработчики для конкретной табличной части
+ if (!table_child_scripts[table_name]) {
+ table_child_scripts[table_name] = {
+ doctype: table_doctypes[table_name] || `${target_doctype} Item`, // По умолчанию используем Parent DocType + Item
+ calculations: [],
+ fields: new Set()
+ };
+ }
+
+ // Добавляем функцию расчета для текущего поля
+ table_child_scripts[table_name].calculations.push({
+ field: field_name_in_table,
+ formula: transform_expression(formula, true, table_name)
+ });
+
+ // Добавляем триггеры
+ triggers.forEach(trigger => {
+ table_child_scripts[table_name].fields.add(trigger);
+ });
+ } else {
+ // Обычная функция расчета для поля основного документа
+ functions.push(`
+function calculate_${fieldname}(frm) {
+ try {
+ let result = ${modified_formula};
+ frm.set_value("${fieldname}", result);
+ } catch (e) {
+ console.error("❌ Ошибка в формуле для ${fieldname}: ${formula}", e);
+ }
+}`);
+
+ // Добавляем триггеры для основного документа
+ triggers.forEach(trigger => {
+ main_script += ` "${trigger}": function(frm) { calculate_${fieldname}(frm); },\n`;
+ });
+ }
+ });
+
+ main_script += `});`;
+
+ // Добавляем обработчики для табличных частей
+ Object.keys(table_child_scripts).forEach(table_name => {
+ let childDoctype = table_child_scripts[table_name].doctype;
+ let table_script = `\n\nfrappe.ui.form.on("${childDoctype}", {\n`;
+
+ // Создаем общую функцию для расчета формул в строке таблицы
+ let calcFunction = `
+function calculate_${table_name}_row(frm, cdt, cdn) {
+ let row = locals[cdt][cdn];
+
+ try {`;
+
+ // Добавляем расчеты для каждого поля табличной части с правильным форматированием
+ table_child_scripts[table_name].calculations.forEach(calc => {
+ calcFunction += `
+ // Расчет для поля ${calc.field}
+ row["${calc.field}"] = ${calc.formula};
+`;
+ });
+
+ calcFunction += `
+ frm.refresh_field("${table_name}");
+ } catch (e) {
+ console.error("❌ Ошибка в формуле для таблицы ${table_name}:", e);
+ }
+}`;
+
+ functions.push(calcFunction);
+
+ // Добавляем обработчики для каждого триггерного поля
+ Array.from(table_child_scripts[table_name].fields).forEach(field => {
+ table_script += ` "${field}": function(frm, cdt, cdn) { calculate_${table_name}_row(frm, cdt, cdn); },\n`;
+ });
+
+ table_script += `});`;
+ main_script += table_script;
+ });
+
+ let final_script = `${main_script}\n\n${functions.join("\n\n")}`;
+ console.log("✅ **Сгенерированный Client Script:**\n", final_script);
+
+ update_or_create_client_script(target_doctype, final_script);
+}
+
+function update_or_create_client_script(target_doctype, script_code) {
+ frappe.call({
+ method: "frappe.client.get_list",
+ args: {
+ doctype: "Client Script",
+ filters: { dt: target_doctype },
+ fields: ["name"]
+ },
+ callback: function(r) {
+ if (r.message && r.message.length > 0) {
+ let existing_script_name = r.message[0].name;
+ console.log(`🔄 Найден существующий Client Script: ${existing_script_name}. Обновляем...`);
+
+ frappe.call({
+ method: "frappe.client.set_value",
+ args: {
+ doctype: "Client Script",
+ name: existing_script_name,
+ fieldname: "script",
+ value: script_code
+ },
+ callback: function(response) {
+ if (response.message) {
+ console.log(`✅ Client Script обновлен для ${target_doctype}`);
+ } else {
+ console.error("❌ Ошибка при обновлении Client Script", response);
+ }
+ }
+ });
+ } else {
+ console.log("⚠️ Существующий Client Script не найден. Создаем новый...");
+
+ frappe.call({
+ method: "frappe.client.insert",
+ args: {
+ doc: {
+ doctype: "Client Script",
+ name: `Client Script for ${target_doctype}`,
+ dt: target_doctype,
+ script_type: "Client",
+ script: script_code,
+ enabled:1
+ }
+ },
+ callback: function(response) {
+ if (response.message) {
+ console.log(`✅ Client Script создан для ${target_doctype}`);
+ } else {
+ console.error("❌ Ошибка при создании Client Script", response);
+ }
+ }
+ });
+ }
+ }
+ });
+}
+
+function generate_dynamic_fields(frm, doctype, callback) {
+ console.log("🔄 Получаем поля для Doctype:", doctype);
+ frappe.call({
+ method: "formula_editor.api.get_doctype_fields",
+ args: { doctype: doctype },
+ callback: function(r) {
+ if (!r.message || r.message.length === 0) {
+ console.warn("⚠️ Нет данных из API get_doctype_fields");
+ return;
+ }
+
+ let fields = r.message;
+ console.log("✅ Получены поля:", fields);
+
+ if (!frm.fields_dict.formula_fields) {
+ console.error("❌ Поле 'formula_fields' не найдено!");
+ return;
+ }
+
+ let wrapper = $(frm.fields_dict.formula_fields.wrapper);
+ wrapper.empty();
+
+ let grouped_fields = { "Основные поля": [] };
+ let table_fieldnames = new Set();
+
+ fields.forEach(field => {
+ if (!field.fieldname) return;
+
+ let label = field.label ? field.label : "*Нет наименования*";
+ let match = label.match(/^(.*?)\s*→\s*(.*?)$/);
+ let table_name = match ? match[1] : field.fieldname.startsWith("table_") ? field.fieldname : "Основные поля";
+ let field_label = match ? match[2] : label;
+
+ if (!grouped_fields[table_name]) {
+ grouped_fields[table_name] = [];
+ }
+ grouped_fields[table_name].push({ fieldname: field.fieldname, label: field_label });
+
+ if (match || field.fieldname.startsWith("table_")) {
+ table_fieldnames.add(field.fieldname);
+ }
+ });
+
+ let container = $('
');
+
+ // Создаем модальное окно формульного редактора
+ createFormulaBuilderModal();
+
+ Object.keys(grouped_fields).forEach(group => {
+ let section_name = group === "Основные поля" ? group : `${group} (${group.replace(/\s+/g, '_').toLowerCase()})`;
+ let section = $('');
+ section.append(`${section_name}
`);
+
+ grouped_fields[group].forEach(field => {
+ let is_table_field = table_fieldnames.has(field.fieldname);
+ let dataAttr = is_table_field ? 'data-is-table-field="true"' : '';
+
+ let field_html = $('');
+ section.append(field_html);
+ });
+
+ container.append(section);
+ });
+
+ wrapper.append(container);
+ attachFormulaBuilderEvents(frm, fields, grouped_fields);
+ frm.refresh();
+ if (callback) callback();
+ }
+ });
+}
+
+function createFormulaBuilderModal() {
+ if ($("#formula_builder_modal").length) {
+ return;
+ }
+
+ const modalHtml = `
+
+ `;
+
+ // Добавляем стили для модального окна
+ const modalStyles = `
+
+ `;
+
+ $('body').append(modalHtml);
+ $('head').append(modalStyles);
+}
+
+function attachFormulaBuilderEvents(frm, fields, grouped_fields) {
+ // Обработчик для кнопок открытия визуального редактора
+ $(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");
+
+ // Unbind all previous event handlers to prevent duplication
+ $modal.off('click', '.field-btn');
+
+ // Очистка предыдущих данных
+ $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');
+ });
+
+ // Обработчик для кнопки применения формулы
+ $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');
+ });
+
+ // Функция добавления токена в формулу
+ function addTokenToFormula(token, type) {
+ if (type === 'field') {
+ let formulaText;
+
+ // Для полей добавляем в соответствующем формате
+ if (token.includes('_') && isTableField) {
+ // Извлекаем имя поля без префикса таблицы для полей таблицы
+ const parts = token.split('_');
+ const actualField = parts.slice(1).join('_');
+ formulaText = `row(${actualField})`;
+
+ const $token = $(`${formulaText}×`);
+ $resultContainer.append($token).append(' ');
+ } else if (token.includes('_') && !isTableField) {
+ // Для суммирования по таблице
+ formulaText = `sum(${token})`;
+
+ const $token = $(`${formulaText}×`);
+ $resultContainer.append($token).append(' ');
+ } else {
+ // Для обычных полей
+ formulaText = `val(${token})`;
+
+ const $token = $(`${formulaText}×`);
+ $resultContainer.append($token).append(' ');
+ }
+ } else if (type === 'operator') {
+ const $token = $(`${token}×`);
+ $resultContainer.append($token).append(' ');
+ }
+
+ // Переносим курсор в конец контейнера
+ setEndOfContentEditable($resultContainer[0]);
+
+ // Добавляем обработчики для удаления токенов
+ attachTokenEvents();
+ }
+
+ // Обработчики событий для токенов формулы
+ function attachTokenEvents() {
+ // Удаление токена
+ $resultContainer.find('.token-remove').off('click').on('click', function(e) {
+ e.stopPropagation();
+ $(this).parent().remove();
+ });
+
+ // Перетаскивание токенов
+ $resultContainer.find('.formula-token').each(function() {
+ $(this).attr('draggable', 'true');
+
+ $(this).off('dragstart').on('dragstart', function(e) {
+ e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
+ type: 'token',
+ value: $(this).data('value'),
+ html: $(this)[0].outerHTML
+ }));
+ window.draggedElement = this;
+ });
+ });
+ }
+
+ // Настраиваем обработчики для полей - используем делегирование событий
+ $modal.on('click', '.field-btn', function() {
+ const fieldname = $(this).data('fieldname');
+ addTokenToFormula(fieldname, 'field');
+ });
+
+ // Показываем модальное окно
+ $modal.modal('show');
+
+ // Добавляем drag and drop функциональность
+ initDragAndDrop($modal);
+
+ // Устанавливаем обработчик для редактируемого контейнера
+ setupEditableContainer($resultContainer);
+
+ // Сразу фокусируемся на редактируемом поле
+ $resultContainer.focus();
+}
+
+function populateFieldsPanel($modal, frm, grouped_fields, isTableField) {
+ const $container = $modal.find(".field-groups-container");
+
+ Object.keys(grouped_fields).forEach(group => {
+ const $fieldGroup = $(`
+
+ `);
+
+ const $buttonsContainer = $fieldGroup.find('.field-buttons-container');
+
+ grouped_fields[group].forEach(field => {
+ // Определяем, является ли это поле таблицы или нет
+ const isField = group === "Основные поля";
+ const isThisTableField = !isField;
+
+ // Создаем кнопку поля с правильными атрибутами для drag-and-drop
+ const $fieldBtn = $(`
+
+ `);
+
+ $buttonsContainer.append($fieldBtn);
+ });
+
+ $container.append($fieldGroup);
+ });
+}
+
+function initDragAndDrop($modal) {
+ // Получаем контейнер результата
+ const $resultContainer = $modal.find(".formula-result");
+
+ // Очищаем предыдущие обработчики drag и drop
+ $modal.find('.field-btn').off('dragstart');
+ $modal.find('.operator-btn').off('dragstart');
+ $modal.find('.function-btn').off('dragstart');
+ $resultContainer.off('dragover dragleave drop');
+
+ // Drag событие для полей
+ $modal.find('.field-btn').on('dragstart', function(e) {
+ const fieldname = $(this).data('fieldname');
+ const label = $(this).data('label');
+ const group = $(this).data('group');
+
+ e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
+ type: 'field',
+ fieldname: fieldname,
+ label: label,
+ group: group
+ }));
+ });
+
+ // Drag событие для операторов
+ $modal.find('.operator-btn').on('dragstart', function(e) {
+ const operator = $(this).data('operator');
+
+ e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
+ type: 'operator',
+ value: operator
+ }));
+ });
+
+ // Drag событие для функций
+ $modal.find('.function-btn').on('dragstart', function(e) {
+ const func = $(this).data('function');
+
+ e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
+ type: 'function',
+ value: func
+ }));
+ });
+
+ // Обработка перетаскивания между элементами в контейнере результата
+ $resultContainer.on('dragover', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ $(this).addClass('drag-hover');
+
+ // Получение данных о позиции курсора
+ const x = e.originalEvent.clientX;
+ const y = e.originalEvent.clientY;
+
+ // Находим позицию для вставки
+ const position = getDropPosition($resultContainer, x, y);
+
+ // Очищаем предыдущий маркер вставки
+ $resultContainer.find('.drop-marker').remove();
+
+ // Добавляем маркер вставки, если позиция найдена
+ if (position.element) {
+ const $marker = $('');
+
+ if (position.position === 'before') {
+ $(position.element).before($marker);
+ } else {
+ $(position.element).after($marker);
+ }
+ }
+ });
+
+ $resultContainer.on('dragleave', function(e) {
+ $(this).removeClass('drag-hover');
+ $(this).find('.drop-marker').remove();
+ });
+
+ $resultContainer.on('drop', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ $(this).removeClass('drag-hover');
+ $(this).find('.drop-marker').remove();
+
+ try {
+ const data = JSON.parse(e.originalEvent.dataTransfer.getData('text/plain'));
+ const isTableField = $modal.data('is-table-field');
+
+ // Получение данных о позиции курсора
+ const x = e.originalEvent.clientX;
+ const y = e.originalEvent.clientY;
+
+ // Находим позицию для вставки
+ const position = getDropPosition($resultContainer, x, y);
+
+ // Если это перетаскивание существующего токена
+ if (data.type === 'token' && window.draggedElement) {
+ const $dragged = $(window.draggedElement);
+
+ // Если позиция найдена, перемещаем элемент
+ if (position.element) {
+ if (position.position === 'before') {
+ $(position.element).before($dragged);
+ } else {
+ $(position.element).after($dragged);
+ }
+ $(position.element).before(' ');
+ $(position.element).after(' ');
+ } else {
+ // Если позиция не найдена, добавляем в конец
+ $resultContainer.append($dragged);
+ $resultContainer.append(' ');
+ }
+
+ window.draggedElement = null;
+ return;
+ }
+
+ // Создаем новый элемент
+ let $newElement;
+
+ if (data.type === 'field') {
+ let formulaText;
+
+ // Подготавливаем текст формулы в зависимости от типа поля
+ if (data.fieldname.includes('_') && isTableField) {
+ const parts = data.fieldname.split('_');
+ const actualField = parts.slice(1).join('_');
+ formulaText = `row(${actualField})`;
+ } else if (data.fieldname.includes('_') && !isTableField) {
+ formulaText = `sum(${data.fieldname})`;
+ } else {
+ formulaText = `val(${data.fieldname})`;
+ }
+
+ $newElement = $(`${formulaText}×`);
+ } else if (data.type === 'operator') {
+ $newElement = $(`${data.value}×`);
+ }
+
+ // Вставляем новый элемент на нужную позицию
+ if ($newElement) {
+ if (position.element) {
+ if (position.position === 'before') {
+ $(position.element).before($newElement);
+ } else {
+ $(position.element).after($newElement);
+ }
+ $(position.element).before(' ');
+ $(position.element).after(' ');
+ } else {
+ $resultContainer.append($newElement);
+ $resultContainer.append(' ');
+ }
+
+ // Инициализируем события для нового элемента
+ $newElement.find('.token-remove').on('click', function(e) {
+ e.stopPropagation();
+ $(this).parent().remove();
+ });
+
+ $newElement.on('dragstart', function(e) {
+ e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
+ type: 'token',
+ value: $(this).data('value'),
+ html: $(this)[0].outerHTML
+ }));
+ window.draggedElement = this;
+ });
+ }
+ } catch (error) {
+ console.error("Ошибка при обработке drop-события:", error);
+ }
+ });
+
+ // Функция для определения позиции вставки
+ function getDropPosition($container, x, y) {
+ let result = { element: null, position: 'after' };
+
+ // Проходим по всем дочерним элементам контейнера
+ $container.contents().each(function() {
+ if (this.nodeType === Node.TEXT_NODE) return;
+
+ const rect = this.getBoundingClientRect();
+
+ // Если курсор находится над элементом
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
+ result.element = this;
+
+ // Определяем, до или после элемента
+ if (x < rect.left + rect.width / 2) {
+ result.position = 'before';
+ } else {
+ result.position = 'after';
+ }
+
+ return false; // Прерываем перебор
+ }
+ });
+
+ return result;
+ }
+}
+
+// Обновляем функцию attachFormulaBuilderEvents, чтобы сделать поля ввода редактируемыми вручную
+function attachFormulaBuilderEvents(frm, fields, grouped_fields) {
+ // Сначала сделаем поля ввода формул редактируемыми
+ $(frm.wrapper).find('.formula-input').prop('readonly', false);
+
+ // Обработчик для кнопок открытия визуального редактора
+ $(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 parseAndRenderFormula($container, formula, isTableField) {
+ if (!formula) return;
+
+ // Регулярные выражения для поиска функций и операторов
+ const funcRegex = /(val|row|sum)\(([^)]+)\)/g;
+ const operatorsRegex = /([+\-*/()%])/g;
+
+ // Сохраняем текущую позицию
+ let lastIndex = 0;
+ let match;
+ let formulaParts = [];
+
+ // Поиск всех функций
+ while ((match = funcRegex.exec(formula)) !== null) {
+ // Добавляем текст до функции
+ if (match.index > lastIndex) {
+ formulaParts.push({
+ type: 'text',
+ value: formula.substring(lastIndex, match.index)
+ });
+ }
+
+ // Добавляем функцию
+ formulaParts.push({
+ type: 'function',
+ value: match[0],
+ func: match[1],
+ param: match[2]
+ });
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ // Добавляем оставшийся текст после последней функции
+ if (lastIndex < formula.length) {
+ formulaParts.push({
+ type: 'text',
+ value: formula.substring(lastIndex)
+ });
+ }
+
+ // Теперь разбираем операторы в частях, которые не являются функциями
+ 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: textAfter
+ });
+ }
+ }
+ }
+ });
+
+ // Рендерим части формулы
+ finalParts.forEach(part => {
+ if (part.type === 'function') {
+ 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} `));
+ }
+ });
+
+ // Добавляем обработчики для удаления и перетаскивания токенов
+ $container.find('.token-remove').off('click').on('click', function(e) {
+ e.stopPropagation();
+ $(this).parent().remove();
+ });
+
+ $container.find('.formula-token').each(function() {
+ $(this).attr('draggable', 'true');
+
+ $(this).off('dragstart').on('dragstart', function(e) {
+ e.originalEvent.dataTransfer.setData('text/plain', JSON.stringify({
+ type: 'token',
+ value: $(this).data('value'),
+ html: $(this)[0].outerHTML
+ }));
+ window.draggedElement = this;
+ });
+ });
+}
+
+function getFormulaText($container) {
+ // Получаем текст формулы, объединяя содержимое всех узлов
+ let formula = '';
+ $container.contents().each(function() {
+ if (this.nodeType === Node.TEXT_NODE) {
+ formula += this.nodeValue;
+ } else if ($(this).hasClass('formula-token')) {
+ formula += $(this).data('value');
+ }
+ });
+
+ // Очищаем лишние пробелы
+ formula = formula.replace(/\s+/g, ' ').trim();
+
+ return formula;
+}
+
+function setEndOfContentEditable(contentEditableElement) {
+ let range, selection;
+
+ // Создаем диапазон выделения
+ range = document.createRange();
+ range.selectNodeContents(contentEditableElement);
+ range.collapse(false); // Перемещаем курсор в конец
+
+ // Применяем выделение
+ selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+}
+
+function setupEditableContainer($container) {
+ // Обработчик нажатия клавиш в контейнере формулы
+ $container.off('keydown').on('keydown', function(e) {
+ // Если нажата клавиша Delete или Backspace
+ if (e.keyCode === 8 || e.keyCode === 46) {
+ const selection = window.getSelection();
+
+ // Проверяем, выделен ли токен
+ if (selection.rangeCount > 0) {
+ const range = selection.getRangeAt(0);
+
+ if (range.commonAncestorContainer.nodeType === Node.TEXT_NODE) {
+ // Текстовый узел - обычное удаление
+ return true;
+ } else {
+ // Возможно, выделен токен
+ const $selectedNode = $(range.commonAncestorContainer);
+
+ if ($selectedNode.hasClass('formula-token') || $selectedNode.parents('.formula-token').length) {
+ // Удаляем токен
+ const $token = $selectedNode.hasClass('formula-token') ?
+ $selectedNode : $selectedNode.parents('.formula-token');
+ $token.remove();
+ e.preventDefault();
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ });
+
+ // Обработчик вставки текста
+ $container.off('paste').on('paste', function(e) {
+ e.preventDefault();
+
+ // Получаем вставляемый текст
+ let text = '';
+ if (e.clipboardData || e.originalEvent.clipboardData) {
+ text = (e.clipboardData || e.originalEvent.clipboardData).getData('text/plain');
+ } else if (window.clipboardData) {
+ text = window.clipboardData.getData('Text');
+ }
+
+ // Вставляем текст как обычный текст
+ if (text) {
+ document.execCommand('insertText', false, text);
+ }
+ });
+}
\ No newline at end of file
diff --git a/formula_editor/formula_editor/doctype/formula_editor/formula_editor.json b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.json
new file mode 100644
index 0000000..5e3f3e2
--- /dev/null
+++ b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.json
@@ -0,0 +1,59 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-02-27 19:10:10.533620",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "target_doctype",
+ "formula_storage",
+ "formula_fields",
+ "test"
+ ],
+ "fields": [
+ {
+ "fieldname": "target_doctype",
+ "fieldtype": "Link",
+ "label": "target_doctype",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "formula_storage",
+ "fieldtype": "Text",
+ "label": "formula_storage"
+ },
+ {
+ "fieldname": "formula_fields",
+ "fieldtype": "Section Break",
+ "label": "formula_fields"
+ },
+ {
+ "fieldname": "test",
+ "fieldtype": "HTML"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-02-27 19:12:22.113845",
+ "modified_by": "Administrator",
+ "module": "Formula Editor",
+ "name": "Formula Editor",
+ "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/formula_editor/formula_editor.py b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.py
new file mode 100644
index 0000000..37fe7e4
--- /dev/null
+++ b/formula_editor/formula_editor/doctype/formula_editor/formula_editor.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, Jey ERP and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class FormulaEditor(Document):
+ pass
diff --git a/formula_editor/formula_editor/doctype/formula_editor/test_formula_editor.py b/formula_editor/formula_editor/doctype/formula_editor/test_formula_editor.py
new file mode 100644
index 0000000..dafff73
--- /dev/null
+++ b/formula_editor/formula_editor/doctype/formula_editor/test_formula_editor.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 UnitTestformula_editor(UnitTestCase):
+ """
+ Unit tests for formula_editor.
+ Use this class for testing individual functions and methods.
+ """
+
+ pass
+
+
+class IntegrationTestformula_editor(IntegrationTestCase):
+ """
+ Integration tests for formula_editor.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass