Add click-to-preview for fetched e-taxes data on Asan/done

Counter rows and the company name on the post-Asan screen open a modal
with the actual records (6 e-taxes lists + company profile fields), so
users can sanity-check what landed before finalizing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-15 15:42:00 +00:00
parent 6ba1bdf53d
commit b982825c71
3 changed files with 330 additions and 16 deletions

View File

@ -1 +1 @@
__version__ = "0.1.25" __version__ = "0.1.26"

View File

@ -219,6 +219,252 @@ def fetch_company_profile():
return {"ok": True, "resolved": resolved, "criteria": criteria} 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() @frappe.whitelist()
def get_cached(kind): def get_cached(kind):
"""Return parsed cached data for a given kind. Used by future wizard steps that """Return parsed cached data for a given kind. Used by future wizard steps that

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.1.25"; const JEY_WIZARD_VERSION = "0.1.26";
// 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.
@ -278,23 +278,26 @@ frappe.setup.SetupWizard = class JeySetupWizard {
} else if (this.asan_state === "done") { } else if (this.asan_state === "done") {
const summary = this.asan_fetch_summary || {}; const summary = this.asan_fetch_summary || {};
const errors = this.asan_fetch_errors || {}; const errors = this.asan_fetch_errors || {};
const labels = { // (cache_field, preview kind for get_preview_data)
objects_json: __("Objects"), const datasets = [
cash_registers_json: __("Cash registers"), ["objects_json", "objects", __("Objects")],
pos_terminals_json: __("POS terminals"), ["cash_registers_json", "cash_registers", __("Cash registers")],
bank_accounts_json: __("Bank accounts"), ["pos_terminals_json", "pos_terminals", __("POS terminals")],
obligation_pacts_json: __("Obligation pacts"), ["bank_accounts_json", "bank_accounts", __("Bank accounts")],
presented_certs_json: __("Presented certificates"), ["obligation_pacts_json", "obligation_pacts", __("Obligation pacts")],
}; ["presented_certs_json", "presented_certs", __("Presented certificates")],
const rows = Object.keys(labels).map((k) => { ];
const label = labels[k]; const rows = datasets.map(([field, kind, label]) => {
if (errors[k]) { if (errors[field]) {
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>`; return `<li><b>${label}:</b> <span style="color:#c00">${__("failed")}</span> <span style="color:#888;font-size:12px">(${frappe.utils.escape_html(errors[field])})</span></li>`;
} }
const n = summary[k]; const n = summary[field];
if (n === undefined) { if (n === undefined) {
return `<li><b>${label}:</b> <span style="color:#888">—</span></li>`; return `<li><b>${label}:</b> <span style="color:#888">—</span></li>`;
} }
if (n > 0) {
return `<li><b>${label}:</b> <a href="#" class="jey-preview-link" data-kind="${kind}" data-title="${frappe.utils.escape_html(label)}">${n}</a> <span style="color:#888;font-size:12px">${__("(click to preview)")}</span></li>`;
}
return `<li><b>${label}:</b> ${n}</li>`; return `<li><b>${label}:</b> ${n}</li>`;
}).join(""); }).join("");
@ -302,7 +305,7 @@ frappe.setup.SetupWizard = class JeySetupWizard {
<h3 style="margin-bottom:16px;color:#080"> ${__("Authenticated & data loaded")}</h3> <h3 style="margin-bottom:16px;color:#080"> ${__("Authenticated & 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> <a href="#" class="jey-preview-link" data-kind="profile" data-title="${frappe.utils.escape_html(__("Company profile"))}">${frappe.utils.escape_html(this.data.company_name)}</a> <span style="color:#888;font-size:12px">${__("(click to preview)")}</span></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>
<div style="margin-top:16px;padding:12px;background:#f7f7f7;border-radius:6px"> <div style="margin-top:16px;padding:12px;background:#f7f7f7;border-radius:6px">
@ -314,6 +317,11 @@ frappe.setup.SetupWizard = class JeySetupWizard {
</div> </div>
`); `);
$body.find(".jey-change-company").on("click", () => this.reset_company_selection()); $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_certificates = [];
this.asan_bearer_token = ""; this.asan_bearer_token = "";
this.asan_verification_code = ""; this.asan_verification_code = "";
this._preview_cache = null;
// Kick off a fresh auth round. Phone and user_id are still on // Kick off a fresh auth round. Phone and user_id are still on
// the Asan Login record, so handle_authentication works without // the Asan Login record, so handle_authentication works without
// re-prompting the user. // re-prompting the user.
@ -1123,6 +1132,7 @@ frappe.setup.SetupWizard = class JeySetupWizard {
const m = r.message || {}; const m = r.message || {};
this.asan_fetch_summary = m.summary || {}; this.asan_fetch_summary = m.summary || {};
this.asan_fetch_errors = m.errors || {}; this.asan_fetch_errors = m.errors || {};
this._preview_cache = null;
this.fetch_company_profile(); this.fetch_company_profile();
}, },
error: () => { 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(`<div style="color:#888;padding:24px;text-align:center">${__("Nothing to preview.")}</div>`);
return;
}
const head = (table.headers || []).map((h) => `<th style="text-align:left;padding:6px 10px;border-bottom:1px solid #ddd;font-weight:600">${frappe.utils.escape_html(h)}</th>`).join("");
const body = table.rows.map((row) => {
const tds = (row || []).map((cell) => `<td style="padding:6px 10px;border-bottom:1px solid #f0f0f0;vertical-align:top">${frappe.utils.escape_html(String(cell == null ? "" : cell))}</td>`).join("");
return `<tr>${tds}</tr>`;
}).join("");
$body.html(`
<div style="max-height:60vh;overflow:auto">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr>${head}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>
<div style="margin-top:8px;color:#888;font-size:12px">${__("Showing")} ${table.rows.length} ${__("row(s)")}.</div>
`);
};
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) { show_error(summary, detail) {
this.$wrap.find(".jey-body").html(` this.$wrap.find(".jey-body").html(`
<h3 style="color:#c00;margin-bottom:8px">${frappe.utils.escape_html(summary)}</h3> <h3 style="color:#c00;margin-bottom:8px">${frappe.utils.escape_html(summary)}</h3>