Files
obsidian-wiki/obsidian_wiki/kingdee_api.py
T
Codex 5f257a7ccb feat(business): add Kingdee sales order API sync
Implement private Kingdee config generation, token login/refresh caching, paginated sales order fetching, official response-field normalization, and CLI filters for deterministic business fact sync. Update docs to keep credentials in .runtime and cover dry-run auth.

Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests; git diff --check.
2026-07-06 19:47:40 +00:00

311 lines
12 KiB
Python

"""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()]