diff --git a/jey_wizard/__init__.py b/jey_wizard/__init__.py index 1276d02..0a8da88 100644 --- a/jey_wizard/__init__.py +++ b/jey_wizard/__init__.py @@ -1 +1 @@ -__version__ = "0.1.5" +__version__ = "0.1.6" diff --git a/jey_wizard/etaxes.py b/jey_wizard/etaxes.py index fe280af..befbed0 100644 --- a/jey_wizard/etaxes.py +++ b/jey_wizard/etaxes.py @@ -172,18 +172,29 @@ def materialize_after_setup(args): # Settings in erpnext.stock.utils.get_valuation_method, but users typically # inspect the Stock Settings page to verify setup, so we set both to avoid the # "I picked Moving Average but Stock Settings still shows FIFO" surprise. + # Mirrors erpnext's own pattern in install_fixtures.update_stock_settings — + # loading the doc and saving, instead of set_single_value — so the write + # definitely sticks past any subsequent cache fetch. chosen_vm = (args or {}).get("valuation_method") or "FIFO" if chosen_vm in ("FIFO", "Moving Average"): + frappe.logger().info( + f"materialize_after_setup: applying valuation_method={chosen_vm} to {company_name}" + ) frappe.db.set_value( "Company", company_name, "valuation_method", chosen_vm, update_modified=False ) try: - frappe.db.set_single_value("Stock Settings", "valuation_method", chosen_vm) + stock_settings = frappe.get_doc("Stock Settings") + stock_settings.valuation_method = chosen_vm + 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}", + f"Stock Settings valuation_method set failed: {exc}\n{traceback.format_exc()}", "Jey Wizard materialize", ) + frappe.db.commit() from invoice_az import company_api @@ -216,46 +227,74 @@ def materialize_after_setup(args): def _materialize_native_banks(company): - """Read E-Taxes Bank Account rows just populated for `company` and create - ERPNext-native Bank / GL Account / Bank Account records for each non-closed - entry. Idempotent — safe to re-run. Closed accounts (status=C) are ignored. - After the loop, unused AZ CoA bank placeholders (names like ` AZXX…` - under account_number=223) are deleted. + """Create ERPNext-native Bank / GL Account / Bank Account for each non-closed + bank from the wizard cache. Reads from Jey Wizard Etaxes Cache.bank_accounts_json + (populated during the Asan step) rather than the E-Taxes Bank Account doctype — + that way the materialization still runs even if the finalize-time invoice_az + re-fetch happens to fail. Closed (status=C) accounts are ignored. Idempotent. """ - cache_rows = frappe.get_all( - "E-Taxes Bank Account", - filters={"company": company}, - fields=[ - "bank_name", - "number", - "currency", - "status", - "account_type", - ], - ) - if not cache_rows: + cache = frappe.get_single("Jey Wizard Etaxes Cache") + raw = (cache.bank_accounts_json or "").strip() + if not raw: + frappe.log_error( + f"materialize_native_banks: empty bank_accounts_json cache for '{company}'", + "Jey Wizard materialize (bank native)", + ) + return + + try: + data = json.loads(raw) + except Exception as exc: + frappe.log_error( + f"materialize_native_banks: cache JSON parse failed: {exc}", + "Jey Wizard materialize (bank native)", + ) + return + + # Cached payload is the raw e-taxes response shape: {"bankAccounts": [...]}. + accounts = [] + if isinstance(data, dict): + accounts = data.get("bankAccounts") or [] + elif isinstance(data, list): + accounts = data + + if not accounts: + frappe.log_error( + f"materialize_native_banks: no bankAccounts in cache for '{company}'", + "Jey Wizard materialize (bank native)", + ) return parent = _find_bank_parent_account(company) if not parent: frappe.log_error( - f"No bank parent account found for company '{company}' — skipping native bank materialize", - "Jey Wizard materialize", + f"materialize_native_banks: no bank parent (22/223) for company '{company}'", + "Jey Wizard materialize (bank native)", ) return - for row in cache_rows: - if (row.get("status") or "").upper() == "C": + created = 0 + skipped_closed = 0 + for acc in accounts: + if (acc.get("status") or "").upper() == "C": + skipped_closed += 1 continue try: - _materialize_one_bank(company, parent, row) + _materialize_one_bank(company, parent, acc) + created += 1 except Exception as exc: frappe.log_error( - f"{row.get('number')}: {exc}\n{traceback.format_exc()}", + f"{acc.get('number')}: {exc}\n{traceback.format_exc()}", "Jey Wizard materialize (bank native)", ) _delete_bank_placeholders(company, parent) + frappe.db.commit() + # Success log is loud on purpose — makes it easy to verify the hook ran end-to-end. + frappe.logger().info( + f"materialize_native_banks: company={company} materialised={created} " + f"skipped_closed={skipped_closed} parent={parent}" + ) def _find_bank_parent_account(company): @@ -281,10 +320,12 @@ def _find_bank_parent_account(company): def _materialize_one_bank(company, parent_account, row): - bank_name = (row.get("bank_name") or "").strip() + # Accepts either the raw e-taxes payload shape (bankName/number/type…) or + # the E-Taxes Bank Account doctype shape (bank_name/number/account_type). + 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" - etx_account_type = (row.get("account_type") or "").strip() + etx_account_type = (row.get("type") or row.get("account_type") or "").strip() if not bank_name or not iban: return diff --git a/jey_wizard/public/js/jey_setup.js b/jey_wizard/public/js/jey_setup.js index d51fc9d..523188c 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.5"; +const JEY_WIZARD_VERSION = "0.1.6"; // 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. @@ -440,31 +440,78 @@ frappe.setup.SetupWizard = class JeySetupWizard { $nameEl.on("input", fillAbbrDefault); $abbrEl.on("input", (e) => { this.data.company_abbr_user_edited = true; - this.data.company_abbr = ($(e.currentTarget).val() || "").toUpperCase().slice(0, 10); - $(e.currentTarget).val(this.data.company_abbr); + const v = ($(e.currentTarget).val() || "").toUpperCase().slice(0, 10); + this.data.company_abbr = v; + $(e.currentTarget).val(v); }); } derive_abbr(rawName) { - // Normalise Azerbaijani company-form boilerplate before tokenising so the - // auto-suggest doesn't spell out each word of "Məhdud Məsuliyyətli Cəmiyyəti" - // as separate initials. Also strip every quote character — the stock - // ERPNext deriver chokes on them. + // Azerbaijani registered names follow the pattern + // "" + // where the core is always quoted and the legal form is spelled out in + // full ("Məhdud Məsuliyyətli Cəmiyyəti"). The nice abbreviation follows + // that split: initials of the core + short code of the legal form + // ("TEST SOFT" MMC → "TS MMC"). If no quotes are present we fall back + // to a simpler deriver. if (!rawName) return ""; - let name = String(rawName).replace(/["“”„‟«»‘’‚‛'`´]/g, " "); - name = name.replace(/[ƏE]/gi, (ch) => (ch === ch.toUpperCase() ? "E" : "e")); - const rules = [ - [/\bMEHDUD\s+MESULIYYETLI\s+CEMIYYETI\b/gi, "MMC"], - [/\bA[CÇ]IQ\s+SEHMDAR\s+CEMIYYETI\b/gi, "ASC"], - [/\bQAPALI\s+SEHMDAR\s+CEMIYYETI\b/gi, "QSC"], - [/\bSEHMDAR\s+CEMIYYETI\b/gi, "SC"], - [/\bKOOPERATIV\b/gi, "K"], - ]; - for (const [re, rep] of rules) name = name.replace(re, rep); - const parts = name.split(/\s+/).filter(Boolean); - if (parts.length === 0) return ""; - if (parts.length === 1) return parts[0].slice(0, 3).toUpperCase(); - return parts.slice(0, 4).map((p) => p[0]).join("").toUpperCase().slice(0, 10); + const QUOTE_CHARS = "\"“”„‟«»‘’‚‛'`´″′‹›"; + const stripEdgeQuotes = (s) => + String(s || "").replace(new RegExp(`^[${QUOTE_CHARS}\\s]+|[${QUOTE_CHARS}\\s]+$`, "g"), ""); + + const legalFormCode = (text) => { + // Treat Ə/ə the same as E/e and match common full-word AZ legal forms. + const ascii = String(text || "").replace(/Ə/g, "E").replace(/ə/g, "e"); + const rules = [ + [/\bMEHDUD\s+MESULIYYETLI\s+CEMIYYETI\b/i, "MMC"], + [/\bA[CÇ]IQ\s+SEHMDAR\s+CEMIYYETI\b/i, "ASC"], + [/\bQAPALI\s+SEHMDAR\s+CEMIYYETI\b/i, "QSC"], + [/\bSEHMDAR\s+CEMIYYETI\b/i, "SC"], + [/\bKOOPERATIV\b/i, "K"], + [/\bFERDI\s+SAHIBKAR\b/i, "FS"], + ]; + for (const [re, code] of rules) if (re.test(ascii)) return code; + return ""; + }; + + const initialsOf = (text, max = 4) => { + const parts = stripEdgeQuotes(text).split(/\s+/).filter(Boolean); + if (parts.length === 0) return ""; + if (parts.length === 1) return parts[0].slice(0, 3).toUpperCase(); + return parts.slice(0, max).map((p) => p[0]).join("").toUpperCase(); + }; + + const raw = String(rawName); + // Find the first quoted segment using any recognised quote character. + const quoted = raw.match(new RegExp(`[${QUOTE_CHARS}]([^${QUOTE_CHARS}]+?)[${QUOTE_CHARS}]`)); + if (quoted) { + const core = quoted[1]; + const outside = (raw.slice(0, quoted.index) + " " + raw.slice(quoted.index + quoted[0].length)) + .trim(); + const coreAbbr = initialsOf(core, 4); + const legalCode = legalFormCode(outside); + const combined = [coreAbbr, legalCode].filter(Boolean).join(" ").slice(0, 10); + if (combined) return combined; + } + + // Unquoted fallback: strip quotes, collapse known legal forms inline, take + // first letters. + let clean = raw.replace(new RegExp(`[${QUOTE_CHARS}]`, "g"), " "); + const legal = legalFormCode(clean); + if (legal) { + // Remove the legal form text from the core, then combine. + clean = clean + .replace(/Ə/g, "E").replace(/ə/g, "e") + .replace(/\bMEHDUD\s+MESULIYYETLI\s+CEMIYYETI\b/gi, " ") + .replace(/\bA[CÇ]IQ\s+SEHMDAR\s+CEMIYYETI\b/gi, " ") + .replace(/\bQAPALI\s+SEHMDAR\s+CEMIYYETI\b/gi, " ") + .replace(/\bSEHMDAR\s+CEMIYYETI\b/gi, " ") + .replace(/\bKOOPERATIV\b/gi, " ") + .replace(/\bFERDI\s+SAHIBKAR\b/gi, " "); + const core = initialsOf(clean, 4); + return [core, legal].filter(Boolean).join(" ").slice(0, 10); + } + return initialsOf(clean, 4).slice(0, 10); } render_confirm($body) { @@ -541,14 +588,19 @@ frappe.setup.SetupWizard = class JeySetupWizard { return false; } this.data.company_name = name; - const abbrRaw = (this.$wrap.find(".jey-company-abbr").val() || "").toUpperCase().replace(/\s+/g, ""); + const abbrRaw = (this.$wrap.find(".jey-company-abbr").val() || "") + .toUpperCase() + .replace(/\s+/g, " ") + .trim(); const abbr = abbrRaw || this.derive_abbr(name); if (!abbr) { this.$wrap.find(".jey-status").text(__("Company abbreviation is required")); return false; } - if (!/^[A-Z0-9]{1,10}$/.test(abbr)) { - this.$wrap.find(".jey-status").text(__("Abbreviation must be 1–10 letters/digits (A–Z, 0–9).")); + // Allow letters, digits and a single space — e.g. "TS MMC" for + // the Azerbaijani quoted-core + legal-form pattern. + if (!/^[A-Z0-9 ]{1,10}$/.test(abbr)) { + this.$wrap.find(".jey-status").text(__("Abbreviation must be 1–10 letters/digits (A–Z, 0–9, spaces allowed).")); return false; } this.data.company_abbr = abbr; @@ -1103,9 +1155,10 @@ frappe.setup.SetupWizard = class JeySetupWizard { build_setup_args() { const company = (this.data.company_name || "").trim() || "Test Company AZ"; // Prefer the user's (possibly edited) abbr captured in capture_current; - // fall back to the deriver if something bypassed validation. + // fall back to the deriver if something bypassed validation. Collapse + // runs of whitespace but keep a single space (e.g. "TS MMC"). const abbr = ( - (this.data.company_abbr || "").toUpperCase().replace(/\s+/g, "") || + (this.data.company_abbr || "").toUpperCase().replace(/\s+/g, " ").trim() || this.derive_abbr(company) || "CO" ).slice(0, 10);