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:
parent
21981a1032
commit
2b9fba7dd7
|
|
@ -1 +1 @@
|
|||
__version__ = "0.0.5"
|
||||
__version__ = "0.0.6"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -35,6 +35,11 @@ required_apps = ["invoice_az"]
|
|||
# created — so overriding frappe.setup.SetupWizard in it swaps the UI entirely.
|
||||
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
|
||||
# web_include_css = "/assets/jey_wizard/css/jey_wizard.css"
|
||||
# web_include_js = "/assets/jey_wizard/js/jey_wizard.js"
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class JeyWizardEtaxesCache(Document):
|
||||
pass
|
||||
|
|
@ -10,7 +10,7 @@ frappe.provide("jey_wizard");
|
|||
// 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
|
||||
// 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
|
||||
// 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);
|
||||
this.pick_certificate(idx);
|
||||
});
|
||||
} else if (this.asan_state === "done") {
|
||||
} else if (this.asan_state === "fetching") {
|
||||
$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 & data loaded</h3>
|
||||
<p>Pulled from the tax portal:</p>
|
||||
<ul style="line-height:1.8">
|
||||
<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>
|
||||
</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 || {};
|
||||
this.data.company_name = m.company_name || this.data.company_name;
|
||||
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.show_current_step();
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue