Read banks from wizard cache; save Stock Settings; quoted-core abbr

Materialising banks off E-Taxes Bank Account made us hostage to
invoice_az's finalize-time re-fetch. If that re-fetch 401'd or hit
a network error the doctype stayed empty, and everything downstream
(Bank / GL Account / Bank Account, placeholder cleanup) silently
no-op'd. Now we read the e-taxes response straight from
Jey Wizard Etaxes Cache.bank_accounts_json which is already
populated during the Asan step, so materialisation runs off a
guaranteed-present payload and is decoupled from the retry-prone
loader call. Logs both entry ("applying valuation_method=…") and
exit ("materialised=N skipped_closed=M parent=…") via
frappe.logger() so we can confirm from the server log that the
hook actually ran end-to-end on a given setup.

Stock Settings valuation_method write switched from
frappe.db.set_single_value to the load-and-.save() pattern that
erpnext itself uses in install_fixtures.update_stock_settings, and
followed by an explicit frappe.db.commit() — should survive any
subsequent cache fetch of the Single doc.

Abbreviation deriver rewritten around the canonical AZ registered
name shape `"<Core>" <LEGAL FORM>`. When quotes are present the core
gets initials and the legal form collapses to its short code
(MMC / ASC / QSC / SC / K / FS), joined with a space — so
"TEST SOFT" MƏHDUD MƏSULİYYƏTLİ CƏMİYYƏTİ now yields "TS MMC"
instead of "″SMM". The quote character class widened to include
U+2033 DOUBLE PRIME (the typography used on the actual reference
site) plus prime, guillemet single ‹›, and other variants. Abbr
validation relaxed to allow a single internal space so the space
between core initials and legal-form code survives setup_complete.
This commit is contained in:
Ali 2026-04-24 14:44:27 +00:00
parent 4ed716637d
commit 3812a06c3c
3 changed files with 148 additions and 54 deletions

View File

@ -1 +1 @@
__version__ = "0.1.5" __version__ = "0.1.6"

View File

