# ======= COMPANY E-TAXES DATA LOADING ======= import frappe import requests from frappe.utils import getdate from invoice_az.auth import DEFAULT_HEADERS, get_default_asan_login, record_etaxes_activity BASE_URL = "https://new.e-taxes.gov.az" OBJECT_LIST_URL = f"{BASE_URL}/api/po/profile/public/v1/object/list" CASH_REGISTER_LIST_URL = f"{BASE_URL}/api/po/profile/public/v2/profile/cashRegister/list" POS_TERMINAL_LIST_URL = f"{BASE_URL}/api/po/profile/public/v2/profile/posTerminal/list" BANK_ACCOUNT_LIST_URL = f"{BASE_URL}/api/po/profile/public/v1/profile/bankAccount/list" PRESENTED_CERT_LIST_URL = f"{BASE_URL}/api/po/edi-legal/public/v1/application/contractor/presented-accepted-e-certificates" PAGE_SIZE = 200 MAX_PAGES = 50 # safety cap (≤ 10 000 records per import) # ======= HELPERS ======= def _get(d, *path): cur = d for key in path: if not isinstance(cur, dict): return None cur = cur.get(key) return cur def _safe_date(value): if not value: return None try: return getdate(value) except Exception: return None def _unauthorized(): return { "error": "unauthorized", "message": "Authentication required. Please login again.", } def _server_error(): return { "error": "server_error", "message": "Service temporarily unavailable. Please try again in a few minutes.", "status_code": 500, } def _auth_headers(): """Return (headers, error_response_or_None). If err is truthy, bail out.""" asan_login = get_default_asan_login() if not asan_login.get("found") or not asan_login.get("main_token"): return None, _unauthorized() headers = DEFAULT_HEADERS.copy() headers["x-authorization"] = f"Bearer {asan_login['main_token']}" return headers, None def _etaxes_request(method, url, headers, **kwargs): """Make an E-Taxes request with the §10 401-retry-once pattern. Returns (response, error_dict) — if error_dict is truthy, caller must return it. """ try: response = requests.request(method, url, headers=headers, timeout=60, **kwargs) if response.status_code == 401: fresh_login = get_default_asan_login() if fresh_login.get("found") and fresh_login.get("main_token"): headers["x-authorization"] = f"Bearer {fresh_login['main_token']}" response = requests.request(method, url, headers=headers, timeout=60, **kwargs) if response.status_code == 401: return None, _unauthorized() if response.status_code == 500: return None, _server_error() if response.status_code == 404: return None, {"success": False, "message": "Resource not found."} response.raise_for_status() return response, None except requests.exceptions.HTTPError as e: code = e.response.status_code if e.response is not None else None if code == 401: return None, _unauthorized() if code == 500: return None, _server_error() frappe.log_error(f"HTTP error {code}: {e}", "E-Taxes Company Load") return None, {"error": "http_error", "message": f"HTTP error {code}: {e}"} except requests.exceptions.RequestException as e: frappe.log_error(f"Network error: {e}", "E-Taxes Company Load") return None, {"error": "connection_error", "message": f"Network error: {e}"} # ======= OBJECTS ======= @frappe.whitelist() def load_company_objects(company): """Load taxpayer objects (registered locations) from E-Taxes into E-Taxes Object doctype.""" try: record_etaxes_activity() if not company or not frappe.db.exists("Company", company): return {"success": False, "message": f"Company '{company}' not found."} headers, err = _auth_headers() if err: return err response, err = _etaxes_request("GET", OBJECT_LIST_URL, headers) if err: return err data = response.json() or {} objects = data.get("objectInfoShortList") or [] created, updated = 0, 0 for obj in objects: code = obj.get("objectCode") if not code: continue values = { "object_name": (obj.get("objectName") or "").strip(), "object_status": obj.get("status") or "active", "address": obj.get("address"), "main_activity_code": _get(obj, "mainActivity", "code"), "main_activity_name": _get(obj, "mainActivity", "name", "az"), "main_class_code": _get(obj, "mainClass", "code"), "main_class_name": _get(obj, "mainClass", "name", "az"), "middle_class_code": _get(obj, "middleClass", "code"), "middle_class_name": _get(obj, "middleClass", "name", "az"), "sub_class_code": _get(obj, "subClass", "code"), "sub_class_name": _get(obj, "subClass", "name", "az"), "company": company, } if frappe.db.exists("E-Taxes Object", code): frappe.db.set_value("E-Taxes Object", code, values, update_modified=False) updated += 1 else: doc = frappe.get_doc({ "doctype": "E-Taxes Object", "object_code": code, **values, }) doc.insert(ignore_permissions=True) created += 1 frappe.db.commit() return { "success": True, "message": f"Objects loaded: {created} created, {updated} updated", "created": created, "updated": updated, "total": len(objects), } except Exception as e: frappe.log_error( f"Unexpected error: {e}\n{frappe.get_traceback()}", "E-Taxes Object Load", ) return {"success": False, "message": f"Error: {e}"} # ======= CASH REGISTERS ======= @frappe.whitelist() def load_company_cash_registers(company): """Load cash registers from E-Taxes into E-Taxes Cash Register doctype. Uses hasMore pagination (CLAUDE.md §10 401-retry-once applies per page). """ try: record_etaxes_activity() if not company or not frappe.db.exists("Company", company): return {"success": False, "message": f"Company '{company}' not found."} headers, err = _auth_headers() if err: return err created, updated, total_fetched = 0, 0, 0 offset = 0 for page in range(MAX_PAGES): payload = {"maxCount": PAGE_SIZE, "offset": offset} response, err = _etaxes_request( "POST", CASH_REGISTER_LIST_URL, headers, json=payload ) if err: return err data = response.json() or {} registers = data.get("cashRegisters") or [] has_more = bool(data.get("hasMore")) if not registers: break for reg in registers: token = reg.get("token") if not token: continue values = { "number": reg.get("number"), "model": reg.get("model"), "ecash_status": reg.get("ecashStatus"), "status_code": reg.get("status"), "object": reg.get("object") if frappe.db.exists("E-Taxes Object", reg.get("object")) else None, "certificate_type": reg.get("certificateType"), "installed_at": _safe_date(reg.get("installedAt")), "certificate_from": _safe_date(reg.get("certificateFrom")), "certificate_to": _safe_date(reg.get("certificateTo")), "replacement_date": _safe_date(reg.get("replacementDate")), "operator_tin": reg.get("operatorTin"), "operator_name": reg.get("operatorName"), "company": company, } if frappe.db.exists("E-Taxes Cash Register", token): frappe.db.set_value( "E-Taxes Cash Register", token, values, update_modified=False ) updated += 1 else: doc = frappe.get_doc({ "doctype": "E-Taxes Cash Register", "token": token, **values, }) doc.insert(ignore_permissions=True) created += 1 total_fetched += len(registers) offset += PAGE_SIZE if not has_more: break else: frappe.log_error( f"Cash register pagination hit safety cap of {MAX_PAGES} pages " f"for company {company}", "E-Taxes Cash Register Load", ) frappe.db.commit() return { "success": True, "message": f"Cash registers loaded: {created} created, {updated} updated", "created": created, "updated": updated, "total": total_fetched, } except Exception as e: frappe.log_error( f"Unexpected error: {e}\n{frappe.get_traceback()}", "E-Taxes Cash Register Load", ) return {"success": False, "message": f"Error: {e}"} # ======= POS TERMINALS ======= def _object_from_registration_number(registration_number): """Registration numbers look like "4800111741-84001P02" — strip the trailing Pxx.""" if not registration_number: return None code = registration_number.rsplit("P", 1)[0] if frappe.db.exists("E-Taxes Object", code): return code return None @frappe.whitelist() def load_company_pos_terminals(company): """Load POS terminals from E-Taxes into E-Taxes POS Terminal doctype. Uses hasMore pagination (CLAUDE.md §10 401-retry-once applies per page). """ try: record_etaxes_activity() if not company or not frappe.db.exists("Company", company): return {"success": False, "message": f"Company '{company}' not found."} headers, err = _auth_headers() if err: return err created, updated, total_fetched = 0, 0, 0 offset = 0 for page in range(MAX_PAGES): payload = {"maxCount": PAGE_SIZE, "offset": offset} response, err = _etaxes_request( "POST", POS_TERMINAL_LIST_URL, headers, json=payload ) if err: return err data = response.json() or {} terminals = data.get("posTerminals") or [] has_more = bool(data.get("hasMore")) if not terminals: break for term in terminals: registration_number = term.get("registrationNumber") if not registration_number: continue values = { "serial_number": term.get("serialNumber"), "status": term.get("status"), "registration_date": _safe_date(term.get("registrationDate")), "bank_name": term.get("bankName"), "object": _object_from_registration_number(registration_number), "company": company, } if frappe.db.exists("E-Taxes POS Terminal", registration_number): frappe.db.set_value( "E-Taxes POS Terminal", registration_number, values, update_modified=False, ) updated += 1 else: doc = frappe.get_doc({ "doctype": "E-Taxes POS Terminal", "registration_number": registration_number, **values, }) doc.insert(ignore_permissions=True) created += 1 total_fetched += len(terminals) offset += PAGE_SIZE if not has_more: break else: frappe.log_error( f"POS terminal pagination hit safety cap of {MAX_PAGES} pages " f"for company {company}", "E-Taxes POS Terminal Load", ) frappe.db.commit() return { "success": True, "message": f"POS terminals loaded: {created} created, {updated} updated", "created": created, "updated": updated, "total": total_fetched, } except Exception as e: frappe.log_error( f"Unexpected error: {e}\n{frappe.get_traceback()}", "E-Taxes POS Terminal Load", ) return {"success": False, "message": f"Error: {e}"} # ======= COMPACT DATE/DATETIME PARSERS ======= def _parse_compact_date(value): """Parse "yyyyMMdd" → Date string "yyyy-MM-dd". Also accepts standard Date.""" if not value: return None s = str(value).strip() if len(s) == 8 and s.isdigit(): s = f"{s[0:4]}-{s[4:6]}-{s[6:8]}" return _safe_date(s) def _parse_compact_datetime(value): """Parse "yyyyMMddHHmmss" → Datetime string. Also accepts standard Datetime.""" if not value: return None s = str(value).strip() if len(s) == 14 and s.isdigit(): return f"{s[0:4]}-{s[4:6]}-{s[6:8]} {s[8:10]}:{s[10:12]}:{s[12:14]}" return s def _upsert(doctype, primary_key_value, values, primary_field): """Upsert by primary field. Returns (created, updated) deltas as (0,1) or (1,0).""" if frappe.db.exists(doctype, primary_key_value): frappe.db.set_value(doctype, primary_key_value, values, update_modified=False) return 0, 1 doc = frappe.get_doc({ "doctype": doctype, primary_field: primary_key_value, **values, }) doc.insert(ignore_permissions=True) return 1, 0 def _summarize(created, updated, total, label): return { "success": True, "message": f"{label}: {created} created, {updated} updated", "created": created, "updated": updated, "total": total, } # ======= BANK ACCOUNTS ======= @frappe.whitelist() def load_company_bank_accounts(company): """Load bank accounts from E-Taxes into E-Taxes Bank Account doctype.""" try: record_etaxes_activity() if not company or not frappe.db.exists("Company", company): return {"success": False, "message": f"Company '{company}' not found."} headers, err = _auth_headers() if err: return err response, err = _etaxes_request("GET", BANK_ACCOUNT_LIST_URL, headers) if err: return err data = response.json() or {} accounts = data.get("bankAccounts") or [] created, updated = 0, 0 for acc in accounts: duplicate_number = acc.get("duplicateNumber") if not duplicate_number: continue values = { "number": acc.get("number"), "currency": acc.get("currency"), "status": acc.get("status"), "account_type": acc.get("type"), "registration_date": _safe_date(acc.get("registrationDate")), "duplicate_date": _safe_date(acc.get("duplicateDate")), "bank_name": acc.get("bankName"), "bank_tin": acc.get("bankTin"), "bank_code": acc.get("bankCode"), "main_bank_code": acc.get("mainBankCode"), "settlement_account": acc.get("settlementAccount"), "company": company, } c, u = _upsert("E-Taxes Bank Account", duplicate_number, values, "duplicate_number") created += c updated += u frappe.db.commit() return _summarize(created, updated, len(accounts), "Bank accounts loaded") except Exception as e: frappe.log_error( f"Unexpected error: {e}\n{frappe.get_traceback()}", "E-Taxes Bank Account Load", ) return {"success": False, "message": f"Error: {e}"} # ======= OBLIGATION PACTS (sub-contractor obl-pact-list) ======= @frappe.whitelist() # ======= PRESENTED / ACCEPTED E-CERTIFICATES ======= def _map_certificate(cert, company): return { "cert_no": cert.get("certNo"), "cert_category": cert.get("certCategory"), "cert_state": cert.get("certState"), "operation_date": _parse_compact_datetime(cert.get("operationDate")), "taxpayer_full_name": cert.get("taxPayerFullName"), "voen": cert.get("voen"), "sazis_name": cert.get("sazisName"), "sazis_code": cert.get("sazisKodu") or None, "contract_start_date": _parse_compact_date(cert.get("contractStartDate")), "contract_end_date": _parse_compact_date(cert.get("contractEndDate")), "cert_start_date": _parse_compact_date(cert.get("certStartDate")), "cert_end_date": _parse_compact_date(cert.get("certEndDate")), "fk_doc_oid": cert.get("fkDocOid"), "company": company, } @frappe.whitelist() def load_company_presented_certificates(company): """Load presented/accepted e-certificates from E-Taxes.""" try: record_etaxes_activity() if not company or not frappe.db.exists("Company", company): return {"success": False, "message": f"Company '{company}' not found."} headers, err = _auth_headers() if err: return err response, err = _etaxes_request("GET", PRESENTED_CERT_LIST_URL, headers) if err: return err data = response.json() or {} certs = data.get("certificateList") or [] created, updated = 0, 0 for cert in certs: oid = cert.get("oid") if not oid: continue values = _map_certificate(cert, company) c, u = _upsert("E-Taxes Presented Certificate", oid, values, "oid") created += c updated += u frappe.db.commit() return _summarize(created, updated, len(certs), "Presented certificates loaded") except Exception as e: frappe.log_error( f"Unexpected error: {e}\n{frappe.get_traceback()}", "E-Taxes Presented Certificate Load", ) return {"success": False, "message": f"Error: {e}"} # ======= BULK ORCHESTRATOR (Socket.IO progress) ======= PROGRESS_EVENT = "company_data_loading_progress" COMPLETE_EVENT = "company_data_loading_complete" @frappe.whitelist() def load_company_data_bulk(company, selections): """Enqueue a background job that runs the selected loaders sequentially and broadcasts progress/summary over Socket.IO. `selections` is either a JSON string or a list of loader keys: ["load_objects", "load_cash_registers", ...] """ import json as _json if isinstance(selections, str): try: selections = _json.loads(selections) except ValueError: selections = [s.strip() for s in selections.split(",") if s.strip()] if not isinstance(selections, list): selections = [] if not company or not frappe.db.exists("Company", company): return {"success": False, "message": f"Company '{company}' not found."} if not selections: return {"success": False, "message": "No loaders selected."} frappe.enqueue( _process_company_data_bulk, company=company, selections=selections, user=frappe.session.user, queue="default", timeout=1800, ) return {"success": True, "enqueued": True, "total": len(selections)} def _process_company_data_bulk(company, selections, user): """Background worker — runs each loader in turn, publishing realtime updates.""" frappe.set_user(user) record_etaxes_activity() loaders = { "load_objects": (load_company_objects, "Objects"), "load_cash_registers": (load_company_cash_registers, "Cash Registers"), "load_pos_terminals": (load_company_pos_terminals, "POS Terminals"), "load_bank_accounts": (load_company_bank_accounts, "Bank Accounts"), "load_presented_certificates": (load_company_presented_certificates, "Presented Certificates"), } total = len(selections) summary = [] frappe.publish_realtime( PROGRESS_EVENT, {"current": 0, "total": total, "label": "", "message": "Starting..."}, user=user, ) for idx, key in enumerate(selections): entry = loaders.get(key) if not entry: summary.append({"key": key, "label": key, "result": { "success": False, "message": f"Unknown loader: {key}" }}) continue loader_func, label = entry frappe.publish_realtime( PROGRESS_EVENT, {"current": idx, "total": total, "label": label, "message": f"Loading {label}..."}, user=user, ) try: result = loader_func(company) except Exception as e: frappe.log_error( f"Bulk loader '{key}' crashed: {e}\n{frappe.get_traceback()}", "Company Data Bulk Load", ) result = {"success": False, "message": f"{label}: {e}"} summary.append({"key": key, "label": label, "result": result}) # Abort early on auth failure — frontend will re-auth and retry if isinstance(result, dict) and result.get("error") == "unauthorized": frappe.publish_realtime( COMPLETE_EVENT, {"summary": summary, "total": total, "completed": idx, "error": "unauthorized", "message": "Authentication required. Please login again."}, user=user, ) return frappe.publish_realtime( PROGRESS_EVENT, {"current": total, "total": total, "label": "", "message": "Done"}, user=user, ) frappe.publish_realtime( COMPLETE_EVENT, {"summary": summary, "total": total, "completed": total}, user=user, ) # ======= REFERENCE LIST (for Tax Policy tab sections) ======= _REFERENCE_LIST_FIELDS = { "E-Taxes Object": ["name", "object_code", "object_name", "object_status"], "E-Taxes Cash Register": ["name", "number", "model", "ecash_status", "object", "installed_at"], "E-Taxes POS Terminal": ["name", "registration_number", "serial_number", "status", "registration_date"], "E-Taxes Bank Account": ["name", "number", "currency", "status", "bank_name"], "E-Taxes Presented Certificate": ["name", "cert_no", "taxpayer_full_name", "cert_category"], } @frappe.whitelist() def get_company_reference_list(doctype, company, limit=50): """Return first `limit` records of an E-Taxes reference doctype, filtered by company. Only doctypes in the allow-list above are accessible via this endpoint. """ try: if doctype not in _REFERENCE_LIST_FIELDS: return {"success": False, "message": f"Doctype '{doctype}' not allowed."} if not company or not frappe.db.exists("Company", company): return {"success": False, "message": f"Company '{company}' not found."} try: limit = int(limit) except (TypeError, ValueError): limit = 50 limit = max(1, min(limit, 200)) fields = _REFERENCE_LIST_FIELDS[doctype] filters = {"company": company} data = frappe.get_all( doctype, filters=filters, fields=fields, limit=limit, order_by="modified desc", ) total_count = frappe.db.count(doctype, filters=filters) return {"success": True, "data": data, "total_count": total_count} except Exception as e: frappe.log_error( f"Reference list load failed for {doctype}: {e}\n{frappe.get_traceback()}", "Company Reference List", ) return {"success": False, "message": f"Error: {e}"}