perf: idempotent bulk update for custom fields on migrate

Replace per-field delete+create loop (which forced ALTER TABLE on all 222
custom fields every migrate) with idempotent bulk update that only writes
when a field's definition actually changed. Saves with ignore_version=True
to bypass the Frappe Version diff bug that crashes on certain int values.

Also remove the top-level create_custom_fields() call so the function only
runs from after_migrate, not on every module import.

Optional JEY_ERP_FORCE_RECREATE_FIELDS=1 env var preserves the old
delete+create path for development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-04-28 10:55:21 +00:00
parent 511e91cbf1
commit 3aa4e61a1d
1 changed files with 91 additions and 21 deletions

View File

@ -1,5 +1,64 @@
import os
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.custom.doctype.custom_field.custom_field import (
create_custom_field,
get_existing_custom_fields,
)
def _apply_custom_fields(custom_fields: dict, ignore_validate: bool = False) -> None:
"""Idempotent bulk create/update for Custom Fields.
Reimplements frappe.custom...create_custom_fields with two differences:
- saves with ignore_version=True to skip Frappe's version diff (which has
a bug formatting some int values for Custom Field), and
- calls frappe.db.updatedb() once per affected doctype, only when needed.
"""
try:
frappe.flags.in_create_custom_fields = True
existing = get_existing_custom_fields(custom_fields)
doctypes_to_update = set()
for doctype, fields in custom_fields.items():
updated = False
for df in fields:
key = (doctype, df["fieldname"])
field = existing.get(key)
if not field:
try:
df = df.copy()
df["owner"] = "Administrator"
cf = create_custom_field(doctype, df, ignore_validate=ignore_validate)
if cf:
existing[key] = cf.__dict__
updated = True
except frappe.exceptions.DuplicateEntryError:
pass
continue
cf = frappe.get_doc({"doctype": "Custom Field", **field})
original = cf.__dict__.copy()
cf.update(df)
if original == cf.__dict__:
continue
cf.flags.ignore_validate = ignore_validate
# ignore_version must be passed as kwarg: _save() overwrites
# self.flags.ignore_version from this argument on every call.
cf.save(ignore_version=True)
existing[key] = cf.__dict__
updated = True
if updated:
doctypes_to_update.add(doctype)
for doctype in doctypes_to_update:
frappe.clear_cache(doctype=doctype)
frappe.db.updatedb(doctype)
finally:
frappe.flags.in_create_custom_fields = False
def create_custom_fields():
custom_fields = {
@ -1860,29 +1919,42 @@ def create_custom_fields():
except Exception as e:
print(f"Failed to delete legacy Cyrillic field: {e}")
# Filter out malformed entries (no fieldname) before passing to Frappe
sanitized = {}
for doctype, fields in custom_fields.items():
clean = []
for field in fields:
# Проверяем наличие fieldname перед обработкой
if 'fieldname' not in field:
print(f"Skipping field without fieldname in {doctype}: {field}")
continue
# Удаление существующего поля, если оно уже есть
if frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": field['fieldname']}):
try:
custom_field_name = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": field['fieldname']}, "name")
frappe.delete_doc("Custom Field", custom_field_name)
frappe.db.commit()
print(f"Deleted field {field['fieldname']} in {doctype}")
except Exception as e:
print(f"Failed to delete field {field['fieldname']} in {doctype}: {e}")
clean.append(field)
if clean:
sanitized[doctype] = clean
# Создание нового поля
try:
create_custom_field(doctype, field)
print(f"Created field {field['fieldname']} in {doctype}")
except Exception as e:
print(f"Failed to create field {field['fieldname']} in {doctype}: {e}")
# Force-recreate mode for development: drop existing fields so Frappe
# creates them from scratch. Slow — only use when bulk update is not
# picking up something. Trigger via: JEY_ERP_FORCE_RECREATE_FIELDS=1 bench migrate
if os.environ.get("JEY_ERP_FORCE_RECREATE_FIELDS"):
for doctype, fields in sanitized.items():
for field in fields:
cf_name = frappe.db.get_value(
"Custom Field",
{"dt": doctype, "fieldname": field['fieldname']},
"name",
)
if cf_name:
try:
frappe.delete_doc("Custom Field", cf_name)
except Exception as e:
print(f"Failed to delete field {field['fieldname']} in {doctype}: {e}")
frappe.db.commit()
# Idempotent bulk create/update. Mirrors frappe.custom...create_custom_fields
# but saves with ignore_version=True. Why: Custom Field has track_changes=1,
# and Frappe's diff formatter (version.get_diff → format_value) crashes on
# certain int values with "expected string or bytes-like object, got 'int'".
# We don't need version history for system-managed fields anyway.
_apply_custom_fields(sanitized, ignore_validate=True)
# === Sub-tab wiring for Salary Slip via custom_subtabs ===
# The custom_subtabs app reads `js_parent_subtab` on Tab Break fields in meta.
@ -1929,6 +2001,4 @@ def create_custom_fields():
# Коммит всех изменений
frappe.db.commit()
print("Setting up VAT calculation hooks...")
create_custom_fields()
print("Setting up VAT calculation hooks...")