diff --git a/jey_wizard/__init__.py b/jey_wizard/__init__.py index 9cb17e7..c11f861 100644 --- a/jey_wizard/__init__.py +++ b/jey_wizard/__init__.py @@ -1 +1 @@ -__version__ = "0.1.8" +__version__ = "0.1.9" diff --git a/jey_wizard/etaxes.py b/jey_wizard/etaxes.py index b64d448..4dbc295 100644 --- a/jey_wizard/etaxes.py +++ b/jey_wizard/etaxes.py @@ -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 - - " + # 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 - - " 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 ` 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 + " 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 + " - - " 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) diff --git a/jey_wizard/public/js/jey_setup.js b/jey_wizard/public/js/jey_setup.js index c838cf0..19e4632 100644 --- a/jey_wizard/public/js/jey_setup.js +++ b/jey_wizard/public/js/jey_setup.js @@ -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.