diff --git a/invoice_az/amas_api.py b/invoice_az/amas_api.py index 2c6e9f3..970bedb 100644 --- a/invoice_az/amas_api.py +++ b/invoice_az/amas_api.py @@ -2760,6 +2760,10 @@ def import_bulk_employees(asan_login_name, employees_data, company, create_desig frappe.db.set_value("Asan Login", asan_login_name, "amas_import_cancel_requested", 0) frappe.db.commit() + # Seed progress immediately so a bar that attaches before the worker emits + # its first tick already shows 0 / total instead of an empty "reconnecting". + _set_import_progress(asan_login_name, 0, len(employees_data), phase="queued") + user = frappe.session.user frappe.enqueue( @@ -2778,9 +2782,24 @@ def import_bulk_employees(asan_login_name, employees_data, company, create_desig @frappe.whitelist() def get_amas_import_status(asan_login_name): - """Return whether an ƏMAS import is currently running for this Asan Login.""" + """Return whether an ƏMAS import is currently running for this Asan Login, + plus the latest progress snapshot. + + Realtime ``amas_import_progress`` events are transient: anything published + before the client subscribes (after the setup wizard redirects to /app, or + a page reload) is lost forever. The list-view progress bar polls this + endpoint as a reliable fallback so it always reflects real progress instead + of sitting frozen until the import finishes. + """ running = frappe.db.get_value("Asan Login", asan_login_name, "amas_import_running") - return {"running": bool(running)} + progress = frappe.cache().get_value(_import_progress_key(asan_login_name)) or {} + return { + "running": bool(running), + "current": progress.get("current") or 0, + "total": progress.get("total") or 0, + "phase": progress.get("phase"), + "employee_name": progress.get("employee_name"), + } @frappe.whitelist() @@ -2849,6 +2868,37 @@ def _clear_import_lock(asan_login_name): frappe.db.set_value("Asan Login", asan_login_name, "amas_import_running", 0) frappe.db.set_value("Asan Login", asan_login_name, "amas_import_cancel_requested", 0) frappe.db.commit() + _clear_import_progress(asan_login_name) + + +def _import_progress_key(asan_login_name): + return f"amas_import_progress:{asan_login_name}" + + +def _set_import_progress(asan_login_name, current, total, phase=None, employee_name=None): + """Persist the latest progress tick to Redis so a reconnecting list-view bar + can poll it (see get_amas_import_status). Best-effort: progress reporting + must never break the import itself.""" + try: + frappe.cache().set_value( + _import_progress_key(asan_login_name), + { + "current": current, + "total": total, + "phase": phase, + "employee_name": employee_name, + }, + expires_in_sec=3600, + ) + except Exception: + pass + + +def _clear_import_progress(asan_login_name): + try: + frappe.cache().delete_value(_import_progress_key(asan_login_name)) + except Exception: + pass def _process_bulk_employees_import(asan_login_name, employees, company, create_designation, user): @@ -2947,6 +2997,7 @@ def _process_bulk_employees_import(asan_login_name, employees, company, create_d or outcome["emp_data"].get("identification_number") or "Employee" ) + _set_import_progress(asan_login_name, completed, total, "fetch", emp_name) frappe.publish_realtime( "amas_import_progress", {"current": completed, "total": total, "employee_name": emp_name, "phase": "fetch"}, @@ -3009,6 +3060,7 @@ def _process_bulk_employees_import(asan_login_name, employees, company, create_d if outcome["error"]: errors.append({"employee": emp_name, "error": outcome["error"]}) + _set_import_progress(asan_login_name, idx + 1, total, "save", emp_name) frappe.publish_realtime( "amas_import_progress", {"current": idx + 1, "total": total, "employee_name": emp_name, "phase": "save"}, @@ -3035,6 +3087,7 @@ def _process_bulk_employees_import(asan_login_name, employees, company, create_d except Exception as e: errors.append({"employee": emp_name, "error": str(e)}) + _set_import_progress(asan_login_name, idx + 1, total, "save", emp_name) frappe.publish_realtime( "amas_import_progress", {"current": idx + 1, "total": total, "employee_name": emp_name, "phase": "save"}, diff --git a/invoice_az/client/employee.js b/invoice_az/client/employee.js index cbe0624..aa8d743 100644 --- a/invoice_az/client/employee.js +++ b/invoice_az/client/employee.js @@ -709,6 +709,16 @@ function attach_amas_import_listeners(listview, asan_login_name, total_hint) { // frappe.cur_progress). Опоздавший 'progress' иначе пересоздаёт модал, который // уже никто не закрывает. Флаг делает обработчик прогресса no-op после finish. let importFinished = false; + // True once we've observed the import actually running (via a realtime tick + // or a status poll). Guards the poll-based completion detector so it can't + // fire before the worker has even started (the running flag is set + // server-side a beat after import_bulk_employees is called). + let sawRunning = (total > 0); + // Highest "current" rendered so far. Realtime and polling both feed the bar; + // this keeps it monotonic so a stale poll can't rewind a fresher realtime + // tick (or vice-versa). + let lastCurrent = 0; + let knownTotal = total || 0; let dialog = frappe.show_progress( __('Importing from ƏMAS'), 0, total, @@ -718,22 +728,83 @@ function attach_amas_import_listeners(listview, asan_login_name, total_hint) { ); ensure_cancel_button(dialog); + // Single entry point for moving the bar, fed by both the realtime handler + // and the status poll. Never regresses; tolerates an unknown total (0). + const renderProgress = function(current, totalArg, phase, employee_name) { + if (importFinished) return; + if (totalArg && totalArg > 0) knownTotal = totalArg; + if (typeof current === 'number' && current > lastCurrent) lastCurrent = current; + + const is_save = phase === 'save'; + const verb = is_save ? __('Saving: ') : __('Fetching: '); + let detail; + if (employee_name) { + detail = verb + employee_name; + } else if (knownTotal > 0) { + detail = verb + __('{0} of {1}', [lastCurrent, knownTotal]); + } else { + detail = __('Reconnecting to import in progress... waiting for next update'); + } + // Guard against total=0 (NaN width); show_progress reuses the dialog. + const d = frappe.show_progress( + __('Importing from ƏMAS'), lastCurrent, knownTotal > 0 ? knownTotal : 1, detail + ); + ensure_cancel_button(d); + }; + frappe.realtime.off('amas_import_progress'); frappe.realtime.on('amas_import_progress', function(data) { if (importFinished) return; - const is_save = data.phase === 'save'; - const verb = is_save ? __('Saving: ') : __('Fetching: '); - const detail = data.employee_name - ? verb + data.employee_name - : verb + __('{0} of {1}', [data.current, data.total]); - const d = frappe.show_progress( - __('Importing from ƏMAS'), data.current, data.total, detail - ); - ensure_cancel_button(d); + sawRunning = true; + renderProgress(data.current, data.total, data.phase, data.employee_name); }); + // Polling fallback. Realtime events are transient — anything published + // before this page subscribed (after the wizard redirects to /app, or a + // reload) is lost, leaving the bar frozen. Polling the server-persisted + // progress guarantees the bar moves, and lets us detect completion even + // when the 'amas_import_complete' event itself was missed. + const finishFromPoll = function() { + if (importFinished) return; + importFinished = true; + frappe.realtime.off('amas_import_progress'); + frappe.realtime.off('amas_import_complete'); + closeAmasProgress(); // also clears amasPollTimer + cleanup_cancel_dialog(); + listview.refresh(); + frappe.show_alert({ message: __('ƏMAS import finished.'), indicator: 'green' }, 5); + }; + + const pollStatus = function() { + if (importFinished) return; + frappe.call({ + method: 'invoice_az.amas_api.get_amas_import_status', + args: { asan_login_name: asan_login_name }, + callback: function(r) { + if (importFinished) return; + const m = r.message || {}; + if (m.running) { + sawRunning = true; + renderProgress(m.current, m.total, m.phase, m.employee_name); + } else if (sawRunning) { + // We watched it run and now it's gone — the realtime + // 'complete' was missed. Wrap up gracefully. + finishFromPoll(); + } + } + }); + }; + + if (amasPollTimer) clearInterval(amasPollTimer); + amasPollTimer = setInterval(pollStatus, 2000); + // Reconnect path (no total hint): the caller already confirmed an import is + // running, so seed the bar from real numbers immediately instead of waiting + // a full poll interval on the bare "reconnecting" text. + if (!total) pollStatus(); + frappe.realtime.off('amas_import_complete'); frappe.realtime.on('amas_import_complete', function(data) { + if (importFinished) return; // poll-based finish already wrapped up importFinished = true; frappe.realtime.off('amas_import_progress'); frappe.realtime.off('amas_import_complete'); @@ -776,9 +847,19 @@ function attach_amas_import_listeners(listview, asan_login_name, total_hint) { }); } +// Module-level handle for the status-poll interval (fallback that drives the +// progress bar when transient realtime events are missed). Only one ƏMAS import +// is ever tracked at a time, so a single timer is enough. closeAmasProgress() +// is the universal teardown path, so clearing it there covers every exit. +let amasPollTimer = null; + // Надёжное закрытие прогресс-бара: frappe.hide_progress() в этом окружении // (jey_theme/Bootstrap) не всегда убирает модал — он зависает. Принудительно. function closeAmasProgress() { + if (amasPollTimer) { + clearInterval(amasPollTimer); + amasPollTimer = null; + } if (frappe.cur_progress) { try { frappe.cur_progress.$wrapper.modal('hide');