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.
311 lines
12 KiB
Python
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()]
|