fix(amas): real, resilient employee-import progress bar

The Employee list-view import bar relied solely on transient
publish_realtime("amas_import_progress") events. After the setup wizard
redirects to /app and the list reattaches, those in-flight events are
already gone — so the bar froze at "Reconnecting..." (0%) and employees
appeared all at once on the final refresh.

Persist each progress tick to Redis (_set_import_progress, exposed via
get_amas_import_status) and add a 2s polling fallback in employee.js that
drives the bar monotonically and detects completion even when the
realtime amas_import_complete is missed. Closing the bar still never
cancels the enqueued worker job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-15 12:40:12 +00:00
parent c6e2101c26
commit b911c6a1ae
2 changed files with 145 additions and 11 deletions

View File

@ -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"},

View File

@ -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');