Added new tool called 'formula_editor' which allows to create formulas for any doctype without tables (wip)
This commit is contained in:
parent
151548f947
commit
244470694e
|
|
@ -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
|
||||||
|
|
@ -19,14 +19,14 @@ def create_custom_fields():
|
||||||
insert_after='swift_number'
|
insert_after='swift_number'
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
"Employee Transfer": [
|
# "Employee Transfer": [
|
||||||
dict(
|
# dict(
|
||||||
fieldname='reason',
|
# fieldname='reason',
|
||||||
label='Reason',
|
# label='Reason',
|
||||||
fieldtype='Data',
|
# fieldtype='Data',
|
||||||
insert_after='amended_from'
|
# insert_after='amended_from'
|
||||||
)
|
# )
|
||||||
],
|
# ],
|
||||||
"Company": [
|
"Company": [
|
||||||
dict(
|
dict(
|
||||||
fieldname='ceo',
|
fieldname='ceo',
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,11 @@ override_doctype_class = {
|
||||||
"Report": "jey_erp.custom_report.CustomReport"
|
"Report": "jey_erp.custom_report.CustomReport"
|
||||||
}
|
}
|
||||||
|
|
||||||
doc_events = {
|
# doc_events = {
|
||||||
"Employee Onboarding": {
|
# "Employee Onboarding": {
|
||||||
"on_update": "jey_erp.custom_employee_onboarding.on_update"
|
# "on_update": "jey_erp.custom_employee_onboarding.on_update"
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
|
|
||||||
after_migrate = "jey_erp.hooks.after_migrate_combined"
|
after_migrate = "jey_erp.hooks.after_migrate_combined"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
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 triggers = $(frm.fields_dict.formula_fields.wrapper)
|
||||||
|
.find(`[data-fieldname="${fieldname}_triggers"] input`).val();
|
||||||
|
|
||||||
|
console.log(`📌 Обрабатываем поле: "${fieldname}"`);
|
||||||
|
console.log(` 🔹 Формула: "${formula}"`);
|
||||||
|
console.log(` 🔹 Триггеры: "${triggers}"`);
|
||||||
|
|
||||||
|
formula_data[fieldname] = {
|
||||||
|
formula: formula || "",
|
||||||
|
triggers: triggers || ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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_triggers = {};
|
||||||
|
|
||||||
|
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()) : [];
|
||||||
|
|
||||||
|
function transform_expression(expression) {
|
||||||
|
console.log("🔄 Исходная формула:", expression);
|
||||||
|
let transformed = expression
|
||||||
|
.replace(/sum\((\w+)_(\w+)\)/g, (match, tbl, fld) => {
|
||||||
|
return `(frm.doc["${tbl}"].reduce((sum, row) => sum + (row["${fld}"] || 0), 0))`;
|
||||||
|
})
|
||||||
|
.replace(/val\((\w+)\)/g, (match, field) => {
|
||||||
|
return `frm.doc["${field}"]`;
|
||||||
|
})
|
||||||
|
.replace(/(frm\.doc\["\w+"\]|\d+)\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);
|
||||||
|
|
||||||
|
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 += `});\n`;
|
||||||
|
let final_script = `${main_script}\n${functions.join("\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: "jey_erp.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 match = field.label.match(/^(.*?)\s*→\s*(.*?)$/);
|
||||||
|
let table_name = match ? match[1] : "Основные поля";
|
||||||
|
let field_label = match ? match[2] : field.label;
|
||||||
|
|
||||||
|
if (!grouped_fields[table_name]) {
|
||||||
|
grouped_fields[table_name] = [];
|
||||||
|
}
|
||||||
|
grouped_fields[table_name].push({ fieldname: field.fieldname, label: field_label });
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
table_fieldnames.add(field.fieldname); // Запоминаем, какие поля относятся к таблицам
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let container = $('<div class="formula-container" style="display:flex;flex-direction:column;align-items:center;justify-content:center;max-width:900px;margin:0 auto;"></div>');
|
||||||
|
|
||||||
|
Object.keys(grouped_fields).forEach(group => {
|
||||||
|
let section = $('<div class="formula-section" style="width:100%;margin-top:20px;padding:10px;border:1px solid #ddd;border-radius:5px;"></div>');
|
||||||
|
section.append(`<h4 style="margin-bottom:10px;">${group}</h4>`);
|
||||||
|
|
||||||
|
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 = $('<div class="formula-row" style="display:flex;justify-content:space-between;gap:20px;align-items:center;width:100%">' +
|
||||||
|
'<div class="frappe-control input-max-width" data-fieldtype="Data" data-fieldname="' + field.fieldname + '" ' + dataAttr + ' style="flex:1;">' +
|
||||||
|
'<div class="form-group horizontal">' +
|
||||||
|
'<div class="clearfix"><label class="control-label">' + field.label + ' (' + field.fieldname + ')</label></div>' +
|
||||||
|
'<div class="control-input-wrapper">' +
|
||||||
|
'<div class="control-input">' +
|
||||||
|
'<input type="text" class="input-with-feedback form-control formula-input" data-fieldtype="Data" data-fieldname="' + field.fieldname + '" ' + dataAttr + ' autocomplete="off" placeholder="Введите формулу">' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="frappe-control input-max-width" data-fieldtype="Data" data-fieldname="' + field.fieldname + '_triggers" ' + dataAttr + ' style="flex:1;">' +
|
||||||
|
'<div class="form-group horizontal">' +
|
||||||
|
'<div class="clearfix"><label class="control-label">Обновлять при изменении</label></div>' +
|
||||||
|
'<div class="control-input-wrapper">' +
|
||||||
|
'<div class="control-input">' +
|
||||||
|
'<input type="text" class="input-with-feedback form-control trigger-input" data-fieldtype="Data" data-fieldname="' + field.fieldname + '_triggers" ' + dataAttr + ' autocomplete="off" placeholder="Введите поля через запятую">' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>');
|
||||||
|
section.append(field_html);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.append(section);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.append(container);
|
||||||
|
frm.refresh();
|
||||||
|
if (callback) callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2025-02-20 14:32:09.445777",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"target_doctype",
|
||||||
|
"formula_storage",
|
||||||
|
"formula_fields",
|
||||||
|
"test",
|
||||||
|
"test_copy"
|
||||||
|
],
|
||||||
|
"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": "Int",
|
||||||
|
"label": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "test_copy",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"label": "test Copy"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-02-20 15:52:59.048027",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Jey Erp",
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Copyright (c) 2025, JeyERP and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class FormulaEditor(Document):
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Copyright (c) 2025, JeyERP 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 UnitTestFormulaEditor(UnitTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for FormulaEditor.
|
||||||
|
Use this class for testing individual functions and methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestFormulaEditor(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for FormulaEditor.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
Loading…
Reference in New Issue