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:
parent
c6e2101c26
commit
b911c6a1ae
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue