feat(formulas): batch report resolution in a taxes_az-owned runtime

Selecting a period on a declaration fired one HTTP request per report reference
— 217 on a VAT return — and each one re-executed an entire Script Report just to
read a single column. Those 217 requests only ever ran 4 distinct reports: 120 of
VAT's 127 report fields read different columns out of the same report with the
same filters.

run_reports_batch groups the references by (report, filters) and executes each
report once, slicing every requested column out of the one result. The extraction
rule (take the report's total row, else sum every row) is ported verbatim — it
decides the figures that end up on a filed tax return.

The runtime lives here rather than in formula_editor because that app is a
design-time tool that gets removed from production machines; generated scripts no
longer reference it at all.

Errors are now surfaced instead of swallowed. A failed report used to yield 0 for
every field that asked for it, so a user without report permission got a
plausible-looking, fully-zeroed declaration and no hint anything was wrong.
Failing reports now contribute no values, the affected formulas are skipped rather
than zeroed, and the user is told which report failed and why.

Also fixes a Frappe core bug that threw on every declaration: Frappe writes the
active tab's fieldname into the URL hash, the URL API percent-encodes our
Azerbaijani fieldnames, and form.js then feeds that hash to jQuery as a CSS
selector.

Land tax and Property tax pointed js_parent_subtab at a parent tab that does not
exist in those doctypes, so their Əlavə tabs never nested.

VAT, one period change: 217 HTTP requests -> 1, 236 refresh_field -> 11, 4.5s -> 1.5s.
Verified identical output across all 426 formulas and all 337 report fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-07-13 12:21:48 +00:00
parent e637a85a13
commit 9d485c7ad4
7 changed files with 795 additions and 16 deletions

File diff suppressed because one or more lines are too long

200
taxes_az/formula_runtime.py Normal file
View File

@ -0,0 +1,200 @@
"""Server side of the declaration formula engine.
Declarations (ФНО) compute their appendix tables from Script Reports. A single
period change resolves hundreds of report references, but they collapse onto a
handful of distinct reports a VAT declaration pulls 127 values out of only 4
(report, filters) pairs. `run_reports_batch` executes each pair once and slices
every requested column out of that single result.
This module deliberately lives in taxes_az, not in the formula_editor app:
formula_editor is a design-time authoring tool that gets removed from production
machines, and declarations must keep calculating without it.
`extract_field_from_report` is a faithful port of the original implementation in
formula_editor.api the aggregation rule (take the report's total row, else sum
every row) decides the numbers that end up on a tax return, so it is reproduced
exactly rather than tidied up.
"""
import json
import frappe
from frappe.desk.query_report import run
@frappe.whitelist()
def run_reports_batch(requests):
"""Resolve many report-field references with one report execution per report.
`requests` is a list of {key, report, filters, field_name}. `key` is opaque to
the server and is echoed back as the key of the returned map, so the client can
pair values with the formula placeholders that asked for them.
Returns {"values": {key: number}, "errors": [{report, message}]}.
A report that fails bad filters, or more commonly a user without report
permission on the underlying doctype contributes NO values and one error. It
deliberately does not contribute zeros: a tax return quietly filled with zeros
looks correct and gets filed, which is far worse than one that refuses to
calculate. The other reports in the batch are unaffected.
"""
if isinstance(requests, str):
requests = json.loads(requests)
groups = {}
for req in requests or []:
filters = req.get("filters") or {}
if isinstance(filters, str):
filters = json.loads(filters)
group_key = (req["report"], json.dumps(filters, sort_keys=True, default=str))
group = groups.setdefault(
group_key, {"report": req["report"], "filters": filters, "items": []}
)
group["items"].append(req)
values = {}
errors = []
for group in groups.values():
resolved, error = _resolve_group(group)
values.update(resolved)
if error:
errors.append(error)
return {"values": values, "errors": errors}
def _resolve_group(group):
"""Run one report once and extract every column the batch asked it for.
Returns (values, error). On failure `values` is empty never zeros.
"""
report = group["report"]
try:
result = run(report, filters=group["filters"])
except Exception as e:
frappe.log_error(
f"Report '{report}' failed for formula batch: {e}", "Formula Runtime"
)
# frappe.throw (a PermissionError, typically) has already queued its own text
# for display. Drop it: the caller reports every failure in one dialog, and
# leaving these queued shows the user each message twice.
frappe.clear_messages()
return {}, {"report": report, "message": str(e)}
# query_report.run signals a refusal by returning a dict carrying `message`
# instead of report data.
if isinstance(result, dict) and result.get("message"):
message = result.get("message")
frappe.log_error(f"Report '{report}' returned: {message}", "Formula Runtime")
frappe.clear_messages()
return {}, {"report": report, "message": message}
columns = result.get("columns", [])
data = result.get("result", [])
values = {
item["key"]: extract_field_from_report(data, columns, item["field_name"])
for item in group["items"]
}
return values, None
def extract_field_from_report(data, columns, field_name):
"""Reduce one report column to a single number.
Prefers the report's own total row; falls back to summing every row. Ported
verbatim from formula_editor.api.extract_field_from_report changing this
changes the figures on filed tax returns.
"""
if not data:
return 0
column_idx = None
for i, col in enumerate(columns):
if isinstance(col, dict):
if col.get("fieldname") == field_name or col.get("label") == field_name:
column_idx = i
break
elif isinstance(col, str) and (col == field_name or field_name.lower() in col.lower()):
column_idx = i
break
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():
column_idx = i
break
if column_idx is None:
return 0
total_markers = ["total", "итого", "grand total", "общий итог", "итого по", "total row"]
for row in data:
is_total_row = False
if isinstance(row, dict):
if row.get("is_total_row") or row.get("bold") or row.get("total_row"):
is_total_row = True
if not is_total_row:
for value in row.values():
if isinstance(value, str) and any(m in value.lower() for m in total_markers):
is_total_row = True
break
elif isinstance(row, list) and len(row) > 0:
first_element = str(row[0]) if row[0] is not None else ""
if any(m in first_element.lower() for m in total_markers):
is_total_row = True
if not is_total_row:
continue
value = None
if isinstance(row, dict):
if field_name in row:
value = row[field_name]
else:
for key in row:
if key.lower() == field_name.lower():
value = row[key]
break
elif isinstance(row, list) and column_idx < len(row):
value = row[column_idx]
try:
if value is not None:
return round(float(value), 2)
except (ValueError, TypeError):
pass
total = 0
for row in data:
value = None
if isinstance(row, dict) and field_name in row:
value = row[field_name]
elif isinstance(row, dict):
for key in row:
if key.lower() == field_name.lower():
value = row[key]
break
elif isinstance(row, list) and column_idx < len(row):
value = row[column_idx]
try:
if value is not None:
total += float(value)
except (ValueError, TypeError):
pass
return round(total, 2)

View File

@ -24,6 +24,16 @@ after_migrate = [
"taxes_az.setup_accounts.check_and_create_accounts"
]
# Bəyannamə düsturlarının runtime-ı. Generasiya olunmuş Client Script-lər yalnız
# CONFIG daşıyır və bu runtime-ı çağırır — formula_editor tətbiqinə heç bir
# runtime asılılığı qalmır, ona görə onu iş maşınlarından silmək olar.
app_include_js = [
"/assets/taxes_az/js/formula_runtime.js",
# Frappe-nin öz baqı: Tab Break fieldname-ləri Azərbaycan hərfləri ilə olduğuna
# görə URL hash percent-encode olunur və jQuery selector kimi partlayır.
"/assets/taxes_az/js/frappe_hash_fix.js",
]
doctype_js = {
"XML Mapping Tool": [
"public/js/xml_mapping/xml_parser.js",

View File

@ -0,0 +1,516 @@
// Runtime for the declaration (ФНО) formula engine.
//
// Formula Editor is a design-time tool: it reads its storage and emits a Client
// Script carrying a plain CONFIG object plus a call into this runtime. Nothing in
// the generated script references the formula_editor app, so it can be removed
// from production machines and declarations keep calculating.
//
// The formula language and CONFIG shape are unchanged from the previous
// generator — the same `formula_storage` / `table_row_formulas_storage` /
// `report_fields_storage` payloads drive it, byte for byte.
//
// What changed is how it runs. The old generated code walked its formulas one by
// one, and each `report(...)` was its own round-trip that re-executed a whole
// Script Report to read a single column — a VAT period change fired 223 requests
// to run 4 distinct reports. Worse, every formula ended with frm.refresh_field(),
// which in Frappe is a full-form refresh (refresh_dependency + refresh_sections +
// refresh_tabs), so a period change also triggered ~236 of those.
//
// Here, a trigger resolves every report reference it needs in ONE batch call, then
// evaluates the formulas, then refreshes each touched table exactly once.
frappe.provide("taxes_az.formula");
(function () {
const BATCH_METHOD = "taxes_az.formula_runtime.run_reports_batch";
// A formula with no explicit order runs in the middle of the pack. Table-row
// formulas have always been ordered as a block at this same default (the old
// generator's `// order:` comment was only emitted for main-document formulas),
// so they keep sorting against parent formulas exactly as before.
const DEFAULT_ORDER = 10;
// The one predefined trigger the editor can attach. It used to mean "recompute
// on every form refresh", which re-ran every report each time a declaration was
// opened. Recalculation is now explicit: change the period, or press the button.
const REFRESH_TOKENS = ["При открытии документа", "when_document_opens", "document_opens"];
const MONTHS = {
Yanvar: 1, Fevral: 2, Mart: 3, Aprel: 4, May: 5, İyun: 6,
İyul: 7, Avqust: 8, Sentyabr: 9, Oktyabr: 10, Noyabr: 11, Dekabr: 12,
};
// ---------------------------------------------------------------- period
// Resolve the declaration's reporting period to a date range. Quarter (`rüb`)
// wins over month (`ay`) when both are present, as it always has.
function period_range(doc) {
if (doc && doc["rüb"] && doc.il) {
const match = String(doc["rüb"]).match(/(\d+)/);
if (!match) return null;
const starts = { 1: 1, 2: 4, 3: 7, 4: 10 };
const ends = { 1: 3, 2: 6, 3: 9, 4: 12 };
const quarter = parseInt(match[1]);
if (!starts[quarter]) return null;
return date_range(doc.il, starts[quarter], ends[quarter]);
}
if (doc && doc.ay && MONTHS[doc.ay]) {
return date_range(doc.il, MONTHS[doc.ay], MONTHS[doc.ay]);
}
return null;
}
function date_range(year, start_month, end_month) {
year = year || new Date().getFullYear();
const last_day = new Date(year, end_month, 0).getDate();
const pad = (n) => String(n).padStart(2, "0");
return {
start: `${year}-${pad(start_month)}-01`,
end: `${year}-${pad(end_month)}-${pad(last_day)}`,
};
}
// Expand the `$...` placeholders the editor stores in a report field's filters.
// `$DefaultFiscalYear` has never resolved to anything and is passed through
// verbatim; no report in use reads `fiscal_year`, and inventing a value here
// would silently change filed figures.
function resolve_filters(frm, filters) {
const resolved = {};
for (const key of Object.keys(filters || {})) {
const value = filters[key];
if (typeof value === "string" && value.startsWith("doc.")) {
const fieldname = value.slice(4);
if (frm.doc[fieldname] !== undefined) {
resolved[key] = frm.doc[fieldname];
continue;
}
}
if (value === "$DefaultCompany") {
resolved[key] = frm.doc.company || frappe.defaults.get_user_default("Company");
continue;
}
if (value === "$AyIlSpecial") {
const range = period_range(frm.doc);
if (!range) {
resolved[key] = frappe.datetime.get_today();
} else if (key === "to_date") {
resolved[key] = range.end;
} else {
resolved[key] = range.start;
}
continue;
}
resolved[key] = value;
}
return resolved;
}
// ------------------------------------------------------------ compilation
// Turn a formula into JavaScript. The substitution order matters and is kept
// as-is: report() first, then table(), then univ(), then the table-prefixed
// sum()/val() forms, and only then the bare val()/row(). The prefixed pass has
// to run before the bare one, or `val(mytable_amount)` would be read as a
// main-document field instead of a column sum.
//
// Report references are compiled to placeholders rather than values, so the
// result depends only on the formula text and can be cached across periods —
// the actual figures are substituted per run.
function compile(cfg, tables, formula, in_row, table_name) {
const cache_key = `${formula} ${in_row} ${table_name}`;
if (cfg.__compiled[cache_key]) return cfg.__compiled[cache_key];
const report_calls = [];
let code = formula;
const missing = [];
code = code.replace(/report\(([^)]+)\)/g, (full, name) => {
// Matched against the raw capture, spaces and all — some stored report
// names genuinely carry a leading space, and trimming would repoint them.
const field_id = cfg.__reports_by_name[name];
if (!field_id) {
// The formula names a report field that does not exist. Keep the code
// evaluable, but remember it: the formula must not be applied, because a
// silent 0 here is indistinguishable from a real 0.
missing.push(name);
return "0";
}
const placeholder = `__RP${report_calls.length}__`;
report_calls.push({ placeholder: placeholder, field_id: field_id });
return placeholder;
});
code = code.replace(/table\(([^,)]+),\s*([^,)]+),\s*([^)]+)\)/g, (m, tbl, row_no, fld) => {
const t = JSON.stringify(tbl.trim().replace(/['"]/g, ""));
const f = JSON.stringify(fld.trim().replace(/['"]/g, ""));
const n = row_no.trim();
return `(function(){try{const rows=frm.doc[${t}];const r=rows&&rows[${n}-1];` +
`return r?(parseFloat(r[${f}])||0):0;}catch(e){return 0;}})()`;
});
for (const field_id of Object.keys(cfg.universal_fields || {})) {
const name = (cfg.universal_fields[field_id] || {}).name;
if (!name) continue;
const pattern = new RegExp(`univ\\(${escape_regex(name)}\\)`, "g");
code = code.replace(
pattern,
`(frm.doc.__universalFields && frm.doc.__universalFields[${JSON.stringify(field_id)}] || 0)`
);
}
for (const prefix of tables) {
const column_sum = (m, col) => {
const t = JSON.stringify(prefix);
const c = JSON.stringify(col);
return `(frm.doc[${t}]&&frm.doc[${t}].reduce((s,r)=>s+(parseFloat(r[${c}])||0),0)||0)`;
};
const p = escape_regex(prefix);
code = code.replace(new RegExp(`sum\\(${p}_([^)]+)\\)`, "g"), column_sum);
code = code.replace(new RegExp(`val\\(${p}_([^)]+)\\)`, "g"), column_sum);
}
if (in_row) {
code = code.replace(/row\(([^)]+)\)/g, (m, f) => `(parseFloat(row[${JSON.stringify(f)}])||0)`);
code = code.replace(/val\(([^)]+)\)/g, (m, f) => {
if (table_name && f.startsWith(table_name + "_")) {
const col = f.slice(table_name.length + 1);
return `(parseFloat(row[${JSON.stringify(col)}])||0)`;
}
return `(parseFloat(frm.doc[${JSON.stringify(f)}])||0)`;
});
} else {
code = code.replace(/val\(([^)]+)\)/g, (m, f) => `(parseFloat(frm.doc[${JSON.stringify(f)}])||0)`);
code = code.replace(/row\(([^)]+)\)/g, () => "0");
}
const compiled = { code: code, report_calls: report_calls, missing: missing };
cfg.__compiled[cache_key] = compiled;
return compiled;
}
function escape_regex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// ------------------------------------------------------------------- plan
// Everything a trigger has to recompute, in the order it must happen — later
// formulas read fields that earlier ones write, via val()/table().
//
// `trigger` of null selects every formula, which is what the recalculate button
// runs. Selecting by trigger and selecting everything must produce the same
// relative order, so both go through here.
function plan_for(cfg, trigger) {
const wanted = (triggers) => trigger === null || has_trigger(triggers, trigger);
const ops = [];
for (const fieldname of Object.keys(cfg.formulas || {})) {
const info = cfg.formulas[fieldname];
if (!info || info.is_table_field || !info.formula) continue;
if (!wanted(info.triggers)) continue;
ops.push({
kind: "field",
fieldname: fieldname,
formula: info.formula.trim(),
order: order_of(info.order),
});
}
const rows = Object.keys(cfg.table_row_formulas || {})
.map((id) => cfg.table_row_formulas[id])
.filter((f) => f && f.formula && wanted(f.triggers))
.sort((a, b) => order_of(a.order) - order_of(b.order));
for (const f of rows) {
ops.push({
kind: "row",
table: f.table_name,
fieldname: f.field_name,
// Stored 1-based, as the editor presents it.
index: parseInt(f.row_index) - 1,
formula: f.formula,
// Table-row formulas sort against main-document formulas at the default,
// while keeping the relative order established just above.
order: DEFAULT_ORDER,
});
}
// Stable, so equal orders keep the sequence built above.
return ops
.map((op, i) => [op, i])
.sort((a, b) => a[0].order - b[0].order || a[1] - b[1])
.map((pair) => pair[0]);
}
function order_of(value) {
const n = parseInt(value);
return isNaN(n) ? DEFAULT_ORDER : n;
}
function has_trigger(triggers, trigger) {
return String(triggers || "")
.split(",")
.map((t) => t.trim())
.includes(trigger);
}
function is_refresh_token(trigger) {
return REFRESH_TOKENS.includes(trigger);
}
// Every trigger the config binds a handler to. Refresh tokens are deliberately
// excluded: opening a declaration no longer recomputes it.
function triggers_of(cfg) {
const found = new Set();
const collect = (triggers) =>
String(triggers || "")
.split(",")
.map((t) => t.trim())
.filter((t) => t && !is_refresh_token(t))
.forEach((t) => found.add(t));
Object.values(cfg.formulas || {}).forEach((f) => f && collect(f.triggers));
Object.values(cfg.table_row_formulas || {}).forEach((f) => f && collect(f.triggers));
return [...found];
}
// ------------------------------------------------------------------- run
// Resolve every report reference the plan needs, with one server round-trip.
// Identical (report, filters, column) references collapse to a single entry, so
// a report is executed once no matter how many formulas read from it.
async function fetch_report_values(frm, cfg, plans) {
const wanted = {};
for (const { compiled } of plans) {
for (const call of compiled.report_calls) {
const field = cfg.report_fields[call.field_id];
if (!field) continue;
const filters = resolve_filters(frm, parse_json(field.filters_json));
const key = JSON.stringify([field.report, sorted(filters), field.field_name]);
call.key = key;
wanted[key] = {
key: key,
report: field.report,
filters: filters,
field_name: field.field_name,
};
}
}
const requests = Object.values(wanted);
if (!requests.length) return { values: {}, errors: [] };
try {
const r = await frappe.call({
method: BATCH_METHOD,
args: { requests: requests },
freeze: false,
});
const message = (r && r.message) || {};
return { values: message.values || {}, errors: message.errors || [] };
} catch (e) {
console.error("taxes_az.formula: report batch failed", e);
return {
values: {},
errors: [{ report: __("Hesabatlar"), message: e.message || String(e) }],
};
}
}
function parse_json(raw) {
try {
return JSON.parse(raw || "{}");
} catch (e) {
return {};
}
}
function sorted(obj) {
return Object.keys(obj)
.sort()
.reduce((acc, k) => ((acc[k] = obj[k]), acc), {});
}
async function run_plan(frm, cfg, ops) {
if (!ops.length) return;
const tables = table_fields(frm);
const plans = ops.map((op) => ({
op: op,
compiled: compile(cfg, tables, op.formula, op.kind === "row", op.table || null),
}));
const { values, errors } = await fetch_report_values(frm, cfg, plans);
const touched = new Set();
const missing_fields = new Set();
let skipped = 0;
for (const { op, compiled } of plans) {
// A formula whose report could not be resolved is left alone rather than
// written as 0. An unexplained 0 on a tax return reads as a real figure and
// gets filed; a field that visibly did not calculate does not.
const unresolved = compiled.report_calls.some((call) => !(call.key in values));
if (unresolved || compiled.missing.length) {
compiled.missing.forEach((name) => missing_fields.add(name));
skipped++;
continue;
}
let code = compiled.code;
for (const call of compiled.report_calls) {
code = code.split(call.placeholder).join(to_number(values[call.key]));
}
if (op.kind === "row") {
apply_to_row(frm, op, code, touched);
} else {
apply_to_field(frm, op, code);
}
}
// One refresh per table that actually changed. The old engine called
// refresh_field() after every single formula, and each of those is a full-form
// refresh that also re-activates the current tab — which is what made subtab
// content flash and vanish while a period was being applied.
touched.forEach((table) => frm.refresh_field(table));
if (errors.length || missing_fields.size) {
report_failure(errors, [...missing_fields], skipped);
return false;
}
return true;
}
// Tell the user, in the form, that the declaration did not fully calculate. The
// engine used to swallow every failure and write 0 — a user without report
// permission got a plausible-looking, fully-zeroed tax return and no hint that
// anything had gone wrong.
function report_failure(errors, missing_fields, skipped) {
const lines = [];
if (errors.length) {
lines.push(`<b>${__("Hesabatlar işləmədi")}:</b><ul>`);
for (const e of errors) {
lines.push(`<li><b>${frappe.utils.escape_html(e.report)}</b> — ` +
`${frappe.utils.escape_html(String(e.message))}</li>`);
}
lines.push("</ul>");
}
if (missing_fields.length) {
lines.push(`<b>${__("Düsturda mövcud olmayan report-sahə")}:</b><ul>`);
for (const name of missing_fields) {
lines.push(`<li><code>${frappe.utils.escape_html(name)}</code></li>`);
}
lines.push("</ul>");
}
lines.push(
`<p>${__("Bu səbəbdən {0} düstur hesablanmadı — sahələr 0 ilə DOLDURULMADI.", [skipped])}</p>`
);
lines.push(`<p>${__("Bəyannaməni göndərməzdən əvvəl problemi aradan qaldırın.")}</p>`);
frappe.msgprint({
title: __("Hesablama tamamlanmadı"),
indicator: "red",
message: lines.join(""),
});
}
function apply_to_row(frm, op, code, touched) {
const rows = frm.doc[op.table];
const row = rows && rows[op.index];
if (!row) return;
const grid = frm.fields_dict[op.table] && frm.fields_dict[op.table].grid;
if (!grid) return;
frappe.model.set_value(grid.doctype, row.name, op.fieldname, evaluate(code, row, frm, op));
touched.add(op.table);
}
function apply_to_field(frm, op, code) {
frm.set_value(op.fieldname, evaluate(code, null, frm, op));
}
function evaluate(code, row, frm, op) {
try {
const result = new Function("row", "frm", `return ${code};`)(row, frm);
return isNaN(result) ? 0 : result;
} catch (e) {
console.error("taxes_az.formula: hesablama alınmadı", op.formula, e);
return 0;
}
}
function to_number(value) {
const n = parseFloat(value);
if (isNaN(n)) return 0;
return Math.round(n * 100) / 100;
}
function table_fields(frm) {
return frm.meta.fields.filter((df) => df.fieldtype === "Table").map((df) => df.fieldname);
}
// ---------------------------------------------------------------- public
// Build the frappe.ui.form.on handler map for a generated Client Script.
taxes_az.formula.handlers = function (cfg) {
cfg.__compiled = {};
cfg.__reports_by_name = {};
for (const id of Object.keys(cfg.report_fields || {})) {
const field = cfg.report_fields[id];
if (field && field.name) cfg.__reports_by_name[field.name] = id;
}
const handlers = {
refresh: function (frm) {
frm.add_custom_button(__("Yenidən hesabla"), () => recalculate_all(frm, cfg));
},
};
for (const trigger of triggers_of(cfg)) {
handlers[trigger] = function (frm) {
return run_plan(frm, cfg, plan_for(cfg, trigger));
};
}
return handlers;
};
// Recompute the whole declaration on demand: every formula, exactly once, in the
// same relative order a trigger would run them in.
function recalculate_all(frm, cfg) {
frappe.dom.freeze(__("Hesablanır..."));
run_plan(frm, cfg, plan_for(cfg, null))
.then((ok) => {
// Only claim success when it actually succeeded — run_plan has already
// shown the user what failed.
if (ok) {
frappe.show_alert({ message: __("Hesablandı"), indicator: "green" });
}
})
.finally(() => frappe.dom.unfreeze());
}
taxes_az.formula.recalculate_all = recalculate_all;
})();

View File

@ -0,0 +1,53 @@
// Works around a Frappe core bug that throws on every Azerbaijani declaration form.
//
// When a tab is activated, Frappe writes the tab's fieldname into the URL hash
// (form.js, set_active_tab). Our Tab Break fieldnames are Azerbaijani — `ümumi_məlumat_tab`
// — and assigning them through the URL API percent-encodes them, so the hash becomes
// `#%C3%BCmumi_m%C9%99lumat_tab`.
//
// Frappe then feeds that hash straight to jQuery as a selector (form.js, scroll_to_element:
// `if ($(window.location.hash).length)`). `%` is not legal in a CSS identifier, so jQuery
// throws "Syntax error, unrecognized expression" — on load, on every declaration.
//
// It is caught as an unhandled page error rather than breaking the form, but it fires
// constantly and buries real errors in the console. Patching Frappe itself would be
// undone by the next update, and renaming the tab fields would touch 24 doctypes and the
// subtab hierarchy, so the guard lives here.
//
// Frappe's intent in that branch is "scroll to the element the hash names, else treat the
// hash as a fieldname". Both paths still work below — the hash is just decoded first, and
// matched with getElementById instead of a CSS selector.
frappe.provide("taxes_az");
(function () {
const Form = frappe.ui && frappe.ui.form && frappe.ui.form.Form;
if (!Form || !Form.prototype.scroll_to_element || Form.prototype.__hash_fix) return;
const original = Form.prototype.scroll_to_element;
Form.prototype.scroll_to_element = function () {
try {
return original.apply(this, arguments);
} catch (e) {
if (!/unrecognized expression/i.test(e.message || "")) throw e;
// jQuery choked on the percent-encoded hash. Redo what Frappe meant to do.
let target;
try {
target = decodeURIComponent(window.location.hash || "").replace("#", "");
} catch (decode_error) {
return;
}
if (!target) return;
if (document.getElementById(target)) {
frappe.utils.scroll_to("#" + target, true, 200, null, null, true);
} else if (this.scroll_to_field(target)) {
history.replaceState(null, null, " ");
}
}
};
Form.prototype.__hash_fix = true;
})();

View File

@ -211,13 +211,13 @@
{
"fieldname": "əlavə_1_tab",
"fieldtype": "Tab Break",
"js_parent_subtab": "Verginin və its-in hesablanması",
"js_parent_subtab": "Verginin Hesablanması",
"label": "Əlavə 1"
},
{
"fieldname": "əlavə_2_tab",
"fieldtype": "Tab Break",
"js_parent_subtab": "Verginin və its-in hesablanması",
"js_parent_subtab": "Verginin Hesablanması",
"label": "Əlavə 2"
},
{

View File

@ -211,7 +211,7 @@
{
"fieldname": "\u0259lav\u0259_1_tab",
"fieldtype": "Tab Break",
"js_parent_subtab": "Verginin hesablanmas\u0131",
"js_parent_subtab": "\u018fmlak vergisinin hesablanmas\u0131",
"label": "\u018flav\u0259 1"
},
{
@ -242,7 +242,7 @@
{
"fieldname": "\u0259lav\u0259_2_tab",
"fieldtype": "Tab Break",
"js_parent_subtab": "Verginin hesablanmas\u0131",
"js_parent_subtab": "\u018fmlak vergisinin hesablanmas\u0131",
"label": "\u018flav\u0259 2"
},
{