Wipe placeholders unconditionally, then number real banks 223.X

Replaces the per-currency-match repurposing scheme with a clean-slate
approach that handles every shape of e-taxes data uniformly:

1. Detach Company.default_bank_account if it points at a placeholder
   (otherwise we'd hit LinkExistsError trying to delete that row).
2. Wipe every XXXX-marked Account under the bank parent. Same
   delete-or-disable fallback as before.
3. Sort the e-taxes accounts by currency preference
   (AZN→USD→EUR→RUB→GBP→others) so the resulting numbering looks
   familiar even though the source order from the API is arbitrary.
4. Allocate sequential 223.X account_numbers (next-free integer under
   the bank parent), creating each GL with an explicit account_number
   so ERPNext's autoname yields "223.X - <CUR> <IBAN> - <abbr>".
5. Repoint Company.default_bank_account to the first AZN-preferred GL
   (or first created if no preferred currency present).

Handles cleanly:
- standard 1-per-currency setups
- multiples of one currency (each gets its own 223.X)
- exotic currencies like TRY (still gets a 223.X prefix)
- zero-bank tenants (placeholders gone, default_bank_account left None)
- idempotent reruns (existing GL with matching account_name reused
  without consuming a number)

Removed now-unused helpers:
_index_placeholders_by_currency, _parse_placeholder_currency,
_claim_placeholder, _peek_placeholder, _convert_placeholder_to_real,
_delete_bank_placeholders.
This commit is contained in:
Ali 2026-04-28 12:13:46 +00:00
parent 830d621a87
commit b739f06c98
3 changed files with 129 additions and 155 deletions

View File

@ -1 +1 @@
__version__ = "0.1.10"
__version__ = "0.1.11"

View File

@ -308,41 +308,50 @@ 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)
# Clean-slate strategy: detach Company.default_bank_account if it points at
# a placeholder (frees the placeholder for deletion), wipe every XXXX row
# under the bank parent, then create real banks with explicit "223.X"
# numbering. Works uniformly for any currency mix (incl. multiples of one
# currency, exotic currencies like TRY, or zero banks) — every materialised
# account ends up with the canonical "223.X - <CUR> <IBAN> - <ABBR>" name.
_detach_placeholder_default_bank(company)
deleted_placeholders = _delete_all_placeholders(company, parent)
# Sort by currency preference so the numbering ends up familiar:
# AZN=223.1, USD=223.2, EUR=223.3, RUB=223.4, GBP=223.5, then anything else.
active_accounts = [a for a in accounts if (a.get("status") or "").upper() != "C"]
active_accounts.sort(key=_currency_sort_key)
skipped_closed = sum(1 for a in accounts if (a.get("status") or "").upper() == "C")
next_num = _next_bank_account_number(company, parent)
created = 0
skipped_closed = 0
created_by_currency = {}
created_gl_names = []
bank_trace = [] # per-bank: action taken + result, dumped to Error Log at end
for acc in accounts:
if (acc.get("status") or "").upper() == "C":
skipped_closed += 1
bank_trace.append({"iban": acc.get("number"), "action": "skipped_closed"})
continue
bank_trace = []
for acc in active_accounts:
cur_for_log = (acc.get("currency") or "AZN").strip() or "AZN"
ph_available_before = bool(_peek_placeholder(placeholder_index, cur_for_log))
try:
gl = _materialize_one_bank(company, parent, acc, placeholder_index)
if gl:
created_gl_names.append(gl)
created_by_currency.setdefault(cur_for_log, []).append(gl)
result = _materialize_one_bank(company, parent, acc, next_num)
if not result:
bank_trace.append({"iban": acc.get("number"), "currency": cur_for_log, "action": "skipped_incomplete"})
continue
gl_name, consumed_number = result
if consumed_number:
next_num += 1
created_gl_names.append(gl_name)
created_by_currency.setdefault(cur_for_log, []).append(gl_name)
created += 1
bank_trace.append({
"iban": acc.get("number"),
"currency": cur_for_log,
"placeholder_was_available": ph_available_before,
"resulting_gl": gl,
"action": "reused_placeholder" if (ph_available_before and gl) else "created_fresh",
"resulting_gl": gl_name,
"action": "created" if consumed_number else "existed",
})
except Exception as exc:
bank_trace.append({
"iban": acc.get("number"),
"currency": cur_for_log,
"placeholder_was_available": ph_available_before,
"action": "error",
"error": str(exc)[:300],
})
@ -352,19 +361,15 @@ def _materialize_native_banks(company):
)
_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.
frappe.log_error(
json.dumps({
"company": company,
"materialised": created,
"skipped_closed": skipped_closed,
"deleted_placeholders": deleted_placeholders,
"input_rows": len(accounts),
"parent": parent,
"placeholder_index_keys": sorted(list(placeholder_index.keys())),
"placeholder_index_counts": {cur: len(items) for cur, items in placeholder_index.items()},
"banks": bank_trace,
}, ensure_ascii=False, indent=2),
"Jey Wizard trace: bank native done",
@ -393,11 +398,18 @@ def _find_bank_parent_account(company):
return None
def _materialize_one_bank(company, parent_account, row, placeholder_index=None):
def _materialize_one_bank(company, parent_account, row, account_number_n):
"""Create Bank + GL Account (numbered "223.<account_number_n>") + Bank
Account for one e-taxes row. Idempotent on the GL: if an Account with the
same account_name already exists for this company, reuse it without
consuming the proposed number.
Returns: (gl_name, consumed) `consumed` is True when we allocated a new
"223.X" and the caller should advance its counter; False when we reused an
existing GL. Returns None when the row is incomplete (no bank/IBAN).
"""
# 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
# Company.default_bank_account before deleting placeholders), None otherwise.
bank_name = (row.get("bankName") or row.get("bank_name") or "").strip()
iban = (row.get("number") or "").strip()
currency = (row.get("currency") or "").strip() or "AZN"
@ -412,33 +424,29 @@ def _materialize_one_bank(company, parent_account, row, placeholder_index=None):
bank_doc.flags.ignore_if_duplicate = True
bank_doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
# 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.
# 2. GL Account with explicit "223.<n>" numbering, falls through to reuse
# if the same account_name already exists (idempotent reruns).
account_name = f"{currency} {iban}"
gl_name = frappe.db.get_value(
"Account",
{"company": company, "account_name": account_name},
"name",
)
consumed = False
if not gl_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
gl_doc = frappe.get_doc({
"doctype": "Account",
"account_name": account_name,
"account_number": f"223.{account_number_n}",
"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
consumed = True
# 3. Bank Account (links Bank + GL + Company)
ba_exists = frappe.db.exists(
@ -463,7 +471,7 @@ def _materialize_one_bank(company, parent_account, row, placeholder_index=None):
})
ba_doc.insert(ignore_permissions=True)
return gl_name
return gl_name, consumed
def _repoint_company_default_bank(company, by_currency, created_gl_names):
@ -498,12 +506,24 @@ def _repoint_company_default_bank(company, by_currency, created_gl_names):
)
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.
def _detach_placeholder_default_bank(company):
"""ERPNext's setup_company → set_default_accounts pointed
Company.default_bank_account at "223.1 - AZN AZXX… - JS" (the AZN
placeholder). Clear that field if it currently references a placeholder so
the placeholder can be deleted; _repoint_company_default_bank reassigns it
to a real bank afterwards.
"""
current = frappe.db.get_value("Company", company, "default_bank_account")
if current and "XXXX" in current:
frappe.db.set_value(
"Company", company, "default_bank_account", None, update_modified=False
)
def _delete_all_placeholders(company, parent_account):
"""Wipe every Account under the bank parent whose account_name carries the
'XXXX' placeholder marker. Returns the number deleted (or disabled, on
LinkExistsError) for trace logging.
"""
candidates = frappe.get_all(
"Account",
@ -515,6 +535,7 @@ def _delete_bank_placeholders(company, parent_account, placeholder_index=None):
},
fields=["name", "account_name"],
)
processed = 0
for row in candidates:
if "XXXX" not in (row.account_name or ""):
continue
@ -522,17 +543,17 @@ def _delete_bank_placeholders(company, parent_account, placeholder_index=None):
frappe.delete_doc(
"Account", row.name, ignore_permissions=True, delete_permanently=True
)
processed += 1
except frappe.LinkExistsError:
# Some other doc still references this placeholder (e.g. a Company
# default field we didn't preempt). Frappe.throw queued a popup
# message before raising — clear it so the user doesn't see a
# scary error after a successful setup, then disable the account
# instead of leaving it active in the chart.
# Some other doc still references this placeholder. Clear the
# popup that frappe.throw queued, then disable instead of leaving
# the user with an error after a successful setup.
frappe.clear_last_message()
try:
frappe.db.set_value(
"Account", row.name, "disabled", 1, update_modified=False
)
processed += 1
except Exception as exc:
frappe.log_error(
f"Placeholder disable fallback failed: {row.name}: {exc}",
@ -543,6 +564,52 @@ def _delete_bank_placeholders(company, parent_account, placeholder_index=None):
f"Placeholder delete failed: {row.name}: {exc}",
"Jey Wizard materialize (bank placeholder)",
)
return processed
def _next_bank_account_number(company, parent_account):
"""Return the next free integer N such that account_number "223.N" is not
used by any sibling under `parent_account`. After _delete_all_placeholders
on a fresh install nothing matches, so this returns 1 but on idempotent
reruns it skips over the numbers already taken by real banks.
"""
rows = frappe.db.sql(
"""
SELECT account_number FROM tabAccount
WHERE company = %s AND parent_account = %s
AND account_number LIKE '223.%%'
""",
(company, parent_account),
as_dict=True,
)
used = set()
for r in rows:
num = (r.account_number or "")
if not num.startswith("223."):
continue
try:
used.add(int(num.split(".", 1)[1]))
except (ValueError, IndexError):
pass
n = 1
while n in used:
n += 1
return n
_CURRENCY_PRIORITY = ["AZN", "USD", "EUR", "RUB", "GBP"]
def _currency_sort_key(acc):
"""Sort accounts so the canonical AZ ordering is preserved when we hand
out 223.X numbers. Known currencies come first in the priority order;
anything else is alphabetical after them.
"""
cur = (acc.get("currency") or "AZN").strip() or "AZN"
try:
return (0, _CURRENCY_PRIORITY.index(cur), cur)
except ValueError:
return (1, 0, cur)
def _materialize_amas_employees(company_name):
@ -671,99 +738,6 @@ 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 _peek_placeholder(placeholder_index, currency):
"""Like _claim_placeholder but doesn't mutate. Used by trace logging."""
if not placeholder_index:
return None
for ph in placeholder_index.get(currency, []):
if not ph["consumed"]:
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. Delegates the rename
to ERPNext's canonical update_account_number helper — that routine handles
the tree-doctype rename, child-company sync, and final frappe.rename_doc
with force=True. Currency is updated separately via direct db write so we
don't go through Account.validate (which on a doc.save would re-fire all
hooks and historically choked on the AZ-CoA placeholder shape).
"""
if currency:
frappe.db.set_value(
"Account", old_name, "account_currency", currency, update_modified=False
)
from erpnext.accounts.doctype.account.account import update_account_number
new_name = update_account_number(old_name, new_account_name, account_number)
return new_name or 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

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.10";
const JEY_WIZARD_VERSION = "0.1.11";
// 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.