jey_wizard/jey_wizard/etaxes.py

1842 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Thin e-taxes fetchers for the wizard.
Unlike invoice_az's company_api which *fetches AND writes to E-Taxes DocTypes* (requires
an existing Company), these fetchers only do the HTTP part and stash raw JSON into the
`Jey Wizard Etaxes Cache` single doctype. They run during the Asan step — before Company
exists — so future wizard steps can query the data.
Final persistence into `E-Taxes Object`, `E-Taxes Cash Register`, etc. happens AFTER
setup_complete (see materialize_after_setup), where we delegate to invoice_az's loaders
with the freshly-created Company. That's a second fetch but avoids duplicating ~500 lines
of field-mapping code.
"""
import json
import traceback
import frappe
import requests
from frappe import _
from invoice_az.auth import DEFAULT_HEADERS, get_default_asan_login
BASE = "https://new.e-taxes.gov.az"
# (cache_field, http_method, url, json_payload_template, is_paginated)
# For paginated endpoints, `offset` is injected per page and `hasMore` drives the loop.
ENDPOINTS = [
(
"objects_json",
"GET",
f"{BASE}/api/po/profile/public/v1/object/list",
None,
False,
),
(
"cash_registers_json",
"POST",
f"{BASE}/api/po/profile/public/v2/profile/cashRegister/list",
{"maxCount": 200, "offset": 0},
True,
),
(
"pos_terminals_json",
"POST",
f"{BASE}/api/po/profile/public/v2/profile/posTerminal/list",
{"maxCount": 200, "offset": 0},
True,
),
(
"bank_accounts_json",
"GET",
f"{BASE}/api/po/profile/public/v1/profile/bankAccount/list",
None,
False,
),
(
"presented_certs_json",
"GET",
f"{BASE}/api/po/edi-legal/public/v1/application/contractor/presented-accepted-e-certificates",
None,
False,
),
]
PAGE_SIZE = 200
MAX_PAGES = 50 # safety cap per paginated endpoint
KIND_TO_FIELD = {
"objects": "objects_json",
"cash_registers": "cash_registers_json",
"pos_terminals": "pos_terminals_json",
"bank_accounts": "bank_accounts_json",
"presented_certs": "presented_certs_json",
}
@frappe.whitelist()
def fetch_all_etaxes():
"""Fetch all six e-taxes lists and cache them. Best-effort: per-endpoint errors are
logged but don't abort the whole run.
"""
_only_admin()
login = get_default_asan_login()
if not login.get("found") or not login.get("main_token"):
return {
"ok": False,
"message": "No authenticated Asan Login with main_token — run Asan auth first.",
}
cache = frappe.get_single("Jey Wizard Etaxes Cache")
summary = {}
errors = {}
for field, method, url, payload, paginated in ENDPOINTS:
try:
data = _fetch_endpoint(method, url, payload, paginated)
cache.set(field, json.dumps(data, ensure_ascii=False))
# Count only active rows so the digit matches the (filtered) preview.
summary[field] = len(_active_items(field, data))
except Exception as exc:
errors[field] = str(exc)
frappe.log_error(
f"{field} fetch failed: {exc}\n{traceback.format_exc()}",
"Jey Wizard etaxes fetch",
)
# Leave the previously cached value alone so partial success is preserved.
cache.fetched_at = frappe.utils.now()
cache.last_summary = json.dumps(summary, ensure_ascii=False)
cache.last_error = json.dumps(errors, ensure_ascii=False) if errors else ""
cache.flags.ignore_permissions = True
cache.save()
frappe.db.commit()
# Diagnostic breadcrumb so we can confirm from Error Log alone that the Asan-step
# fetch ran and what it produced (counts per endpoint, per-endpoint errors).
frappe.log_error(
json.dumps({"summary": summary, "errors": errors}, ensure_ascii=False, indent=2),
"Jey Wizard trace: fetch_all_etaxes done",
)
return {
"ok": True,
"summary": summary,
"errors": errors,
}
@frappe.whitelist()
def fetch_company_profile():
"""Pull /api/po/profile/public/v2/profile via taxes_az helper + resolve
taxAuthorityCode/propertyType to human names through the e-taxes
dictionaries. Stash the merged blob in cache.company_profile_json so the
finalize step can write it onto Company without another network round-trip
(by which point main_token may already be invalid).
"""
_only_admin()
from taxes_az.company_etaxes import (
get_company_profile,
get_tax_authority_name_by_code,
get_property_type_name_by_code,
)
resp = get_company_profile()
if not resp or not resp.get("success"):
return {
"ok": False,
"message": (resp or {}).get("message", "get_company_profile failed"),
"error": (resp or {}).get("error"),
}
profile_blob = resp.get("data") or {}
# taxes_az's helper sometimes wraps the actual profile in {"profile": {...}}.
# Mirror the JS unwrap (see updateCompanyFromProfile in taxes_az/client/company.js).
profile = profile_blob.get("profile") if isinstance(profile_blob, dict) and isinstance(profile_blob.get("profile"), dict) else profile_blob
# Resolve dictionary codes to display names while we still have a fresh
# main_token. Failures degrade gracefully: keep the raw code.
resolved = {}
tax_authority_code = profile.get("taxAuthorityCode") if isinstance(profile, dict) else None
if tax_authority_code:
try:
r = get_tax_authority_name_by_code(tax_authority_code)
if r and r.get("success"):
resolved["tax_authority"] = r.get("name") or tax_authority_code
except Exception as exc:
frappe.log_error(
f"tax_authority lookup failed for {tax_authority_code}: {exc}",
"Jey Wizard fetch_company_profile",
)
property_type_code = profile.get("propertyType") if isinstance(profile, dict) else None
if property_type_code:
try:
r = get_property_type_name_by_code(property_type_code)
if r and r.get("success"):
resolved["property_type"] = r.get("name") or property_type_code
except Exception as exc:
frappe.log_error(
f"property_type lookup failed for {property_type_code}: {exc}",
"Jey Wizard fetch_company_profile",
)
cache = frappe.get_single("Jey Wizard Etaxes Cache")
cache.company_profile_json = json.dumps(
{"profile": profile, "resolved": resolved},
ensure_ascii=False,
)
cache.flags.ignore_permissions = True
cache.save()
frappe.db.commit()
# Lower-case the raw criteria once so the JS layer can do a simple
# equality check (criteriaOfBusinessEntity arrives capitalised in some
# tenants — "Micro" vs "micro").
criteria_raw = profile.get("criteriaOfBusinessEntity") if isinstance(profile, dict) else None
criteria = str(criteria_raw).strip().lower() if criteria_raw else ""
frappe.log_error(
json.dumps({
"has_profile": bool(profile),
"profile_keys": sorted(list(profile.keys())) if isinstance(profile, dict) else None,
"resolved": resolved,
"criteria": criteria,
}, ensure_ascii=False, indent=2),
"Jey Wizard trace: fetch_company_profile done",
)
return {"ok": True, "resolved": resolved, "criteria": criteria}
@frappe.whitelist()
def get_preview_data():
"""Return display-ready preview tables for everything pulled in the Asan
step. Shape per kind: {"headers": [str, ...], "rows": [[cell, ...], ...]}.
Profile uses the same shape (two-column label/value table).
The wizard's done-screen modal renders this verbatim — no business logic
in the JS. Field names mirror invoice_az.company_api so the preview
matches what eventually lands in the E-Taxes DocTypes.
"""
_only_admin()
cache = frappe.get_single("Jey Wizard Etaxes Cache")
return {
"objects": _preview_objects(cache.objects_json),
"cash_registers": _preview_cash_registers(cache.cash_registers_json),
"pos_terminals": _preview_pos_terminals(cache.pos_terminals_json),
"bank_accounts": _preview_bank_accounts(cache.bank_accounts_json),
"presented_certs": _preview_presented_certs(cache.presented_certs_json),
"profile": _preview_profile(cache.company_profile_json),
}
PREVIEW_ROW_LIMIT = 200
def _safe_parse(raw):
if not raw:
return None
try:
return json.loads(raw)
except Exception:
return None
def _flatten_pages(data, list_key):
"""Paginated cache payloads land as a list of page dicts ([{list_key: [...], hasMore: ...}, ...]).
Non-paginated ones land as a single dict {list_key: [...]}.
"""
if isinstance(data, list):
out = []
for page in data:
if isinstance(page, dict):
out.extend(page.get(list_key) or [])
return out
if isinstance(data, dict):
return data.get(list_key) or []
return []
# Active-status semantics — single source of truth for counts AND preview.
# Mirrors _purge_inactive_etaxes_records on the materialize side, so what the
# user sees in the wizard list matches what actually survives into the
# E-Taxes doctypes after finalize. Inactive rows are never counted or shown.
_ACTIVE_STATUS = {"A", "ACTIVE"}
def _extract_items(field, data):
"""Pull the raw item list out of a cached endpoint payload, handling the
paginated list-of-pages vs single-dict shapes."""
if field == "objects_json":
return (data.get("objectInfoShortList") or data.get("objects") or []) if isinstance(data, dict) else []
if field == "cash_registers_json":
return _flatten_pages(data, "cashRegisters")
if field == "pos_terminals_json":
return _flatten_pages(data, "posTerminals")
if field == "bank_accounts_json":
if isinstance(data, dict):
return data.get("bankAccounts") or []
if isinstance(data, list):
return data
return []
if field == "presented_certs_json":
return (data.get("certificateList") or []) if isinstance(data, dict) else []
return []
def _is_active(field, item):
"""Active-row predicate per endpoint. Presented certificates are audit
records with no active/inactive notion — always kept."""
if not isinstance(item, dict):
return False
if field == "bank_accounts_json":
# 'C' = closed; anything else (incl. blank) counts as open.
return (item.get("status") or "").strip().upper() != "C"
if field in ("objects_json", "cash_registers_json", "pos_terminals_json"):
return (item.get("status") or "").strip().upper() in _ACTIVE_STATUS
return True
def _active_items(field, data):
"""Extracted items minus the inactive ones — the list the wizard counts,
previews, and (indirectly) loads."""
return [it for it in _extract_items(field, data) if _is_active(field, it)]
def _empty_preview(headers):
return {"headers": headers, "rows": []}
def _preview_objects(raw):
headers = [_("Code"), _("Name"), _("Address")]
data = _safe_parse(raw)
if data is None:
return _empty_preview(headers)
rows = []
for obj in _active_items("objects_json", data)[:PREVIEW_ROW_LIMIT]:
rows.append([
obj.get("objectCode") or "",
(obj.get("objectName") or "").strip(),
obj.get("address") or "",
])
return {"headers": headers, "rows": rows}
def _preview_cash_registers(raw):
headers = [_("Number"), _("Model"), _("Object"), _("Operator")]
data = _safe_parse(raw)
if data is None:
return _empty_preview(headers)
rows = []
for reg in _active_items("cash_registers_json", data)[:PREVIEW_ROW_LIMIT]:
rows.append([
reg.get("number") or "",
reg.get("model") or "",
reg.get("object") or "",
reg.get("operatorName") or "",
])
return {"headers": headers, "rows": rows}
def _preview_pos_terminals(raw):
headers = [_("Serial number"), _("Registration date"), _("Bank")]
data = _safe_parse(raw)
if data is None:
return _empty_preview(headers)
rows = []
for term in _active_items("pos_terminals_json", data)[:PREVIEW_ROW_LIMIT]:
rows.append([
term.get("serialNumber") or "",
(term.get("registrationDate") or "")[:10],
term.get("bankName") or "",
])
return {"headers": headers, "rows": rows}
def _preview_bank_accounts(raw):
headers = [_("IBAN"), _("Currency"), _("Bank")]
data = _safe_parse(raw)
if data is None:
return _empty_preview(headers)
rows = []
for acc in _active_items("bank_accounts_json", data)[:PREVIEW_ROW_LIMIT]:
rows.append([
acc.get("number") or "",
acc.get("currency") or "",
acc.get("bankName") or "",
])
return {"headers": headers, "rows": rows}
def _preview_presented_certs(raw):
headers = [_("Cert №"), _("Category"), _("State"), _("Counterparty"), _("VÖEN"), _("Operation date")]
data = _safe_parse(raw)
if data is None:
return _empty_preview(headers)
items = []
if isinstance(data, dict):
items = data.get("certificateList") or []
rows = []
for cert in items[:PREVIEW_ROW_LIMIT]:
if not isinstance(cert, dict):
continue
op = cert.get("operationDate") or ""
# Compact YYYYMMDDhhmmss → YYYY-MM-DD when applicable.
if isinstance(op, str) and len(op) >= 8 and op[:8].isdigit():
op = f"{op[0:4]}-{op[4:6]}-{op[6:8]}"
rows.append([
cert.get("certNo") or "",
cert.get("certCategory") or "",
cert.get("certState") or "",
cert.get("taxPayerFullName") or "",
cert.get("voen") or "",
op,
])
return {"headers": headers, "rows": rows}
def _preview_profile(raw):
"""Two-column label/value table from the cached company profile blob.
Mirrors the most useful subset of _build_company_field_updates so the
user sees what's about to land on Company.
"""
headers = [_("Field"), _("Value")]
data = _safe_parse(raw)
if not isinstance(data, dict):
return _empty_preview(headers)
profile = data.get("profile") if isinstance(data.get("profile"), dict) else data
resolved = data.get("resolved") or {}
if not isinstance(profile, dict):
return _empty_preview(headers)
def _addr_or(top, legal_obj_keys):
if top:
return top
obj = profile.get("legalAddressObject") or {}
if not isinstance(obj, dict):
return ""
return ", ".join(str(obj.get(k)) for k in legal_obj_keys if obj.get(k))
pairs = [
(_("VÖEN"), profile.get("tin")),
(_("Director"), profile.get("companyDirectorName")),
(_("Director PIN"), profile.get("pin")),
(_("Main activity"), profile.get("mainActivity")),
(_("Business classification"), profile.get("criteriaOfBusinessEntity")),
(_("Tax rate"), profile.get("taxRate")),
(_("Tax authority"), resolved.get("tax_authority") or profile.get("taxAuthorityCode")),
(_("Property type"), resolved.get("property_type") or profile.get("propertyType")),
(_("Registration date"), (profile.get("taxpayerRegistrationDate") or "")[:10]),
(_("Mobile phone"), profile.get("mobilePhoneNumber")),
(_("Landline phone"), profile.get("landlinePhoneNumber")),
(_("Email"), profile.get("email")),
(_("Legal address"), _addr_or(profile.get("legalAddress"), ("region", "locality", "street", "houseNumber"))),
(_("Actual address"), profile.get("actualAddress")),
(_("Employee count"), (profile.get("employeeData") or {}).get("employeeCount") if isinstance(profile.get("employeeData"), dict) else None),
]
vat_info = profile.get("vatInfo") if isinstance(profile.get("vatInfo"), dict) else None
if vat_info:
vat_date = vat_info.get("prOperationTableOperationDate") or ""
if isinstance(vat_date, str) and len(vat_date) == 8 and vat_date.isdigit():
vat_date = f"{vat_date[0:4]}-{vat_date[4:6]}-{vat_date[6:8]}"
pairs.extend([
(_("VAT certificate №"), vat_info.get("recDocumentDocumentNumber")),
(_("VAT registration date"), vat_date),
])
rows = [[label, str(value)] for label, value in pairs if value not in (None, "", [])]
return {"headers": headers, "rows": rows}
@frappe.whitelist()
def get_cached(kind):
"""Return parsed cached data for a given kind. Used by future wizard steps that
need to consume e-taxes data before Company exists. Returns None if nothing cached.
"""
_only_admin()
field = KIND_TO_FIELD.get(kind)
if not field:
frappe.throw(_("Unknown kind: {0}").format(kind))
cache = frappe.get_single("Jey Wizard Etaxes Cache")
raw = cache.get(field)
if not raw:
return None
try:
return json.loads(raw)
except Exception:
return None
def materialize_after_setup(args):
"""setup_wizard_complete hook. After Company is created by the stock finalizer,
call invoice_az's loaders to persist everything into E-Taxes DocTypes with the
real Company link. Best-effort: per-loader failures are logged, not raised.
"""
# Single trace at entry: confirms the hook fires and dumps every input the rest
# of materialize depends on (args, Company existence, Asan auth state, cache
# state). Without this we can't tell from logs alone whether materialize even ran.
try:
_log_materialize_entry(args)
except Exception:
# Diagnostic must never break the actual hook.
pass
company_name = (args or {}).get("company_name")
if not company_name:
frappe.log_error(
"materialize_after_setup: no company_name in args, skipping",
"Jey Wizard materialize",
)
return
if not frappe.db.exists("Company", company_name):
frappe.log_error(
f"materialize_after_setup: Company '{company_name}' not found, skipping",
"Jey Wizard materialize",
)
return
# Apply inventory valuation method. Company.valuation_method wins over Stock
# 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
)
# Tax Policy: accounting_method ("Hesablama metodu" / "Kassa metodu")
# is collected on the same wizard step as valuation_method, so we apply
# both here. The e-taxes /v2/profile payload does not carry this field
# — it's purely a user choice. _apply_company_profile later won't
# override unless that changes upstream.
chosen_am = (args or {}).get("accounting_method") or ""
if chosen_am in ("Hesablama metodu", "Kassa metodu"):
frappe.db.set_value(
"Company", company_name, "accounting_method", chosen_am, update_modified=False
)
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/warehouse set failed: {exc}\n{traceback.format_exc()}",
"Jey Wizard materialize",
)
frappe.db.commit()
from invoice_az import company_api
# (kind, loader, doctype-with-company-link). Doctype is used purely to
# count rows before/after each loader as a diagnostic breadcrumb.
loaders = [
("objects", company_api.load_company_objects, "E-Taxes Object"),
("cash_registers", company_api.load_company_cash_registers, "E-Taxes Cash Register"),
("pos_terminals", company_api.load_company_pos_terminals, "E-Taxes POS Terminal"),
("bank_accounts", company_api.load_company_bank_accounts, "E-Taxes Bank Account"),
("presented_certs", company_api.load_company_presented_certificates, "E-Taxes Presented Certificate"),
]
loader_trace = {}
for name, loader, doctype in loaders:
before = _safe_count(doctype, company_name)
try:
loader(company_name)
after = _safe_count(doctype, company_name)
loader_trace[name] = {"before": before, "after": after, "delta": (after - before) if (before is not None and after is not None) else None}
except Exception as exc:
loader_trace[name] = {"before": before, "error": str(exc)[:200]}
frappe.log_error(
f"materialize_after_setup: {name} loader failed for {company_name}: {exc}\n"
f"{traceback.format_exc()}",
"Jey Wizard materialize",
)
frappe.log_error(
json.dumps(loader_trace, ensure_ascii=False, indent=2),
"Jey Wizard trace: loaders done",
)
# invoice_az's loaders write everything they receive — closed bank
# accounts, terminated objects, etc. The wizard's contract is "active
# data only", so prune the dead rows here. Best-effort: per-row failures
# are logged, not raised.
_purge_inactive_etaxes_records(company_name)
# invoice_az's bank loader only populates the E-Taxes Bank Account cache doctype.
# For the wizard we also want native ERPNext Bank / Bank Account / GL Account so
# the user can post transactions on day one, plus we clear out unused CoA
# placeholders. Runs off the just-populated cache, not another e-taxes fetch.
_materialize_native_banks(company_name)
_apply_company_profile(company_name)
_materialize_amas_employees(company_name)
# Drop toast-style messages that piled up during the various install/translate
# stages — notably az_locale's "Sənəd X yenidən adlandırıldı" notifications
# fired when fixtures get renamed to Azerbaijani. These are cosmetic and would
# spam the post-setup screen. Modal warnings (non-alert msgprints — e.g. HRMS
# Salary Component "accounts not assigned") stay so genuine open items remain
# visible to the user.
_drop_alert_messages()
def _materialize_native_banks(company):
"""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 = 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"materialize_native_banks: no bank parent (22/223) for company '{company}'",
"Jey Wizard materialize (bank native)",
)
return
# 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
created_by_currency = {}
created_gl_names = []
bank_trace = []
for acc in active_accounts:
cur_for_log = (acc.get("currency") or "AZN").strip() or "AZN"
try:
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,
"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,
"action": "error",
"error": str(exc)[:300],
})
frappe.log_error(
f"{acc.get('number')}: {exc}\n{traceback.format_exc()}",
"Jey Wizard materialize (bank native)",
)
_repoint_company_default_bank(company, created_by_currency, created_gl_names)
frappe.db.commit()
frappe.log_error(
json.dumps({
"company": company,
"materialised": created,
"skipped_closed": skipped_closed,
"deleted_placeholders": deleted_placeholders,
"input_rows": len(accounts),
"parent": parent,
"banks": bank_trace,
}, ensure_ascii=False, indent=2),
"Jey Wizard trace: bank native done",
)
def _find_bank_parent_account(company):
"""Find the CoA group to place bank accounts under. In the AZ chart 223
'Bank hesablaşma hesabları' is a leaf (not a group) and its numbered
siblings 223.1/223.2/... all live under 22 'Pul vəsaitləri və onların
ekvivalentləri' — which is a group. Real bank accounts go there too.
Preference order:
1. 223 if it's ever been converted to a group.
2. 22 (Pul vəsaitləri…), the actual AZ CoA group.
3. Any group with name starting with 'Pul vəsaitləri'.
"""
for filters in (
{"company": company, "account_number": "223", "is_group": 1},
{"company": company, "account_number": "22", "is_group": 1},
{"company": company, "account_name": ("like", "Pul vəsaitləri%"), "is_group": 1},
):
parent = frappe.db.get_value("Account", filters, "name")
if parent:
return parent
return 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).
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"
if not bank_name or not iban:
return None
# 1. Bank (global, unique on bank_name)
if not frappe.db.exists("Bank", bank_name):
bank_doc = frappe.get_doc({"doctype": "Bank", "bank_name": bank_name})
bank_doc.flags.ignore_permissions = True
bank_doc.flags.ignore_if_duplicate = True
bank_doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
# 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:
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(
"Bank Account",
{"bank": bank_name, "account_name": account_name, "company": company},
)
if not ba_exists:
# Bank Account.account_type is a Link to the Bank Account Type doctype
# (effectively empty on a fresh install). The e-taxes 'type' field is a
# numeric code (e.g. "2" for card) that has no Bank Account Type row to
# match — populating it raises "Hesab Növü: 2 tapılmadı" on validate.
# Field is optional, so just leave it unset.
ba_doc = frappe.get_doc({
"doctype": "Bank Account",
"account_name": account_name,
"bank": bank_name,
"account": gl_name,
"company": company,
"is_company_account": 1,
"bank_account_no": iban,
"iban": iban,
})
ba_doc.insert(ignore_permissions=True)
return gl_name, consumed
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). 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.
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",
chosen, update_modified=False,
)
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",
filters={
"company": company,
"parent_account": parent_account,
"account_type": "Bank",
"is_group": 0,
},
fields=["name", "account_name"],
)
processed = 0
for row in candidates:
if "XXXX" not in (row.account_name or ""):
continue
try:
frappe.delete_doc(
"Account", row.name, ignore_permissions=True, delete_permanently=True
)
processed += 1
except frappe.LinkExistsError:
# 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}",
"Jey Wizard materialize (bank placeholder)",
)
except Exception as exc:
frappe.log_error(
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 _apply_company_profile(company):
"""Read cache.company_profile_json (populated during the Asan step by
fetch_company_profile) and write the Tax Policy fields onto Company.
Mapping mirrors taxes_az/client/company.js::_parseProfileData; we delegate
the actual write to taxes_az.company_etaxes.force_update_company so the
logic stays aligned with the in-app "Update from E-Taxes" button.
Best-effort: failures are logged, never raised.
"""
cache = frappe.get_single("Jey Wizard Etaxes Cache")
raw = (cache.company_profile_json or "").strip()
if not raw:
frappe.log_error(
f"company={company} reason=profile_cache_empty",
"Jey Wizard trace: company profile skipped",
)
return
try:
blob = json.loads(raw)
except Exception as exc:
frappe.log_error(
f"company={company} bad profile cache JSON: {exc}",
"Jey Wizard apply company profile",
)
return
profile = blob.get("profile") if isinstance(blob, dict) else None
resolved = (blob.get("resolved") if isinstance(blob, dict) else None) or {}
if not isinstance(profile, dict):
frappe.log_error(
f"company={company} no profile dict in cache",
"Jey Wizard apply company profile",
)
return
field_updates = _build_company_field_updates(profile, resolved)
additional_activities = []
if isinstance(profile.get("additionalActivityCodes"), list):
additional_activities = [c for c in profile["additionalActivityCodes"] if c]
affiliate_names = list(profile.get("affiliatesNames") or [])
affiliate_tins = list(profile.get("affiliatesTins") or [])
try:
from taxes_az.company_etaxes import force_update_company
result = force_update_company(
company_name=company,
field_updates=json.dumps(field_updates, ensure_ascii=False),
additional_activities=json.dumps(additional_activities, ensure_ascii=False),
affiliate_names=json.dumps(affiliate_names, ensure_ascii=False),
affiliate_tins=json.dumps(affiliate_tins, ensure_ascii=False),
) or {}
except Exception as exc:
frappe.log_error(
f"force_update_company failed for {company}: {exc}\n{traceback.format_exc()}",
"Jey Wizard apply company profile",
)
return
frappe.log_error(
json.dumps({
"company": company,
"updated_fields_count": len(field_updates),
"updated_field_names": sorted(list(field_updates.keys())),
"additional_activities": len(additional_activities),
"affiliate_organizations": max(len(affiliate_names), len(affiliate_tins)),
"force_update_result": {
k: result.get(k) for k in ("success", "message", "updated_fields", "additional_activities_added", "affiliates_added")
} if isinstance(result, dict) else None,
}, ensure_ascii=False, indent=2),
"Jey Wizard trace: company profile applied",
)
def _build_company_field_updates(profile, resolved):
"""Map the e-taxes /v2/profile JSON onto Company fieldnames. Mirrors the
JS function _parseProfileData in taxes_az/client/company.js: same source
keys, same target fieldnames, same value normalisation.
"""
updates = {}
def _put(field, value):
if value is None:
return
# Booleans → "1"/"0" so the same code path works for Check fields.
if isinstance(value, bool):
updates[field] = "1" if value else "0"
return
s = str(value).strip()
if s == "":
return
updates[field] = s
def _fmt_compact_date(s):
# E-Taxes uses YYYYMMDD for VAT dates (see formatDate in JS).
if not s or len(str(s)) != 8:
return None
s = str(s)
return f"{s[0:4]}-{s[4:6]}-{s[6:8]}"
def _fmt_iso_date(s):
# Other dates come as ISO already; keep YYYY-MM-DD prefix only.
if not s:
return None
return str(s)[:10]
# BASIC
_put("tax_id", profile.get("tin"))
_put("registration_details", profile.get("stateRegistrationDocumentNumber"))
mobile = profile.get("mobilePhoneNumber") or _extract_phone(profile, "mobile")
landline = profile.get("landlinePhoneNumber") or _extract_phone(profile, "landline")
if mobile:
_put("phone_no", mobile)
elif landline:
_put("phone_no", landline)
_put("mobile_phone", mobile)
_put("landline_phone", landline)
_put("email", profile.get("email"))
_put("date_of_establishment", _fmt_iso_date(profile.get("taxpayerRegistrationDate")))
_put("state_registration_document_issued_date", _fmt_iso_date(profile.get("stateRegistrationDocumentIssuedDate")))
if profile.get("mainActivity"):
_put("domain", profile.get("mainActivity"))
_put("main_activity", profile.get("mainActivity"))
# MANAGEMENT
_put("director_name_etaxes", profile.get("companyDirectorName"))
_put("director_pin", profile.get("pin"))
# BUSINESS / TAX INFO
_put("legal_form_code", profile.get("legalFormCode"))
tax_rate = profile.get("taxRate")
if tax_rate:
if tax_rate in ("ƏDV", "VAT", "18%"):
_put("taxation_system", "18% Value-added tax")
elif tax_rate in ("0%", "ƏDV 0%"):
_put("taxation_system", "0% Value-added tax")
else:
_put("taxation_system", tax_rate)
criteria = profile.get("criteriaOfBusinessEntity")
if criteria:
c = str(criteria).lower()
mapping = {"micro": "Micro Entrepreneur", "small": "Small Entrepreneur",
"middle": "Medium Entrepreneur", "medium": "Medium Entrepreneur",
"big": "Big Entrepreneur", "large": "Big Entrepreneur"}
_put("business_classification", mapping.get(c, f"{criteria} Entrepreneur"))
_put("state_registration_authority", profile.get("stateRegistrationName"))
_put("taxpayer_activity_group", profile.get("activityGroup"))
if profile.get("specialTaxRegime") is not None:
_put("special_tax_regime", bool(profile.get("specialTaxRegime")))
_put("tin_type", profile.get("tinType"))
if profile.get("isRisky") is not None:
_put("is_risky_taxpayer", bool(profile.get("isRisky")))
if profile.get("sportBettingOperator") is not None:
_put("is_sport_betting_operator", bool(profile.get("sportBettingOperator")))
if profile.get("hasActiveProductionObject") is not None:
_put("has_active_production_object", bool(profile.get("hasActiveProductionObject")))
_put("tax_system_type", profile.get("taxSystemType"))
if profile.get("isTaxPayerInCancellationProcess") is not None:
_put("is_taxpayer_in_cancellation_process", bool(profile.get("isTaxPayerInCancellationProcess")))
if profile.get("isChiefOfAnyLegalEntity") is not None:
_put("is_chief_of_any_legal_entity", bool(profile.get("isChiefOfAnyLegalEntity")))
# VAT INFO (sub-object)
vat_info = profile.get("vatInfo")
if isinstance(vat_info, dict):
_put("vat_registration_date", _fmt_compact_date(vat_info.get("prOperationTableOperationDate")))
_put("vat_certificate_number", vat_info.get("recDocumentDocumentNumber"))
_put("vat_certificate_date", _fmt_compact_date(vat_info.get("prVatOperationsRegDate")))
# ORG STRUCTURE
head_org = profile.get("headOrganizationName")
if isinstance(head_org, list) and head_org:
_put("parent_organization", ", ".join(str(x) for x in head_org if x))
_put("parent_organization_tin", profile.get("headOrganizationTin"))
if isinstance(profile.get("employeeData"), dict):
_put("employee_count", profile["employeeData"].get("employeeCount"))
# REGISTRATION DATES
_put("suspension_start_date", _fmt_iso_date(profile.get("suspensionStartDate")))
_put("suspension_end_date", _fmt_iso_date(profile.get("suspensionEndDate")))
_put("liquidation_date", _fmt_iso_date(profile.get("liquidationDate")))
# ADDRESSES
_put("legal_address_full", profile.get("legalAddress"))
_put("actual_address_full", profile.get("actualAddress"))
_put("address_for_mail", profile.get("addressForMail"))
addr = profile.get("legalAddressObject")
if isinstance(addr, dict):
_put("legal_address_postcode", addr.get("postcode"))
_put("legal_address_region", addr.get("region"))
_put("legal_address_locality", addr.get("locality"))
_put("legal_address_street", addr.get("street"))
_put("legal_address_house", addr.get("houseNumber"))
_put("legal_address_room", addr.get("roomNumber"))
# TAX SYSTEMS
tax_systems = profile.get("taxSystems")
if isinstance(tax_systems, list) and tax_systems:
_put("tax_systems_list", ", ".join(str(x) for x in tax_systems if x))
# EMPLOYER
emp = profile.get("employer")
if isinstance(emp, dict):
_put("employer_name", emp.get("name"))
_put("employer_position", emp.get("position"))
# Resolved dictionary names (set by fetch_company_profile)
if resolved.get("tax_authority"):
_put("tax_authority", resolved["tax_authority"])
if resolved.get("property_type"):
_put("property_type", resolved["property_type"])
return updates
def _extract_phone(profile, preferred=None):
"""Mirror of ETaxesCompany._extractPhoneNumber from taxes_az/client/company.js
— picks the best phone from `contacts` when the top-level fields are blank.
"""
if preferred == "mobile" and profile.get("mobilePhoneNumber"):
return profile["mobilePhoneNumber"]
if preferred == "landline" and profile.get("landlinePhoneNumber"):
return profile["landlinePhoneNumber"]
if profile.get("mobilePhoneNumber"):
return profile["mobilePhoneNumber"]
if profile.get("landlinePhoneNumber"):
return profile["landlinePhoneNumber"]
contacts = profile.get("contacts")
if isinstance(contacts, list):
for wanted in ("mobile", "homePhone"):
for c in contacts:
if isinstance(c, dict) and c.get("type") == wanted and c.get("contact"):
prefix = c.get("prefix") or ""
return f"{prefix}{c['contact']}"
return None
def _materialize_amas_employees(company_name):
"""If the user loaded ƏMAS employees during the wizard, enqueue bulk import
now that Company exists. Skipped silently when the cache is empty (i.e.
user skipped the AMAS step).
invoice_az's import_bulk_employees runs in two phases inside its background
job: a parallel ThreadPoolExecutor fetches per-employee detail (CSRF +
7 ƏMAS calls per row), then a sequential loop does the DB writes with the
prefetched payloads. For us, that's still fire-and-forget — the user lands
on /app and the Employee list shows progress via realtime
`amas_import_progress` / `amas_import_complete` events.
The bulk endpoint also takes a per-Asan-Login lock
(Asan Login.amas_import_running) before enqueueing. If that lock is set
(e.g. a stuck previous run), it returns {success: False, already_running:
True}. We capture and trace that response so we can spot it from the
Error Log alone — at this point in the wizard a stale lock would be
surprising on a freshly-dropped site.
"""
cache = frappe.get_single("Jey Wizard Etaxes Cache")
raw = (cache.amas_selected_employees_json or "").strip()
if not raw:
frappe.log_error(
f"company={company_name} reason=amas_cache_empty (user skipped AMAS step or fetch never ran)",
"Jey Wizard trace: amas skipped",
)
return
try:
payload = json.loads(raw)
except Exception as exc:
frappe.log_error(
f"materialize_after_setup: bad amas cache JSON: {exc}\n{traceback.format_exc()}",
"Jey Wizard materialize",
)
return
employees = payload.get("employees") or []
if not employees:
frappe.log_error(
f"company={company_name} reason=amas_payload_empty employees=0",
"Jey Wizard trace: amas skipped",
)
return
create_designation = payload.get("create_designation", 0)
asan_login = frappe.db.get_value("Asan Login", {"is_default": 1}, "name")
if not asan_login:
frappe.log_error(
"materialize_after_setup: no default Asan Login — cannot import employees",
"Jey Wizard materialize",
)
return
# Pre-seed standard Gender / Employment Type rows that the AMAS-imported
# Employees Link to. Frappe normally ships these via fixtures, but a
# fresh wizard install may finalize before HRMS' install fixtures land,
# in which case the bulk import worker errors out on the very first
# `emp.insert()` with LinkValidationError("Could not find Gender: Male")
# and never creates anything.
_seed_amas_employee_dependencies()
try:
from invoice_az import amas_api as _amas_api
from invoice_az.amas_api import import_bulk_employees
# Diagnostic flag: BULK_IMPORT_PARALLELISM only exists in the post-
# refactor version that runs Phase A (ThreadPool fetch) + Phase B
# (sequential DB writes). If this is False on the test machine, the
# pulled / cached amas_api.py is still the old sequential version
# and the wizard's import will be slow even though invoice_az's
# in-app "Load from ƏMAS" appears fast (different module instance,
# different process state).
parallel_module_available = hasattr(_amas_api, "BULK_IMPORT_PARALLELISM")
bulk_parallelism = getattr(_amas_api, "BULK_IMPORT_PARALLELISM", None)
result = import_bulk_employees(
asan_login_name=asan_login,
employees_data=json.dumps(employees, ensure_ascii=False),
company=company_name,
create_designation=create_designation,
)
# New (post-parallel-import refactor) response shape:
# {success: True, enqueued: True, total: N}
# {success: False, already_running: True, message: "..."}
# Old call signature is unchanged, only the return value matters.
# If the lock is stuck on a fresh site, force-clear and retry once.
if isinstance(result, dict) and result.get("already_running"):
try:
frappe.db.set_value(
"Asan Login", asan_login,
{"amas_import_running": 0, "amas_import_cancel_requested": 0},
update_modified=False,
)
frappe.db.commit()
result = import_bulk_employees(
asan_login_name=asan_login,
employees_data=json.dumps(employees, ensure_ascii=False),
company=company_name,
create_designation=create_designation,
)
except Exception as exc:
frappe.log_error(
f"amas import lock force-clear failed for {asan_login}: {exc}",
"Jey Wizard materialize",
)
# Single fat diagnostic — everything we'd otherwise have to chase
# across RQ Job, Error Log, Asan Login and the wizard cache. Lives
# under the dedicated "Jey Wizard trace: amas dispatch" method so
# the user can just grep that one row and hand it back.
_log_amas_dispatch(
company_name=company_name,
asan_login=asan_login,
employees=employees,
create_designation=create_designation,
parallel_module_available=parallel_module_available,
bulk_parallelism=bulk_parallelism,
result=result,
)
# Follow-up tracer — independent background job in the "long" queue
# that waits for the bulk-import worker to settle, then writes a
# second Error Log entry with the final state (RQ status, employee
# count in DB, recent AMAS errors, post-run lock state). Together
# with "amas dispatch" this gives a single grep target — the user
# never has to open the console for amas diagnostics again.
try:
frappe.enqueue(
"jey_wizard.etaxes._log_amas_outcome",
asan_login=asan_login,
company=company_name,
expected_count=len(employees) if isinstance(employees, list) else 0,
queue="long",
timeout=180,
)
except Exception as exc:
frappe.log_error(
f"failed to enqueue amas outcome tracer: {exc}",
"Jey Wizard trace: amas dispatch",
)
except Exception as exc:
frappe.log_error(
f"materialize_after_setup: amas import enqueue failed: {exc}\n{traceback.format_exc()}",
"Jey Wizard materialize",
)
# ---------- internals ----------
def _fetch_endpoint(method, url, payload, paginated):
if not paginated:
return _http_call(method, url, json_body=payload)
# Paginated: collect raw page responses in a list. Subsequent readers can iterate
# pages themselves; invoice_az will re-fetch anyway at finalize.
pages = []
offset = 0
for _ in range(MAX_PAGES):
page_payload = dict(payload)
page_payload["offset"] = offset
resp = _http_call(method, url, json_body=page_payload)
pages.append(resp)
if not (isinstance(resp, dict) and resp.get("hasMore")):
break
offset += PAGE_SIZE
return pages
def _http_call(method, url, json_body=None):
"""HTTP with invoice_az-compatible 401-retry-once: pulls fresh main_token on 401."""
headers = _auth_headers()
response = requests.request(method, url, headers=headers, json=json_body, timeout=60)
if response.status_code == 401:
fresh_headers = _auth_headers()
if fresh_headers.get("x-authorization") != headers.get("x-authorization"):
response = requests.request(
method, url, headers=fresh_headers, json=json_body, timeout=60
)
response.raise_for_status()
return response.json() or {}
def _auth_headers():
login = get_default_asan_login()
if not login.get("main_token"):
raise frappe.AuthenticationError("No main_token on default Asan Login")
headers = DEFAULT_HEADERS.copy()
headers["x-authorization"] = f"Bearer {login['main_token']}"
return headers
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 _purge_inactive_etaxes_records(company):
"""Remove inactive rows from the E-Taxes doctypes that invoice_az's
loaders just populated. Active-status semantics per doctype:
- E-Taxes Bank Account: status='C' means closed (matches the same
filter we already apply in _materialize_native_banks).
- E-Taxes Object: object_status='A' or 'active' means active (mirrors
invoice_az.send_sales_api which filters status=='A' before send).
- E-Taxes Cash Register: status_code='A' means active.
- E-Taxes POS Terminal: status='A' means active.
Presented Certificates carry historical state (cert_state) but are
primarily an audit record — we leave them alone.
Only deletes rows belonging to the freshly-created Company so prior
data on the site stays untouched if anyone re-runs the materialize.
"""
report = {}
def _delete_where(doctype, filters, label):
try:
rows = frappe.get_all(doctype, filters=filters, pluck="name")
except Exception as exc:
frappe.log_error(
f"_purge_inactive_etaxes_records: list {doctype}: {exc}",
"Jey Wizard purge inactive",
)
report[label] = {"error": str(exc)[:200]}
return
deleted = 0
for name in rows:
try:
frappe.delete_doc(doctype, name, ignore_permissions=True, delete_permanently=True)
deleted += 1
except Exception as exc:
frappe.log_error(
f"_purge_inactive_etaxes_records: delete {doctype}/{name}: {exc}",
"Jey Wizard purge inactive",
)
report[label] = {"deleted": deleted, "matched": len(rows)}
# Closed bank accounts (status="C")
_delete_where(
"E-Taxes Bank Account",
{"company": company, "status": "C"},
"bank_accounts_closed",
)
# Inactive objects (anything that's NOT 'A'/'active')
_delete_where(
"E-Taxes Object",
{"company": company, "object_status": ("not in", ("A", "active", "Active"))},
"objects_inactive",
)
# Inactive cash registers (status_code anything but 'A')
_delete_where(
"E-Taxes Cash Register",
{"company": company, "status_code": ("not in", ("A", "active", "Active"))},
"cash_registers_inactive",
)
# Inactive POS terminals (status anything but 'A')
_delete_where(
"E-Taxes POS Terminal",
{"company": company, "status": ("not in", ("A", "active", "Active"))},
"pos_terminals_inactive",
)
frappe.db.commit()
frappe.log_error(
json.dumps(report, ensure_ascii=False, indent=2),
"Jey Wizard trace: inactive purge done",
)
def _seed_amas_employee_dependencies():
"""Pre-create global Link targets the AMAS-imported Employee rows expect.
Anything Frappe / HRMS would normally ship via fixtures but might not
have loaded yet on a freshly-finalized site. Idempotent.
"""
# Standard Gender values used by AMAS payloads + Frappe's own fixtures.
for g in ("Male", "Female", "Other", "Prefer not to say", "Non-Conforming", "Transgender"):
if not frappe.db.exists("Gender", g):
try:
frappe.get_doc({"doctype": "Gender", "gender": g}).insert(ignore_permissions=True)
except Exception as exc:
frappe.log_error(
f"seed Gender '{g}' failed: {exc}",
"Jey Wizard seed amas deps",
)
# Employment Type — Employee.employment_type is a Link; AMAS feeds in
# common ones. HRMS normally creates these as fixtures.
for et in ("Full-time", "Part-time", "Probation", "Contract", "Commission", "Piecework", "Intern", "Apprentice"):
if not frappe.db.exists("Employment Type", et):
try:
frappe.get_doc({"doctype": "Employment Type", "employee_type_name": et}).insert(
ignore_permissions=True
)
except Exception as exc:
frappe.log_error(
f"seed Employment Type '{et}' failed: {exc}",
"Jey Wizard seed amas deps",
)
frappe.db.commit()
def _log_amas_dispatch(
company_name,
asan_login,
employees,
create_designation,
parallel_module_available,
bulk_parallelism,
result,
):
"""Single Error Log entry covering everything the user might need to
diagnose 'wizard says employees will import in background, but I see 0
employees afterwards'. Dumps:
- what we asked import_bulk_employees to do, and what it returned
- whether the worker module exposes the parallel-mode constant
- Asan Login state (auth, AMAS account selection, import lock,
presence of session/csrf — values themselves are sensitive so we
only log lengths)
- shape of the cached selection (count, first-row keys, the set of
contract_status values present)
- the most recent RQ Job rows for the bulk import worker.
Failures are swallowed — diagnostic must never break the actual hook.
"""
try:
blob = {
"company": company_name,
"asan_login": asan_login,
"create_designation": create_designation,
"employees_passed_to_import": len(employees) if isinstance(employees, list) else None,
"parallel_module_available": bool(parallel_module_available),
"bulk_parallelism": bulk_parallelism,
"import_bulk_employees_result": result if isinstance(result, dict) else {"raw": str(result)},
}
try:
al = frappe.db.get_value(
"Asan Login", asan_login,
[
"auth_status", "amas_auth_status",
"amas_account_oid", "amas_account_name", "amas_account_number",
"amas_import_running", "amas_import_cancel_requested",
"amas_session", "amas_csrf_token", "main_token", "mygovid_token",
"amas_last_activity",
],
as_dict=True,
) or {}
blob["asan_login_state"] = {
"auth_status": al.get("auth_status"),
"amas_auth_status": al.get("amas_auth_status"),
"amas_account_oid": al.get("amas_account_oid"),
"amas_account_name": al.get("amas_account_name"),
"amas_account_number": al.get("amas_account_number"),
"amas_import_running": bool(al.get("amas_import_running")),
"amas_import_cancel_requested": bool(al.get("amas_import_cancel_requested")),
"amas_session_len": len(al.get("amas_session") or ""),
"amas_csrf_token_len": len(al.get("amas_csrf_token") or ""),
"main_token_present": bool(al.get("main_token")),
"mygovid_token_present": bool(al.get("mygovid_token")),
"amas_last_activity": str(al.get("amas_last_activity") or ""),
}
except Exception as exc:
blob["asan_login_state_error"] = str(exc)[:200]
try:
cache = frappe.get_single("Jey Wizard Etaxes Cache")
raw = (cache.amas_selected_employees_json or "").strip()
cache_info = {"raw_len": len(raw)}
if raw:
payload = json.loads(raw)
emps = payload.get("employees") or []
cache_info["employees_in_cache"] = len(emps)
cache_info["create_designation"] = payload.get("create_designation")
if emps:
sample = emps[0] if isinstance(emps[0], dict) else {}
cache_info["first_row_keys"] = sorted(list(sample.keys()))
cache_info["first_row_doc_oid_present"] = bool(sample.get("doc_oid"))
cache_info["first_row_full_name_sample"] = sample.get("full_name", "")[:40]
statuses = {}
for e in emps:
if not isinstance(e, dict):
continue
s = str(e.get("contract_status") or "").strip().lower() or "<empty>"
statuses[s] = statuses.get(s, 0) + 1
cache_info["contract_status_distribution"] = statuses
blob["wizard_cache"] = cache_info
except Exception as exc:
blob["wizard_cache_error"] = str(exc)[:200]
try:
rq_rows = frappe.get_all(
"RQ Job",
filters={"job_name": ("like", "%bulk_employees%")},
fields=["job_id", "status", "time_taken", "exc_info"],
order_by="creation desc",
limit_page_length=3,
)
# Truncate exc_info per row so the JSON stays readable.
for r in rq_rows:
if r.get("exc_info"):
r["exc_info"] = str(r["exc_info"])[:500]
blob["recent_rq_jobs"] = rq_rows
except Exception as exc:
blob["recent_rq_jobs_error"] = str(exc)[:200]
frappe.log_error(
json.dumps(blob, ensure_ascii=False, indent=2, default=str),
"Jey Wizard trace: amas dispatch",
)
except Exception as exc:
# Last-ditch fallback so we always leave SOME breadcrumb.
try:
frappe.log_error(
f"_log_amas_dispatch failed: {exc}",
"Jey Wizard trace: amas dispatch",
)
except Exception:
pass
def _log_amas_outcome(asan_login, company, expected_count):
"""Background follow-up: poll RQ for the bulk-import job to settle,
then write a single Error Log entry with everything needed to know
whether the import actually delivered employees:
- rq job final status + truncated exc_info
- employees in DB for this company (the question the user actually
asks: "I see 0 employees, where did they go?")
- post-run Asan Login lock state (running flag should be cleared,
cancel flag should be cleared)
- recent ƏMAS-related Error Log rows from the worker (CSRF abort,
session expired, single-employee failures)
Bounded to ~90s of polling so a stuck job doesn't pin the worker.
"""
import time
deadline = time.time() + 90
last_row = None
while time.time() < deadline:
try:
rows = frappe.get_all(
"RQ Job",
filters={"job_name": ("like", "%bulk_employees%")},
fields=["job_id", "status", "time_taken", "exc_info", "creation"],
order_by="creation desc",
limit_page_length=1,
)
except Exception as exc:
last_row = {"_query_error": str(exc)[:200]}
break
if rows:
last_row = rows[0]
status = (last_row.get("status") or "").lower()
if "finish" in status or "fail" in status or "stop" in status or "cancel" in status:
break
time.sleep(2)
blob = {
"company": company,
"asan_login": asan_login,
"expected_employees": expected_count,
}
if isinstance(last_row, dict):
row_copy = dict(last_row)
if row_copy.get("exc_info"):
row_copy["exc_info"] = str(row_copy["exc_info"])[:1000]
row_copy["creation"] = str(row_copy.get("creation") or "")
blob["rq_job_final"] = row_copy
else:
blob["rq_job_final"] = None
try:
blob["employees_in_db_for_company"] = frappe.db.count("Employee", {"company": company})
except Exception as exc:
blob["employees_in_db_for_company_error"] = str(exc)[:200]
try:
blob["amas_employees_in_db"] = frappe.db.count("Amas Employees")
except Exception as exc:
blob["amas_employees_in_db_error"] = str(exc)[:200]
try:
al = frappe.db.get_value(
"Asan Login", asan_login,
[
"amas_auth_status", "amas_import_running", "amas_import_cancel_requested",
"amas_session", "amas_csrf_token",
],
as_dict=True,
) or {}
blob["asan_login_state_after"] = {
"amas_auth_status": al.get("amas_auth_status"),
"amas_import_running": bool(al.get("amas_import_running")),
"amas_import_cancel_requested": bool(al.get("amas_import_cancel_requested")),
"amas_session_len": len(al.get("amas_session") or ""),
"amas_csrf_token_len": len(al.get("amas_csrf_token") or ""),
}
except Exception as exc:
blob["asan_login_state_after_error"] = str(exc)[:200]
try:
recent = frappe.db.sql(
"""
SELECT method, LEFT(error, 1500) AS err, creation
FROM `tabError Log`
WHERE creation > NOW() - INTERVAL 10 MINUTE
AND (
method LIKE '%MAS%'
OR method LIKE '%mas%'
OR error LIKE '%CSRF%'
OR error LIKE '%aborted%'
OR error LIKE '%employee%'
OR error LIKE '%session expired%'
)
AND method NOT LIKE 'Jey Wizard trace:%'
ORDER BY creation DESC
LIMIT 12
""",
as_dict=True,
)
for r in recent:
r["creation"] = str(r.get("creation") or "")
blob["recent_amas_related_errors"] = recent
except Exception as exc:
blob["recent_amas_related_errors_error"] = str(exc)[:200]
frappe.log_error(
json.dumps(blob, ensure_ascii=False, indent=2, default=str),
"Jey Wizard trace: amas outcome",
)
def _drop_alert_messages():
"""Wipe everything in frappe.local.message_log that piled up during the
setup_complete request. We need the entire log gone, not just alerts:
besides the az_locale rename toasts (alert=True), HRMS Salary Component
warnings ("Hesablar təyin edilməyib") and Frappe's own user-creation
notes ("Employee Self Service rolu silindi") come through plain
msgprints — and they're noise for an automated setup. Genuine failures
would have raised an exception and been handled separately, so blanket
clearing is safe here.
"""
try:
frappe.clear_messages()
except Exception:
try:
frappe.local.message_log = []
except Exception:
pass
def _only_admin():
if frappe.session.user != "Administrator":
frappe.throw(_("Only Administrator may run setup"), frappe.PermissionError)
def _safe_count(doctype, company):
"""Count rows in `doctype` filtered by company. Returns None on any failure
(missing doctype, missing company field, db error) — used purely for trace
breadcrumbs, must never throw.
"""
try:
if not frappe.db.exists("DocType", doctype):
return None
return frappe.db.count(doctype, {"company": company})
except Exception:
try:
return frappe.db.count(doctype)
except Exception:
return None
def _log_materialize_entry(args):
"""Dump everything materialize_after_setup decides on at entry: input args,
Company existence, default Asan Login auth state, cache freshness/sizes.
"""
company_name = (args or {}).get("company_name")
asan = frappe.db.get_value(
"Asan Login",
{"is_default": 1},
["name", "auth_status", "main_token", "bearer_token"],
as_dict=True,
)
cache_present = frappe.db.exists("DocType", "Jey Wizard Etaxes Cache")
cache_info = {}
if cache_present:
try:
c = frappe.get_single("Jey Wizard Etaxes Cache")
cache_info = {
"fetched_at": str(c.fetched_at) if c.fetched_at else None,
"last_summary": c.last_summary,
"last_error": c.last_error,
"objects_json_len": len(c.objects_json or ""),
"cash_registers_json_len": len(c.cash_registers_json or ""),
"pos_terminals_json_len": len(c.pos_terminals_json or ""),
"bank_accounts_json_len": len(c.bank_accounts_json or ""),
"presented_certs_json_len": len(c.presented_certs_json or ""),
"amas_selected_employees_json_len": len(c.amas_selected_employees_json or ""),
}
except Exception as exc:
cache_info = {"_load_error": str(exc)[:200]}
trace = {
"args_keys": sorted(list((args or {}).keys())),
"company_name": company_name,
"company_exists": bool(company_name and frappe.db.exists("Company", company_name)),
"valuation_method": (args or {}).get("valuation_method"),
"accounting_method": (args or {}).get("accounting_method"),
"chart_of_accounts": (args or {}).get("chart_of_accounts"),
"country": (args or {}).get("country"),
"currency": (args or {}).get("currency"),
"asan_default_login": asan.name if asan else None,
"asan_auth_status": asan.auth_status if asan else None,
"asan_has_main_token": bool(asan and asan.main_token),
"asan_has_bearer_token": bool(asan and asan.bearer_token),
"cache_doctype_exists": bool(cache_present),
"cache": cache_info,
}
frappe.log_error(
json.dumps(trace, ensure_ascii=False, indent=2),
"Jey Wizard trace: materialize start",
)