diff --git a/jey_wizard/__init__.py b/jey_wizard/__init__.py index b1a19e3..034f46c 100644 --- a/jey_wizard/__init__.py +++ b/jey_wizard/__init__.py @@ -1 +1 @@ -__version__ = "0.0.5" +__version__ = "0.0.6" diff --git a/jey_wizard/etaxes.py b/jey_wizard/etaxes.py new file mode 100644 index 0000000..d6a5ba6 --- /dev/null +++ b/jey_wizard/etaxes.py @@ -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) diff --git a/jey_wizard/hooks.py b/jey_wizard/hooks.py index 37b9d26..33ec720 100644 --- a/jey_wizard/hooks.py +++ b/jey_wizard/hooks.py @@ -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" diff --git a/jey_wizard/jey_wizard/doctype/__init__.py b/jey_wizard/jey_wizard/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jey_wizard/jey_wizard/doctype/jey_wizard_etaxes_cache/__init__.py b/jey_wizard/jey_wizard/doctype/jey_wizard_etaxes_cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jey_wizard/jey_wizard/doctype/jey_wizard_etaxes_cache/jey_wizard_etaxes_cache.json b/jey_wizard/jey_wizard/doctype/jey_wizard_etaxes_cache/jey_wizard_etaxes_cache.json new file mode 100644 index 0000000..90d0cf5 --- /dev/null +++ b/jey_wizard/jey_wizard/doctype/jey_wizard_etaxes_cache/jey_wizard_etaxes_cache.json @@ -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": [] +} diff --git a/jey_wizard/jey_wizard/doctype/jey_wizard_etaxes_cache/jey_wizard_etaxes_cache.py b/jey_wizard/jey_wizard/doctype/jey_wizard_etaxes_cache/jey_wizard_etaxes_cache.py new file mode 100644 index 0000000..316c8db --- /dev/null +++ b/jey_wizard/jey_wizard/doctype/jey_wizard_etaxes_cache/jey_wizard_etaxes_cache.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class JeyWizardEtaxesCache(Document): + pass diff --git a/jey_wizard/public/js/jey_setup.js b/jey_wizard/public/js/jey_setup.js index b0cd1a8..6f93467 100644 --- a/jey_wizard/public/js/jey_setup.js +++ b/jey_wizard/public/js/jey_setup.js @@ -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(` -

✓ Authenticated

+

Loading data from tax portal...

+

Pulling objects, cash registers, POS terminals, bank accounts, obligation pacts and presented certificates. This usually takes 10-30 seconds.

+
+
+
Fetching...
+
+ + `); + } 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 `
  • ${label}: failed (${frappe.utils.escape_html(errors[k])})
  • `; + } + const n = summary[k]; + if (n === undefined) { + return `
  • ${label}:
  • `; + } + return `
  • ${label}: ${n}
  • `; + }).join(""); + + $body.html(` +

    ✓ Authenticated & data loaded

    Pulled from the tax portal:

    -

    Click Next to continue — you'll be able to edit the company name on the next step.

    +
    +
    Cached datasets
    + +
    +

    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.

    `); } } @@ -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(); },