diff --git a/jey_wizard/__init__.py b/jey_wizard/__init__.py index 43a0e4e..c8ec146 100644 --- a/jey_wizard/__init__.py +++ b/jey_wizard/__init__.py @@ -1 +1 @@ -__version__ = "0.1.25" +__version__ = "0.1.26" diff --git a/jey_wizard/etaxes.py b/jey_wizard/etaxes.py index 527759b..f2c23fd 100644 --- a/jey_wizard/etaxes.py +++ b/jey_wizard/etaxes.py @@ -219,6 +219,252 @@ def fetch_company_profile(): return {"ok": True, "resolved": resolved, "criteria": criteria} +@frappe.whitelist() +def get_preview_data(): + """Return display-ready preview tables for everything pulled in the Asan + step. Shape per kind: {"headers": [str, ...], "rows": [[cell, ...], ...]}. + Profile uses the same shape (two-column label/value table). + + The wizard's done-screen modal renders this verbatim — no business logic + in the JS. Field names mirror invoice_az.company_api so the preview + matches what eventually lands in the E-Taxes DocTypes. + """ + _only_admin() + cache = frappe.get_single("Jey Wizard Etaxes Cache") + return { + "objects": _preview_objects(cache.objects_json), + "cash_registers": _preview_cash_registers(cache.cash_registers_json), + "pos_terminals": _preview_pos_terminals(cache.pos_terminals_json), + "bank_accounts": _preview_bank_accounts(cache.bank_accounts_json), + "obligation_pacts": _preview_obligation_pacts(cache.obligation_pacts_json), + "presented_certs": _preview_presented_certs(cache.presented_certs_json), + "profile": _preview_profile(cache.company_profile_json), + } + + +PREVIEW_ROW_LIMIT = 200 + + +def _safe_parse(raw): + if not raw: + return None + try: + return json.loads(raw) + except Exception: + return None + + +def _flatten_pages(data, list_key): + """Paginated cache payloads land as a list of page dicts ([{list_key: [...], hasMore: ...}, ...]). + Non-paginated ones land as a single dict {list_key: [...]}. + """ + if isinstance(data, list): + out = [] + for page in data: + if isinstance(page, dict): + out.extend(page.get(list_key) or []) + return out + if isinstance(data, dict): + return data.get(list_key) or [] + return [] + + +def _empty_preview(headers): + return {"headers": headers, "rows": []} + + +def _preview_objects(raw): + headers = [_("Code"), _("Name"), _("Status"), _("Address")] + data = _safe_parse(raw) + if data is None: + return _empty_preview(headers) + items = [] + if isinstance(data, dict): + items = data.get("objectInfoShortList") or data.get("objects") or [] + rows = [] + for obj in items[:PREVIEW_ROW_LIMIT]: + if not isinstance(obj, dict): + continue + rows.append([ + obj.get("objectCode") or "", + (obj.get("objectName") or "").strip(), + obj.get("status") or "", + obj.get("address") or "", + ]) + return {"headers": headers, "rows": rows} + + +def _preview_cash_registers(raw): + headers = [_("Number"), _("Model"), _("Status"), _("Object"), _("Operator")] + data = _safe_parse(raw) + if data is None: + return _empty_preview(headers) + items = _flatten_pages(data, "cashRegisters") + rows = [] + for reg in items[:PREVIEW_ROW_LIMIT]: + if not isinstance(reg, dict): + continue + rows.append([ + reg.get("number") or "", + reg.get("model") or "", + reg.get("status") or "", + reg.get("object") or "", + reg.get("operatorName") or "", + ]) + return {"headers": headers, "rows": rows} + + +def _preview_pos_terminals(raw): + headers = [_("Serial number"), _("Status"), _("Registration date"), _("Bank")] + data = _safe_parse(raw) + if data is None: + return _empty_preview(headers) + items = _flatten_pages(data, "posTerminals") + rows = [] + for term in items[:PREVIEW_ROW_LIMIT]: + if not isinstance(term, dict): + continue + rows.append([ + term.get("serialNumber") or "", + term.get("status") or "", + (term.get("registrationDate") or "")[:10], + term.get("bankName") or "", + ]) + return {"headers": headers, "rows": rows} + + +def _preview_bank_accounts(raw): + headers = [_("IBAN"), _("Currency"), _("Status"), _("Bank")] + data = _safe_parse(raw) + if data is None: + return _empty_preview(headers) + items = [] + if isinstance(data, dict): + items = data.get("bankAccounts") or [] + elif isinstance(data, list): + items = data + rows = [] + for acc in items[:PREVIEW_ROW_LIMIT]: + if not isinstance(acc, dict): + continue + rows.append([ + acc.get("number") or "", + acc.get("currency") or "", + acc.get("status") or "", + acc.get("bankName") or "", + ]) + return {"headers": headers, "rows": rows} + + +def _preview_obligation_pacts(raw): + headers = [_("Code"), _("Name"), _("Valid from"), _("Expires")] + data = _safe_parse(raw) + if data is None: + return _empty_preview(headers) + items = [] + if isinstance(data, dict): + items = data.get("oblPacts") or [] + rows = [] + for item in items[:PREVIEW_ROW_LIMIT]: + if not isinstance(item, dict): + continue + pact = item.get("oblPact") or {} + name_blob = pact.get("name") + # name may be {"az": "..."} or a plain string depending on tenant. + if isinstance(name_blob, dict): + name = name_blob.get("az") or name_blob.get("en") or next(iter(name_blob.values()), "") + else: + name = name_blob or "" + rows.append([ + pact.get("code") or "", + name, + (item.get("validityDate") or "")[:10], + (item.get("expireDate") or "")[:10], + ]) + return {"headers": headers, "rows": rows} + + +def _preview_presented_certs(raw): + headers = [_("Cert №"), _("Category"), _("State"), _("Counterparty"), _("VÖEN"), _("Operation date")] + data = _safe_parse(raw) + if data is None: + return _empty_preview(headers) + items = [] + if isinstance(data, dict): + items = data.get("certificateList") or [] + rows = [] + for cert in items[:PREVIEW_ROW_LIMIT]: + if not isinstance(cert, dict): + continue + op = cert.get("operationDate") or "" + # Compact YYYYMMDDhhmmss → YYYY-MM-DD when applicable. + if isinstance(op, str) and len(op) >= 8 and op[:8].isdigit(): + op = f"{op[0:4]}-{op[4:6]}-{op[6:8]}" + rows.append([ + cert.get("certNo") or "", + cert.get("certCategory") or "", + cert.get("certState") or "", + cert.get("taxPayerFullName") or "", + cert.get("voen") or "", + op, + ]) + return {"headers": headers, "rows": rows} + + +def _preview_profile(raw): + """Two-column label/value table from the cached company profile blob. + Mirrors the most useful subset of _build_company_field_updates so the + user sees what's about to land on Company. + """ + headers = [_("Field"), _("Value")] + data = _safe_parse(raw) + if not isinstance(data, dict): + return _empty_preview(headers) + profile = data.get("profile") if isinstance(data.get("profile"), dict) else data + resolved = data.get("resolved") or {} + if not isinstance(profile, dict): + return _empty_preview(headers) + + def _addr_or(top, legal_obj_keys): + if top: + return top + obj = profile.get("legalAddressObject") or {} + if not isinstance(obj, dict): + return "" + return ", ".join(str(obj.get(k)) for k in legal_obj_keys if obj.get(k)) + + pairs = [ + (_("VÖEN"), profile.get("tin")), + (_("Director"), profile.get("companyDirectorName")), + (_("Director PIN"), profile.get("pin")), + (_("Main activity"), profile.get("mainActivity")), + (_("Business classification"), profile.get("criteriaOfBusinessEntity")), + (_("Tax rate"), profile.get("taxRate")), + (_("Tax authority"), resolved.get("tax_authority") or profile.get("taxAuthorityCode")), + (_("Property type"), resolved.get("property_type") or profile.get("propertyType")), + (_("Registration date"), (profile.get("taxpayerRegistrationDate") or "")[:10]), + (_("Mobile phone"), profile.get("mobilePhoneNumber")), + (_("Landline phone"), profile.get("landlinePhoneNumber")), + (_("Email"), profile.get("email")), + (_("Legal address"), _addr_or(profile.get("legalAddress"), ("region", "locality", "street", "houseNumber"))), + (_("Actual address"), profile.get("actualAddress")), + (_("Employee count"), (profile.get("employeeData") or {}).get("employeeCount") if isinstance(profile.get("employeeData"), dict) else None), + ] + + vat_info = profile.get("vatInfo") if isinstance(profile.get("vatInfo"), dict) else None + if vat_info: + vat_date = vat_info.get("prOperationTableOperationDate") or "" + if isinstance(vat_date, str) and len(vat_date) == 8 and vat_date.isdigit(): + vat_date = f"{vat_date[0:4]}-{vat_date[4:6]}-{vat_date[6:8]}" + pairs.extend([ + (_("VAT certificate №"), vat_info.get("recDocumentDocumentNumber")), + (_("VAT registration date"), vat_date), + ]) + + rows = [[label, str(value)] for label, value in pairs if value not in (None, "", [])] + return {"headers": headers, "rows": rows} + + @frappe.whitelist() def get_cached(kind): """Return parsed cached data for a given kind. Used by future wizard steps that diff --git a/jey_wizard/public/js/jey_setup.js b/jey_wizard/public/js/jey_setup.js index c848cdc..9b41439 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.1.25"; +const JEY_WIZARD_VERSION = "0.1.26"; // 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. @@ -278,23 +278,26 @@ frappe.setup.SetupWizard = class JeySetupWizard { } 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])})
  • `; + // (cache_field, preview kind for get_preview_data) + const datasets = [ + ["objects_json", "objects", __("Objects")], + ["cash_registers_json", "cash_registers", __("Cash registers")], + ["pos_terminals_json", "pos_terminals", __("POS terminals")], + ["bank_accounts_json", "bank_accounts", __("Bank accounts")], + ["obligation_pacts_json", "obligation_pacts", __("Obligation pacts")], + ["presented_certs_json", "presented_certs", __("Presented certificates")], + ]; + const rows = datasets.map(([field, kind, label]) => { + if (errors[field]) { + return `
  • ${label}: ${__("failed")} (${frappe.utils.escape_html(errors[field])})
  • `; } - const n = summary[k]; + const n = summary[field]; if (n === undefined) { return `
  • ${label}:
  • `; } + if (n > 0) { + return `
  • ${label}: ${n} ${__("(click to preview)")}
  • `; + } return `
  • ${label}: ${n}
  • `; }).join(""); @@ -302,7 +305,7 @@ frappe.setup.SetupWizard = class JeySetupWizard {

    ✓ ${__("Authenticated & data loaded")}

    ${__("Pulled from the tax portal:")}

    @@ -314,6 +317,11 @@ frappe.setup.SetupWizard = class JeySetupWizard {
    `); $body.find(".jey-change-company").on("click", () => this.reset_company_selection()); + $body.find(".jey-preview-link").on("click", (e) => { + e.preventDefault(); + const $a = $(e.currentTarget); + this.show_preview_modal($a.data("kind"), $a.data("title")); + }); } } @@ -1100,6 +1108,7 @@ frappe.setup.SetupWizard = class JeySetupWizard { this.asan_certificates = []; this.asan_bearer_token = ""; this.asan_verification_code = ""; + this._preview_cache = null; // Kick off a fresh auth round. Phone and user_id are still on // the Asan Login record, so handle_authentication works without // re-prompting the user. @@ -1123,6 +1132,7 @@ frappe.setup.SetupWizard = class JeySetupWizard { const m = r.message || {}; this.asan_fetch_summary = m.summary || {}; this.asan_fetch_errors = m.errors || {}; + this._preview_cache = null; this.fetch_company_profile(); }, error: () => { @@ -1499,6 +1509,64 @@ frappe.setup.SetupWizard = class JeySetupWizard { }); } + show_preview_modal(kind, title) { + // Lazy-load the full preview blob once per done-screen visit, then + // dispatch to the requested kind. One round-trip covers every dataset + // (the cache doc is single-row, payloads are small after row-limit). + const renderInto = ($body, table) => { + if (!table || !table.rows || !table.rows.length) { + $body.html(`
    ${__("Nothing to preview.")}
    `); + return; + } + const head = (table.headers || []).map((h) => `${frappe.utils.escape_html(h)}`).join(""); + const body = table.rows.map((row) => { + const tds = (row || []).map((cell) => `${frappe.utils.escape_html(String(cell == null ? "" : cell))}`).join(""); + return `${tds}`; + }).join(""); + $body.html(` +
    + + ${head} + ${body} +
    +
    +
    ${__("Showing")} ${table.rows.length} ${__("row(s)")}.
    + `); + }; + + const open = (table) => { + const dlg = new frappe.ui.Dialog({ + title: title || __("Preview"), + size: "extra-large", + fields: [{fieldtype: "HTML", fieldname: "body"}], + }); + renderInto(dlg.fields_dict.body.$wrapper, table); + dlg.show(); + }; + + if (this._preview_cache) { + open(this._preview_cache[kind]); + return; + } + frappe.dom.freeze(__("Loading preview...")); + frappe.call({ + method: "jey_wizard.etaxes.get_preview_data", + callback: (r) => { + frappe.dom.unfreeze(); + if (!r || !r.message) { + frappe.msgprint(__("No preview data available.")); + return; + } + this._preview_cache = r.message; + open(this._preview_cache[kind]); + }, + error: () => { + frappe.dom.unfreeze(); + frappe.msgprint(__("Failed to load preview.")); + }, + }); + } + show_error(summary, detail) { this.$wrap.find(".jey-body").html(`

    ${frappe.utils.escape_html(summary)}