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:
parent
6ba1bdf53d
commit
b982825c71
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.25"
|
||||
__version__ = "0.1.26"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `<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>`;
|
||||
// (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 `<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) {
|
||||
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>`;
|
||||
}).join("");
|
||||
|
||||
|
|
@ -302,7 +305,7 @@ frappe.setup.SetupWizard = class JeySetupWizard {
|
|||
<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>${__("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>
|
||||
</ul>
|
||||
<div style="margin-top:16px;padding:12px;background:#f7f7f7;border-radius:6px">
|
||||
|
|
@ -314,6 +317,11 @@ frappe.setup.SetupWizard = class JeySetupWizard {
|
|||
</div>
|
||||
`);
|
||||
$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(`<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) {
|
||||
this.$wrap.find(".jey-body").html(`
|
||||
<h3 style="color:#c00;margin-bottom:8px">${frappe.utils.escape_html(summary)}</h3>
|
||||
|
|
|
|||
Loading…
Reference in New Issue