"""Kingdee sales-order API adapter with local token/config management.""" from __future__ import annotations import json import os import stat import time import uuid import urllib.request from pathlib import Path from typing import Any, Callable from obsidian_wiki.config import parse_config_file from obsidian_wiki.file_assets import clean_config_value DEFAULT_SALORDER_API_URL = "https://beijida.kdgalaxy.com/kapi/v2/h5a3/sm/sm_salorder/querySalOrder" DEFAULT_CONFIG_PATH = Path(".runtime") / "kingdee.env" DEFAULT_TOKEN_CACHE_PATH = Path(".runtime") / "kingdee-token.json" DEFAULT_PAGE_SIZE = 100 DEFAULT_MAX_PAGES = 100 TOKEN_SKEW_SECONDS = 60 Urlopen = Callable[[urllib.request.Request], Any] KINGDEE_CONFIG_TEMPLATE = f"""# Kingdee sales order API configuration. # This file is local-only; .runtime/ is ignored by git. KINGDEE_SALORDER_API_URL={DEFAULT_SALORDER_API_URL} # Token endpoint details from your Kingdee OpenAPI app. KINGDEE_TOKEN_URL= KINGDEE_TOKEN_BODY_JSON= KINGDEE_TOKEN_HEADERS_JSON={{}} KINGDEE_TOKEN_FIELD=data.access_token KINGDEE_TOKEN_EXPIRES_IN_FIELD=data.expires_in KINGDEE_REFRESH_TOKEN_FIELD=data.refresh_token # Optional refresh endpoint. Use {{refresh_token}} in the JSON body template. KINGDEE_REFRESH_URL= KINGDEE_REFRESH_BODY_JSON= # Required by the sales order API. KINGDEE_ACGW_IDENTITY= KINGDEE_PAGE_SIZE={DEFAULT_PAGE_SIZE} """ def ensure_kingdee_config(config_path: str | Path | None = None) -> bool: path = Path(config_path or DEFAULT_CONFIG_PATH).expanduser() if path.exists(): return False _ensure_private_parent(path) path.write_text(KINGDEE_CONFIG_TEMPLATE, encoding="utf-8") path.chmod(0o600) return True def load_kingdee_config(config_path: str | Path | None = None) -> dict[str, str]: path = Path(config_path or DEFAULT_CONFIG_PATH).expanduser() values = parse_config_file(path) aliases = { "KINGDEE_API_URL": "KINGDEE_SALORDER_API_URL", "KINGDEE_API_TOKEN": "KINGDEE_ACCESS_TOKEN", "KINGDEE_LOGIN_URL": "KINGDEE_TOKEN_URL", "KINGDEE_LOGIN_BODY_JSON": "KINGDEE_TOKEN_BODY_JSON", "KINGDEE_LOGIN_HEADERS_JSON": "KINGDEE_TOKEN_HEADERS_JSON", } for key, value in os.environ.items(): if key.startswith("KINGDEE_") and value: values[key] = clean_config_value(value) for old, new in aliases.items(): if values.get(old) and not values.get(new): values[new] = values[old] values.setdefault("KINGDEE_SALORDER_API_URL", DEFAULT_SALORDER_API_URL) values.setdefault("KINGDEE_TOKEN_HEADERS_JSON", "{}") values.setdefault("KINGDEE_TOKEN_FIELD", "data.access_token") values.setdefault("KINGDEE_TOKEN_EXPIRES_IN_FIELD", "data.expires_in") values.setdefault("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token") values.setdefault("KINGDEE_PAGE_SIZE", str(DEFAULT_PAGE_SIZE)) return values def token_cache_path_for_config(config_path: str | Path | None = None) -> Path: if config_path is None: return DEFAULT_TOKEN_CACHE_PATH return Path(config_path).expanduser().with_name("kingdee-token.json") def get_kingdee_access_token( config: dict[str, str], *, cache_path: str | Path = DEFAULT_TOKEN_CACHE_PATH, urlopen: Callable[..., Any] = urllib.request.urlopen, now_epoch: float | None = None, ) -> str: now = time.time() if now_epoch is None else float(now_epoch) if config.get("KINGDEE_ACCESS_TOKEN"): return config["KINGDEE_ACCESS_TOKEN"] cache_path = Path(cache_path).expanduser() cached = _read_token_cache(cache_path) if cached.get("access_token") and float(cached.get("expires_at") or 0) > now + TOKEN_SKEW_SECONDS: return str(cached["access_token"]) token_payload: dict[str, Any] | None = None refresh_token = str(cached.get("refresh_token") or "") if refresh_token and config.get("KINGDEE_REFRESH_URL"): try: token_payload = _request_token( config["KINGDEE_REFRESH_URL"], _render_json_template(config.get("KINGDEE_REFRESH_BODY_JSON", ""), {"refresh_token": refresh_token}), config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"), urlopen=urlopen, ) except Exception: token_payload = None if token_payload is None: token_url = config.get("KINGDEE_TOKEN_URL", "") if not token_url: raise RuntimeError("KINGDEE_TOKEN_URL is required when no cached KINGDEE_ACCESS_TOKEN is available") token_payload = _request_token( token_url, config.get("KINGDEE_TOKEN_BODY_JSON", ""), config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"), urlopen=urlopen, ) access_token = _extract_path(token_payload, config.get("KINGDEE_TOKEN_FIELD", "data.access_token")) if not access_token: raise RuntimeError("Kingdee token response did not contain an access token") expires_in = _int_or_default(_extract_path(token_payload, config.get("KINGDEE_TOKEN_EXPIRES_IN_FIELD", "data.expires_in")), 3600) refresh_token = _extract_path(token_payload, config.get("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token")) or refresh_token cache = { "access_token": str(access_token), "refresh_token": str(refresh_token or ""), "expires_at": now + max(expires_in - TOKEN_SKEW_SECONDS, 1), "obtained_at": now, } _write_private_json(cache_path, cache) return str(access_token) def fetch_kingdee_sales_orders( config: dict[str, str], *, filters: dict[str, Any] | None = None, token_cache_path: str | Path = DEFAULT_TOKEN_CACHE_PATH, page_size: int | None = None, max_pages: int = DEFAULT_MAX_PAGES, urlopen: Callable[..., Any] = urllib.request.urlopen, now_epoch: float | None = None, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: access_token = get_kingdee_access_token(config, cache_path=token_cache_path, urlopen=urlopen, now_epoch=now_epoch) identity = config.get("KINGDEE_ACGW_IDENTITY", "") if not identity: raise RuntimeError("KINGDEE_ACGW_IDENTITY is required for Kingdee sales order API") api_url = config.get("KINGDEE_SALORDER_API_URL") or DEFAULT_SALORDER_API_URL size = page_size or _int_or_default(config.get("KINGDEE_PAGE_SIZE"), DEFAULT_PAGE_SIZE) rows: list[dict[str, Any]] = [] pages: list[dict[str, Any]] = [] total_count: int | None = None safe_filters = filters or {} for page_no in range(1, max_pages + 1): body = {"data": safe_filters, "pageNo": page_no, "pageSize": size} request = urllib.request.Request( api_url, data=json.dumps(body, ensure_ascii=False).encode("utf-8"), headers={ "Content-Type": "application/json", "accesstoken": access_token, "x-acgw-identity": identity, "Idempotency-Key": str(uuid.uuid4()), }, method="POST", ) with urlopen(request, timeout=60) as response: payload = json.loads(response.read().decode("utf-8")) if payload.get("status") is False: raise RuntimeError( f"Kingdee sales order API failed: {payload.get('errorCode', '')} {payload.get('message', '')}".strip() ) page_data = payload.get("data") if isinstance(payload.get("data"), dict) else {} page_rows = page_data.get("rows") if isinstance(page_data.get("rows"), list) else [] pages.append(payload) rows.extend([row for row in page_rows if isinstance(row, dict)]) total = page_data.get("totalCount") if isinstance(total, int) and total >= 0: total_count = total if not page_rows or len(page_rows) < size: break if total_count is not None and len(rows) >= total_count: break return rows, {"source": "kingdee-sales-order-api", "pages_fetched": len(pages), "rows": rows, "pages": pages} def build_sales_order_filters(args: Any) -> dict[str, Any]: fields = { "start_bizdate": getattr(args, "start_bizdate", None), "end_bizdate": getattr(args, "end_bizdate", None), "start_createtime": getattr(args, "start_createtime", None), "end_createtime": getattr(args, "end_createtime", None), "start_modifytime": getattr(args, "start_modifytime", None) or getattr(args, "since", None), "end_modifytime": getattr(args, "end_modifytime", None), "start_auditdate": getattr(args, "start_auditdate", None), "end_auditdate": getattr(args, "end_auditdate", None), } filters = {key: value for key, value in fields.items() if value} array_fields = { "id": getattr(args, "id", None), "billno": getattr(args, "billno", None), "billstatus": getattr(args, "billstatus", None), "org_number": getattr(args, "org_number", None), "customer_number": getattr(args, "customer_number", None), "customer_createorg_number": getattr(args, "customer_createorg_number", None), } for key, value in array_fields.items(): values = _split_values(value) if values: filters[key] = values return filters def _request_token( url: str, body_json: str, headers_json: str, *, urlopen: Callable[..., Any], ) -> dict[str, Any]: body = json.loads(body_json or "{}") headers = {"Content-Type": "application/json", **json.loads(headers_json or "{}")} request = urllib.request.Request( url, data=json.dumps(body, ensure_ascii=False).encode("utf-8"), headers=headers, method="POST", ) with urlopen(request, timeout=60) as response: payload = json.loads(response.read().decode("utf-8")) if isinstance(payload, dict) and payload.get("status") is False: raise RuntimeError(f"Kingdee token API failed: {payload.get('errorCode', '')} {payload.get('message', '')}".strip()) return payload def _render_json_template(template: str, values: dict[str, str]) -> str: if not template: return "{}" rendered = template for key, value in values.items(): rendered = rendered.replace("{" + key + "}", value) return rendered def _extract_path(payload: Any, path: str | None) -> Any: if not path: return None current = payload for part in path.split("."): if not isinstance(current, dict): return None current = current.get(part) return current def _read_token_cache(path: Path) -> dict[str, Any]: if not path.is_file(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} return data if isinstance(data, dict) else {} def _write_private_json(path: Path, data: dict[str, Any]) -> None: _ensure_private_parent(path) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") path.chmod(0o600) def _ensure_private_parent(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) try: path.parent.chmod(0o700) except OSError: pass def _int_or_default(value: Any, default: int) -> int: try: parsed = int(str(value)) except (TypeError, ValueError): return default return parsed if parsed > 0 else default def _split_values(value: Any) -> list[str]: if not value: return [] if isinstance(value, (list, tuple)): raw = [] for item in value: raw.extend(str(item).split(",")) else: raw = str(value).split(",") return [item.strip() for item in raw if item.strip()]