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.set_value("Asan Login", asan_login_name, "amas_import_cancel_requested", 0)
|
||||||
frappe.db.commit()
|
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
|
user = frappe.session.user
|
||||||
|
|
||||||
frappe.enqueue(
|
frappe.enqueue(
|
||||||
|
|
@ -2778,9 +2782,24 @@ def import_bulk_employees(asan_login_name, employees_data, company, create_desig
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_amas_import_status(asan_login_name):
|
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")
|
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()
|
@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_running", 0)
|
||||||
frappe.db.set_value("Asan Login", asan_login_name, "amas_import_cancel_requested", 0)
|
frappe.db.set_value("Asan Login", asan_login_name, "amas_import_cancel_requested", 0)
|
||||||
frappe.db.commit()
|
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):
|
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 outcome["emp_data"].get("identification_number")
|
||||||
or "Employee"
|
or "Employee"
|
||||||
)
|
)
|
||||||
|
_set_import_progress(asan_login_name, completed, total, "fetch", emp_name)
|
||||||
frappe.publish_realtime(
|
frappe.publish_realtime(
|
||||||
"amas_import_progress",
|
"amas_import_progress",
|
||||||
{"current": completed, "total": total, "employee_name": emp_name, "phase": "fetch"},
|
{"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"]:
|
if outcome["error"]:
|
||||||
errors.append({"employee": emp_name, "error": 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(
|
frappe.publish_realtime(
|
||||||
"amas_import_progress",
|
"amas_import_progress",
|
||||||
{"current": idx + 1, "total": total, "employee_name": emp_name, "phase": "save"},
|
{"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:
|
except Exception as e:
|
||||||
errors.append({"employee": emp_name, "error": str(e)})
|
errors.append({"employee": emp_name, "error": str(e)})
|
||||||
|
|
||||||
|
_set_import_progress(asan_login_name, idx + 1, total, "save", emp_name)
|
||||||
frappe.publish_realtime(
|
frappe.publish_realtime(
|
||||||
"amas_import_progress",
|
"amas_import_progress",
|
||||||
{"current": idx + 1, "total": total, "employee_name": emp_name, "phase": "save"},
|
{"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' иначе пересоздаёт модал, который
|
// frappe.cur_progress). Опоздавший 'progress' иначе пересоздаёт модал, который
|
||||||
// уже никто не закрывает. Флаг делает обработчик прогресса no-op после finish.
|
// уже никто не закрывает. Флаг делает обработчик прогресса no-op после finish.
|
||||||
let importFinished = false;
|
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(
|
let dialog = frappe.show_progress(
|
||||||
__('Importing from ƏMAS'), 0, total,
|
__('Importing from ƏMAS'), 0, total,
|
||||||
|
|
@ -718,22 +728,83 @@ function attach_amas_import_listeners(listview, asan_login_name, total_hint) {
|
||||||
);
|
);
|
||||||
ensure_cancel_button(dialog);
|
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.off('amas_import_progress');
|
||||||
frappe.realtime.on('amas_import_progress', function(data) {
|
frappe.realtime.on('amas_import_progress', function(data) {
|
||||||
if (importFinished) return;
|
if (importFinished) return;
|
||||||
const is_save = data.phase === 'save';
|
sawRunning = true;
|
||||||
const verb = is_save ? __('Saving: ') : __('Fetching: ');
|
renderProgress(data.current, data.total, data.phase, data.employee_name);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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.off('amas_import_complete');
|
||||||
frappe.realtime.on('amas_import_complete', function(data) {
|
frappe.realtime.on('amas_import_complete', function(data) {
|
||||||
|
if (importFinished) return; // poll-based finish already wrapped up
|
||||||
importFinished = true;
|
importFinished = true;
|
||||||
frappe.realtime.off('amas_import_progress');
|
frappe.realtime.off('amas_import_progress');
|
||||||
frappe.realtime.off('amas_import_complete');
|
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() в этом окружении
|
// Надёжное закрытие прогресс-бара: frappe.hide_progress() в этом окружении
|
||||||
// (jey_theme/Bootstrap) не всегда убирает модал — он зависает. Принудительно.
|
// (jey_theme/Bootstrap) не всегда убирает модал — он зависает. Принудительно.
|
||||||
function closeAmasProgress() {
|
function closeAmasProgress() {
|
||||||
|
if (amasPollTimer) {
|
||||||
|
clearInterval(amasPollTimer);
|
||||||
|
amasPollTimer = null;
|
||||||
|
}
|
||||||
if (frappe.cur_progress) {
|
if (frappe.cur_progress) {
|
||||||
try {
|
try {
|
||||||
frappe.cur_progress.$wrapper.modal('hide');
|
frappe.cur_progress.$wrapper.modal('hide');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue