Added new tool called 'formula_editor' which allows to create formulas for any doctype without tables (wip)

This commit is contained in:
Ali 2025-02-26 15:06:35 +04:00
parent 151548f947
commit 244470694e
8 changed files with 435 additions and 13 deletions

24
jey_erp/api.py Normal file
View File

@ -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

View File

@ -19,14 +19,14 @@ def create_custom_fields():
insert_after='swift_number'
)
],
"Employee Transfer": [
dict(
fieldname='reason',
label='Reason',
fieldtype='Data',
insert_after='amended_from'
)
],
# "Employee Transfer": [
# dict(
# fieldname='reason',
# label='Reason',
# fieldtype='Data',
# insert_after='amended_from'
# )
# ],
"Company": [
dict(
fieldname='ceo',

View File

@ -16,11 +16,11 @@ override_doctype_class = {
"Report": "jey_erp.custom_report.CustomReport"
}
doc_events = {
"Employee Onboarding": {
"on_update": "jey_erp.custom_employee_onboarding.on_update"
}
}
# doc_events = {
# "Employee Onboarding": {
# "on_update": "jey_erp.custom_employee_onboarding.on_update"
# }
# }
after_migrate = "jey_erp.hooks.after_migrate_combined"

View File

@ -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();
}
});
}

View File

@ -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": []
}

View File

@ -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

View File

@ -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