Fetch e-taxes data after Asan auth, cache for wizard, materialize on finalize

Two-phase data loading so future wizard steps can consume tax-portal data before
Company exists, while still ending up with properly-linked E-Taxes DocType records.

Phase A (Asan step, after main_token):
- jey_wizard.etaxes.fetch_all_etaxes hits 6 endpoints (objects, cash registers,
  POS terminals, bank accounts, obligation pacts, presented certs), using
  invoice_az's Asan Login for the main_token. Paginated endpoints follow hasMore.
- Raw JSON per endpoint is stashed in the new single DocType
  "Jey Wizard Etaxes Cache". Per-endpoint errors are logged but don't abort.
- jey_wizard.etaxes.get_cached(kind) is the read API for future wizard steps.
- UI: new "fetching" sub-state between cert pick and done. "done" now shows a
  count summary of what was cached (or per-row error badge if a kind failed).

Phase B (setup_wizard_complete hook, after Company is created):
- jey_wizard.etaxes.materialize_after_setup receives setup args, reads
  args.company_name, and calls invoice_az.company_api.load_company_* for each
  of the six kinds. Those re-fetch and write into E-Taxes Object /
  E-Taxes Cash Register / etc with the real Company link. Each loader is
  best-effort; failures are logged, not raised.

Why re-fetch instead of materialising from the cache:
- invoice_az's loaders already own the ~500-line field mapping. Re-fetching
  once with a still-fresh main_token beats duplicating that code.

Bump to 0.0.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-04-22 15:36:02 +00:00
parent 21981a1032
commit 2b9fba7dd7
8 changed files with 456 additions and 5 deletions

View File

@ -1 +1 @@
__version__ = "0.0.5" __version__ = "0.0.6"

261
jey_wizard/etaxes.py Normal file
View File

