Repurpose CoA bank placeholders in place + polish defaults

Three small wins for the post-setup chart of accounts:

1. Replace placeholders, don't sit beside them
   The AZ chart ships with 223.1 - AZN AZXX… - JS, 223.2 - USD AZXX…,
   etc. Previously we created our real banks with auto-generated names
   ("AZN AZ96AIIB… - JS") and deleted the placeholders, so the chart
   format flipped between numbered and unnumbered rows. Now we index
   placeholders by leading-3-letter currency before the loop, and for
   each materialized bank we rename the matching placeholder in place
   (account_name + account_currency updated, then frappe.rename_doc to
   "<account_number> - <CUR> <IBAN> - <abbr>"). Real banks inherit the
   223.X numbering. Leftover placeholders (extra currencies, the Kart
   row) get the same delete-or-disable treatment as before. As a bonus,
   frappe.rename_doc auto-fixes Company.default_bank_account when the
   AZN placeholder it pointed at is repurposed.

2. Prefer AZN for Company.default_bank_account
   _repoint_company_default_bank now picks AZN → USD → EUR → RUB → GBP
   → first-created instead of always taking the first GL out of the
   e-taxes response order (which was landing on RUB).

3. Set Stock Settings.default_warehouse
   ERPNext's update_stock_settings looks the warehouse up by
   `_("Stores")`, which at language=az is "Anbarlar" — but the warehouse
   was created with the English name, so the lookup misses and the field
   stays None. The Stock Settings save block in materialize_after_setup
   now resolves the warehouse directly against the new Company.
This commit is contained in:
Ali 2026-04-28 11:36:56 +00:00
parent 1bc9fd6b13
commit 048ab04d7a
3 changed files with 179 additions and 32 deletions

View File

@ -1 +1 @@
__version__ = "0.1.8"
__version__ = "0.1.9"

View File

