feat(default-data): auto-sync leave types and company defaults on migrate

Mirrors the salary-component pattern for two more pieces of master data:

- sync_leave_types(): full upsert from default_data/leave_types.json.
  Creates missing, updates existing, deletes those no longer in JSON.
  Leave Type has no `disabled` field, so when a deletion fails due to
  linked Leave Allocation/Application records, the sync logs and skips
  (vs. marking disabled like Salary Component does).

- sync_company_defaults(): for every Company, resolves each account_number
  in default_data/company_defaults.json to the company-scoped Account
  name and sets the corresponding Company field. Non-destructive: if
  the company has no account with that number, the field is left as-is.

Both hooked into after_migrate_combined after sync_salary_components,
since Leave Type's earning_component references a Salary Component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-01 12:55:27 +00:00
parent eef0c82032
commit 91268cc466
2 changed files with 109 additions and 1 deletions

View File

@ -112,11 +112,25 @@ def after_migrate_combined():
rebuild_tree("Tax Free Indicator")
except Exception as e:
print(f"Tax Free Indicator NSM rebuild failed: {e}")
from jey_erp.setup.setup_wizard_handler import sync_salary_components
# Order matters: Salary Component must sync first because Leave Type's
# earning_component field references it.
from jey_erp.setup.setup_wizard_handler import (
sync_salary_components,
sync_leave_types,
sync_company_defaults,
)
try:
sync_salary_components()
except Exception as e:
print(f"Salary Component sync failed: {e}")
try:
sync_leave_types()
except Exception as e:
print(f"Leave Type sync failed: {e}")
try:
sync_company_defaults()
except Exception as e:
print(f"Company defaults sync failed: {e}")
# REMOVED: Asset categories creation moved to setup_wizard_complete hook
# from jey_erp.custom.create_asset_categories import create_asset_categories
# create_asset_categories()

View File

@ -182,6 +182,66 @@ def create_default_leave_types(args):
frappe.db.commit()
LEAVE_TYPE_SYNC_FIELDS = (
"max_leaves_allowed", "applicable_after", "max_continuous_days_allowed",
"is_carry_forward", "is_lwp", "is_ppl",
"fraction_of_daily_salary_per_leave", "is_optional_leave",
"allow_negative", "allow_over_allocation", "include_holiday",
"is_compensatory", "maximum_carry_forwarded_leaves",
"expire_carry_forwarded_leaves_after_days",
"allow_encashment", "max_encashable_leaves", "non_encashable_leaves",
"earning_component", "is_earned_leave", "earned_leave_frequency",
"allocate_on_day", "rounding",
)
def sync_leave_types():
"""Полная синхронизация Leave Type с default_data/leave_types.json.
JSON single source of truth. Создаёт отсутствующие, обновляет
существующие, удаляет лишние. Если удалить нельзя из-за linked records
(Leave Allocation/Application) пишет в Error Log и пропускает
(у Leave Type нет поля disabled). Идемпотентна.
Должна запускаться ПОСЛЕ sync_salary_components, т.к. earning_component
может ссылаться на Salary Component.
"""
if not os.path.exists(LEAVE_TYPES_JSON):
return
with open(LEAVE_TYPES_JSON, encoding="utf-8") as f:
leave_types = json.load(f)
json_names = set()
for data in leave_types:
name = data.get("leave_type_name")
if not name:
continue
json_names.add(name)
if frappe.db.exists("Leave Type", name):
doc = frappe.get_doc("Leave Type", name)
for field in LEAVE_TYPE_SYNC_FIELDS:
if field in data:
doc.set(field, data[field])
doc.save(ignore_permissions=True)
else:
new_data = dict(data)
new_data["doctype"] = "Leave Type"
frappe.get_doc(new_data).insert(ignore_permissions=True)
for name in frappe.get_all("Leave Type", pluck="name"):
if name in json_names:
continue
try:
frappe.delete_doc("Leave Type", name, ignore_permissions=True)
except Exception as e:
frappe.log_error(
f"Cannot delete Leave Type '{name}' (likely linked records): {e}",
"Leave Type Sync",
)
frappe.db.commit()
def create_default_company_accounts(args):
if not os.path.exists(COMPANY_DEFAULTS_JSON):
return
@ -209,6 +269,40 @@ def create_default_company_accounts(args):
frappe.db.commit()
def sync_company_defaults():
"""Синхронизирует default-счета на ВСЕХ Company с default_data/company_defaults.json.
Для каждого поля из JSON ищет Account по account_number в каждой компании
и проставляет полное имя счёта на Company. Поля, которых в JSON нет,
не трогает. Если в компании нет аккаунта с таким номером поле этой
компании не меняется (silent skip). Идемпотентна.
"""
if not os.path.exists(COMPANY_DEFAULTS_JSON):
return
with open(COMPANY_DEFAULTS_JSON, encoding="utf-8") as f:
defaults = json.load(f)
if not defaults:
return
for company in frappe.get_all("Company", pluck="name"):
doc = frappe.get_doc("Company", company)
updated = False
for fieldname, account_number in defaults.items():
full = frappe.db.get_value(
"Account",
{"account_number": account_number, "company": company},
"name",
)
if full and doc.get(fieldname) != full:
doc.set(fieldname, full)
updated = True
if updated:
doc.save(ignore_permissions=True)
frappe.db.commit()
def setup_wizard_complete_handler(args):
"""
Вызывается после завершения ERPNext setup wizard.