@ -172,18 +172,29 @@ def materialize_after_setup(args):
# Settings in erpnext.stock.utils.get_valuation_method, but users typically # 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 # 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. # "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" chosen_vm = (args or {}).get("valuation_method") or "FIFO"
if chosen_vm in ("FIFO", "Moving Average"): 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( frappe.db.set_value(
"Company", company_name, "valuation_method", chosen_vm, update_modified=False "Company", company_name, "valuation_method", chosen_vm, update_modified=False
) )
try: 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: except Exception as exc:
frappe.log_error( 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", "Jey Wizard materialize",
) )
frappe.db.commit()
from invoice_az import company_api from invoice_az import company_api
@ -216,46 +227,74 @@ def materialize_after_setup(args):
def _materialize_native_banks(company): def _materialize_native_banks(company):
"""Read E-Taxes Bank Account rows just populated for `company` and create """Create ERPNext-native Bank / GL Account / Bank Account for each non-closed
ERPNext-native Bank / GL Account / Bank Account records for each non-closed bank from the wizard cache. Reads from Jey Wizard Etaxes Cache.bank_accounts_json
entry. Idempotent safe to re-run. Closed accounts (status=C) are ignored. (populated during the Asan step) rather than the E-Taxes Bank Account doctype
After the loop, unused AZ CoA bank placeholders (names like `<CUR> AZXX` that way the materialization still runs even if the finalize-time invoice_az
under account_number=223) are deleted. re-fetch happens to fail. Closed (status=C) accounts are ignored. Idempotent.
""" """
cache_rows = frappe.get_all( cache = frappe.get_single("Jey Wizard Etaxes Cache")
"E-Taxes Bank Account", raw = (cache.bank_accounts_json or "").strip()
filters={"company": company}, if not raw:
fields=[ frappe.log_error(
"bank_name", f"materialize_native_banks: empty bank_accounts_json cache for '{company}'",
"number", "Jey Wizard materialize (bank native)",
"currency", )
"status", return
"account_type",
], try:
) data = json.loads(raw)
if not cache_rows: 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 return
parent = _find_bank_parent_account(company) parent = _find_bank_parent_account(company)
if not parent: if not parent:
frappe.log_error( frappe.log_error(
f"No bank parent account found for company '{company}' — skipping native bank materialize", f"materialize_native_banks: no bank parent (22/223) for company '{company}'",
"Jey Wizard materialize", "Jey Wizard materialize (bank native)",
) )
return return
for row in cache_rows: created = 0
if (row.get("status") or "").upper() == "C": skipped_closed = 0
for acc in accounts:
if (acc.get("status") or "").upper() == "C":
skipped_closed += 1
continue continue
try: try:
_materialize_one_bank(company, parent, row) _materialize_one_bank(company, parent, acc)
created += 1
except Exception as exc: except Exception as exc:
frappe.log_error( 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)", "Jey Wizard materialize (bank native)",
) )
_delete_bank_placeholders(company, parent) _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): def _find_bank_parent_account(company):
@ -281,10 +320,12 @@ def _find_bank_parent_account(company):
def _materialize_one_bank(company, parent_account, row): 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() iban = (row.get("number") or "").strip()
currency = (row.get("currency") or "").strip() or "AZN" 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: if not bank_name or not iban:
return return

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 // 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 // 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. // 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 // 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. // 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); $nameEl.on("input", fillAbbrDefault);
$abbrEl.on("input", (e) => { $abbrEl.on("input", (e) => {
this.data.company_abbr_user_edited = true; this.data.company_abbr_user_edited = true;
this.data.company_abbr = ($(e.currentTarget).val() || "").toUpperCase().slice(0, 10); const v = ($(e.currentTarget).val() || "").toUpperCase().slice(0, 10);
$(e.currentTarget).val(this.data.company_abbr); this.data.company_abbr = v;
$(e.currentTarget).val(v);
}); });
} }
derive_abbr(rawName) { derive_abbr(rawName) {
// Normalise Azerbaijani company-form boilerplate before tokenising so the // Azerbaijani registered names follow the pattern
// auto-suggest doesn't spell out each word of "Məhdud Məsuliyyətli Cəmiyyəti" // "<Core Name>" <LEGAL FORM>
// as separate initials. Also strip every quote character — the stock // where the core is always quoted and the legal form is spelled out in
// ERPNext deriver chokes on them. // 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 ""; if (!rawName) return "";
let name = String(rawName).replace(/["“”„‟«»‘’‚‛'`´]/g, " "); const QUOTE_CHARS = "\"“”„‟«»‘’‚‛'`´″′‹›";
name = name.replace(/[ƏE]/gi, (ch) => (ch === ch.toUpperCase() ? "E" : "e")); const stripEdgeQuotes = (s) =>
const rules = [ String(s || "").replace(new RegExp(`^[${QUOTE_CHARS}\\s]+|[${QUOTE_CHARS}\\s]+$`, "g"), "");
[/\bMEHDUD\s+MESULIYYETLI\s+CEMIYYETI\b/gi, "MMC"],
[/\bA[CÇ]IQ\s+SEHMDAR\s+CEMIYYETI\b/gi, "ASC"], const legalFormCode = (text) => {
[/\bQAPALI\s+SEHMDAR\s+CEMIYYETI\b/gi, "QSC"], // Treat Ə/ə the same as E/e and match common full-word AZ legal forms.
[/\bSEHMDAR\s+CEMIYYETI\b/gi, "SC"], const ascii = String(text || "").replace(/Ə/g, "E").replace(/ə/g, "e");
[/\bKOOPERATIV\b/gi, "K"], const rules = [
]; [/\bMEHDUD\s+MESULIYYETLI\s+CEMIYYETI\b/i, "MMC"],
for (const [re, rep] of rules) name = name.replace(re, rep); [/\bA[CÇ]IQ\s+SEHMDAR\s+CEMIYYETI\b/i, "ASC"],
const parts = name.split(/\s+/).filter(Boolean); [/\bQAPALI\s+SEHMDAR\s+CEMIYYETI\b/i, "QSC"],
if (parts.length === 0) return ""; [/\bSEHMDAR\s+CEMIYYETI\b/i, "SC"],
if (parts.length === 1) return parts[0].slice(0, 3).toUpperCase(); [/\bKOOPERATIV\b/i, "K"],
return parts.slice(0, 4).map((p) => p[0]).join("").toUpperCase().slice(0, 10); [/\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) { render_confirm($body) {
@ -541,14 +588,19 @@ frappe.setup.SetupWizard = class JeySetupWizard {
return false; return false;
} }
this.data.company_name = name; 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); const abbr = abbrRaw || this.derive_abbr(name);
if (!abbr) { if (!abbr) {
this.$wrap.find(".jey-status").text(__("Company abbreviation is required")); this.$wrap.find(".jey-status").text(__("Company abbreviation is required"));
return false; return false;
} }
if (!/^[A-Z0-9]{1,10}$/.test(abbr)) { // Allow letters, digits and a single space — e.g. "TS MMC" for
this.$wrap.find(".jey-status").text(__("Abbreviation must be 110 letters/digits (AZ, 09).")); // the Azerbaijani quoted-core + legal-form pattern.
if (!/^[A-Z0-9 ]{1,10}$/.test(abbr)) {
this.$wrap.find(".jey-status").text(__("Abbreviation must be 110 letters/digits (AZ, 09, spaces allowed)."));
return false; return false;
} }
this.data.company_abbr = abbr; this.data.company_abbr = abbr;
@ -1103,9 +1155,10 @@ frappe.setup.SetupWizard = class JeySetupWizard {
build_setup_args() { build_setup_args() {
const company = (this.data.company_name || "").trim() || "Test Company AZ"; const company = (this.data.company_name || "").trim() || "Test Company AZ";
// Prefer the user's (possibly edited) abbr captured in capture_current; // 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 = ( const abbr = (
(this.data.company_abbr || "").toUpperCase().replace(/\s+/g, "") || (this.data.company_abbr || "").toUpperCase().replace(/\s+/g, " ").trim() ||
this.derive_abbr(company) || this.derive_abbr(company) ||
"CO" "CO"
).slice(0, 10); ).slice(0, 10);