perf(amas): parallel detail fetch + per-Asan-Login import lock
Bulk import is now two-phase:
Phase A — parallel network. Up to BULK_IMPORT_PARALLELISM (=10)
ThreadPoolExecutor workers call get_employee_detail() concurrently.
Workers run in stateless mode via the new _post_amas helper and the
_state= parameter threaded through make_amas_request and the seven
detail helpers (get_edit_form_data, get_staff_data, get_person_data,
get_address_data, get_doc_main_data, get_contract_attachments,
download_contract_file). They share an in-memory snapshot of the
ƏMAS session/csrf/cookies and never touch the DB. If any worker is
rejected with HTTP 419 or a response-level CSRF code the whole
import aborts cleanly with a hint to lower BULK_IMPORT_PARALLELISM.
Phase B — sequential DB writes. create_single_employee_from_amas
takes a new prefetched_detail kwarg; the worker loop passes the
Phase-A payload through so the per-employee 7-call fetch is skipped.
Frappe ORM is not thread-safe, so writes stay sequential — that's
where the previous flow spent most of its time anyway.
Concurrency control:
- Asan Login picks up two new hidden Check fields,
amas_import_running and amas_import_cancel_requested.
- import_bulk_employees refuses to enqueue if the running flag is
already set, returning {success: False, already_running: True,
message}. The flag is set BEFORE frappe.enqueue (atomic guard
against fast double-clicks) and cleared in the worker's finally
block no matter how it exits.
- New whitelisted endpoints get_amas_import_status and
cancel_amas_import expose the flag to the UI. The worker checks
the cancel flag between Phase A futures and between Phase B
iterations.
Realtime events upgraded:
- amas_import_progress now carries phase: "fetch" | "save".
- amas_import_complete carries cancelled and aborted flags.
Frontend (employee.js):
- Bootstrap-4.6 stacked-modal fix re-applies modal-open class on
body when a nested modal closes (cancel-confirm over progress)
so the underlying backdrop isn't orphaned.
- reattach_amas_import_if_running re-binds the realtime listeners
on listview onload by polling get_connected_asan_logins +
get_amas_import_status, so refreshing /app/employee while an
import is in flight still shows the progress bar + Cancel button.
Plus the side fixes from the same session: connect_amas now opens
the org-picker dialog on success, error humanizer for common ƏMAS
codes, skip the dashboard prefetch when the cached CSRF is still
good. Schema changes need bench migrate.
This commit is contained in:
parent
dd096dd795
commit
213329afff
|
|
@ -15,6 +15,7 @@ import http.client
|
|||
import json
|
||||
import random
|
||||
import string
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import unquote
|
||||
|
||||
import frappe
|
||||
|
|
@ -89,6 +90,102 @@ AMAS_GENERIC_UNAVAILABLE_MESSAGE = (
|
|||
)
|
||||
|
||||
|
||||
def _post_amas(session_data, csrf_token, cookies, endpoint, data):
|
||||
"""Stateless POST to ƏMAS. No DB access.
|
||||
|
||||
Used both by stateful `make_amas_request` and by parallel workers that
|
||||
can't safely touch Frappe ORM. Caller manages session/csrf/cookies state.
|
||||
|
||||
Returns:
|
||||
On success: {"success": True, "data": <result>, "cookies": <merged>}
|
||||
On 419 / response-level CSRF: {"success": False, "csrf_error": True, ...}
|
||||
On 401: {"success": False, "session_expired": True, ...}
|
||||
Otherwise: {"success": False, "message": ..., "status_code": ..., "exception": ...}
|
||||
"""
|
||||
headers = AMAS_HEADERS.copy()
|
||||
headers["X-CSRF-TOKEN"] = csrf_token or ""
|
||||
headers["MAGUS-REQUEST-NUMBER"] = generate_request_number()
|
||||
|
||||
for k in ("requestNumber", "_token", "currentTabToken"):
|
||||
data.pop(k, None)
|
||||
data["requestNumber"] = headers["MAGUS-REQUEST-NUMBER"]
|
||||
data["_token"] = csrf_token or ""
|
||||
|
||||
token = session_data.get("token")
|
||||
if token:
|
||||
data["currentTabToken"] = token
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{AMAS_BASE_URL}{endpoint}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
timeout=60,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": AMAS_GENERIC_UNAVAILABLE_MESSAGE,
|
||||
"exception": str(e),
|
||||
}
|
||||
|
||||
new_cookies = parse_cookies_from_response(response)
|
||||
merged_cookies = {**cookies, **new_cookies} if new_cookies else cookies
|
||||
|
||||
if response.status_code == 419:
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": "CSRF token rejected (HTTP 419)",
|
||||
"cookies": merged_cookies,
|
||||
}
|
||||
|
||||
if response.status_code == 401:
|
||||
return {
|
||||
"success": False,
|
||||
"session_expired": True,
|
||||
"message": "Session expired (HTTP 401)",
|
||||
"cookies": merged_cookies,
|
||||
}
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"message": AMAS_GENERIC_UNAVAILABLE_MESSAGE,
|
||||
"status_code": response.status_code,
|
||||
"cookies": merged_cookies,
|
||||
}
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid response from ƏMAS",
|
||||
"cookies": merged_cookies,
|
||||
}
|
||||
|
||||
response_info = result.get("response", {}) if isinstance(result, dict) else {}
|
||||
error_code = response_info.get("code", "")
|
||||
error_msg = str(response_info.get("message", ""))
|
||||
|
||||
csrf_codes = ("CSRF", "TOKENS_ARE_NOT_SAME", "CSRF_TOKEN_MISMATCH")
|
||||
if (
|
||||
error_code in csrf_codes
|
||||
or "CSRF" in error_msg
|
||||
or "TOKEN" in error_msg.upper()
|
||||
):
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": f"CSRF rejected by API: code={error_code}",
|
||||
"cookies": merged_cookies,
|
||||
}
|
||||
|
||||
return {"success": True, "data": result, "cookies": merged_cookies}
|
||||
|
||||
|
||||
def humanize_amas_error(code, message):
|
||||
"""Translate raw ƏMAS API error codes/messages into a user-friendly response dict.
|
||||
|
||||
|
|
@ -452,15 +549,46 @@ def refresh_csrf_token_internal(asan_login_name):
|
|||
return session_data, doc.amas_csrf_token
|
||||
|
||||
|
||||
def make_amas_request(asan_login_name, endpoint, data, _retried=False):
|
||||
def make_amas_request(asan_login_name, endpoint, data, _retried=False, _state=None):
|
||||
"""Make an authenticated request to ƏMAS API.
|
||||
|
||||
Uses the cached CSRF token + session cookies (no upfront dashboard download).
|
||||
Cookies and tokens are refreshed from each response's Set-Cookie. Only when
|
||||
ƏMAS actually rejects the CSRF (HTTP 419 or response code in CSRF /
|
||||
TOKENS_ARE_NOT_SAME / CSRF_TOKEN_MISMATCH) we fall back to fetching a fresh
|
||||
token from /core.dashboard and retry the request once.
|
||||
Two modes:
|
||||
- **Stateful** (default, _state=None): reads session/cookies from the Asan Login
|
||||
document, calls _post_amas, persists rotated cookies back to DB. On real
|
||||
CSRF rejection (HTTP 419 or response code CSRF/TOKENS_ARE_NOT_SAME/
|
||||
CSRF_TOKEN_MISMATCH) refreshes the token from /core.dashboard and retries
|
||||
the request once.
|
||||
- **Stateless** (_state is a mutable dict with keys session_data, csrf_token,
|
||||
cookies): used by parallel workers. Reads/updates only the in-memory state;
|
||||
never touches the DB. CSRF errors are bubbled up to the caller — the caller
|
||||
decides whether to abort the parallel batch.
|
||||
"""
|
||||
if _state is not None:
|
||||
session_data = _state["session_data"]
|
||||
csrf_token = _state.get("csrf_token") or session_data.get("xsrf_token", "")
|
||||
cookies = _state.get("cookies") or session_data.get("all_cookies", {})
|
||||
|
||||
result = _post_amas(session_data, csrf_token, cookies, endpoint, data)
|
||||
|
||||
# Persist rotated cookies for the session, but DO NOT overwrite csrf_token
|
||||
# from the XSRF-TOKEN cookie: that cookie holds Laravel's *encrypted* CSRF,
|
||||
# while the body `_token` field expects the *plaintext* CSRF (from the
|
||||
# `<meta name="csrf-token">` tag). The plaintext CSRF is stable per session,
|
||||
# so we keep the initial value and only refresh it via /core.dashboard
|
||||
# (refresh_csrf_token_internal) if the API actually rejects it.
|
||||
if result.get("cookies"):
|
||||
_state["cookies"] = result["cookies"]
|
||||
|
||||
if result.get("success"):
|
||||
return {"success": True, "data": result["data"]}
|
||||
|
||||
out = {"success": False, "message": result.get("message", AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
if result.get("csrf_error"):
|
||||
out["csrf_error"] = True
|
||||
if result.get("session_expired"):
|
||||
out["session_expired"] = True
|
||||
return out
|
||||
|
||||
session_data = get_amas_session_data(asan_login_name)
|
||||
if not session_data:
|
||||
return {
|
||||
|
|
@ -469,119 +597,70 @@ def make_amas_request(asan_login_name, endpoint, data, _retried=False):
|
|||
}
|
||||
|
||||
doc = frappe.get_doc("Asan Login", asan_login_name)
|
||||
|
||||
headers = AMAS_HEADERS.copy()
|
||||
csrf_token = doc.amas_csrf_token or session_data.get("xsrf_token", "")
|
||||
headers["X-CSRF-TOKEN"] = csrf_token
|
||||
headers["MAGUS-REQUEST-NUMBER"] = generate_request_number()
|
||||
|
||||
cookies = session_data.get("all_cookies", {})
|
||||
|
||||
# Reset per-request fields (in case of retry, regenerate them)
|
||||
for k in ("requestNumber", "_token", "currentTabToken"):
|
||||
data.pop(k, None)
|
||||
data["requestNumber"] = headers["MAGUS-REQUEST-NUMBER"]
|
||||
data["_token"] = csrf_token
|
||||
result = _post_amas(session_data, csrf_token, cookies, endpoint, data)
|
||||
|
||||
# The token from config is already in format "{uuid}.{accountOid}" — don't append accountOid again
|
||||
token = session_data.get("token")
|
||||
if token:
|
||||
data["currentTabToken"] = token
|
||||
|
||||
try:
|
||||
url = f"{AMAS_BASE_URL}{endpoint}"
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
new_cookies = parse_cookies_from_response(response)
|
||||
if new_cookies:
|
||||
cookies.update(new_cookies)
|
||||
session_data["all_cookies"] = cookies
|
||||
|
||||
if "XSRF-TOKEN" in new_cookies:
|
||||
decoded_token = unquote(new_cookies["XSRF-TOKEN"])
|
||||
doc.amas_csrf_token = decoded_token
|
||||
session_data["xsrf_token"] = decoded_token
|
||||
new_cookies = result.get("cookies")
|
||||
if new_cookies and new_cookies is not cookies:
|
||||
session_data["all_cookies"] = new_cookies
|
||||
# NOTE: Do NOT overwrite amas_csrf_token / xsrf_token from the rotated
|
||||
# XSRF-TOKEN cookie. That cookie holds Laravel's *encrypted* CSRF; the
|
||||
# body `_token` field expects the *plaintext* CSRF (from the meta tag
|
||||
# on /core.dashboard). The plaintext CSRF is stable per session — we
|
||||
# only refresh it via refresh_csrf_token_internal() on a real CSRF
|
||||
# rejection.
|
||||
doc.amas_session = json.dumps(session_data)
|
||||
doc.amas_last_activity = frappe.utils.now_datetime()
|
||||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
if result.get("success"):
|
||||
api_data = result["data"]
|
||||
if isinstance(api_data, dict) and api_data.get("token"):
|
||||
session_data["token"] = api_data["token"]
|
||||
doc.amas_session = json.dumps(session_data)
|
||||
doc.amas_last_activity = frappe.utils.now_datetime()
|
||||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
return {"success": True, "data": api_data}
|
||||
|
||||
if response.status_code == 419:
|
||||
if not _retried:
|
||||
refresh_csrf_token_internal(asan_login_name)
|
||||
return make_amas_request(asan_login_name, endpoint, data, _retried=True)
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": "CSRF token expired. Please reconnect to ƏMAS."
|
||||
}
|
||||
|
||||
if response.status_code == 401:
|
||||
doc.amas_auth_status = "Error"
|
||||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
return {
|
||||
"success": False,
|
||||
"session_expired": True,
|
||||
"message": "ƏMAS session expired. Please reconnect."
|
||||
}
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
result = response.json()
|
||||
|
||||
response_info = result.get("response", {}) if isinstance(result, dict) else {}
|
||||
error_code = response_info.get("code", "")
|
||||
error_msg = str(response_info.get("message", ""))
|
||||
|
||||
csrf_codes = ("CSRF", "TOKENS_ARE_NOT_SAME", "CSRF_TOKEN_MISMATCH")
|
||||
csrf_failed = (
|
||||
error_code in csrf_codes
|
||||
or "CSRF" in error_msg
|
||||
or "TOKEN" in error_msg.upper()
|
||||
)
|
||||
if csrf_failed:
|
||||
if not _retried:
|
||||
refresh_csrf_token_internal(asan_login_name)
|
||||
return make_amas_request(asan_login_name, endpoint, data, _retried=True)
|
||||
frappe.log_error(
|
||||
f"ƏMAS CSRF error after retry: code={error_code} msg={error_msg}",
|
||||
"ƏMAS Token Error"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": "Token error. Please reconnect to ƏMAS."
|
||||
}
|
||||
|
||||
if isinstance(result, dict) and result.get("token"):
|
||||
session_data["token"] = result.get("token")
|
||||
doc.amas_session = json.dumps(session_data)
|
||||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
return {"success": True, "data": result}
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "message": "Invalid response from ƏMAS"}
|
||||
|
||||
# Unexpected HTTP status
|
||||
if result.get("csrf_error"):
|
||||
if not _retried:
|
||||
refresh_csrf_token_internal(asan_login_name)
|
||||
return make_amas_request(asan_login_name, endpoint, data, _retried=True)
|
||||
frappe.log_error(
|
||||
f"ƏMAS API error: status {response.status_code}\nResponse: {response.text[:500]}",
|
||||
f"ƏMAS CSRF error after retry: {result.get('message')}",
|
||||
"ƏMAS Token Error"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"csrf_error": True,
|
||||
"message": "Token error. Please reconnect to ƏMAS."
|
||||
}
|
||||
|
||||
if result.get("session_expired"):
|
||||
doc.amas_auth_status = "Error"
|
||||
doc.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
return {
|
||||
"success": False,
|
||||
"session_expired": True,
|
||||
"message": "ƏMAS session expired. Please reconnect."
|
||||
}
|
||||
|
||||
if "status_code" in result:
|
||||
frappe.log_error(
|
||||
f"ƏMAS API error: status {result['status_code']}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
elif result.get("exception"):
|
||||
frappe.log_error(
|
||||
f"ƏMAS API network error: {result['exception']}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": AMAS_GENERIC_UNAVAILABLE_MESSAGE}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
frappe.log_error(f"ƏMAS API network error: {str(e)}", "ƏMAS API Error")
|
||||
return {"success": False, "message": AMAS_GENERIC_UNAVAILABLE_MESSAGE}
|
||||
return {"success": False, "message": result.get("message", AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -1675,7 +1754,7 @@ def create_single_employee_from_amas(asan_login_name, employee_data, company, cr
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_single_employee_from_amas(asan_login_name, emp_data, company, create_designation=0):
|
||||
def create_single_employee_from_amas(asan_login_name, emp_data, company, create_designation=0, prefetched_detail=None):
|
||||
"""
|
||||
Create/update a SINGLE Employee record from ƏMAS data.
|
||||
This is called from frontend in a loop for progress bar updates.
|
||||
|
|
@ -1685,6 +1764,9 @@ def create_single_employee_from_amas(asan_login_name, emp_data, company, create_
|
|||
emp_data: Single employee data dict from ƏMAS API
|
||||
company: Company name
|
||||
create_designation: 1/0 to create designation if not found
|
||||
prefetched_detail: Optional dict from a prior get_employee_detail call.
|
||||
When supplied, the per-employee 7-call ƏMAS fetch is skipped — used
|
||||
by the parallel bulk import worker which fetches details upfront.
|
||||
|
||||
Returns:
|
||||
{
|
||||
|
|
@ -1724,11 +1806,12 @@ def create_single_employee_from_amas(asan_login_name, emp_data, company, create_
|
|||
doc_no = emp_data.get("doc_no")
|
||||
doc_type = emp_data.get("doc_type", "docType_47")
|
||||
|
||||
# Fetch detail data for complete employee information
|
||||
detail_data = None
|
||||
# Fetch detail data for complete employee information (or use the
|
||||
# one already fetched in parallel by the bulk-import worker).
|
||||
detail_data = prefetched_detail
|
||||
full_name = str(emp_data.get("full_name") or "").strip()
|
||||
|
||||
if doc_oid:
|
||||
if detail_data is None and doc_oid:
|
||||
detail_result = get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type)
|
||||
if detail_result.get('success'):
|
||||
detail_data = detail_result.get('data')
|
||||
|
|
@ -2276,7 +2359,7 @@ def create_employees_from_amas(asan_login_name, employees, company, create_desig
|
|||
# (called when viewing employee details in ƏMAS, not from list report)
|
||||
|
||||
|
||||
def get_edit_form_data(asan_login_name, doc_oid, doc_no, doc_type):
|
||||
def get_edit_form_data(asan_login_name, doc_oid, doc_no, doc_type, _state=None):
|
||||
"""
|
||||
Call eroom.getEditForm to get contract form data.
|
||||
Returns: EmasContractsEnt, EmasContractDetailsEnt, EmasContractTextDetailsEnt
|
||||
|
|
@ -2287,38 +2370,38 @@ def get_edit_form_data(asan_login_name, doc_oid, doc_no, doc_type):
|
|||
"docNo": doc_no,
|
||||
"docType": doc_type
|
||||
}
|
||||
return make_amas_request(asan_login_name, "/service/eroom.getEditForm", data)
|
||||
return make_amas_request(asan_login_name, "/service/eroom.getEditForm", data, _state=_state)
|
||||
|
||||
|
||||
def get_staff_data(asan_login_name, staff_oid):
|
||||
def get_staff_data(asan_login_name, staff_oid, _state=None):
|
||||
"""Call embas.getStaffData for position info"""
|
||||
data = {"staffOid": staff_oid}
|
||||
return make_amas_request(asan_login_name, "/service/embas.getStaffData", data)
|
||||
return make_amas_request(asan_login_name, "/service/embas.getStaffData", data, _state=_state)
|
||||
|
||||
|
||||
def get_person_data(asan_login_name, entity_oid):
|
||||
def get_person_data(asan_login_name, entity_oid, _state=None):
|
||||
"""Call refdata.getPersonDataByEntOid for personal data"""
|
||||
data = {"entityOid": entity_oid} # Fixed: was "entOid", should be "entityOid"
|
||||
return make_amas_request(asan_login_name, "/service/refdata.getPersonDataByEntOid", data)
|
||||
return make_amas_request(asan_login_name, "/service/refdata.getPersonDataByEntOid", data, _state=_state)
|
||||
|
||||
|
||||
def get_address_data(asan_login_name, address_oid):
|
||||
def get_address_data(asan_login_name, address_oid, _state=None):
|
||||
"""Call refdata.getAddressData for work address"""
|
||||
data = {"addressOid": address_oid}
|
||||
return make_amas_request(asan_login_name, "/service/refdata.getAddressData", data)
|
||||
return make_amas_request(asan_login_name, "/service/refdata.getAddressData", data, _state=_state)
|
||||
|
||||
|
||||
def get_doc_main_data(asan_login_name, doc_oid, doc_no, doc_type):
|
||||
def get_doc_main_data(asan_login_name, doc_oid, doc_no, doc_type, _state=None):
|
||||
"""Call eroom.getDocMainData for document metadata"""
|
||||
data = {
|
||||
"docOid": doc_oid,
|
||||
"docNo": doc_no,
|
||||
"docType": doc_type
|
||||
}
|
||||
return make_amas_request(asan_login_name, "/service/eroom.getDocMainData", data)
|
||||
return make_amas_request(asan_login_name, "/service/eroom.getDocMainData", data, _state=_state)
|
||||
|
||||
|
||||
def get_contract_attachments(asan_login_name, doc_oid, attach_type="attachType_5"):
|
||||
def get_contract_attachments(asan_login_name, doc_oid, attach_type="attachType_5", _state=None):
|
||||
"""
|
||||
Call eroom.getAttachListByType to get list of contract attachments.
|
||||
Returns list of attachments with fileId for downloading.
|
||||
|
|
@ -2327,23 +2410,27 @@ def get_contract_attachments(asan_login_name, doc_oid, attach_type="attachType_5
|
|||
"docOid[]": doc_oid,
|
||||
"attachType": attach_type
|
||||
}
|
||||
return make_amas_request(asan_login_name, "/service/eroom.getAttachListByType", data)
|
||||
return make_amas_request(asan_login_name, "/service/eroom.getAttachListByType", data, _state=_state)
|
||||
|
||||
|
||||
def download_contract_file(asan_login_name, file_id):
|
||||
def download_contract_file(asan_login_name, file_id, _state=None):
|
||||
"""
|
||||
Call request/readFromStorage to download contract HTML file.
|
||||
Returns HTML content of the employment contract.
|
||||
"""
|
||||
data = {"fileId": file_id}
|
||||
return make_amas_request(asan_login_name, "/request/readFromStorage", data)
|
||||
return make_amas_request(asan_login_name, "/request/readFromStorage", data, _state=_state)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47"):
|
||||
def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47", _state=None):
|
||||
"""
|
||||
Fetch complete employee detail from ƏMAS.
|
||||
Makes 7 API calls to collect all data.
|
||||
Makes up to 7 sequential API calls to collect all data.
|
||||
|
||||
When `_state` is provided, runs in stateless mode (no DB access) so this
|
||||
can be called from worker threads. CSRF errors are propagated to the
|
||||
caller via {"success": False, "csrf_error": True}.
|
||||
|
||||
Returns combined dict with:
|
||||
- EmasContractsEnt, EmasContractDetailsEnt, EmasContractTextDetailsEnt
|
||||
|
|
@ -2354,7 +2441,9 @@ def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47")
|
|||
|
||||
try:
|
||||
# 1. Get contract form data
|
||||
form_result = get_edit_form_data(asan_login_name, doc_oid, doc_no, doc_type)
|
||||
form_result = get_edit_form_data(asan_login_name, doc_oid, doc_no, doc_type, _state=_state)
|
||||
if form_result and form_result.get('csrf_error'):
|
||||
return form_result
|
||||
if form_result and form_result.get('success'):
|
||||
data = form_result.get('data')
|
||||
if data:
|
||||
|
|
@ -2366,22 +2455,24 @@ def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47")
|
|||
if 'EmasContractsEnt' in combined_data and isinstance(combined_data['EmasContractsEnt'], list) and len(combined_data['EmasContractsEnt']) > 0:
|
||||
staff_oid = combined_data['EmasContractsEnt'][0].get('staff')
|
||||
if staff_oid:
|
||||
staff_result = get_staff_data(asan_login_name, staff_oid)
|
||||
staff_result = get_staff_data(asan_login_name, staff_oid, _state=_state)
|
||||
if staff_result and staff_result.get('csrf_error'):
|
||||
return staff_result
|
||||
if staff_result and staff_result.get('success'):
|
||||
data = staff_result.get('data')
|
||||
if data:
|
||||
# staffData is on top level, not in resultData
|
||||
combined_data['staffData'] = data.get('staffData')
|
||||
|
||||
# 3. Get person data (need entity_oid from contract)
|
||||
if 'EmasContractsEnt' in combined_data and isinstance(combined_data['EmasContractsEnt'], list) and len(combined_data['EmasContractsEnt']) > 0:
|
||||
entity_oid = combined_data['EmasContractsEnt'][0].get('entity')
|
||||
if entity_oid:
|
||||
person_result = get_person_data(asan_login_name, entity_oid)
|
||||
person_result = get_person_data(asan_login_name, entity_oid, _state=_state)
|
||||
if person_result and person_result.get('csrf_error'):
|
||||
return person_result
|
||||
if person_result and person_result.get('success'):
|
||||
data = person_result.get('data')
|
||||
if data:
|
||||
# iamasBean and addressBean are on top level, not in resultData
|
||||
combined_data['iamasBean'] = data.get('iamasBean')
|
||||
combined_data['addressBean'] = data.get('addressBean')
|
||||
|
||||
|
|
@ -2389,20 +2480,21 @@ def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47")
|
|||
if 'EmasContractDetailsEnt' in combined_data and isinstance(combined_data['EmasContractDetailsEnt'], list) and len(combined_data['EmasContractDetailsEnt']) > 0:
|
||||
address_oid = combined_data['EmasContractDetailsEnt'][0].get('address')
|
||||
if address_oid:
|
||||
addr_result = get_address_data(asan_login_name, address_oid)
|
||||
addr_result = get_address_data(asan_login_name, address_oid, _state=_state)
|
||||
if addr_result and addr_result.get('csrf_error'):
|
||||
return addr_result
|
||||
if addr_result and addr_result.get('success'):
|
||||
data = addr_result.get('data')
|
||||
if data:
|
||||
# addressData is on top level, not in resultData
|
||||
combined_data['workAddress'] = data.get('addressData')
|
||||
|
||||
# 5. Get document main data
|
||||
doc_result = get_doc_main_data(asan_login_name, doc_oid, doc_no, doc_type)
|
||||
doc_result = get_doc_main_data(asan_login_name, doc_oid, doc_no, doc_type, _state=_state)
|
||||
if doc_result and doc_result.get('csrf_error'):
|
||||
return doc_result
|
||||
if doc_result and doc_result.get('success'):
|
||||
data = doc_result.get('data')
|
||||
if data:
|
||||
# Document fields are on top level, not in resultData
|
||||
# Build docMainData from top-level fields
|
||||
combined_data['docMainData'] = {
|
||||
'docOid': data.get('docOid'),
|
||||
'docNo': data.get('docNo'),
|
||||
|
|
@ -2413,16 +2505,19 @@ def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47")
|
|||
}
|
||||
|
||||
# 6. Get contract document (HTML)
|
||||
attachments_result = get_contract_attachments(asan_login_name, doc_oid)
|
||||
attachments_result = get_contract_attachments(asan_login_name, doc_oid, _state=_state)
|
||||
if attachments_result and attachments_result.get('csrf_error'):
|
||||
return attachments_result
|
||||
if attachments_result and attachments_result.get('success'):
|
||||
data = attachments_result.get('data')
|
||||
if data and 'attachList' in data:
|
||||
attach_list = data.get('attachList', [])
|
||||
if attach_list and len(attach_list) > 0:
|
||||
# Get the first attachment (main contract document)
|
||||
file_id = attach_list[0].get('fileId')
|
||||
if file_id:
|
||||
file_result = download_contract_file(asan_login_name, file_id)
|
||||
file_result = download_contract_file(asan_login_name, file_id, _state=_state)
|
||||
if file_result and file_result.get('csrf_error'):
|
||||
return file_result
|
||||
if file_result and file_result.get('success'):
|
||||
file_data = file_result.get('data')
|
||||
if file_data:
|
||||
|
|
@ -2637,7 +2732,10 @@ def on_delete_employee(doc, method):
|
|||
|
||||
@frappe.whitelist()
|
||||
def import_bulk_employees(asan_login_name, employees_data, company, create_designation=0):
|
||||
"""Enqueue bulk employee import as a background job with realtime progress via Socket.IO."""
|
||||
"""Enqueue bulk employee import as a background job with realtime progress via Socket.IO.
|
||||
|
||||
Refuses to enqueue if an import for the same Asan Login is already running.
|
||||
"""
|
||||
if isinstance(employees_data, str):
|
||||
employees_data = json.loads(employees_data)
|
||||
|
||||
|
|
@ -2646,6 +2744,22 @@ def import_bulk_employees(asan_login_name, employees_data, company, create_desig
|
|||
except (ValueError, TypeError):
|
||||
create_designation = 0
|
||||
|
||||
if frappe.db.get_value("Asan Login", asan_login_name, "amas_import_running"):
|
||||
return {
|
||||
"success": False,
|
||||
"already_running": True,
|
||||
"message": (
|
||||
"An ƏMAS import is already running for this Asan Login. "
|
||||
"Wait for it to finish, or click Cancel on the progress bar."
|
||||
),
|
||||
}
|
||||
|
||||
# Mark as running BEFORE enqueue so a fast double-click can't slip past.
|
||||
# Reset cancel flag from any previous run.
|
||||
frappe.db.set_value("Asan Login", asan_login_name, "amas_import_running", 1)
|
||||
frappe.db.set_value("Asan Login", asan_login_name, "amas_import_cancel_requested", 0)
|
||||
frappe.db.commit()
|
||||
|
||||
user = frappe.session.user
|
||||
|
||||
frappe.enqueue(
|
||||
|
|
@ -2662,8 +2776,98 @@ def import_bulk_employees(asan_login_name, employees_data, company, create_desig
|
|||
return {"success": True, "enqueued": True, "total": len(employees_data)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_amas_import_status(asan_login_name):
|
||||
"""Return whether an ƏMAS import is currently running for this Asan Login."""
|
||||
running = frappe.db.get_value("Asan Login", asan_login_name, "amas_import_running")
|
||||
return {"running": bool(running)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_amas_import(asan_login_name):
|
||||
"""Request cancellation of the running ƏMAS import.
|
||||
|
||||
The worker checks this flag between iterations; cancellation is best-effort
|
||||
(the worker finishes its current employee before bailing out).
|
||||
"""
|
||||
if not frappe.db.get_value("Asan Login", asan_login_name, "amas_import_running"):
|
||||
return {"success": False, "message": "No import is currently running."}
|
||||
|
||||
frappe.db.set_value("Asan Login", asan_login_name, "amas_import_cancel_requested", 1)
|
||||
frappe.db.commit()
|
||||
return {"success": True, "message": "Cancellation requested. The import will stop shortly."}
|
||||
|
||||
|
||||
BULK_IMPORT_PARALLELISM = 10
|
||||
|
||||
|
||||
def _fetch_employee_detail_for_bulk(asan_login_name, emp_data, state):
|
||||
"""Worker: fetch detail for one employee using stateless ƏMAS calls.
|
||||
|
||||
Returns dict {emp_data, detail_data, error, csrf_error}.
|
||||
"""
|
||||
doc_oid = emp_data.get("doc_oid")
|
||||
if not doc_oid:
|
||||
return {"emp_data": emp_data, "detail_data": None, "error": None, "csrf_error": False}
|
||||
|
||||
try:
|
||||
result = get_employee_detail(
|
||||
asan_login_name=asan_login_name,
|
||||
doc_oid=doc_oid,
|
||||
doc_no=emp_data.get("doc_no"),
|
||||
doc_type=emp_data.get("doc_type", "docType_47"),
|
||||
_state=state,
|
||||
)
|
||||
if result.get("csrf_error"):
|
||||
return {"emp_data": emp_data, "detail_data": None, "error": None, "csrf_error": True}
|
||||
if not result.get("success"):
|
||||
return {
|
||||
"emp_data": emp_data,
|
||||
"detail_data": None,
|
||||
"error": result.get("message", "fetch failed"),
|
||||
"csrf_error": False,
|
||||
}
|
||||
return {
|
||||
"emp_data": emp_data,
|
||||
"detail_data": result.get("data"),
|
||||
"error": None,
|
||||
"csrf_error": False,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"emp_data": emp_data, "detail_data": None, "error": str(e), "csrf_error": False}
|
||||
|
||||
|
||||
def _is_cancel_requested(asan_login_name):
|
||||
"""Fresh-from-DB check of the cancel flag (no Frappe doc cache)."""
|
||||
return bool(
|
||||
frappe.db.get_value("Asan Login", asan_login_name, "amas_import_cancel_requested")
|
||||
)
|
||||
|
||||
|
||||
def _clear_import_lock(asan_login_name):
|
||||
"""Clear running + cancel flags. Called from the worker's finally block."""
|
||||
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()
|
||||
|
||||
|
||||
def _process_bulk_employees_import(asan_login_name, employees, company, create_designation, user):
|
||||
"""Background job: import each employee with realtime progress."""
|
||||
"""Background job: import employees with realtime progress.
|
||||
|
||||
Phase A — parallel network: fetch get_employee_detail() for all employees
|
||||
concurrently using BULK_IMPORT_PARALLELISM threads. Workers share an
|
||||
in-memory snapshot of the ƏMAS session and never touch the DB. If any
|
||||
worker hits a CSRF error (HTTP 419 or response-level CSRF code), the
|
||||
whole import aborts with a hint to lower parallelism.
|
||||
|
||||
Phase B — sequential DB writes: pass the prefetched detail into
|
||||
create_single_employee_from_amas so it skips its own per-employee fetch.
|
||||
|
||||
The Asan Login.amas_import_running flag is cleared in `finally` no matter
|
||||
how the worker exits. The user can request cancellation by flipping
|
||||
Asan Login.amas_import_cancel_requested; we check it between iterations
|
||||
of both phases.
|
||||
"""
|
||||
frappe.set_user(user)
|
||||
|
||||
total = len(employees)
|
||||
|
|
@ -2671,44 +2875,185 @@ def _process_bulk_employees_import(asan_login_name, employees, company, create_d
|
|||
updated_count = 0
|
||||
errors = []
|
||||
|
||||
for idx, emp_data in enumerate(employees):
|
||||
emp_name = emp_data.get("full_name") or emp_data.get("identification_number") or "Employee"
|
||||
try:
|
||||
# Refresh the plaintext CSRF token from /core.dashboard once before going
|
||||
# parallel. The DB may have a stale (or previously-corrupted) token; the
|
||||
# parallel workers can't refresh on their own (no DB access), so we ensure
|
||||
# the snapshot starts from a known-good plaintext value.
|
||||
refreshed_session, refreshed_csrf = refresh_csrf_token_internal(asan_login_name)
|
||||
if not refreshed_session:
|
||||
frappe.publish_realtime(
|
||||
"amas_import_complete",
|
||||
{
|
||||
"total": total,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": [{"employee": "", "error": "ƏMAS session not found. Please reconnect."}],
|
||||
"aborted": True,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = create_single_employee_from_amas(
|
||||
asan_login_name=asan_login_name,
|
||||
emp_data=emp_data,
|
||||
company=company,
|
||||
create_designation=create_designation,
|
||||
asan_doc = frappe.get_doc("Asan Login", asan_login_name)
|
||||
state = {
|
||||
"session_data": refreshed_session,
|
||||
"csrf_token": refreshed_csrf or asan_doc.amas_csrf_token or refreshed_session.get("xsrf_token", ""),
|
||||
"cookies": dict(refreshed_session.get("all_cookies", {})),
|
||||
}
|
||||
|
||||
# Phase A: parallel detail fetches
|
||||
detail_by_idx = {}
|
||||
aborted_csrf = False
|
||||
cancelled = False
|
||||
completed = 0
|
||||
|
||||
with ThreadPoolExecutor(max_workers=BULK_IMPORT_PARALLELISM) as executor:
|
||||
futures = {
|
||||
executor.submit(_fetch_employee_detail_for_bulk, asan_login_name, emp, state): idx
|
||||
for idx, emp in enumerate(employees)
|
||||
}
|
||||
|
||||
for fut in as_completed(futures):
|
||||
idx = futures[fut]
|
||||
try:
|
||||
outcome = fut.result()
|
||||
except Exception as e:
|
||||
outcome = {
|
||||
"emp_data": employees[idx],
|
||||
"detail_data": None,
|
||||
"error": str(e),
|
||||
"csrf_error": False,
|
||||
}
|
||||
|
||||
if outcome["csrf_error"]:
|
||||
aborted_csrf = True
|
||||
for f in futures:
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
break
|
||||
|
||||
if _is_cancel_requested(asan_login_name):
|
||||
cancelled = True
|
||||
for f in futures:
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
break
|
||||
|
||||
detail_by_idx[idx] = outcome
|
||||
completed += 1
|
||||
emp_name = (
|
||||
outcome["emp_data"].get("full_name")
|
||||
or outcome["emp_data"].get("identification_number")
|
||||
or "Employee"
|
||||
)
|
||||
frappe.publish_realtime(
|
||||
"amas_import_progress",
|
||||
{"current": completed, "total": total, "employee_name": emp_name, "phase": "fetch"},
|
||||
user=user,
|
||||
)
|
||||
|
||||
if aborted_csrf:
|
||||
frappe.log_error(
|
||||
"Bulk import aborted: ƏMAS rejected a CSRF token during parallel fetch. "
|
||||
f"Parallelism was {BULK_IMPORT_PARALLELISM}.",
|
||||
"ƏMAS Bulk Import Abort"
|
||||
)
|
||||
frappe.publish_realtime(
|
||||
"amas_import_complete",
|
||||
{
|
||||
"total": total,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": [{
|
||||
"employee": "",
|
||||
"error": (
|
||||
f"ƏMAS rejected one of the parallel requests. The import was stopped to "
|
||||
f"avoid a partial state. This usually means the parallelism level "
|
||||
f"({BULK_IMPORT_PARALLELISM}) is too high — try lowering "
|
||||
f"BULK_IMPORT_PARALLELISM in invoice_az/amas_api.py to 5 and retry."
|
||||
),
|
||||
}],
|
||||
"aborted": True,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
return
|
||||
|
||||
if cancelled:
|
||||
frappe.publish_realtime(
|
||||
"amas_import_complete",
|
||||
{
|
||||
"total": total,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": [{"employee": "", "error": "Import cancelled by user."}],
|
||||
"aborted": True,
|
||||
"cancelled": True,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
return
|
||||
|
||||
# Phase B: sequential DB writes (Frappe ORM is not thread-safe).
|
||||
for idx in range(total):
|
||||
if _is_cancel_requested(asan_login_name):
|
||||
cancelled = True
|
||||
break
|
||||
|
||||
outcome = detail_by_idx.get(idx)
|
||||
if outcome is None:
|
||||
continue
|
||||
emp_data = outcome["emp_data"]
|
||||
emp_name = emp_data.get("full_name") or emp_data.get("identification_number") or "Employee"
|
||||
|
||||
if outcome["error"]:
|
||||
errors.append({"employee": emp_name, "error": outcome["error"]})
|
||||
frappe.publish_realtime(
|
||||
"amas_import_progress",
|
||||
{"current": idx + 1, "total": total, "employee_name": emp_name, "phase": "save"},
|
||||
user=user,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
result = create_single_employee_from_amas(
|
||||
asan_login_name=asan_login_name,
|
||||
emp_data=emp_data,
|
||||
company=company,
|
||||
create_designation=create_designation,
|
||||
prefetched_detail=outcome["detail_data"],
|
||||
)
|
||||
if result and result.get("success"):
|
||||
if result.get("action") == "created":
|
||||
created_count += 1
|
||||
elif result.get("action") == "updated":
|
||||
updated_count += 1
|
||||
else:
|
||||
error_msg = result.get("message", "Unknown error") if result else "Empty result"
|
||||
errors.append({"employee": emp_name, "error": error_msg})
|
||||
except Exception as e:
|
||||
errors.append({"employee": emp_name, "error": str(e)})
|
||||
|
||||
frappe.publish_realtime(
|
||||
"amas_import_progress",
|
||||
{"current": idx + 1, "total": total, "employee_name": emp_name, "phase": "save"},
|
||||
user=user,
|
||||
)
|
||||
|
||||
if result and result.get("success"):
|
||||
if result.get("action") == "created":
|
||||
created_count += 1
|
||||
elif result.get("action") == "updated":
|
||||
updated_count += 1
|
||||
else:
|
||||
error_msg = result.get("message", "Unknown error") if result else "Empty result"
|
||||
errors.append({"employee": emp_name, "error": error_msg})
|
||||
|
||||
except Exception as e:
|
||||
errors.append({"employee": emp_name, "error": str(e)})
|
||||
frappe.db.commit()
|
||||
|
||||
frappe.publish_realtime(
|
||||
"amas_import_progress",
|
||||
{"current": idx + 1, "total": total, "employee_name": emp_name},
|
||||
"amas_import_complete",
|
||||
{
|
||||
"total": total,
|
||||
"created": created_count,
|
||||
"updated": updated_count,
|
||||
"errors": errors,
|
||||
"cancelled": cancelled,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
frappe.db.commit()
|
||||
|
||||
frappe.publish_realtime(
|
||||
"amas_import_complete",
|
||||
{
|
||||
"total": total,
|
||||
"created": created_count,
|
||||
"updated": updated_count,
|
||||
"errors": errors,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
finally:
|
||||
_clear_import_lock(asan_login_name)
|
||||
|
|
|
|||
|
|
@ -273,17 +273,33 @@ function populate_amas_integration_tab(data) {
|
|||
$('#amas_integration_data').html(html);
|
||||
}
|
||||
|
||||
// Bootstrap 4.6 stacked-modal fix (twbs/bootstrap#4182): when any modal hides,
|
||||
// BS4 unconditionally strips `modal-open` from <body>, which orphans the
|
||||
// backdrop of any modal still open underneath (e.g. progress + nested confirm).
|
||||
// Re-apply the class if at least one modal is still visible. Idempotent: the
|
||||
// flag prevents duplicate listeners if this file is re-loaded.
|
||||
if (!window._amas_stacked_modal_fix_installed) {
|
||||
window._amas_stacked_modal_fix_installed = true;
|
||||
$(document).on('hidden.bs.modal', '.modal', function () {
|
||||
if ($('.modal.show').length) {
|
||||
$('body').addClass('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// List view — add "Load from ƏMAS" button
|
||||
if (frappe.listview_settings['Employee']) {
|
||||
const orig_onload = frappe.listview_settings['Employee'].onload;
|
||||
frappe.listview_settings['Employee'].onload = function(listview) {
|
||||
if (orig_onload) orig_onload(listview);
|
||||
add_amas_load_button(listview);
|
||||
reattach_amas_import_if_running(listview);
|
||||
};
|
||||
} else {
|
||||
frappe.listview_settings['Employee'] = {
|
||||
onload: function(listview) {
|
||||
add_amas_load_button(listview);
|
||||
reattach_amas_import_if_running(listview);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -294,6 +310,39 @@ function add_amas_load_button(listview) {
|
|||
});
|
||||
}
|
||||
|
||||
// On listview mount, check if a worker is currently running an import for any
|
||||
// of the user's connected Asan Logins. If yes, re-register the realtime
|
||||
// listeners so the user can see progress + Cancel on the bar even after a
|
||||
// page reload.
|
||||
function reattach_amas_import_if_running(listview) {
|
||||
frappe.call({
|
||||
method: 'invoice_az.amas_api.get_connected_asan_logins',
|
||||
callback: function(r) {
|
||||
if (!r.message || !r.message.success) return;
|
||||
const asan_logins = r.message.asan_logins || [];
|
||||
if (!asan_logins.length) return;
|
||||
|
||||
// Probe each login until we find one with an active job.
|
||||
const tryNext = function(idx) {
|
||||
if (idx >= asan_logins.length) return;
|
||||
const name = asan_logins[idx].name;
|
||||
frappe.call({
|
||||
method: 'invoice_az.amas_api.get_amas_import_status',
|
||||
args: { asan_login_name: name },
|
||||
callback: function(r2) {
|
||||
if (r2.message && r2.message.running) {
|
||||
attach_amas_import_listeners(listview, name, 0);
|
||||
} else {
|
||||
tryNext(idx + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
tryNext(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Get default connected Asan Login → fetch employees from ƏMAS API
|
||||
function start_amas_employee_import(listview) {
|
||||
frappe.call({
|
||||
|
|
@ -597,21 +646,82 @@ function show_employee_selection(listview, asan_login_name, organization_name, e
|
|||
}
|
||||
|
||||
// Step 5: Create employees via backend - Socket.IO bulk import
|
||||
function create_employees(listview, asan_login_name, selected_employees, company, create_designation) {
|
||||
const total = selected_employees.length;
|
||||
// Wire realtime listeners for an in-flight ƏMAS import. Used both when the
|
||||
// user starts a new import and when reattaching to an already-running one
|
||||
// after a page reload.
|
||||
function attach_amas_import_listeners(listview, asan_login_name, total_hint) {
|
||||
const total = total_hint || 0;
|
||||
|
||||
frappe.show_progress(
|
||||
__('Loading from ƏMAS'), 0, total,
|
||||
__('Starting import of {0} employees...', [total])
|
||||
// Reused single confirm dialog. frappe.confirm creates a fresh dialog on
|
||||
// each call and never removes the old element from DOM, which causes
|
||||
// Bootstrap's modal-backdrop stack to glitch on repeat use.
|
||||
let cancel_confirm_dialog = null;
|
||||
|
||||
const ensure_cancel_button = function(dialog) {
|
||||
if (!dialog || dialog._amas_cancel_attached) return;
|
||||
dialog._amas_cancel_attached = true;
|
||||
dialog.set_primary_action(__('Cancel Import'), function() {
|
||||
if (!cancel_confirm_dialog) {
|
||||
cancel_confirm_dialog = new frappe.ui.Dialog({
|
||||
title: __('Confirm'),
|
||||
primary_action_label: __('Yes, cancel'),
|
||||
primary_action: function() {
|
||||
cancel_confirm_dialog.hide();
|
||||
frappe.call({
|
||||
method: 'invoice_az.amas_api.cancel_amas_import',
|
||||
args: { asan_login_name: asan_login_name },
|
||||
callback: function() {
|
||||
frappe.show_alert({
|
||||
message: __('Cancellation requested. Finishing current employee...'),
|
||||
indicator: 'orange'
|
||||
}, 5);
|
||||
}
|
||||
});
|
||||
},
|
||||
secondary_action_label: __('No, keep importing'),
|
||||
secondary_action: function() {
|
||||
cancel_confirm_dialog.hide();
|
||||
}
|
||||
});
|
||||
cancel_confirm_dialog.$body.append(
|
||||
'<p>' + __('Cancel the running ƏMAS import? Already-saved employees will stay; the rest will be skipped.') + '</p>'
|
||||
);
|
||||
}
|
||||
cancel_confirm_dialog.show();
|
||||
// Progress dialog forces z-index 2000; lift the confirm above it.
|
||||
cancel_confirm_dialog.$wrapper.css('z-index', 2100);
|
||||
});
|
||||
};
|
||||
|
||||
// Hand the reusable confirm dialog over to the import-complete handler so
|
||||
// it can fully remove the leftover element from DOM when the import ends.
|
||||
const cleanup_cancel_dialog = function() {
|
||||
if (cancel_confirm_dialog) {
|
||||
cancel_confirm_dialog.hide();
|
||||
cancel_confirm_dialog.$wrapper.remove();
|
||||
cancel_confirm_dialog = null;
|
||||
}
|
||||
};
|
||||
|
||||
let dialog = frappe.show_progress(
|
||||
__('Importing from ƏMAS'), 0, total,
|
||||
total
|
||||
? __('Starting import of {0} employees...', [total])
|
||||
: __('Reconnecting to import in progress... waiting for next update')
|
||||
);
|
||||
ensure_cancel_button(dialog);
|
||||
|
||||
frappe.realtime.off('amas_import_progress');
|
||||
frappe.realtime.on('amas_import_progress', function(data) {
|
||||
let detail = __('Processing employee {0} of {1}', [data.current, data.total]);
|
||||
if (data.employee_name) {
|
||||
detail = __('Processing: ') + data.employee_name;
|
||||
}
|
||||
frappe.show_progress(__('Loading from ƏMAS'), data.current, data.total, detail);
|
||||
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);
|
||||
});
|
||||
|
||||
frappe.realtime.off('amas_import_complete');
|
||||
|
|
@ -619,6 +729,32 @@ function create_employees(listview, asan_login_name, selected_employees, company
|
|||
frappe.realtime.off('amas_import_progress');
|
||||
frappe.realtime.off('amas_import_complete');
|
||||
frappe.hide_progress();
|
||||
cleanup_cancel_dialog();
|
||||
|
||||
if (data.cancelled) {
|
||||
frappe.msgprint({
|
||||
title: __('Import Cancelled'),
|
||||
indicator: 'orange',
|
||||
message: __(
|
||||
'Cancelled. Created: {0}, updated: {1}.',
|
||||
[data.created || 0, data.updated || 0]
|
||||
)
|
||||
});
|
||||
listview.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.aborted) {
|
||||
const reason = (data.errors && data.errors[0] && data.errors[0].error)
|
||||
|| __('Import was aborted.');
|
||||
frappe.msgprint({
|
||||
title: __('Import Aborted'),
|
||||
indicator: 'red',
|
||||
message: reason
|
||||
});
|
||||
listview.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const error_messages = (data.errors || []).map(function(err) {
|
||||
return (err.employee || 'Unknown') + ': ' + (err.error || 'Unknown error');
|
||||
|
|
@ -629,6 +765,12 @@ function create_employees(listview, asan_login_name, selected_employees, company
|
|||
|
||||
listview.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function create_employees(listview, asan_login_name, selected_employees, company, create_designation) {
|
||||
const total = selected_employees.length;
|
||||
|
||||
attach_amas_import_listeners(listview, asan_login_name, total);
|
||||
|
||||
frappe.call({
|
||||
method: 'invoice_az.amas_api.import_bulk_employees',
|
||||
|
|
@ -639,6 +781,17 @@ function create_employees(listview, asan_login_name, selected_employees, company
|
|||
create_designation: create_designation ? 1 : 0
|
||||
},
|
||||
callback: function(r) {
|
||||
if (r.message && r.message.already_running) {
|
||||
frappe.realtime.off('amas_import_progress');
|
||||
frappe.realtime.off('amas_import_complete');
|
||||
frappe.hide_progress();
|
||||
frappe.msgprint({
|
||||
title: __('Import Already Running'),
|
||||
indicator: 'orange',
|
||||
message: r.message.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!r.message || !r.message.enqueued) {
|
||||
frappe.realtime.off('amas_import_progress');
|
||||
frappe.realtime.off('amas_import_complete');
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@
|
|||
"amas_account_oid",
|
||||
"amas_last_activity",
|
||||
"amas_session",
|
||||
"amas_csrf_token"
|
||||
"amas_csrf_token",
|
||||
"amas_import_running",
|
||||
"amas_import_cancel_requested"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -205,6 +207,20 @@
|
|||
"fieldtype": "Small Text",
|
||||
"hidden": 1,
|
||||
"label": "\u018fMAS CSRF Token"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "amas_import_running",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "\u018fMAS Import Running"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "amas_import_cancel_requested",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "\u018fMAS Import Cancel Requested"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
|
|
|
|||
Loading…
Reference in New Issue