@ -202,12 +202,19 @@ def materialize_after_setup(args):
try:
stock_settings = frappe.get_doc("Stock Settings")
stock_settings.valuation_method = chosen_vm
# ERPNext's update_stock_settings looks up the default warehouse by
# `_("Stores")` — at language=az that's "Anbarlar", but the warehouse
# was created with English "Stores", so the lookup misses and
# default_warehouse stays None. Resolve directly here against the
# real warehouse list for the new Company.
if not stock_settings.default_warehouse:
stock_settings.default_warehouse = _find_default_warehouse(company_name)
stock_settings.flags.ignore_permissions = True
stock_settings.flags.ignore_validate_update_after_submit = True
stock_settings.save(ignore_permissions=True)
except Exception as exc:
frappe.log_error(
f"Stock Settings valuation_method set failed: {exc}\n{traceback.format_exc()}",
f"Stock Settings valuation_method/warehouse set failed: {exc}\n{traceback.format_exc()}",
"Jey Wizard materialize",
)
frappe.db.commit()
@ -301,17 +308,25 @@ def _materialize_native_banks(company):
)
return
# Build a currency → [placeholders] index up front. Real banks reuse a
# matching placeholder (rename in place to keep the "223.X - <CUR> <IBAN> - <ABBR>"
# format the chart already uses); leftover placeholders get deleted at the end.
placeholder_index = _index_placeholders_by_currency(company, parent)
created = 0
skipped_closed = 0
created_by_currency = {}
created_gl_names = []
for acc in accounts:
if (acc.get("status") or "").upper() == "C":
skipped_closed += 1
continue
try:
gl = _materialize_one_bank(company, parent, acc)
gl = _materialize_one_bank(company, parent, acc, placeholder_index)
if gl:
created_gl_names.append(gl)
cur = (acc.get("currency") or "AZN").strip() or "AZN"
created_by_currency.setdefault(cur, []).append(gl)
created += 1
except Exception as exc:
frappe.log_error(
@ -319,8 +334,8 @@ def _materialize_native_banks(company):
"Jey Wizard materialize (bank native)",
)
_repoint_company_default_bank(company, created_gl_names)
_delete_bank_placeholders(company, parent)
_repoint_company_default_bank(company, created_by_currency, created_gl_names)
_delete_bank_placeholders(company, parent, placeholder_index)
frappe.db.commit()
# Success breadcrumb in Error Log so we can verify end-to-end run from the same
# place where loader/cache traces live.
@ -353,7 +368,7 @@ def _find_bank_parent_account(company):
return None
def _materialize_one_bank(company, parent_account, row):
def _materialize_one_bank(company, parent_account, row, placeholder_index=None):
# Accepts either the raw e-taxes payload shape (bankName/number/type…) or
# the E-Taxes Bank Account doctype shape (bank_name/number/account_type).
# Returns the GL account name on success (used by the caller to repoint
@ -372,7 +387,9 @@ def _materialize_one_bank(company, parent_account, row):
bank_doc.flags.ignore_if_duplicate = True
bank_doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
# 2. GL Account under parent 223
# 2. GL Account: prefer to repurpose a same-currency placeholder so the
# chart keeps its "223.X - <CUR> <IBAN> - <ABBR>" numbering. Fall back to a
# fresh insert if no placeholder is left for this currency.
account_name = f"{currency} {iban}"
gl_name = frappe.db.get_value(
"Account",
@ -380,17 +397,23 @@ def _materialize_one_bank(company, parent_account, row):
"name",
)
if not gl_name:
gl_doc = frappe.get_doc({
"doctype": "Account",
"account_name": account_name,
"parent_account": parent_account,
"company": company,
"account_type": "Bank",
"account_currency": currency,
"is_group": 0,
})
gl_doc.insert(ignore_permissions=True)
gl_name = gl_doc.name
ph = _claim_placeholder(placeholder_index, currency) if placeholder_index else None
if ph:
gl_name = _convert_placeholder_to_real(
ph["name"], ph["account_number"], company, account_name, currency
)
else:
gl_doc = frappe.get_doc({
"doctype": "Account",
"account_name": account_name,
"parent_account": parent_account,
"company": company,
"account_type": "Bank",
"account_currency": currency,
"is_group": 0,
})
gl_doc.insert(ignore_permissions=True)
gl_name = gl_doc.name
# 3. Bank Account (links Bank + GL + Company)
ba_exists = frappe.db.exists(
@ -418,34 +441,44 @@ def _materialize_one_bank(company, parent_account, row):
return gl_name
def _repoint_company_default_bank(company, created_gl_names):
def _repoint_company_default_bank(company, by_currency, created_gl_names):
"""ERPNext's setup_company → set_default_accounts picks the first
account_type='Bank' Account it finds and writes it to
Company.default_bank_account. On a fresh AZ-CoA install that's a placeholder
(223.1 - AZN AZXX - JS), and _delete_bank_placeholders then can't delete it
(LinkExistsError pointing back at Company). Repoint the default to the first
real bank GL we just created so the placeholder is free to remove.
(223.1 - AZN AZXX - JS). When we reuse the AZN placeholder via rename,
frappe.rename_doc auto-updates this link to the real account. But if the
user has no AZN bank, the link is left dangling repoint it to the most
useful real GL we just created.
Only repoints when the current value is unset or matches the placeholder
pattern never overwrites a value the user explicitly set.
Currency preference order: AZN USD EUR RUB GBP first created.
Never overwrites a value the user explicitly set (anything without 'XXXX').
"""
if not created_gl_names:
return
current = frappe.db.get_value("Company", company, "default_bank_account")
if current and "XXXX" not in current:
# Either user-set or already a real account (e.g. auto-updated by
# frappe.rename_doc when we repurposed the AZN placeholder).
return
chosen = None
for cur in ("AZN", "USD", "EUR", "RUB", "GBP"):
if by_currency.get(cur):
chosen = by_currency[cur][0]
break
if not chosen:
chosen = created_gl_names[0]
frappe.db.set_value(
"Company", company, "default_bank_account",
created_gl_names[0], update_modified=False,
chosen, update_modified=False,
)
def _delete_bank_placeholders(company, parent_account):
"""Remove the stock template accounts from AZ CoA once real banks have been
materialised. Covers both `<CUR> AZXX` and `Kart XXXX` patterns anything
with a run of four or more X characters in the name, under the bank parent.
Fresh-setup only nothing references these placeholders yet, so deletion
is safe. Per-row failures logged.
def _delete_bank_placeholders(company, parent_account, placeholder_index=None):
"""Remove unconsumed placeholders. Same-currency placeholders that we
repurposed via rename in _convert_placeholder_to_real are no longer "XXXX"
rows so the sweep won't touch them. Anything left under the bank parent
with `XXXX` in account_name (Kart placeholder, extra-currency rows) is
either deleted or if some other doc still links to it disabled.
"""
candidates = frappe.get_all(
"Account",
@ -613,6 +646,120 @@ def _count_items(data, paginated):
return 0
def _index_placeholders_by_currency(company, parent_account):
"""Scan the bank parent for accounts whose account_name contains 'XXXX'
(the AZ-CoA placeholder marker) and group them by currency. Currency is
parsed from the account_name as the leading 3-letter uppercase token
"Kart …" placeholders return None and are excluded so they're never
repurposed for a regular IBAN; the leftover sweep deletes them later.
Returns: {currency: [{"name", "account_number", "consumed": False}, ...]}
Sorted by account_number so 223.1 is consumed before 223.7 etc.
"""
rows = frappe.get_all(
"Account",
filters={
"company": company,
"parent_account": parent_account,
"account_type": "Bank",
"is_group": 0,
},
fields=["name", "account_number", "account_name"],
)
index = {}
for row in rows:
if "XXXX" not in (row.account_name or ""):
continue
currency = _parse_placeholder_currency(row.account_name)
if not currency:
continue
index.setdefault(currency, []).append({
"name": row.name,
"account_number": row.account_number or "",
"consumed": False,
})
for cur in index:
index[cur].sort(key=lambda p: p["account_number"] or "")
return index
def _parse_placeholder_currency(account_name):
"""Extract the 3-letter currency code from AZ-CoA placeholder names like
'AZN AZXXXXX…' or 'USD AZXXXXX…'. Returns None for 'Kart XXXX… AZN' style
patterns so they're never picked for repurposing — only the canonical
"<CUR> AZXX…" placeholders match.
"""
if not account_name:
return None
parts = account_name.strip().split()
if not parts:
return None
first = parts[0]
if first.isalpha() and len(first) == 3 and first.isupper():
return first
return None
def _claim_placeholder(placeholder_index, currency):
"""Return the next unconsumed placeholder for `currency`, marking it
consumed in place. Returns None if no slot is available.
"""
for ph in placeholder_index.get(currency, []):
if not ph["consumed"]:
ph["consumed"] = True
return ph
return None
def _convert_placeholder_to_real(old_name, account_number, company, new_account_name, currency):
"""Repurpose an existing placeholder Account in place: update its
account_name and account_currency, then rename to the canonical
"<account_number> - <account_name> - <abbr>" form. frappe.rename_doc
updates Link references in other docs (notably Company.default_bank_account
and any GL entries) to the new name automatically.
"""
abbr = frappe.db.get_value("Company", company, "abbr") or ""
doc = frappe.get_doc("Account", old_name)
doc.account_name = new_account_name
doc.account_currency = currency
doc.flags.ignore_permissions = True
doc.flags.ignore_validate_update_after_submit = True
doc.save(ignore_permissions=True)
if account_number:
new_name = f"{account_number} - {new_account_name} - {abbr}"
else:
new_name = f"{new_account_name} - {abbr}"
if old_name != new_name and not frappe.db.exists("Account", new_name):
frappe.rename_doc(
"Account", old_name, new_name,
force=True, ignore_permissions=True, show_alert=False,
)
return new_name
return old_name
def _find_default_warehouse(company):
"""Pick a sensible default warehouse for Stock Settings. ERPNext creates
'Stores' for every new company (is_group=0); under language=az the
`_("Stores")` lookup in update_stock_settings misses because the warehouse
name is still English. Look up directly by company instead.
"""
wh = frappe.db.get_value(
"Warehouse",
{"company": company, "warehouse_name": ("in", ["Stores", _("Stores")])},
"name",
)
if wh:
return wh
return frappe.db.get_value(
"Warehouse",
{"company": company, "is_group": 0},
"name",
)
def _only_admin():
if frappe.session.user != "Administrator":
frappe.throw(_("Only Administrator may run setup"), frappe.PermissionError)

View File

@ -10,7 +10,7 @@ frappe.provide("jey_wizard");
// Bump this string in every commit that changes wizard code. Displayed in the badge so
// we can tell at a glance which version is actually running on a given machine. Kept in
// sync with __version__ in jey_wizard/__init__.py.
const JEY_WIZARD_VERSION = "0.1.8";
const JEY_WIZARD_VERSION = "0.1.9";
// Wipe Frappe + ERPNext default slides so their `before_load`/`after_load` listeners
// don't try to mutate a wizard that isn't slide-based anymore.