"""Standalone Kingdee reader restricted to material codes and names.""" from __future__ import annotations import getpass import http.cookiejar import json import os import subprocess import urllib.error import urllib.request from pathlib import Path from typing import Any BASE_URL = "https://kingdeeapi.zeroerr.com" LOCAL_CREDENTIALS_PATH = Path(__file__).resolve().parents[2] / "configs/material_cache_erp.local" LOGIN_PATH = "/Kingdee.BOS.WebApi.ServicesStub.AuthService.ValidateUser.common.kdsvc" QUERY_PATH = "/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.ExecuteBillQuery.common.kdsvc" class KingdeeMaterialError(RuntimeError): pass def credentials() -> dict[str, str]: result = {key: os.environ.get(env, "") for key, env in ( ("acct_id", "KINGDEE_ACCT_ID"), ("username", "KINGDEE_USERNAME"), ("password", "KINGDEE_PASSWORD"), )} if any(result.values()): if not all(result.values()): raise KingdeeMaterialError("金蝶运行环境凭证配置不完整。") return result if LOCAL_CREDENTIALS_PATH.exists(): try: data = json.loads(LOCAL_CREDENTIALS_PATH.read_text(encoding="utf-8")) result = {key: data.get(key) for key in result} if not all(isinstance(value, str) and value for value in result.values()): raise ValueError return result except (OSError, ValueError, AttributeError) as exc: raise KingdeeMaterialError("本机金蝶凭证文件配置无效。") from exc account = getpass.getuser() for key, service in ( ("acct_id", "TH51_KINGDEE_ACCT_ID"), ("username", "TH51_KINGDEE_USERNAME"), ("password", "TH51_KINGDEE_PASSWORD"), ): try: completed = subprocess.run( ["security", "find-generic-password", "-a", account, "-s", service, "-w"], capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.TimeoutExpired) as exc: raise KingdeeMaterialError("本机金蝶只读凭证不可用。") from exc if completed.returncode: raise KingdeeMaterialError("本机未配置金蝶只读凭证。") result[key] = completed.stdout.rstrip("\r\n") # macOS may return non-ASCII Keychain text as UTF-8 hexadecimal bytes. for key, value in result.items(): try: decoded = bytes.fromhex(value).decode("utf-8") except (ValueError, UnicodeError): continue if any(ord(char) > 127 for char in decoded): result[key] = decoded return result def build_opener() -> urllib.request.OpenerDirector: return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) def post(opener: Any, path: str, payload: Any) -> Any: request = urllib.request.Request( BASE_URL + path, data=json.dumps(payload, ensure_ascii=True).encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, method="POST", ) try: with opener.open(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: raise KingdeeMaterialError(f"金蝶只读接口返回 HTTP {exc.code}。") from exc except (urllib.error.URLError, OSError) as exc: raise KingdeeMaterialError("金蝶只读接口网络或 TLS 连接失败。") from exc except (ValueError, UnicodeError) as exc: raise KingdeeMaterialError("金蝶只读接口响应格式无效。") from exc def login(opener: Any) -> None: values = credentials() result = post(opener, LOGIN_PATH, { "acctID": values["acct_id"], "username": values["username"], "password": values["password"], "lcid": 2052, }) if not isinstance(result, dict) or not (result.get("IsSuccess") is True or result.get("LoginResultType") == 1): raise KingdeeMaterialError("金蝶登录失败,请检查本机只读账号和凭证。") def execute_list_query(opener: Any, args: Any) -> list[Any]: if args.form_id != "BD_MATERIAL" or args.fields != "FNumber,FName" or args.filter: raise KingdeeMaterialError("物料名称客户端只允许未筛选的 BD_MATERIAL 编码和名称查询。") parameters = { "FormId": "BD_MATERIAL", "FieldKeys": "FNumber,FName", "FilterString": "", "OrderString": "FNumber ASC,FMaterialId ASC", "TopRowCount": 0, "StartRow": args.start_row, "Limit": args.limit, } for payload in (parameters, {"data": parameters}): result = post(opener, QUERY_PATH, payload) if isinstance(result, list) and all(isinstance(row, list) and len(row) == 2 for row in result): return result raise KingdeeMaterialError("金蝶物料名称查询未返回有效数据,请核对只读查询权限。")