1196 lines
41 KiB
Python
1196 lines
41 KiB
Python
"""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",
|
||
)
|