invoice_az/invoice_az/amas_api.py

3060 lines
118 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
ƏMAS (e-social.gov.az) Integration API
This module provides functions to interact with ƏMAS (Employment Management System)
of Azərbaycan's Ministry of Labour and Social Protection.
ƏMAS uses MyGovID/ASAN authentication. The flow is:
1. Get MyGovID JWT token via ASAN Sign (same as existing MyGovID Login)
2. Use JWT token to get authorization code for ƏMAS via MyGovID OAuth API
3. Exchange authorization code for ƏMAS session via SSO callback
4. Use session cookies for ƏMAS API calls
"""
import http.client
import json
import random
import string
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import unquote
import frappe
import requests
# Increase header limit - AMAS returns many Set-Cookie headers
http.client._MAXHEADERS = 1000
# AMAS Base URL
AMAS_BASE_URL = "https://eroom.e-social.gov.az"
# AMAS API Endpoints
AMAS_SSO_CALLBACK = "/ssoAsan/v2"
AMAS_CHANGE_ACCOUNT = "/service/aas.changeAccount"
AMAS_EXECUTE_REPORT = "/service/magusbi.executeReport"
AMAS_GET_DOMAINS = "/service/aas.domains"
AMAS_GET_ACCOUNTS = "/request/accounts"
# MyGovID OAuth API
MYGOVID_API_URL = "https://api.mygovid.gov.az"
MYGOVID_AUTH_CODES_URL = f"{MYGOVID_API_URL}/ssoauth/oauth2/auth/codes"
# AMAS OAuth Client ID (from HAR analysis)
AMAS_CLIENT_ID = "64942e33ec8d49059333a1e0ebad7fe2"
AMAS_REDIRECT_URI = f"{AMAS_BASE_URL}{AMAS_SSO_CALLBACK}"
# AMAS request headers
AMAS_HEADERS = {
"Accept": "text/plain, */*; q=0.01",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Origin": AMAS_BASE_URL,
"Referer": f"{AMAS_BASE_URL}/",
"X-MAGUS-RECAPTCHA": "null",
}
MYGOVID_HEADERS = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "az",
"Content-Type": "application/json",
"Origin": "https://mygovid.gov.az",
"Referer": "https://mygovid.gov.az/",
}
def generate_request_number():
"""Generate a unique request number for AMAS API calls"""
# Format: 1XXXXXXXXXXXXXXX (alphanumeric)
chars = string.ascii_uppercase + string.digits
return "1" + "".join(random.choices(chars, k=13))
def generate_state():
"""Generate a unique state for SSO flow"""
# Format: XXXXXXXXXXXXX:eroom:emas
chars = string.hexdigits.lower()[:16]
return "".join(random.choices(chars, k=13)) + ":eroom:emas"
def parse_cookies_from_response(response):
"""Extract cookies from response and return as dict"""
cookies = {}
for cookie in response.cookies:
cookies[cookie.name] = cookie.value
return cookies
AMAS_GENERIC_UNAVAILABLE_MESSAGE = (
"ƏMAS service is currently unavailable. Please try again in a few minutes."
)
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.
ƏMAS returns codes like 'PERMISSION_ERROR' which are useless for end users.
Returns a dict that callers can spread into their failure response — it always
contains 'message', and sets 'permission_error': True for the permission case
so the frontend can offer a Reconnect action.
"""
code_str = (str(code) if code else "").strip().lower()
msg_str = (str(message) if message else "").strip().lower()
if "permission" in code_str or "permission" in msg_str:
return {
"message": (
"The selected ƏMAS certificate doesn't have permission for this "
"operation. You can reconnect to ƏMAS and choose a different "
"organization (certificate)."
),
"permission_error": True,
}
return {"message": AMAS_GENERIC_UNAVAILABLE_MESSAGE}
@frappe.whitelist()
def connect_amas(asan_login_name):
"""
Connect to AMAS using MyGovID JWT token.
This is the main entry point for AMAS connection.
If MyGovID token exists - uses it to get AMAS session automatically.
If not - returns error asking user to login with MyGovID first.
"""
try:
doc = frappe.get_doc("Asan Login", asan_login_name)
# Check if we have MyGovID token
if not doc.mygovid_token:
return {
"success": False,
"need_mygovid_login": True,
"message": "MyGovID token not found. Please login with MyGovID first."
}
# Step 1: Get authorization code from MyGovID
auth_code_result = get_amas_auth_code(doc.mygovid_token)
if not auth_code_result.get("success"):
# Token might be expired
if "401" in str(auth_code_result.get("message", "")):
return {
"success": False,
"need_mygovid_login": True,
"message": "MyGovID token expired. Please login with MyGovID again."
}
return auth_code_result
auth_code = auth_code_result.get("code")
state = auth_code_result.get("state")
# Step 2: Exchange authorization code for EMAS session
session_result = exchange_code_for_session(auth_code, state)
if not session_result.get("success"):
return session_result
# Step 3: Save session data (but don't save account yet - user must select)
session_data = session_result.get("session_data")
doc.amas_session = json.dumps(session_data)
doc.amas_csrf_token = session_data.get("xsrf_token")
doc.amas_auth_status = "Connected"
doc.amas_last_activity = frappe.utils.now_datetime()
# Clear any previous account selection - user must choose
doc.amas_account_oid = None
doc.amas_account_name = None
doc.amas_account_number = None
doc.save(ignore_permissions=True)
frappe.db.commit()
# Step 4: Fetch available accounts from /request/accounts
accounts_result = get_amas_accounts(asan_login_name)
accounts = accounts_result.get("accounts", []) if accounts_result.get("success") else []
return {
"success": True,
"message": "ƏMAS connected! Please select an organization.",
"accounts": accounts,
"has_accounts": len(accounts) > 0,
"need_account_selection": True
}
except Exception as e:
frappe.log_error(
f"EMAS connect error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
def get_amas_auth_code(mygovid_token):
"""
Get EMAS authorization code using MyGovID JWT token.
This calls the MyGovID OAuth API to get an authorization code
that can be exchanged for EMAS session.
"""
try:
state = generate_state()
payload = {
"client_id": AMAS_CLIENT_ID,
"state": state,
"response_type": "code",
"redirect_uri": AMAS_REDIRECT_URI
}
headers = MYGOVID_HEADERS.copy()
headers["Authorization"] = mygovid_token
response = requests.post(
MYGOVID_AUTH_CODES_URL,
json=payload,
headers=headers,
timeout=30
)
if response.status_code == 201:
data = response.json()
return {
"success": True,
"code": data.get("code"),
"state": data.get("state", state)
}
elif response.status_code == 401:
return {
"success": False,
"message": "MyGovID token expired (401)"
}
else:
return {
"success": False,
"message": f"Failed to get auth code: status {response.status_code}"
}
except requests.exceptions.RequestException as e:
return {"success": False, "message": f"Network error: {str(e)}"}
except Exception as e:
return {"success": False, "message": f"Error: {str(e)}"}
def parse_config_from_html(html_text):
"""
Parse the 'var config = {...}' JavaScript object from EMAS HTML page.
Returns a dict with account info.
"""
import re
config = {}
# Extract the var config = {...} block
# Pattern matches: var config = { ... };
config_match = re.search(r'var\s+config\s*=\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}', html_text)
if not config_match:
return config
config_text = config_match.group(1)
# Parse individual fields using pattern: 'key' : 'value' or 'key' : value
# Fields we want to extract
fields = [
'token', 'accountOid', 'accountName', 'accountNumber',
'firstName', 'lastName', 'middleName', 'userId',
'userLevel', 'userBranchId', 'userBranchName', 'userPosId',
'customerOid', 'domain', 'loginType', 'entityType'
]
for field in fields:
# Try 'field' : 'value' pattern (string value)
pattern = rf"'{field}'\s*:\s*'([^']*)'"
match = re.search(pattern, config_text)
if match:
config[field] = match.group(1)
continue
# Try 'field' : value pattern (non-string value)
pattern = rf"'{field}'\s*:\s*([^,\n]+)"
match = re.search(pattern, config_text)
if match:
value = match.group(1).strip().strip("'\"")
config[field] = value
return config
def exchange_code_for_session(code, state):
"""
Exchange authorization code for EMAS session.
Makes request to EMAS SSO callback endpoint and captures session cookies.
Also parses the account info from the HTML response config.
"""
try:
# Build SSO callback URL
callback_url = f"{AMAS_REDIRECT_URI}?code={code}&state={state}"
# Create session to capture cookies
session = requests.Session()
response = session.get(
callback_url,
allow_redirects=True,
timeout=30
)
# Extract cookies
cookies = {}
for cookie in session.cookies:
cookies[cookie.name] = cookie.value
# Look for required cookies
session_cookie = cookies.get("emek_ve_mesgulluq_alt_sistemi_session")
xsrf_token_raw = cookies.get("XSRF-TOKEN")
# Also check for _s cookie (Laravel session with data)
s_cookie = cookies.get("emek_ve_mesgulluq_alt_sistemi_s")
if not session_cookie:
return {
"success": False,
"message": "Failed to get ƏMAS session cookie"
}
# IMPORTANT: Laravel's XSRF-TOKEN cookie is URL-encoded, must decode it!
# The cookie value contains %3D instead of = etc.
xsrf_token = unquote(xsrf_token_raw) if xsrf_token_raw else None
# Try to extract CSRF token from HTML meta tag (preferred - already decoded)
import re
meta_csrf_token = None
match = re.search(r'name="csrf-token" content="([^"]+)"', response.text)
if match:
meta_csrf_token = match.group(1)
# Prefer meta tag token over cookie (meta tag is already decoded)
csrf_token = meta_csrf_token or xsrf_token
# Parse token and account info from HTML config
html_config = parse_config_from_html(response.text)
token = html_config.get("token")
# Extract account info from the config
account_info = None
if html_config.get("accountOid"):
account_info = {
"accountOid": html_config.get("accountOid"),
"accountName": html_config.get("accountName", ""),
"accountNumber": html_config.get("accountNumber", ""),
"userId": html_config.get("userId", ""),
"userLevel": html_config.get("userLevel", "owner"),
"branchId": html_config.get("userBranchId", "0000"),
"positionId": html_config.get("userPosId", "0000"),
}
session_data = {
"session_cookie": session_cookie,
"xsrf_token": csrf_token,
"s_cookie": s_cookie,
"token": token,
"all_cookies": cookies,
"account_info": account_info,
"html_config": html_config
}
return {
"success": True,
"session_data": session_data
}
except requests.exceptions.RequestException as e:
return {"success": False, "message": f"Network error: {str(e)}"}
except Exception as e:
return {"success": False, "message": f"Error: {str(e)}"}
def get_amas_session_data(asan_login_name):
"""Get EMAS session data from document"""
doc = frappe.get_doc("Asan Login", asan_login_name)
if not doc.amas_session:
return None
try:
return json.loads(doc.amas_session)
except (json.JSONDecodeError, TypeError):
return None
def refresh_csrf_token_internal(asan_login_name):
"""
Refresh CSRF token by making a GET request to EMAS dashboard.
EMAS requires fresh CSRF token for EVERY operation.
Returns updated session_data and csrf_token.
"""
import re
session_data = get_amas_session_data(asan_login_name)
if not session_data:
return None, None
doc = frappe.get_doc("Asan Login", asan_login_name)
cookies = session_data.get("all_cookies", {})
try:
# Make GET request to dashboard to get fresh CSRF token
response = requests.get(
f"{AMAS_BASE_URL}/core.dashboard",
cookies=cookies,
timeout=30
)
if response.status_code != 200:
return session_data, doc.amas_csrf_token
# Update cookies from response
for cookie in response.cookies:
cookies[cookie.name] = cookie.value
session_data["all_cookies"] = cookies
# Extract CSRF token from HTML meta tag (preferred)
csrf_token = None
match = re.search(r'name="csrf-token" content="([^"]+)"', response.text)
if match:
csrf_token = match.group(1)
# Also try from cookie
if not csrf_token and "XSRF-TOKEN" in cookies:
csrf_token = unquote(cookies["XSRF-TOKEN"])
if csrf_token:
doc.amas_csrf_token = csrf_token
session_data["xsrf_token"] = csrf_token
# Update token from page config if available
html_config = parse_config_from_html(response.text)
if html_config.get("token"):
session_data["token"] = html_config.get("token")
# Save updated session
doc.amas_session = json.dumps(session_data)
doc.amas_last_activity = frappe.utils.now_datetime()
doc.save(ignore_permissions=True)
frappe.db.commit()
return session_data, csrf_token
except Exception as e:
frappe.log_error(f"CSRF refresh error: {str(e)}", "ƏMAS API Error")
return session_data, doc.amas_csrf_token
def make_amas_request(asan_login_name, endpoint, data, _retried=False, _state=None):
"""Make an authenticated request to ƏMAS API.
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 {
"success": False,
"message": "ƏMAS session not found. Please connect to ƏMAS first."
}
doc = frappe.get_doc("Asan Login", asan_login_name)
csrf_token = doc.amas_csrf_token or session_data.get("xsrf_token", "")
cookies = session_data.get("all_cookies", {})
result = _post_amas(session_data, csrf_token, cookies, endpoint, data)
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.save(ignore_permissions=True)
frappe.db.commit()
return {"success": True, "data": api_data}
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 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": result.get("message", AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
@frappe.whitelist()
def get_amas_accounts(asan_login_name):
"""
Get list of organizations/accounts user has access to in EMAS.
Fetches from /request/accounts endpoint.
"""
try:
session_data = get_amas_session_data(asan_login_name)
if not session_data:
return {"success": False, "accounts": [], "message": "No ƏMAS session"}
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()
# Build cookies (updated by refresh)
cookies = session_data.get("all_cookies", {})
# Build request data with fresh token
data = {
"requestNumber": headers["MAGUS-REQUEST-NUMBER"],
"_token": csrf_token,
}
# Add currentTabToken
token = session_data.get("token")
if token:
data["currentTabToken"] = token
# Make request to /request/accounts
response = requests.post(
f"{AMAS_BASE_URL}{AMAS_GET_ACCOUNTS}",
data=data,
headers=headers,
cookies=cookies,
timeout=30
)
# Update cookies from response
new_cookies = parse_cookies_from_response(response)
if new_cookies:
cookies.update(new_cookies)
session_data["all_cookies"] = cookies
# Update CSRF token if changed
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
doc.amas_session = json.dumps(session_data)
doc.save(ignore_permissions=True)
frappe.db.commit()
if response.status_code == 200:
try:
accounts = response.json()
# The response is a direct array of accounts
if isinstance(accounts, list):
return {"success": True, "accounts": accounts}
else:
return {"success": False, "accounts": [], "message": "Unexpected response format"}
except json.JSONDecodeError:
return {"success": False, "accounts": [], "message": "Invalid JSON response"}
else:
return {"success": False, "accounts": [], "message": f"HTTP {response.status_code}"}
except Exception as e:
frappe.log_error(
f"EMAS get accounts error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}", "accounts": []}
@frappe.whitelist()
def change_amas_account(asan_login_name, account_oid, account_name, account_number, role_name="chairman"):
"""
Select/switch to a specific organization account in EMAS.
"""
try:
data = {
"accountOid": account_oid,
"accountName": account_name,
"accountNumber": account_number,
"roleName": role_name,
"branchId": "0000",
"positionId": "0000",
"domain": "emas"
}
result = make_amas_request(
asan_login_name,
AMAS_CHANGE_ACCOUNT,
data
)
if not result.get("success"):
return result
response_data = result.get("data", {})
# Check response code
response_info = response_data.get("response", {})
if response_info.get("code") != "0":
raw_code = response_info.get("code")
raw_msg = response_info.get("message")
frappe.log_error(
f"changeAccount failed: code={raw_code}, message={raw_msg}",
"ƏMAS Account Switch Error"
)
return {"success": False, **humanize_amas_error(raw_code, raw_msg)}
# Store selected account info
doc = frappe.get_doc("Asan Login", asan_login_name)
doc.amas_account_oid = account_oid
doc.amas_account_name = account_name
doc.amas_account_number = account_number
doc.amas_auth_status = "Connected"
doc.amas_last_activity = frappe.utils.now_datetime()
# Update session with token from response
token = response_data.get("token")
if token:
session_data = get_amas_session_data(asan_login_name)
if session_data:
session_data["token"] = token
doc.amas_session = json.dumps(session_data)
doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"success": True,
"message": f"Switched to account: {account_name}"
}
except Exception as e:
frappe.log_error(
f"EMAS change account error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
@frappe.whitelist()
def get_employees_report(asan_login_name, offset=0, limit=100):
"""
Get list of employees from EMAS.
Uses the emasValidEmploymentContractOnline report.
"""
try:
doc = frappe.get_doc("Asan Login", asan_login_name)
if not doc.amas_account_oid:
return {
"success": False,
"message": "No ƏMAS account selected. Please select an organization first."
}
data = {
"reportId": "emasValidEmploymentContractOnline",
"pagination[offset]": str(offset),
"pagination[limit]": str(limit),
"attributes[newExcel]": "true"
}
result = make_amas_request(asan_login_name, AMAS_EXECUTE_REPORT, data)
if not result.get("success"):
return result
response_data = result.get("data", {})
# Check response code
response_info = response_data.get("response", {})
if response_info.get("code") != "0":
raw_code = response_info.get("code")
raw_msg = response_info.get("message")
frappe.log_error(
f"getEmployeesReport failed: code={raw_code}, message={raw_msg}",
"ƏMAS Employees Report Error"
)
return {"success": False, **humanize_amas_error(raw_code, raw_msg)}
# Extract employees from result
employees = response_data.get("result", [])
has_more = response_data.get("hasMore", False)
return {
"success": True,
"employees": employees,
"has_more": has_more,
"offset": offset,
"limit": limit
}
except Exception as e:
frappe.log_error(
f"EMAS employees report error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
@frappe.whitelist()
def get_contract_stats(asan_login_name):
"""
Get employment contract statistics from EMAS.
"""
try:
doc = frappe.get_doc("Asan Login", asan_login_name)
if not doc.amas_account_oid:
return {
"success": False,
"message": "No ƏMAS account selected. Please select an organization first."
}
data = {
"reportId": "graphContractStatsEmployer",
"pagination[offset]": "0",
"pagination[limit]": "100"
}
result = make_amas_request(asan_login_name, AMAS_EXECUTE_REPORT, data)
if not result.get("success"):
return result
response_data = result.get("data", {})
response_info = response_data.get("response", {})
if response_info.get("code") != "0":
raw_code = response_info.get("code")
raw_msg = response_info.get("message")
frappe.log_error(
f"getContractStats failed: code={raw_code}, message={raw_msg}",
"ƏMAS Contract Stats Error"
)
return {"success": False, **humanize_amas_error(raw_code, raw_msg)}
stats = response_data.get("result", [])
return {
"success": True,
"stats": stats[0] if stats else {}
}
except Exception as e:
frappe.log_error(
f"EMAS contract stats error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
@frappe.whitelist()
def check_amas_connection(asan_login_name):
"""
Check if EMAS connection is active.
"""
try:
doc = frappe.get_doc("Asan Login", asan_login_name)
return {
"success": True,
"amas_auth_status": doc.amas_auth_status or "Not Connected",
"amas_account_name": doc.amas_account_name,
"amas_account_number": doc.amas_account_number,
"amas_last_activity": str(doc.amas_last_activity) if doc.amas_last_activity else None,
"has_session": bool(doc.amas_session),
"has_mygovid_token": bool(doc.mygovid_token)
}
except Exception as e:
frappe.log_error(
f"EMAS check connection error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
@frappe.whitelist()
def refresh_amas_session(asan_login_name):
"""
Refresh EMAS session by making a GET request to get fresh CSRF token.
Use this when you get CSRF_TOKEN_MISMATCH errors.
"""
try:
doc = frappe.get_doc("Asan Login", asan_login_name)
session_data = get_amas_session_data(asan_login_name)
if not session_data:
return {
"success": False,
"message": "No ƏMAS session found. Please connect first."
}
cookies = session_data.get("all_cookies", {})
# Make GET request to EMAS dashboard to get fresh CSRF token
response = requests.get(
f"{AMAS_BASE_URL}/core.dashboard",
cookies=cookies,
timeout=30
)
if response.status_code != 200:
return {
"success": False,
"message": f"Failed to refresh session: status {response.status_code}"
}
# Update cookies
for cookie in response.cookies:
cookies[cookie.name] = cookie.value
session_data["all_cookies"] = cookies
# Extract new CSRF token from HTML
import re
match = re.search(r'name="csrf-token" content="([^"]+)"', response.text)
if match:
new_csrf_token = match.group(1)
doc.amas_csrf_token = new_csrf_token
session_data["xsrf_token"] = new_csrf_token
# Also update from cookie if available
if "XSRF-TOKEN" in cookies:
decoded_token = unquote(cookies["XSRF-TOKEN"])
# Prefer meta tag token, but update session data
if not match:
doc.amas_csrf_token = decoded_token
session_data["xsrf_token"] = decoded_token
# Parse config for updated token
html_config = parse_config_from_html(response.text)
if html_config.get("token"):
session_data["token"] = html_config.get("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,
"message": "ƏMAS session refreshed successfully"
}
except Exception as e:
frappe.log_error(
f"EMAS refresh session error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
@frappe.whitelist()
def disconnect_amas(asan_login_name):
"""
Disconnect from EMAS by clearing session data.
"""
try:
doc = frappe.get_doc("Asan Login", asan_login_name)
doc.amas_session = None
doc.amas_csrf_token = None
doc.amas_account_oid = None
doc.amas_account_name = None
doc.amas_account_number = None
doc.amas_auth_status = "Not Connected"
doc.amas_last_activity = None
doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"success": True,
"message": "Disconnected from ƏMAS"
}
except Exception as e:
frappe.log_error(
f"EMAS disconnect error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
@frappe.whitelist()
def get_connected_asan_logins():
"""
Get list of Asan Logins with active ƏMAS connection.
Prioritizes is_default = 1, similar to e-taxes.
"""
try:
# First try to get default Asan Login with ƏMAS connection
default_login = frappe.get_all(
"Asan Login",
filters={
"is_default": 1,
"amas_auth_status": "Connected",
"amas_account_oid": ["is", "set"]
},
fields=["name", "amas_account_name", "amas_account_number", "amas_account_oid"],
limit=1
)
if default_login:
# Use default login
asan_logins = default_login
else:
# Fall back to any connected login
asan_logins = frappe.get_all(
"Asan Login",
filters={
"amas_auth_status": "Connected",
"amas_account_oid": ["is", "set"]
},
fields=["name", "amas_account_name", "amas_account_number", "amas_account_oid"],
limit=1
)
result = []
for al in asan_logins:
result.append({
"name": al.name,
"organization_name": al.amas_account_name or "Unknown",
"organization_voen": al.amas_account_number or ""
})
return {
"success": True,
"asan_logins": result
}
except Exception as e:
frappe.log_error(
f"Get connected Asan Logins error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}", "asan_logins": []}
# Exact list of API fields that map 1:1 to Əmas Employees DocType fields
AMAS_EMPLOYEE_FIELDS = [
"full_name",
"identification_number",
"ssn",
"birthday",
"work_position_text",
"monthly_salary",
"doc_no",
"doc_date",
"doc_type",
"contract_type",
"contract_status",
"begin_date",
"end_date",
"main_contract_begin_date",
"emp_job_start_date",
"sign_type",
"is_valid",
"dur_state",
"operation",
"after_exec_label",
"workplace_type",
"form_size",
"flow",
"flow_alias",
"tree_path_name",
"view_type",
"doc_oid",
]
@frappe.whitelist()
def import_employees(asan_login_name, employees):
"""
Import employees from ƏMAS to Əmas Employees doctype.
NOW FETCHES COMPLETE DETAIL DATA for each employee.
Args:
asan_login_name: Name of the Asan Login document
employees: List of employee data from ƏMAS API (list report)
"""
try:
if isinstance(employees, str):
employees = json.loads(employees)
doc = frappe.get_doc("Asan Login", asan_login_name)
organization_name = doc.amas_account_name
organization_voen = doc.amas_account_number
imported_count = 0
updated_count = 0
error_count = 0
errors = []
for emp_data in employees:
try:
doc_oid = emp_data.get('doc_oid')
doc_no = emp_data.get('doc_no')
doc_type = emp_data.get('doc_type', 'docType_47')
if not doc_oid:
# Can't fetch details without doc_oid, skip
name_val = emp_data.get('full_name', '?')
errors.append(f"Employee {name_val}: No doc_oid")
error_count += 1
continue
# FETCH COMPLETE DETAIL DATA
detail_result = get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type)
if not detail_result.get('success'):
# Detail fetch failed, use list data as fallback
name_val = emp_data.get('full_name', '?')
frappe.log_error(
f"Failed to fetch detail for {doc_oid}: {detail_result.get('message')}",
"EMAS Employee Detail Fetch Error"
)
errors.append(f"{name_val}: Detail fetch failed, using list data")
# Fallback to list data
fin = emp_data.get("identification_number")
if not fin:
error_count += 1
continue
fin = str(fin).strip()
existing = frappe.db.exists(
"Amas Employees",
{"identification_number": fin, "organization_voen": organization_voen}
)
if existing:
employee_doc = frappe.get_doc("Amas Employees", existing)
update_employee_from_data(employee_doc, emp_data)
employee_doc.save(ignore_permissions=True)
updated_count += 1
else:
employee_doc = frappe.new_doc("Amas Employees")
employee_doc.asan_login = asan_login_name
employee_doc.organization_name = organization_name
employee_doc.organization_voen = organization_voen
update_employee_from_data(employee_doc, emp_data)
employee_doc.insert(ignore_permissions=True)
imported_count += 1
continue
detail_data = detail_result.get('data')
# Get FIN from detail data (more reliable than list)
fin = None
if detail_data and 'EmasContractsEnt' in detail_data and isinstance(detail_data.get('EmasContractsEnt'), list) and len(detail_data['EmasContractsEnt']) > 0:
fin = detail_data['EmasContractsEnt'][0].get('pin')
if not fin:
name_val = emp_data.get('full_name', '?')
errors.append(f"{name_val}: No FIN in detail data")
error_count += 1
continue
fin = str(fin).strip()
# Check if exists
existing = frappe.db.exists(
"Amas Employees",
{
"identification_number": fin,
"organization_voen": organization_voen
}
)
if existing:
employee_doc = frappe.get_doc("Amas Employees", existing)
# First populate basic fields from list data
update_employee_from_data(employee_doc, emp_data)
# Then overlay with detailed data
update_employee_from_detail_data(employee_doc, detail_data)
# Ensure FIN is set (fallback to verified FIN)
if not employee_doc.identification_number:
employee_doc.identification_number = fin
employee_doc.save(ignore_permissions=True)
updated_count += 1
else:
employee_doc = frappe.new_doc("Amas Employees")
employee_doc.asan_login = asan_login_name
employee_doc.organization_name = organization_name
employee_doc.organization_voen = organization_voen
# Set FIN first (required field)
employee_doc.identification_number = fin
# First populate basic fields from list data
update_employee_from_data(employee_doc, emp_data)
# Then overlay with detailed data
update_employee_from_detail_data(employee_doc, detail_data)
# Ensure FIN is still set after mapping
if not employee_doc.identification_number:
employee_doc.identification_number = fin
employee_doc.insert(ignore_permissions=True)
imported_count += 1
except Exception as e:
name_val = emp_data.get('full_name', '?')
error_msg = f"{name_val}: {str(e)}"
frappe.log_error(
f"{error_msg}\n{frappe.get_traceback()}",
"EMAS Employee Import Error"
)
errors.append(error_msg)
error_count += 1
frappe.db.commit()
return {
"success": True,
"imported_count": imported_count,
"updated_count": updated_count,
"error_count": error_count,
"errors": errors[:10],
"total": len(employees)
}
except Exception as e:
frappe.log_error(
f"Import employees error: {str(e)}\n{frappe.get_traceback()}",
"EMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
def update_employee_from_data(employee_doc, emp_data):
"""
Update employee document fields from ƏMAS API data.
Hard 1:1 mapping - API field names match DocType field names exactly.
"""
# Map all API fields directly to DocType fields
for field in AMAS_EMPLOYEE_FIELDS:
value = emp_data.get(field)
if value is not None:
setattr(employee_doc, field, str(value))
else:
setattr(employee_doc, field, None)
# Store raw data for reference
employee_doc.raw_data = json.dumps(emp_data, ensure_ascii=False, indent=2)
# Import timestamp
employee_doc.import_date = frappe.utils.now_datetime()
def parse_amas_date(date_str):
"""
Parse date string from ƏMAS into YYYY-MM-DD format for Frappe Date fields.
ƏMAS dates can be in various formats.
"""
if not date_str or not str(date_str).strip():
return None
date_str = str(date_str).strip()
from datetime import datetime
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(date_str, fmt).strftime("%Y-%m-%d")
except (ValueError, TypeError):
continue
return None
def map_gender(amas_gender):
"""
Transform AMAS gender to ERPNext gender.
AMAS values: "Kişi" (Male), "Qadın" (Female), "Male", "Female"
ERPNext values: "Male", "Female", "Other"
"""
if not amas_gender:
return "Male" # Default fallback
gender_str = str(amas_gender).strip().lower()
# Map Azərbaycan values
if gender_str in ("kişi", "kisi", "male", "m", "erkək", "erkek"):
return "Male"
elif gender_str in ("qadın", "qadin", "female", "f", "qız", "qiz"):
return "Female"
return "Male" # Default fallback
def map_marital_status(amas_status):
"""
Transform AMAS marital status to ERPNext marital status.
AMAS values: "Evli", "Subay", "Married", "Single", etc.
ERPNext values: "Single", "Married", "Divorced", "Widowed"
"""
if not amas_status:
return None
status_str = str(amas_status).strip().lower()
# Map Azərbaycan values
if status_str in ("evli", "married", "nikahli", "nikаhlı"):
return "Married"
elif status_str in ("subay", "subаy", "single", "bekar", "bekаr"):
return "Single"
elif status_str in ("boşanmış", "bosanmis", "divorced"):
return "Divorced"
elif status_str in ("dul", "widowed", "widow"):
return "Widowed"
return None
def map_blood_group(amas_blood):
"""
Transform AMAS blood type to ERPNext blood group.
AMAS format: "A(II) Rh+", "O(I) Rh-", etc.
ERPNext values: "A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"
"""
if not amas_blood:
return None
blood_str = str(amas_blood).strip().upper()
# Extract blood type and Rh factor
# Examples: "A(II) Rh+", "AB(IV) Rh-", "O(I) Rh+"
# Map blood group letter
if "AB" in blood_str or "IV" in blood_str:
blood_type = "AB"
elif "A" in blood_str or "II" in blood_str:
blood_type = "A"
elif "B" in blood_str or "III" in blood_str:
blood_type = "B"
elif "O" in blood_str or "I" in blood_str or "0" in blood_str:
blood_type = "O"
else:
return None
# Map Rh factor
if "+" in blood_str or "POSITIVE" in blood_str or "POZİTİV" in blood_str:
rh_factor = "+"
elif "-" in blood_str or "NEGATIVE" in blood_str or "NEQATİV" in blood_str:
rh_factor = "-"
else:
rh_factor = "+" # Default to positive
return f"{blood_type}{rh_factor}"
def build_full_address(address, city, district):
"""
Combine AMAS address fields into single address string.
"""
parts = []
if address:
parts.append(str(address).strip())
if district:
parts.append(str(district).strip())
if city:
parts.append(str(city).strip())
return ", ".join(parts) if parts else None
def resolve_designation(position_text, create_if_missing):
"""
Find or create a Designation from ƏMAS position text.
Returns designation name or None.
"""
if not position_text or not str(position_text).strip():
return None
position_text = str(position_text).strip()
# Search for existing designation by name
existing = frappe.db.exists("Designation", position_text)
if existing:
return existing
# Try case-insensitive search by designation_name field
result = frappe.db.get_value(
"Designation",
filters={"designation_name": ["like", position_text]},
fieldname="name"
)
if result:
return result
# Not found — create if allowed
if create_if_missing:
designation = frappe.new_doc("Designation")
designation.designation_name = position_text
designation.insert(ignore_permissions=True)
return designation.name
return None
@frappe.whitelist()
def create_single_employee_from_amas(asan_login_name, employee_data, company, create_designation=0):
"""
Create/update single Employee record from ƏMAS data.
Used for sequential processing with frontend progress updates.
"""
try:
if isinstance(employee_data, str):
employee_data = json.loads(employee_data)
create_designation = int(create_designation)
if not company:
return {"success": False, "message": "Company is required"}
doc = frappe.get_doc("Asan Login", asan_login_name)
organization_name = doc.amas_account_name
organization_voen = doc.amas_account_number
# Status mapping
status_map = {
"qüvvədədir": "Active",
"aktiv": "Active",
"active": "Active",
"ləğv edilib": "Left",
"xitam": "Left",
"terminated": "Left",
"dayandırılıb": "Suspended",
"suspended": "Suspended",
}
doc_oid = employee_data.get("doc_oid")
doc_no = employee_data.get("doc_no")
doc_type = employee_data.get("doc_type", "docType_47")
# Fetch detail data
detail_data = None
full_name = str(employee_data.get("full_name") or "").strip()
if 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')
# Get FIN
fin = None
if detail_data and 'EmasContractsEnt' in detail_data and isinstance(detail_data.get('EmasContractsEnt'), list) and len(detail_data['EmasContractsEnt']) > 0:
fin = detail_data['EmasContractsEnt'][0].get('pin')
if 'iamasBean' in detail_data and detail_data.get('iamasBean'):
person = detail_data['iamasBean']
name = person.get('name', '')
surname = person.get('surname', '')
full_name = f"{name} {surname}".strip()
if not fin:
fin = employee_data.get("identification_number")
if not fin:
return {"success": False, "message": f"{full_name}: No identification_number"}
fin = str(fin).strip()
# Save/update Amas Employees record
amas_existing = None
if doc_oid:
amas_existing = frappe.db.exists("Amas Employees", {"doc_oid": str(doc_oid)})
if not amas_existing:
amas_existing = frappe.db.exists(
"Amas Employees",
{"identification_number": fin, "organization_voen": organization_voen}
)
if amas_existing:
amas_doc = frappe.get_doc("Amas Employees", amas_existing)
update_employee_from_data(amas_doc, employee_data)
if detail_data:
update_employee_from_detail_data(amas_doc, detail_data)
if not amas_doc.identification_number:
amas_doc.identification_number = fin
amas_doc.save(ignore_permissions=True)
else:
amas_doc = frappe.new_doc("Amas Employees")
amas_doc.asan_login = asan_login_name
amas_doc.organization_name = organization_name
amas_doc.organization_voen = organization_voen
amas_doc.identification_number = fin
update_employee_from_data(amas_doc, employee_data)
if detail_data:
update_employee_from_detail_data(amas_doc, detail_data)
if not amas_doc.identification_number:
amas_doc.identification_number = fin
amas_doc.insert(ignore_permissions=True)
# Create/update Employee record
existing_employee = frappe.db.exists("Employee", {"passport_number": fin})
# Split full_name
name_parts = full_name.split() if full_name else []
first_name = name_parts[0] if name_parts else "?"
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
# Parse dates
dob = None
doj = None
contract_end = None
if detail_data:
if 'iamasBean' in detail_data and detail_data.get('iamasBean'):
dob = parse_amas_date(detail_data['iamasBean'].get('birthDate'))
if 'EmasContractDetailsEnt' in detail_data and isinstance(detail_data.get('EmasContractDetailsEnt'), list) and len(detail_data['EmasContractDetailsEnt']) > 0:
details = detail_data['EmasContractDetailsEnt'][0]
doj = parse_amas_date(details.get('beginDate'))
contract_end = parse_amas_date(details.get('endDate'))
else:
dob = parse_amas_date(employee_data.get("birthday"))
doj = parse_amas_date(employee_data.get("begin_date"))
contract_end = parse_amas_date(employee_data.get("end_date"))
# Map status
raw_status = ""
if detail_data and 'EmasContractsEnt' in detail_data and isinstance(detail_data.get('EmasContractsEnt'), list) and len(detail_data['EmasContractsEnt']) > 0:
raw_status = str(detail_data['EmasContractsEnt'][0].get('contractStatus') or "").strip().lower()
else:
raw_status = str(employee_data.get("contract_status") or "").strip().lower()
status = status_map.get(raw_status, "Active")
# Parse salary
salary = None
if detail_data and 'EmasContractDetailsEnt' in detail_data and isinstance(detail_data.get('EmasContractDetailsEnt'), list) and len(detail_data['EmasContractDetailsEnt']) > 0:
raw_salary = detail_data['EmasContractDetailsEnt'][0].get('monthlySalary')
else:
raw_salary = employee_data.get("monthly_salary")
if raw_salary:
try:
salary = float(str(raw_salary).replace(",", ".").replace(" ", ""))
except (ValueError, TypeError):
pass
# Resolve designation
position_text = None
if detail_data and 'staffData' in detail_data and detail_data.get('staffData'):
position_text = detail_data['staffData'].get('positionText')
else:
position_text = employee_data.get("work_position_text")
designation = resolve_designation(position_text, create_designation)
# Extract additional fields from amas_doc (already populated with detail data)
middle_name = amas_doc.father_name if hasattr(amas_doc, 'father_name') else None
gender = map_gender(amas_doc.gender if hasattr(amas_doc, 'gender') else None)
cell_number = amas_doc.contact_phone if hasattr(amas_doc, 'contact_phone') else None
personal_email = amas_doc.contact_email if hasattr(amas_doc, 'contact_email') else None
marital_status = map_marital_status(amas_doc.marital_status if hasattr(amas_doc, 'marital_status') else None)
blood_group = map_blood_group(amas_doc.blood_type if hasattr(amas_doc, 'blood_type') else None)
date_of_issue = parse_amas_date(amas_doc.document_issue_date if hasattr(amas_doc, 'document_issue_date') else None)
valid_upto = parse_amas_date(amas_doc.document_expiry_date if hasattr(amas_doc, 'document_expiry_date') else None)
place_of_issue = amas_doc.document_issued_by if hasattr(amas_doc, 'document_issued_by') else None
current_address = build_full_address(
amas_doc.personal_address if hasattr(amas_doc, 'personal_address') else None,
amas_doc.personal_city if hasattr(amas_doc, 'personal_city') else None,
amas_doc.personal_district if hasattr(amas_doc, 'personal_district') else None
)
if existing_employee:
emp = frappe.get_doc("Employee", existing_employee)
emp.first_name = first_name
if middle_name:
emp.middle_name = middle_name
emp.last_name = last_name
emp.employee_name = full_name or first_name
emp.gender = gender
if dob:
emp.date_of_birth = dob
if doj:
emp.date_of_joining = doj
if contract_end:
emp.contract_end_date = contract_end
if salary is not None:
emp.ctc = salary
if designation:
emp.designation = designation
if cell_number:
emp.cell_number = cell_number
if personal_email:
emp.personal_email = personal_email
if marital_status:
emp.marital_status = marital_status
if blood_group:
emp.blood_group = blood_group
if date_of_issue:
emp.date_of_issue = date_of_issue
if valid_upto:
emp.valid_upto = valid_upto
if place_of_issue:
emp.place_of_issue = place_of_issue
if current_address:
emp.current_address = current_address
# Link to AMAS Employees record
emp.custom_amas_employee = amas_doc.name
emp.save(ignore_permissions=True)
# Link Amas Employees → Employee
amas_doc.employee = emp.name
amas_doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"success": True,
"updated": True,
"created": False,
"employee_name": emp.name
}
else:
emp = frappe.new_doc("Employee")
emp.first_name = first_name
if middle_name:
emp.middle_name = middle_name
emp.last_name = last_name
emp.company = company
emp.passport_number = fin
emp.employee_number = fin
emp.date_of_birth = dob or "2000-01-01"
emp.date_of_joining = doj or frappe.utils.today()
emp.gender = gender
emp.status = status
if contract_end:
emp.contract_end_date = contract_end
if salary is not None:
emp.ctc = salary
if designation:
emp.designation = designation
if cell_number:
emp.cell_number = cell_number
if personal_email:
emp.personal_email = personal_email
if marital_status:
emp.marital_status = marital_status
if blood_group:
emp.blood_group = blood_group
if date_of_issue:
emp.date_of_issue = date_of_issue
if valid_upto:
emp.valid_upto = valid_upto
if place_of_issue:
emp.place_of_issue = place_of_issue
if current_address:
emp.current_address = current_address
# Link to AMAS Employees record
emp.custom_amas_employee = amas_doc.name
emp.insert(ignore_permissions=True)
# Link Amas Employees → Employee
amas_doc.employee = emp.name
amas_doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"success": True,
"created": True,
"updated": False,
"employee_name": emp.name
}
except Exception as e:
frappe.log_error(
f"Single employee create error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS Employee Create Error"
)
return {
"success": False,
"message": str(e)
}
@frappe.whitelist()
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.
Args:
asan_login_name: Asan Login name
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:
{
"success": True/False,
"action": "created"/"updated"/"error",
"employee_name": "...",
"full_name": "...",
"message": "..."
}
"""
try:
if isinstance(emp_data, str):
emp_data = json.loads(emp_data)
create_designation = int(create_designation)
if not company:
return {"success": False, "message": "Company is required"}
doc = frappe.get_doc("Asan Login", asan_login_name)
organization_name = doc.amas_account_name
organization_voen = doc.amas_account_number
# Status mapping
status_map = {
"qüvvədədir": "Active",
"aktiv": "Active",
"active": "Active",
"ləğv edilib": "Left",
"xitam": "Left",
"terminated": "Left",
"dayandırılıb": "Suspended",
"suspended": "Suspended",
}
doc_oid = emp_data.get("doc_oid")
doc_no = emp_data.get("doc_no")
doc_type = emp_data.get("doc_type", "docType_47")
# 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 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')
# Get FIN from detail or list data
fin = None
if detail_data and 'EmasContractsEnt' in detail_data and isinstance(detail_data.get('EmasContractsEnt'), list) and len(detail_data['EmasContractsEnt']) > 0:
fin = detail_data['EmasContractsEnt'][0].get('pin')
# Also update full_name from detail data if available
if 'iamasBean' in detail_data and detail_data.get('iamasBean'):
person = detail_data['iamasBean']
name = person.get('name', '')
surname = person.get('surname', '')
full_name = f"{name} {surname}".strip()
if not fin:
fin = emp_data.get("identification_number")
if not fin:
return {
"success": False,
"action": "error",
"full_name": full_name,
"message": f"{full_name}: No identification_number"
}
fin = str(fin).strip()
# 1. Save/update Amas Employees record
amas_existing = None
if doc_oid:
amas_existing = frappe.db.exists("Amas Employees", {"doc_oid": str(doc_oid)})
if not amas_existing:
amas_existing = frappe.db.exists(
"Amas Employees",
{"identification_number": fin, "organization_voen": organization_voen}
)
if amas_existing:
amas_doc = frappe.get_doc("Amas Employees", amas_existing)
update_employee_from_data(amas_doc, emp_data)
if detail_data:
update_employee_from_detail_data(amas_doc, detail_data)
if not amas_doc.identification_number:
amas_doc.identification_number = fin
amas_doc.save(ignore_permissions=True)
else:
amas_doc = frappe.new_doc("Amas Employees")
amas_doc.asan_login = asan_login_name
amas_doc.organization_name = organization_name
amas_doc.organization_voen = organization_voen
amas_doc.identification_number = fin
update_employee_from_data(amas_doc, emp_data)
if detail_data:
update_employee_from_detail_data(amas_doc, detail_data)
if not amas_doc.identification_number:
amas_doc.identification_number = fin
amas_doc.insert(ignore_permissions=True)
# 2. Create/update Employee record
existing_employee = frappe.db.exists("Employee", {"passport_number": fin})
# Split full_name into first/last
name_parts = full_name.split() if full_name else []
first_name = name_parts[0] if name_parts else "?"
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
# Parse dates from detail data if available, otherwise from list data
dob = None
doj = None
contract_end = None
if detail_data:
if 'iamasBean' in detail_data and detail_data.get('iamasBean'):
dob = parse_amas_date(detail_data['iamasBean'].get('birthDate'))
if 'EmasContractDetailsEnt' in detail_data and isinstance(detail_data.get('EmasContractDetailsEnt'), list) and len(detail_data['EmasContractDetailsEnt']) > 0:
details = detail_data['EmasContractDetailsEnt'][0]
doj = parse_amas_date(details.get('beginDate'))
contract_end = parse_amas_date(details.get('endDate'))
else:
dob = parse_amas_date(emp_data.get("birthday"))
doj = parse_amas_date(emp_data.get("begin_date"))
contract_end = parse_amas_date(emp_data.get("end_date"))
# Map status
raw_status = ""
if detail_data and 'EmasContractsEnt' in detail_data and isinstance(detail_data.get('EmasContractsEnt'), list) and len(detail_data['EmasContractsEnt']) > 0:
raw_status = str(detail_data['EmasContractsEnt'][0].get('contractStatus') or "").strip().lower()
else:
raw_status = str(emp_data.get("contract_status") or "").strip().lower()
status = status_map.get(raw_status, "Active")
# Parse salary
salary = None
if detail_data and 'EmasContractDetailsEnt' in detail_data and isinstance(detail_data.get('EmasContractDetailsEnt'), list) and len(detail_data['EmasContractDetailsEnt']) > 0:
raw_salary = detail_data['EmasContractDetailsEnt'][0].get('monthlySalary')
else:
raw_salary = emp_data.get("monthly_salary")
if raw_salary:
try:
salary = float(str(raw_salary).replace(",", ".").replace(" ", ""))
except (ValueError, TypeError):
pass
# Resolve designation
position_text = None
if detail_data and 'staffData' in detail_data and detail_data.get('staffData'):
position_text = detail_data['staffData'].get('positionText')
else:
position_text = emp_data.get("work_position_text")
designation = resolve_designation(position_text, create_designation)
# Extract additional fields from amas_doc
middle_name = amas_doc.father_name if hasattr(amas_doc, 'father_name') else None
gender = map_gender(amas_doc.gender if hasattr(amas_doc, 'gender') else None)
cell_number = amas_doc.contact_phone if hasattr(amas_doc, 'contact_phone') else None
personal_email = amas_doc.contact_email if hasattr(amas_doc, 'contact_email') else None
marital_status_mapped = map_marital_status(amas_doc.marital_status if hasattr(amas_doc, 'marital_status') else None)
blood_group_mapped = map_blood_group(amas_doc.blood_type if hasattr(amas_doc, 'blood_type') else None)
date_of_issue = parse_amas_date(amas_doc.document_issue_date if hasattr(amas_doc, 'document_issue_date') else None)
valid_upto = parse_amas_date(amas_doc.document_expiry_date if hasattr(amas_doc, 'document_expiry_date') else None)
place_of_issue = amas_doc.document_issued_by if hasattr(amas_doc, 'document_issued_by') else None
current_address = build_full_address(
amas_doc.personal_address if hasattr(amas_doc, 'personal_address') else None,
amas_doc.personal_city if hasattr(amas_doc, 'personal_city') else None,
amas_doc.personal_district if hasattr(amas_doc, 'personal_district') else None
)
if existing_employee:
emp = frappe.get_doc("Employee", existing_employee)
emp.first_name = first_name
if middle_name:
emp.middle_name = middle_name
emp.last_name = last_name
emp.employee_name = full_name or first_name
emp.gender = gender
if dob:
emp.date_of_birth = dob
if doj:
emp.date_of_joining = doj
if contract_end:
emp.contract_end_date = contract_end
if salary is not None:
emp.ctc = salary
if designation:
emp.designation = designation
if cell_number:
emp.cell_number = cell_number
if personal_email:
emp.personal_email = personal_email
if marital_status_mapped:
emp.marital_status = marital_status_mapped
if blood_group_mapped:
emp.blood_group = blood_group_mapped
if date_of_issue:
emp.date_of_issue = date_of_issue
if valid_upto:
emp.valid_upto = valid_upto
if place_of_issue:
emp.place_of_issue = place_of_issue
if current_address:
emp.current_address = current_address
emp.save(ignore_permissions=True)
action = "updated"
else:
emp = frappe.new_doc("Employee")
emp.first_name = first_name
if middle_name:
emp.middle_name = middle_name
emp.last_name = last_name
emp.company = company
emp.passport_number = fin
emp.employee_number = fin
emp.date_of_birth = dob or "2000-01-01"
emp.date_of_joining = doj or frappe.utils.today()
emp.gender = gender
emp.status = status
if contract_end:
emp.contract_end_date = contract_end
if salary is not None:
emp.ctc = salary
if designation:
emp.designation = designation
if cell_number:
emp.cell_number = cell_number
if personal_email:
emp.personal_email = personal_email
if marital_status_mapped:
emp.marital_status = marital_status_mapped
if blood_group_mapped:
emp.blood_group = blood_group_mapped
if date_of_issue:
emp.date_of_issue = date_of_issue
if valid_upto:
emp.valid_upto = valid_upto
if place_of_issue:
emp.place_of_issue = place_of_issue
if current_address:
emp.current_address = current_address
emp.insert(ignore_permissions=True)
action = "created"
# Link Amas Employees → Employee
amas_doc.employee = emp.name
amas_doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"success": True,
"action": action,
"employee_name": emp.name,
"full_name": full_name,
"message": f"{action.capitalize()}: {full_name}"
}
except Exception as e:
fin_val = emp_data.get("identification_number", "?") if isinstance(emp_data, dict) else "?"
name_val = emp_data.get("full_name", "?") if isinstance(emp_data, dict) else "?"
error_msg = f"{name_val} ({fin_val}): {str(e)}"
frappe.log_error(
f"{error_msg}\n{frappe.get_traceback()}",
"EMAS Single Employee Create Error"
)
return {
"success": False,
"action": "error",
"full_name": name_val,
"message": error_msg
}
@frappe.whitelist()
def create_employees_from_amas(asan_login_name, employees, company, create_designation=0):
"""
Create Employee records directly from ƏMAS API data.
Receives raw employee data from the ƏMAS API (same format as get_employees_report returns).
Also saves to Emas Employees doctype for record keeping.
Field mapping (ƏMAS → Employee):
full_name → first_name / last_name (split by space)
identification_number → passport_number
birthday → date_of_birth
begin_date → date_of_joining
end_date → contract_end_date
monthly_salary → ctc
contract_status → status
work_position_text → designation (Link, searched/created)
"""
try:
if isinstance(employees, str):
employees = json.loads(employees)
create_designation = int(create_designation)
if not company:
return {"success": False, "message": "Company is required"}
doc = frappe.get_doc("Asan Login", asan_login_name)
organization_name = doc.amas_account_name
organization_voen = doc.amas_account_number
created_count = 0
updated_count = 0
error_count = 0
errors = []
# Status mapping: ƏMAS contract_status → Employee status
status_map = {
"qüvvədədir": "Active",
"aktiv": "Active",
"active": "Active",
"ləğv edilib": "Left",
"xitam": "Left",
"terminated": "Left",
"dayandırılıb": "Suspended",
"suspended": "Suspended",
}
total_employees = len(employees)
for idx, emp_data in enumerate(employees, start=1):
try:
# Publish progress
frappe.publish_progress(
percent=(idx / total_employees) * 100,
title=f"Loading employee {idx} of {total_employees}",
description=f"Processing {emp_data.get('full_name', 'employee')}..."
)
doc_oid = emp_data.get("doc_oid")
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
full_name = str(emp_data.get("full_name") or "").strip()
if 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')
# Get FIN from detail or list data
fin = None
if detail_data and 'EmasContractsEnt' in detail_data and isinstance(detail_data.get('EmasContractsEnt'), list) and len(detail_data['EmasContractsEnt']) > 0:
fin = detail_data['EmasContractsEnt'][0].get('pin')
# Also update full_name from detail data if available
if 'iamasBean' in detail_data and detail_data.get('iamasBean'):
person = detail_data['iamasBean']
name = person.get('name', '')
surname = person.get('surname', '')
full_name = f"{name} {surname}".strip()
if not fin:
fin = emp_data.get("identification_number")
if not fin:
errors.append(f"{full_name}: No identification_number")
error_count += 1
continue
fin = str(fin).strip()
# 1. Save/update Emas Employees record
amas_existing = None
if doc_oid:
amas_existing = frappe.db.exists("Amas Employees", {"doc_oid": str(doc_oid)})
if not amas_existing:
amas_existing = frappe.db.exists(
"Amas Employees",
{"identification_number": fin, "organization_voen": organization_voen}
)
if amas_existing:
amas_doc = frappe.get_doc("Amas Employees", amas_existing)
# Always populate basic fields from list data first
update_employee_from_data(amas_doc, emp_data)
# Then overlay with detail data if available
if detail_data:
update_employee_from_detail_data(amas_doc, detail_data)
# Ensure FIN is set (required field)
if not amas_doc.identification_number:
amas_doc.identification_number = fin
amas_doc.save(ignore_permissions=True)
else:
amas_doc = frappe.new_doc("Amas Employees")
amas_doc.asan_login = asan_login_name
amas_doc.organization_name = organization_name
amas_doc.organization_voen = organization_voen
# Set FIN first (required field)
amas_doc.identification_number = fin
# Always populate basic fields from list data first
update_employee_from_data(amas_doc, emp_data)
# Then overlay with detail data if available
if detail_data:
update_employee_from_detail_data(amas_doc, detail_data)
# Ensure FIN is still set after mapping
if not amas_doc.identification_number:
amas_doc.identification_number = fin
amas_doc.insert(ignore_permissions=True)
# 2. Create/update Employee record
existing_employee = frappe.db.exists("Employee", {"passport_number": fin})
# Split full_name into first/last
name_parts = full_name.split() if full_name else []
first_name = name_parts[0] if name_parts else "?"
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
# Parse dates from detail data if available, otherwise from list data
dob = None
doj = None
contract_end = None
if detail_data:
# Get from detail data
if 'iamasBean' in detail_data and detail_data.get('iamasBean'):
dob = parse_amas_date(detail_data['iamasBean'].get('birthDate'))
if 'EmasContractDetailsEnt' in detail_data and isinstance(detail_data.get('EmasContractDetailsEnt'), list) and len(detail_data['EmasContractDetailsEnt']) > 0:
details = detail_data['EmasContractDetailsEnt'][0]
doj = parse_amas_date(details.get('beginDate'))
contract_end = parse_amas_date(details.get('endDate'))
else:
# Fallback to list data
dob = parse_amas_date(emp_data.get("birthday"))
doj = parse_amas_date(emp_data.get("begin_date"))
contract_end = parse_amas_date(emp_data.get("end_date"))
# Map status
raw_status = ""
if detail_data and 'EmasContractsEnt' in detail_data and isinstance(detail_data.get('EmasContractsEnt'), list) and len(detail_data['EmasContractsEnt']) > 0:
raw_status = str(detail_data['EmasContractsEnt'][0].get('contractStatus') or "").strip().lower()
else:
raw_status = str(emp_data.get("contract_status") or "").strip().lower()
status = status_map.get(raw_status, "Active")
# Parse salary
salary = None
if detail_data and 'EmasContractDetailsEnt' in detail_data and isinstance(detail_data.get('EmasContractDetailsEnt'), list) and len(detail_data['EmasContractDetailsEnt']) > 0:
raw_salary = detail_data['EmasContractDetailsEnt'][0].get('monthlySalary')
else:
raw_salary = emp_data.get("monthly_salary")
if raw_salary:
try:
salary = float(str(raw_salary).replace(",", ".").replace(" ", ""))
except (ValueError, TypeError):
pass
# Resolve designation
position_text = None
if detail_data and 'staffData' in detail_data and detail_data.get('staffData'):
position_text = detail_data['staffData'].get('positionText')
else:
position_text = emp_data.get("work_position_text")
designation = resolve_designation(position_text, create_designation)
# Extract additional fields from amas_doc (already populated with detail data)
middle_name = amas_doc.father_name if hasattr(amas_doc, 'father_name') else None
gender = map_gender(amas_doc.gender if hasattr(amas_doc, 'gender') else None)
cell_number = amas_doc.contact_phone if hasattr(amas_doc, 'contact_phone') else None
personal_email = amas_doc.contact_email if hasattr(amas_doc, 'contact_email') else None
marital_status_mapped = map_marital_status(amas_doc.marital_status if hasattr(amas_doc, 'marital_status') else None)
blood_group_mapped = map_blood_group(amas_doc.blood_type if hasattr(amas_doc, 'blood_type') else None)
date_of_issue = parse_amas_date(amas_doc.document_issue_date if hasattr(amas_doc, 'document_issue_date') else None)
valid_upto = parse_amas_date(amas_doc.document_expiry_date if hasattr(amas_doc, 'document_expiry_date') else None)
place_of_issue = amas_doc.document_issued_by if hasattr(amas_doc, 'document_issued_by') else None
current_address = build_full_address(
amas_doc.personal_address if hasattr(amas_doc, 'personal_address') else None,
amas_doc.personal_city if hasattr(amas_doc, 'personal_city') else None,
amas_doc.personal_district if hasattr(amas_doc, 'personal_district') else None
)
if existing_employee:
emp = frappe.get_doc("Employee", existing_employee)
emp.first_name = first_name
if middle_name:
emp.middle_name = middle_name
emp.last_name = last_name
emp.employee_name = full_name or first_name
emp.gender = gender
if dob:
emp.date_of_birth = dob
if doj:
emp.date_of_joining = doj
if contract_end:
emp.contract_end_date = contract_end
if salary is not None:
emp.ctc = salary
if designation:
emp.designation = designation
if cell_number:
emp.cell_number = cell_number
if personal_email:
emp.personal_email = personal_email
if marital_status_mapped:
emp.marital_status = marital_status_mapped
if blood_group_mapped:
emp.blood_group = blood_group_mapped
if date_of_issue:
emp.date_of_issue = date_of_issue
if valid_upto:
emp.valid_upto = valid_upto
if place_of_issue:
emp.place_of_issue = place_of_issue
if current_address:
emp.current_address = current_address
emp.save(ignore_permissions=True)
updated_count += 1
else:
emp = frappe.new_doc("Employee")
emp.first_name = first_name
if middle_name:
emp.middle_name = middle_name
emp.last_name = last_name
emp.company = company
emp.passport_number = fin
emp.employee_number = fin
emp.date_of_birth = dob or "2000-01-01"
emp.date_of_joining = doj or frappe.utils.today()
emp.gender = gender
emp.status = status
if contract_end:
emp.contract_end_date = contract_end
if salary is not None:
emp.ctc = salary
if designation:
emp.designation = designation
if cell_number:
emp.cell_number = cell_number
if personal_email:
emp.personal_email = personal_email
if marital_status_mapped:
emp.marital_status = marital_status_mapped
if blood_group_mapped:
emp.blood_group = blood_group_mapped
if date_of_issue:
emp.date_of_issue = date_of_issue
if valid_upto:
emp.valid_upto = valid_upto
if place_of_issue:
emp.place_of_issue = place_of_issue
if current_address:
emp.current_address = current_address
emp.insert(ignore_permissions=True)
created_count += 1
# Link Amas Employees → Employee
amas_doc.employee = emp.name
amas_doc.save(ignore_permissions=True)
except Exception as e:
fin_val = emp_data.get("identification_number", "?")
name_val = emp_data.get("full_name", "?")
error_msg = f"{name_val} ({fin_val}): {str(e)}"
frappe.log_error(error_msg, "EMAS Employee Create Error")
errors.append(error_msg)
error_count += 1
frappe.db.commit()
return {
"success": True,
"created_count": created_count,
"updated_count": updated_count,
"error_count": error_count,
"errors": errors[:10]
}
except Exception as e:
frappe.log_error(
f"Create employees from EMAS error: {str(e)}\n{frappe.get_traceback()}",
"EMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
# ==================== EMPLOYEE DETAIL API FUNCTIONS ====================
# These functions fetch complete employee details from ƏMAS
# (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, _state=None):
"""
Call eroom.getEditForm to get contract form data.
Returns: EmasContractsEnt, EmasContractDetailsEnt, EmasContractTextDetailsEnt
"""
data = {
"serviceName": "AppEmploymentContractOnline",
"docOid": doc_oid,
"docNo": doc_no,
"docType": doc_type
}
return make_amas_request(asan_login_name, "/service/eroom.getEditForm", data, _state=_state)
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, _state=_state)
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, _state=_state)
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, _state=_state)
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, _state=_state)
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.
"""
data = {
"docOid[]": doc_oid,
"attachType": attach_type
}
return make_amas_request(asan_login_name, "/service/eroom.getAttachListByType", data, _state=_state)
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, _state=_state)
@frappe.whitelist()
def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47", _state=None):
"""
Fetch complete employee detail from ƏMAS.
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
- staffData, iamasBean, addressBean, workAddress
- docMainData, contractHtml
"""
combined_data = {}
try:
# 1. Get contract form data
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:
result_data = data.get('resultData', {})
if result_data:
combined_data.update(result_data)
# 2. Get staff data (need staff_oid from contract)
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, _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:
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, _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:
combined_data['iamasBean'] = data.get('iamasBean')
combined_data['addressBean'] = data.get('addressBean')
# 4. Get work address (need address_oid from contract details)
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, _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:
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, _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:
combined_data['docMainData'] = {
'docOid': data.get('docOid'),
'docNo': data.get('docNo'),
'docDate': data.get('docDate'),
'docType': data.get('docType'),
'docState': data.get('docState'),
'listDocInfo': data.get('listDocInfo', [])
}
# 6. Get contract document (HTML)
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:
file_id = attach_list[0].get('fileId')
if 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:
combined_data['contractHtml'] = file_data.get('fileContent')
return {
"success": True,
"data": combined_data
}
except Exception as e:
frappe.log_error(
f"Get employee detail error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
def update_employee_from_detail_data(employee_doc, detail_data):
"""
Map all 115 fields from detail API responses to Emas Employees document.
Handles nested objects from 6 different API calls.
"""
if not detail_data:
return
# Map EmasContractsEnt fields (contract info)
if 'EmasContractsEnt' in detail_data and isinstance(detail_data.get('EmasContractsEnt'), list) and len(detail_data['EmasContractsEnt']) > 0:
contract = detail_data['EmasContractsEnt'][0]
employee_doc.contract_number = contract.get('contractNumber')
employee_doc.ssn = contract.get('ssn')
employee_doc.contract_type = contract.get('contractType')
employee_doc.contract_status = contract.get('contractStatus')
# Only set identification_number if it exists in detail data
pin = contract.get('pin')
if pin:
employee_doc.identification_number = pin
employee_doc.employer_tin = contract.get('employerTin')
employee_doc.employer_pin = contract.get('employerPin')
employee_doc.prev_contract = contract.get('prevContract')
employee_doc.is_signed = 1 if contract.get('isSigned') else 0
employee_doc.contract_validity = contract.get('validity')
employee_doc.workplace_type = contract.get('workplaceType')
employee_doc.staff_oid = contract.get('staff')
employee_doc.structure_unit_oid = contract.get('structureUnitOid')
employee_doc.entity_oid = contract.get('entity')
employee_doc.entity_details_oid = contract.get('entityDetails')
employee_doc.from_migration = 1 if contract.get('fromMigration') else 0
employee_doc.doc_oid = contract.get('fkDocOid')
# Map EmasContractDetailsEnt fields (salary, schedule, vacation)
if 'EmasContractDetailsEnt' in detail_data and isinstance(detail_data.get('EmasContractDetailsEnt'), list) and len(detail_data['EmasContractDetailsEnt']) > 0:
details = detail_data['EmasContractDetailsEnt'][0]
employee_doc.base_salary = details.get('salary')
employee_doc.monthly_salary = str(details.get('monthlySalary') or '')
employee_doc.salary_add = details.get('salaryAdd')
employee_doc.salary_add_hw = details.get('salaryAddHw')
employee_doc.award_amount = details.get('awardAmount')
employee_doc.award_type = details.get('awardType')
employee_doc.overtime_amount = details.get('overTimeAmount')
employee_doc.bad_condition_amount = details.get('badCondAmount')
employee_doc.bad_condition_id = details.get('badCondId')
employee_doc.currency_type = details.get('currencyType')
employee_doc.payment_type = details.get('paymentType')
employee_doc.payment_first_day = details.get('paymentFirstDay')
employee_doc.bank_oid = details.get('bank')
# Work schedule
employee_doc.work_mode_type = details.get('workModeType')
employee_doc.is_full_time = details.get('isFullTime')
employee_doc.first_shift_start = details.get('firstShiftStart')
employee_doc.first_shift_end = details.get('firstShiftEnd')
employee_doc.second_shift_start = details.get('secondShiftStart')
employee_doc.second_shift_end = details.get('secondShiftEnd')
employee_doc.lunch_start = details.get('lunchStart')
employee_doc.lunch_end = details.get('lunchEnd')
employee_doc.work_time = details.get('workTime')
employee_doc.working_time_cumulative = 1 if details.get('workingTimeCumulative') else 0
# Vacation
employee_doc.vacation_days_count = details.get('vacationDaysCount')
employee_doc.vacation_general_duration = details.get('vacGenDur')
employee_doc.vacation_main_duration = details.get('vacMainDur')
employee_doc.vacation_add_dur_one = details.get('vacAddDurOne')
employee_doc.vacation_add_dur_two = details.get('vacAddDurTwo')
employee_doc.vacation_add_dur_four = details.get('vacAddDurFour')
employee_doc.vacation_amount = details.get('vacAmount')
employee_doc.profession_employee_for_vacation = details.get('professionEmployeeForVacation')
# Education
employee_doc.education = details.get('education')
employee_doc.scientific_degree = details.get('scientificDegree')
employee_doc.speciality_degree = details.get('specialityDegree')
employee_doc.state_speciality_degree = details.get('stateSpecialityDegree')
employee_doc.qualification_degree_salary_add = details.get('qualificationDegreeSalaryAdd')
# Dates
employee_doc.main_contract_begin_date = str(details.get('mainContractBeginDate') or '')
employee_doc.contract_begin_date = parse_amas_date(details.get('beginDate'))
employee_doc.contract_end_date = parse_amas_date(details.get('endDate'))
employee_doc.emp_job_start_date = str(details.get('empJobStartDate') or '')
employee_doc.begin_date = str(details.get('beginDate') or '')
employee_doc.end_date = str(details.get('endDate') or '')
# Other fields
employee_doc.position_id = details.get('positionId')
employee_doc.activity_code = details.get('activityCode')
employee_doc.work_address_oid = details.get('address')
employee_doc.chairman_pin = details.get('chairmanPin')
employee_doc.contract_reason = details.get('contractReason')
employee_doc.workplace_sector = details.get('workplaceSector')
# Map EmasContractTextDetailsEnt fields
if 'EmasContractTextDetailsEnt' in detail_data and isinstance(detail_data.get('EmasContractTextDetailsEnt'), list) and len(detail_data['EmasContractTextDetailsEnt']) > 0:
text_details = detail_data['EmasContractTextDetailsEnt'][0]
if text_details:
employee_doc.work_position = text_details.get('workPosition')
employee_doc.activity_name_detail = text_details.get('activityName')
employee_doc.labour_function = text_details.get('labourFunction')
employee_doc.protection_tools = text_details.get('protectionTools')
employee_doc.payment_other_cond = text_details.get('paymentOtherCond')
employee_doc.rule_text = text_details.get('ruleText')
# Map staffData fields
if 'staffData' in detail_data and detail_data.get('staffData'):
staff = detail_data['staffData']
employee_doc.position_text = staff.get('positionText')
employee_doc.tree_path_name = staff.get('treePathName')
employee_doc.staff_unit = staff.get('staffUnit')
employee_doc.structure_path = staff.get('treePathName')
employee_doc.work_position_text = staff.get('positionText')
# Map iamasBean (personal data)
if 'iamasBean' in detail_data and detail_data.get('iamasBean'):
person = detail_data['iamasBean']
# Construct full name
name = person.get('name', '')
surname = person.get('surname', '')
employee_doc.full_name = f"{name} {surname}".strip()
employee_doc.father_name = person.get('fatherName')
employee_doc.gender = person.get('gender')
employee_doc.birthday = str(person.get('birthDate') or '')
employee_doc.nationality = person.get('nationality')
employee_doc.blood_type = person.get('bloodType')
employee_doc.marital_status = person.get('maritalStatus')
employee_doc.military_status = person.get('militaryStatus')
employee_doc.document_number = person.get('documentNumber')
employee_doc.document_issue_date = parse_amas_date(person.get('issueDate'))
employee_doc.document_expiry_date = parse_amas_date(person.get('expireDate'))
employee_doc.document_issued_by = person.get('issuedBy')
employee_doc.personal_address = person.get('address')
employee_doc.personal_city = person.get('city')
employee_doc.personal_district = person.get('district')
# Map addressBean (contact info)
if 'addressBean' in detail_data and detail_data.get('addressBean'):
address = detail_data['addressBean']
employee_doc.contact_phone = address.get('mobilePhoneNumber')
employee_doc.contact_email = address.get('email')
# Map workAddress
if 'workAddress' in detail_data and detail_data.get('workAddress'):
work_addr = detail_data['workAddress']
employee_doc.work_address_street = work_addr.get('street')
employee_doc.work_address_district = work_addr.get('district')
# Map docMainData
if 'docMainData' in detail_data and detail_data.get('docMainData'):
doc_main = detail_data['docMainData']
employee_doc.doc_no = doc_main.get('docNo')
employee_doc.doc_date = str(doc_main.get('docDate') or '')
employee_doc.doc_type = doc_main.get('docType')
employee_doc.doc_state = doc_main.get('docState')
employee_doc.flow = doc_main.get('flow')
employee_doc.flow_alias = doc_main.get('flowAlias')
if 'listDocInfo' in doc_main and isinstance(doc_main['listDocInfo'], list) and len(doc_main['listDocInfo']) > 0:
employee_doc.after_exec_label = doc_main['listDocInfo'][0].get('afterExecLabel')
created_date_str = doc_main['listDocInfo'][0].get('createDate')
if created_date_str:
employee_doc.created_date = created_date_str
# Map contract HTML document
if 'contractHtml' in detail_data and detail_data.get('contractHtml'):
employee_doc.contract_html = detail_data.get('contractHtml')
# Store complete raw data as JSON (only if detail_data has meaningful content)
# Check if detail_data has any of the expected keys
has_detail_content = any(key in detail_data for key in [
'EmasContractsEnt', 'EmasContractDetailsEnt', 'EmasContractTextDetailsEnt',
'staffData', 'iamasBean', 'addressBean', 'workAddress', 'docMainData', 'contractHtml'
])
if has_detail_content:
employee_doc.raw_data = json.dumps(detail_data, ensure_ascii=False, indent=2)
employee_doc.import_date = frappe.utils.now_datetime()
def on_delete_employee(doc, method):
"""
When an Employee is deleted, also delete linked Emas Employees records.
"""
amas_records = frappe.get_all(
"Amas Employees",
filters={"employee": doc.name},
fields=["name"]
)
for rec in amas_records:
frappe.delete_doc("Amas Employees", rec.name, ignore_permissions=True)
@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.
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)
try:
create_designation = int(create_designation)
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(
_process_bulk_employees_import,
asan_login_name=asan_login_name,
employees=employees_data,
company=company,
create_designation=create_designation,
user=user,
queue="default",
timeout=1200,
)
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 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)
created_count = 0
updated_count = 0
errors = []
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
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,
)
frappe.db.commit()
frappe.publish_realtime(
"amas_import_complete",
{
"total": total,
"created": created_count,
"updated": updated_count,
"errors": errors,
"cancelled": cancelled,
},
user=user,
)
finally:
_clear_import_lock(asan_login_name)