jey_wizard/jey_wizard/etaxes.py

1196 lines
41 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,
),
(
"obligation_pacts_json",
"GET",
f"{BASE}/api/po/edi-legal/public/v1/application/sub-contractor/obl-pact-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",
"obligation_pacts": "obligation_pacts_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))
summary[field] = _count_items(data, paginated)
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_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"),
("obligation_pacts", company_api.load_company_obligation_pacts, "E-Taxes Obligation Pact"),
("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 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)."""
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
try:
from invoice_az.amas_api import import_bulk_employees
import_bulk_employees(
asan_login_name=asan_login,
employees_data=json.dumps(employees, ensure_ascii=False),
company=company_name,
create_designation=create_designation,
)
frappe.log_error(
f"company={company_name} asan_login={asan_login} employees={len(employees)} create_designation={create_designation}",
"Jey Wizard trace: amas enqueued",
)
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 _count_items(data, paginated):
"""Lightweight count for UI summary. Digs one level deep to find the list."""
if paginated and isinstance(data, list):
total = 0
for page in data:
if not isinstance(page, dict):
continue
for value in page.values():
if isinstance(value, list):
total += len(value)
break
return total
if isinstance(data, dict):
for value in data.values():
if isinstance(value, list):
return len(value)
if isinstance(data, list):
return len(data)
return 0
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 _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 ""),
"obligation_pacts_json_len": len(c.obligation_pacts_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",
)