@ -0,0 +1,261 @@
"""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()
return {
"ok": True,
"summary": summary,
"errors": errors,
}
@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.
"""
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
from invoice_az import company_api
loaders = [
("objects", company_api.load_company_objects),
("cash_registers", company_api.load_company_cash_registers),
("pos_terminals", company_api.load_company_pos_terminals),
("bank_accounts", company_api.load_company_bank_accounts),
("obligation_pacts", company_api.load_company_obligation_pacts),
("presented_certs", company_api.load_company_presented_certificates),
]
for name, loader in loaders:
try:
loader(company_name)
except Exception as exc:
frappe.log_error(
f"materialize_after_setup: {name} loader failed for {company_name}: {exc}\n"
f"{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 _only_admin():
if frappe.session.user != "Administrator":
frappe.throw(_("Only Administrator may run setup"), frappe.PermissionError)

View File

@ -35,6 +35,11 @@ required_apps = ["invoice_az"]
# created — so overriding frappe.setup.SetupWizard in it swaps the UI entirely. # created — so overriding frappe.setup.SetupWizard in it swaps the UI entirely.
setup_wizard_requires = ["/assets/jey_wizard/js/jey_setup.js"] setup_wizard_requires = ["/assets/jey_wizard/js/jey_setup.js"]
# Runs AFTER stock setup_complete stages (so Company exists). We use it to push the
# e-taxes data we already fetched+cached during the Asan step into the real
# E-Taxes Object / Cash Register / ... doctypes, now linked to the new Company.
setup_wizard_complete = "jey_wizard.etaxes.materialize_after_setup"
# include js, css files in header of web template # include js, css files in header of web template
# web_include_css = "/assets/jey_wizard/css/jey_wizard.css" # web_include_css = "/assets/jey_wizard/css/jey_wizard.css"
# web_include_js = "/assets/jey_wizard/js/jey_wizard.js" # web_include_js = "/assets/jey_wizard/js/jey_wizard.js"

View File

@ -0,0 +1,119 @@
{
"actions": [],
"allow_rename": 0,
"creation": "2026-04-22 19:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"status_section",
"fetched_at",
"last_summary",
"last_error",
"data_section",
"objects_json",
"cash_registers_json",
"pos_terminals_json",
"bank_accounts_json",
"obligation_pacts_json",
"presented_certs_json"
],
"fields": [
{
"fieldname": "status_section",
"fieldtype": "Section Break",
"label": "Status"
},
{
"fieldname": "fetched_at",
"fieldtype": "Datetime",
"label": "Last Fetched",
"read_only": 1
},
{
"fieldname": "last_summary",
"fieldtype": "Code",
"label": "Last Summary",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "last_error",
"fieldtype": "Small Text",
"label": "Last Error",
"read_only": 1
},
{
"fieldname": "data_section",
"fieldtype": "Section Break",
"label": "Cached Data"
},
{
"fieldname": "objects_json",
"fieldtype": "Code",
"label": "Objects",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "cash_registers_json",
"fieldtype": "Code",
"label": "Cash Registers",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "pos_terminals_json",
"fieldtype": "Code",
"label": "POS Terminals",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "bank_accounts_json",
"fieldtype": "Code",
"label": "Bank Accounts",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "obligation_pacts_json",
"fieldtype": "Code",
"label": "Obligation Pacts",
"options": "JSON",
"read_only": 1
},
{
"fieldname": "presented_certs_json",
"fieldtype": "Code",
"label": "Presented Certificates",
"options": "JSON",
"read_only": 1
}
],
"index_web_pages_for_search": 0,
"issingle": 1,
"links": [],
"modified": "2026-04-22 19:00:00.000000",
"modified_by": "Administrator",
"module": "Jey Wizard",
"name": "Jey Wizard Etaxes Cache",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 0,
"email": 0,
"export": 0,
"print": 0,
"read": 1,
"report": 0,
"role": "System Manager",
"share": 0,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,5 @@
from frappe.model.document import Document
class JeyWizardEtaxesCache(Document):
pass

View File

@ -10,7 +10,7 @@ frappe.provide("jey_wizard");
// Bump this string in every commit that changes wizard code. Displayed in the badge so // Bump this string in every commit that changes wizard code. Displayed in the badge so
// we can tell at a glance which version is actually running on a given machine. Kept in // we can tell at a glance which version is actually running on a given machine. Kept in
// sync with __version__ in jey_wizard/__init__.py. // sync with __version__ in jey_wizard/__init__.py.
const JEY_WIZARD_VERSION = "0.0.5"; const JEY_WIZARD_VERSION = "0.0.6";
// Wipe Frappe + ERPNext default slides so their `before_load`/`after_load` listeners // Wipe Frappe + ERPNext default slides so their `before_load`/`after_load` listeners
// don't try to mutate a wizard that isn't slide-based anymore. // don't try to mutate a wizard that isn't slide-based anymore.
@ -172,15 +172,51 @@ frappe.setup.SetupWizard = class JeySetupWizard {
const idx = parseInt($(e.currentTarget).attr("data-idx"), 10); const idx = parseInt($(e.currentTarget).attr("data-idx"), 10);
this.pick_certificate(idx); this.pick_certificate(idx);
}); });
} else if (this.asan_state === "done") { } else if (this.asan_state === "fetching") {
$body.html(` $body.html(`
<h3 style="margin-bottom:16px;color:#080"> Authenticated</h3> <h3 style="margin-bottom:16px">Loading data from tax portal...</h3>
<p style="color:#666">Pulling objects, cash registers, POS terminals, bank accounts, obligation pacts and presented certificates. This usually takes 10-30 seconds.</p>
<div style="display:flex;align-items:center;gap:12px;margin-top:20px">
<div class="jey-spinner" style="width:18px;height:18px;border:3px solid #ddd;border-top-color:#444;border-radius:50%;animation:jey-spin 0.8s linear infinite"></div>
<div style="color:#666">Fetching...</div>
</div>
<style>@keyframes jey-spin { to { transform: rotate(360deg) } }</style>
`);
} else if (this.asan_state === "done") {
const summary = this.asan_fetch_summary || {};
const errors = this.asan_fetch_errors || {};
const labels = {
objects_json: "Objects",
cash_registers_json: "Cash registers",
pos_terminals_json: "POS terminals",
bank_accounts_json: "Bank accounts",
obligation_pacts_json: "Obligation pacts",
presented_certs_json: "Presented certificates",
};
const rows = Object.keys(labels).map((k) => {
const label = labels[k];
if (errors[k]) {
return `<li><b>${label}:</b> <span style="color:#c00">failed</span> <span style="color:#888;font-size:12px">(${frappe.utils.escape_html(errors[k])})</span></li>`;
}
const n = summary[k];
if (n === undefined) {
return `<li><b>${label}:</b> <span style="color:#888">—</span></li>`;
}
return `<li><b>${label}:</b> ${n}</li>`;
}).join("");
$body.html(`
<h3 style="margin-bottom:16px;color:#080"> Authenticated &amp; data loaded</h3>
<p>Pulled from the tax portal:</p> <p>Pulled from the tax portal:</p>
<ul style="line-height:1.8"> <ul style="line-height:1.8">
<li><b>Company:</b> ${frappe.utils.escape_html(this.data.company_name)}</li> <li><b>Company:</b> ${frappe.utils.escape_html(this.data.company_name)}</li>
<li><b>VÖEN:</b> ${frappe.utils.escape_html(this.data.asan_voen)}</li> <li><b>VÖEN:</b> ${frappe.utils.escape_html(this.data.asan_voen)}</li>
</ul> </ul>
<p style="color:#666;margin-top:12px">Click Next to continue you'll be able to edit the company name on the next step.</p> <div style="margin-top:16px;padding:12px;background:#f7f7f7;border-radius:6px">
<div style="font-weight:600;margin-bottom:6px">Cached datasets</div>
<ul style="line-height:1.6;margin:0;padding-left:20px">${rows}</ul>
</div>
<p style="color:#666;margin-top:12px">Everything above is now available to later wizard steps. After finalization, the data will be persisted into the E-Taxes DocTypes linked to your new Company.</p>
`); `);
} }
} }
@ -463,6 +499,31 @@ frappe.setup.SetupWizard = class JeySetupWizard {
const m = r.message || {}; const m = r.message || {};
this.data.company_name = m.company_name || this.data.company_name; this.data.company_name = m.company_name || this.data.company_name;
this.data.asan_voen = m.voen || ""; this.data.asan_voen = m.voen || "";
// Move UI into the "fetching" sub-state so the user sees progress while
// we pull all six e-taxes lists into the cache doctype.
this.asan_state = "fetching";
this.show_current_step();
this.fetch_etaxes_data();
},
});
}
fetch_etaxes_data() {
frappe.call({
method: "jey_wizard.etaxes.fetch_all_etaxes",
// Deliberately no freeze_message — UI already shows a spinner.
callback: (r) => {
const m = r.message || {};
this.asan_fetch_summary = m.summary || {};
this.asan_fetch_errors = m.errors || {};
this.asan_state = "done";
this.show_current_step();
},
error: () => {
// The fetch is best-effort; we still proceed to done and let the user
// continue. The setup_wizard_complete hook will re-fetch + persist later.
this.asan_fetch_summary = {};
this.asan_fetch_errors = { all: "fetch call failed — see server logs" };
this.asan_state = "done"; this.asan_state = "done";
this.show_current_step(); this.show_current_step();
}, },