Files
obsidian-wiki/obsidian_wiki/kingdee_api.py
T
Codex 4e34e5b3fe feat(business): split Kingdee token body config
Allow Kingdee token requests to be built from app key, app secret, configurable field names, optional timestamp fields, and extra JSON. Keep raw KINGDEE_TOKEN_BODY_JSON as an advanced override with current-time placeholders.

Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests; git diff --check.
2026-07-06 20:03:28 +00:00

459 lines
18 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 datetime import datetime, timezone
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"""# 金蝶销售订单 API 本地配置。
# 这个文件只保存在本机;.runtime/ 已被 git 忽略,请不要提交密钥或 token。
# 变量:KINGDEE_SALORDER_API_URL
# 用途:查询销售订单 API 地址。
# 是否必填:可选,默认已是当前项目使用的官方 querySalOrder 接口。
KINGDEE_SALORDER_API_URL={DEFAULT_SALORDER_API_URL}
# 变量:KINGDEE_ACCESS_TOKEN
# 用途:直接指定 access token,绕过自动登录/刷新流程。
# 是否必填:可选,手动测试用;长期定时同步建议使用自动登录配置。
KINGDEE_ACCESS_TOKEN=
# 变量:KINGDEE_TOKEN_URL
# 用途:获取 access token 的登录/token 接口地址。
# 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。
KINGDEE_TOKEN_URL=
# 推荐填法:用下面这些拆分变量让程序自动拼 token JSON body。
# 如果你的 token 接口字段名不是 app_key/app_secret/timestamp,就改对应 FIELD。
# 变量:KINGDEE_APP_KEY
# 用途:金蝶 OpenAPI 应用的 app key/client id。
# 是否必填:自动登录时通常必填;具体以金蝶 token 文档为准。
KINGDEE_APP_KEY=
# 变量:KINGDEE_APP_SECRET
# 用途:金蝶 OpenAPI 应用的 app secret/client secret。
# 是否必填:自动登录时通常必填;具体以金蝶 token 文档为准。
KINGDEE_APP_SECRET=
# 变量:KINGDEE_TOKEN_APP_KEY_FIELD
# 用途:token JSON body 中 app key 对应的字段名。
# 是否必填:可选,默认 app_key;如果金蝶文档写 client_id,就改成 client_id。
KINGDEE_TOKEN_APP_KEY_FIELD=app_key
# 变量:KINGDEE_TOKEN_APP_SECRET_FIELD
# 用途:token JSON body 中 app secret 对应的字段名。
# 是否必填:可选,默认 app_secret;如果金蝶文档写 client_secret,就改成 client_secret。
KINGDEE_TOKEN_APP_SECRET_FIELD=app_secret
# 变量:KINGDEE_TOKEN_TIMESTAMP_FIELD
# 用途:token JSON body 中当前时间/时间戳对应的字段名;留空则不发送时间。
# 是否必填:仅当金蝶 token 文档要求当前时间时填写,例如 timestamp。
KINGDEE_TOKEN_TIMESTAMP_FIELD=
# 变量:KINGDEE_TOKEN_TIMESTAMP_FORMAT
# 用途:当前时间格式。可选值:epoch_ms、epoch_seconds、datetime_utc、iso_utc、date_utc。
# 是否必填:可选,默认 epoch_ms。
KINGDEE_TOKEN_TIMESTAMP_FORMAT=epoch_ms
# 变量:KINGDEE_TOKEN_BODY_EXTRA_JSON
# 用途:token JSON body 的额外固定字段,例如租户、账套等;会合并到自动拼出的 body。
# 是否必填:可选;没有额外字段时保持 {{}}。
KINGDEE_TOKEN_BODY_EXTRA_JSON={{}}
# 高级填法:如果 token body 结构特殊,可直接填写完整 JSON。
# 填了 KINGDEE_TOKEN_BODY_JSON 后,会覆盖上面的拆分变量。
# 支持占位符:{{now_ms}}、{{now_epoch}}、{{now_seconds}}、{{now_datetime_utc}}、{{now_iso_utc}}、{{today_utc}}。
# 变量:KINGDEE_TOKEN_BODY_JSON
# 用途:调用登录/token 接口时发送的完整 JSON 请求体。
# 是否必填:可选;只有不用拆分变量时才填写,必须是一行合法 JSON。
KINGDEE_TOKEN_BODY_JSON=
# 变量:KINGDEE_TOKEN_HEADERS_JSON
# 用途:调用登录/token 接口时额外发送的请求头。
# 是否必填:可选;没有额外 header 时保持 {{}}。
KINGDEE_TOKEN_HEADERS_JSON={{}}
# 变量:KINGDEE_TOKEN_FIELD
# 用途:从 token 接口返回 JSON 中读取 access token 的字段路径。
# 是否必填:可选,默认从 data.access_token 读取。
KINGDEE_TOKEN_FIELD=data.access_token
# 变量:KINGDEE_TOKEN_EXPIRES_IN_FIELD
# 用途:从 token 接口返回 JSON 中读取有效期秒数的字段路径。
# 是否必填:可选,默认从 data.expires_in 读取;读不到时按 3600 秒处理。
KINGDEE_TOKEN_EXPIRES_IN_FIELD=data.expires_in
# 变量:KINGDEE_REFRESH_TOKEN_FIELD
# 用途:从 token 接口返回 JSON 中读取 refresh token 的字段路径。
# 是否必填:可选;只有配置 refresh 流程时才有用。
KINGDEE_REFRESH_TOKEN_FIELD=data.refresh_token
# 变量:KINGDEE_REFRESH_URL
# 用途:refresh token 接口地址。
# 是否必填:可选;不填时 token 过期会重新调用 KINGDEE_TOKEN_URL。
KINGDEE_REFRESH_URL=
# 变量:KINGDEE_REFRESH_BODY_JSON
# 用途:调用 refresh 接口时发送的 JSON 请求体;可使用 {{refresh_token}} 占位符。
# 是否必填:可选;填写 KINGDEE_REFRESH_URL 时通常需要填写。
KINGDEE_REFRESH_BODY_JSON=
# 变量:KINGDEE_ACGW_IDENTITY
# 用途:销售订单 API 要求的 x-acgw-identity 请求头。
# 是否必填:必填;不填无法调用销售订单查询 API。
KINGDEE_ACGW_IDENTITY=
# 变量:KINGDEE_PAGE_SIZE
# 用途:销售订单 API 分页大小,对应请求体 pageSize。
# 是否必填:可选,默认 100。
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_APP_KEY_FIELD", "app_key")
values.setdefault("KINGDEE_TOKEN_APP_SECRET_FIELD", "app_secret")
values.setdefault("KINGDEE_TOKEN_TIMESTAMP_FORMAT", "epoch_ms")
values.setdefault("KINGDEE_TOKEN_BODY_EXTRA_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", ""),
_template_values(now, {"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,
_build_token_body_json(config, now),
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 _build_token_body_json(config: dict[str, str], now_epoch: float) -> str:
body_json = config.get("KINGDEE_TOKEN_BODY_JSON", "")
if body_json:
return _render_json_template(body_json, _template_values(now_epoch))
body: dict[str, Any] = {}
app_key = config.get("KINGDEE_APP_KEY", "")
app_secret = config.get("KINGDEE_APP_SECRET", "")
app_key_field = config.get("KINGDEE_TOKEN_APP_KEY_FIELD") or "app_key"
app_secret_field = config.get("KINGDEE_TOKEN_APP_SECRET_FIELD") or "app_secret"
if app_key:
body[app_key_field] = app_key
if app_secret:
body[app_secret_field] = app_secret
timestamp_field = config.get("KINGDEE_TOKEN_TIMESTAMP_FIELD", "")
if timestamp_field:
body[timestamp_field] = _format_timestamp(now_epoch, config.get("KINGDEE_TOKEN_TIMESTAMP_FORMAT", "epoch_ms"))
extra = json.loads(config.get("KINGDEE_TOKEN_BODY_EXTRA_JSON", "{}") or "{}")
if not isinstance(extra, dict):
raise RuntimeError("KINGDEE_TOKEN_BODY_EXTRA_JSON must be a JSON object")
body.update(extra)
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
def _template_values(now_epoch: float, extra: dict[str, str] | None = None) -> dict[str, str]:
dt = datetime.fromtimestamp(now_epoch, tz=timezone.utc)
values = {
"now_ms": str(int(now_epoch * 1000)),
"now_epoch": str(int(now_epoch)),
"now_seconds": str(int(now_epoch)),
"now_datetime_utc": dt.strftime("%Y-%m-%d %H:%M:%S"),
"now_iso_utc": dt.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"today_utc": dt.strftime("%Y-%m-%d"),
}
values.update(extra or {})
return values
def _format_timestamp(now_epoch: float, timestamp_format: str) -> str | int:
normalized = (timestamp_format or "epoch_ms").strip().lower()
dt = datetime.fromtimestamp(now_epoch, tz=timezone.utc)
if normalized in {"epoch_ms", "milliseconds", "millis"}:
return int(now_epoch * 1000)
if normalized in {"epoch_seconds", "seconds", "unix"}:
return int(now_epoch)
if normalized == "datetime_utc":
return dt.strftime("%Y-%m-%d %H:%M:%S")
if normalized == "iso_utc":
return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
if normalized == "date_utc":
return dt.strftime("%Y-%m-%d")
raise RuntimeError(f"unsupported KINGDEE_TOKEN_TIMESTAMP_FORMAT: {timestamp_format}")
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()]