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.
This commit is contained in:
Codex
2026-07-06 19:47:40 +00:00
parent 4dfef7e099
commit 5f257a7ccb
9 changed files with 785 additions and 43 deletions
+8
View File
@@ -28,6 +28,14 @@ OBSIDIAN_SOURCE_CATALOG_DIR=_sources/catalog
# SQLite source-file locator. Leave empty to use _meta/file-assets.sqlite.
OBSIDIAN_FILE_ASSET_DB=
# --- Business Facts / Kingdee Sales Orders (optional) ---
#
# Exact operational statistics use <vault>/_meta/business-facts.sqlite.
# Run `obsidian-wiki business config kingdee` to create the local private
# .runtime/kingdee.env file for real Kingdee credentials and token settings.
# Do not put real app secrets or access tokens in this example file.
OBSIDIAN_BUSINESS_FACTS_DB=
# --- Source File MCP Server (optional) ---
#
# Run with: obsidian-wiki-mcp
+11 -5
View File
@@ -322,10 +322,16 @@ Operational counts and amounts belong in a structured fact database, not in mark
# Sync from an exported or API-fetched Kingdee JSON payload.
obsidian-wiki business sync kingdee --input ./kingdee-orders.json
# Or fetch from a simple JSON endpoint.
KINGDEE_API_URL="https://example.internal/orders" \
KINGDEE_API_TOKEN="..." \
obsidian-wiki business sync kingdee --since 2026-07-01T00:00:00Z
# Or create the private local config for the real Kingdee API/token flow.
obsidian-wiki business config kingdee
vim .runtime/kingdee.env
obsidian-wiki business sync kingdee --dry-run-auth
# Then fetch reviewed June orders from Kingdee.
obsidian-wiki business sync kingdee \
--start-bizdate 2026-06-01 \
--end-bizdate 2026-06-30 \
--billstatus C
# Ask deterministic statistics questions.
obsidian-wiki business query "2026年6月接了多少单"
@@ -333,7 +339,7 @@ obsidian-wiki business query "2026-06 sales amount"
obsidian-wiki business query "2026-06 top customer"
```
V1 uses fixed templates for monthly order count, monthly sales amount, monthly top customers, and a customer trend query. The default order-count basis is distinct Kingdee sales order number by order date. Detailed order rows stay in SQLite; the wiki remains for business context, customer/project knowledge, and later summary pages when you choose to generate them.
V1 uses fixed templates for monthly order count, monthly sales amount, monthly top customers, and a customer trend query. The default order-count basis is distinct Kingdee sales order number by order date. Detailed order rows stay in SQLite; the wiki remains for business context, customer/project knowledge, and later summary pages when you choose to generate them. Real Kingdee credentials live in `.runtime/kingdee.env`, and fetched tokens are cached in `.runtime/kingdee-token.json`; both files are local-only and ignored by git.
### Optional: Source File MCP Server
+14 -3
View File
@@ -127,8 +127,7 @@ Knowledge that's project-specific goes under `projects/<name>/`. Knowledge that'
|---|---|---|
| `OBSIDIAN_SOURCES_DIR` | Directories with docs to ingest (comma-separated) | *(empty — point agent at specific files)* |
| `OBSIDIAN_BUSINESS_FACTS_DB` | Optional path for structured business facts such as Kingdee orders | `<vault>/_meta/business-facts.sqlite` |
| `KINGDEE_API_URL` | JSON endpoint used by `obsidian-wiki business sync kingdee` when `--input` is omitted | *(unset)* |
| `KINGDEE_API_TOKEN` | Bearer token for the Kingdee-compatible endpoint | *(unset)* |
| `.runtime/kingdee.env` | Private local Kingdee sales-order API config created by `obsidian-wiki business config kingdee` | official sales-order URL, blank credentials |
| `OBSIDIAN_CATEGORIES` | Wiki page categories | `concepts,entities,skills,references,synthesis,journal` |
| `OBSIDIAN_MAX_PAGES_PER_INGEST` | Max pages updated per ingest | `15` |
| `CLAUDE_HISTORY_PATH` | Where to find Claude data | *auto-discovers from `~/.claude`* |
@@ -161,11 +160,23 @@ Use the business fact database for exact operational questions, for example mont
```bash
obsidian-wiki business sync kingdee --input ./kingdee-orders.json
obsidian-wiki business config kingdee
# Fill .runtime/kingdee.env, then test login/refresh without syncing orders:
obsidian-wiki business sync kingdee --dry-run-auth
obsidian-wiki business sync kingdee \
--start-bizdate 2026-06-01 \
--end-bizdate 2026-06-30 \
--billstatus C
obsidian-wiki business query "2026年6月接了多少单"
obsidian-wiki business query "2026-06 sales amount"
```
Without `--input`, the Kingdee sync command reads `KINGDEE_API_URL` and optional `KINGDEE_API_TOKEN`, then stores the fetched JSON response in `<vault>/_meta/business-facts.sqlite`. The answer reports the statistic, counting basis, data source, and last successful sync time. If the latest successful sync is before today, the answer warns that newer data may be missing.
Without `--input`, the Kingdee sync command creates/reads `.runtime/kingdee.env`, obtains or refreshes an access token, calls the official sales order query API, then stores the fetched JSON response in `<vault>/_meta/business-facts.sqlite`. The private config supports configurable login/token URL, request body, response field paths, refresh URL/body, and required `x-acgw-identity`; fetched tokens are cached in `.runtime/kingdee-token.json`. `.runtime/` is ignored by git.
The answer reports the statistic, counting basis, data source, and last successful sync time. If the latest successful sync is before today, the answer warns that newer data may be missing.
## How It Works
+26 -2
View File
@@ -17,13 +17,37 @@ The wiki remains the place for context: customer background, project history, sa
```bash
obsidian-wiki business sync kingdee --input ./kingdee-orders.json
obsidian-wiki business sync kingdee --since 2026-07-01T00:00:00Z
obsidian-wiki business config kingdee
# Fill .runtime/kingdee.env, then test the login/refresh flow:
obsidian-wiki business sync kingdee --dry-run-auth
obsidian-wiki business sync kingdee \
--start-bizdate 2026-06-01 \
--end-bizdate 2026-06-30 \
--billstatus C
obsidian-wiki business sync kingdee --start-modifytime "2026-07-01 00:00:00"
obsidian-wiki business query "2026年6月接了多少单"
obsidian-wiki business query "2026-06 sales amount"
obsidian-wiki business query "2026-06 top customer"
```
When `--input` is omitted, sync reads `KINGDEE_API_URL` and optional `KINGDEE_API_TOKEN`. The API response must be JSON and can be either a list of order objects or an object containing `orders`, `data`, `records`, or `items`.
When `--input` is omitted, sync reads `.runtime/kingdee.env`, gets or refreshes an access token, calls the official Kingdee sales order API, and stores the returned rows. If the file does not exist, `business sync kingdee` creates the template and asks you to fill it before retrying.
The config template includes:
- `KINGDEE_SALORDER_API_URL`: defaults to the official `querySalOrder` endpoint.
- `KINGDEE_TOKEN_URL` and `KINGDEE_TOKEN_BODY_JSON`: login/access-token request.
- `KINGDEE_TOKEN_HEADERS_JSON`: optional token request headers, such as app-specific headers.
- `KINGDEE_TOKEN_FIELD`, `KINGDEE_TOKEN_EXPIRES_IN_FIELD`, `KINGDEE_REFRESH_TOKEN_FIELD`: dot paths used to read token response fields.
- `KINGDEE_REFRESH_URL` and `KINGDEE_REFRESH_BODY_JSON`: optional refresh request. Use `{refresh_token}` in the JSON body template.
- `KINGDEE_ACGW_IDENTITY`: value sent as the required `x-acgw-identity` sales-order API header.
- `KINGDEE_PAGE_SIZE`: default sales-order page size.
`KINGDEE_LOGIN_URL`, `KINGDEE_LOGIN_BODY_JSON`, and `KINGDEE_LOGIN_HEADERS_JSON` are accepted as aliases for the token settings. `KINGDEE_ACCESS_TOKEN` can also be set for one-off manual testing. Fetched tokens are cached in `.runtime/kingdee-token.json`; both local files are ignored by git and should not be copied into `.env.example`.
The API response can still be imported from a JSON file for replay or testing. JSON may be a list of order objects or an object containing `orders`, `rows`, `data`, `records`, or `items`.
## Database
+50 -30
View File
@@ -9,16 +9,14 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import sqlite3
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from obsidian_wiki.file_assets import clean_config_value
from obsidian_wiki.kingdee_api import fetch_kingdee_sales_orders, load_kingdee_config, token_cache_path_for_config
BUSINESS_DB_RELATIVE_PATH = Path("_meta") / "business-facts.sqlite"
@@ -115,20 +113,33 @@ def load_kingdee_payload_from_json(path: str | Path) -> tuple[list[dict[str, Any
return extract_orders_from_payload(payload), payload
def fetch_kingdee_payload(*, api_url: str | None = None, token: str | None = None, since: str | None = None) -> tuple[list[dict[str, Any]], Any]:
url = api_url or os.environ.get("KINGDEE_API_URL", "")
if not url:
raise RuntimeError("KINGDEE_API_URL is required when --input is not provided")
separator = "&" if "?" in url else "?"
if since:
url = f"{url}{separator}updated_since={urllib.parse.quote(since)}"
request = urllib.request.Request(url)
auth_token = token or os.environ.get("KINGDEE_API_TOKEN", "")
if auth_token:
request.add_header("Authorization", f"Bearer {auth_token}")
with urllib.request.urlopen(request, timeout=60) as response:
payload = json.loads(response.read().decode("utf-8"))
return extract_orders_from_payload(payload), payload
def fetch_kingdee_payload(
*,
api_url: str | None = None,
token: str | None = None,
since: str | None = None,
config_path: str | Path | None = None,
config_overrides: dict[str, str] | None = None,
filters: dict[str, Any] | None = None,
page_size: int | None = None,
max_pages: int = 100,
) -> tuple[list[dict[str, Any]], Any]:
config = load_kingdee_config(config_path)
config.update(config_overrides or {})
if api_url:
config["KINGDEE_SALORDER_API_URL"] = api_url
if token:
config["KINGDEE_ACCESS_TOKEN"] = token
safe_filters = dict(filters or {})
if since and not safe_filters.get("start_modifytime"):
safe_filters["start_modifytime"] = since
return fetch_kingdee_sales_orders(
config,
filters=safe_filters,
token_cache_path=token_cache_path_for_config(config_path),
page_size=page_size,
max_pages=max_pages,
)
def extract_orders_from_payload(payload: Any) -> list[dict[str, Any]]:
@@ -136,7 +147,7 @@ def extract_orders_from_payload(payload: Any) -> list[dict[str, Any]]:
return [item for item in payload if isinstance(item, dict)]
if not isinstance(payload, dict):
raise RuntimeError("Kingdee payload must be a JSON object or array")
for key in ("orders", "data", "records", "items"):
for key in ("orders", "rows", "data", "records", "items"):
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
@@ -216,17 +227,17 @@ def sync_kingdee_orders(
def normalize_order(record: dict[str, Any]) -> dict[str, Any]:
order_id = _text(_first(record, "order_id", "id", "fid", "FID", "FId", "f_id"))
order_no = _text(_first(record, "order_no", "bill_no", "billNo", "FBillNo", "f_bill_no", "number"))
order_no = _text(_first(record, "order_no", "billno", "bill_no", "billNo", "FBillNo", "f_bill_no", "number"))
if not order_id:
order_id = order_no
if not order_no:
order_no = order_id
order_date = _date_text(_first(record, "order_date", "date", "FDate", "orderDate", "bill_date"))
order_date = _date_text(_first(record, "order_date", "bizdate", "date", "FDate", "orderDate", "bill_date"))
if not order_no or not order_date:
raise RuntimeError("Kingdee order record must include order_no/order_id and order_date")
customer = record.get("customer") if isinstance(record.get("customer"), dict) else {}
customer_id = _text(_first(record, "customer_id", "customerId", "FCustomerId") or _first(customer, "id", "number"))
customer_id = _text(_first(record, "customer_number", "customer_id", "customerId", "FCustomerId") or _first(customer, "number", "id"))
customer_name = _text(_first(record, "customer_name", "customerName", "FCustomerName") or _first(customer, "name"))
items = _items(record)
return {
@@ -235,10 +246,10 @@ def normalize_order(record: dict[str, Any]) -> dict[str, Any]:
"order_date": order_date,
"customer_id": customer_id,
"customer_name": customer_name,
"total_amount": _float(_first(record, "total_amount", "amount", "amount_total", "FAllAmount", "FTotalAmount")),
"currency": _text(_first(record, "currency", "FCurrency")),
"status": _text(_first(record, "status", "document_status", "FDocumentStatus")),
"updated_at": _date_time_text(_first(record, "updated_at", "updatedAt", "FModifyDate", "modify_date")),
"total_amount": _float(_first(record, "total_amount", "totalallamount", "totalamount", "amount", "amount_total", "FAllAmount", "FTotalAmount")),
"currency": _text(_first(record, "settlecurrency_number", "currency_number", "currency", "FCurrency")),
"status": _status_text(record),
"updated_at": _date_time_text(_first(record, "updated_at", "modifytime", "updatedAt", "FModifyDate", "modify_date")),
"items": items,
}
@@ -515,7 +526,7 @@ def _upsert_order(conn: sqlite3.Connection, order: dict[str, Any], *, raw_hash:
def _items(record: dict[str, Any]) -> list[dict[str, Any]]:
raw_items = None
for key in ("items", "entries", "order_items", "FEntity"):
for key in ("items", "billentry", "entries", "order_items", "FEntity"):
if isinstance(record.get(key), list):
raw_items = record[key]
break
@@ -526,15 +537,15 @@ def _items(record: dict[str, Any]) -> list[dict[str, Any]]:
if not isinstance(raw, dict):
continue
product = raw.get("product") if isinstance(raw.get("product"), dict) else {}
product_id = _text(_first(raw, "product_id", "productId", "material_id", "FMaterialId") or _first(product, "id", "number"))
product_name = _text(_first(raw, "product_name", "productName", "material_name", "FMaterialName") or _first(product, "name"))
product_id = _text(_first(raw, "material_masterid_number", "product_id", "productId", "material_id", "FMaterialId") or _first(product, "number", "id"))
product_name = _text(_first(raw, "material_masterid_name", "product_name", "productName", "material_name", "FMaterialName") or _first(product, "name"))
items.append(
{
"line_id": _text(_first(raw, "line_id", "line_no", "entry_id", "FEntryID", "seq")) or str(index),
"line_id": _text(_first(raw, "line_id", "id", "line_no", "entry_id", "FEntryID", "seq")) or str(index),
"product_id": product_id,
"product_name": product_name,
"quantity": _float(_first(raw, "quantity", "qty", "FQty")),
"amount": _float(_first(raw, "amount", "line_amount", "FAmount")),
"amount": _float(_first(raw, "amountandtax", "amount", "line_amount", "FAmount")),
}
)
return items
@@ -572,3 +583,12 @@ def _float(value: Any) -> float:
def _extract_customer_name(question: str) -> str:
match = re.search(r"customer\s+(.+?)\s+trend", question, re.IGNORECASE)
return match.group(1).strip() if match else ""
def _status_text(record: dict[str, Any]) -> str:
parts = []
for key in ("billstatus", "orderstatus", "closestatus", "status", "document_status", "FDocumentStatus"):
value = _text(record.get(key))
if value:
parts.append(f"{key}={value}")
return ";".join(parts)
+80 -3
View File
@@ -34,6 +34,13 @@ from obsidian_wiki.file_assets import (
search_assets,
stage_files,
)
from obsidian_wiki.kingdee_api import (
build_sales_order_filters,
ensure_kingdee_config,
get_kingdee_access_token,
load_kingdee_config,
token_cache_path_for_config,
)
HOME = Path.home()
GLOBAL_CONFIG_DIR = HOME / ".obsidian-wiki"
@@ -506,7 +513,27 @@ def cmd_business_sync_kingdee(args: argparse.Namespace) -> int:
if args.input:
records, payload = load_kingdee_payload_from_json(args.input)
else:
records, payload = fetch_kingdee_payload(api_url=args.api_url, token=args.token, since=args.since)
config_path = getattr(args, "config", None)
created = ensure_kingdee_config(config_path)
overrides = _kingdee_cli_overrides(args)
config = load_kingdee_config(config_path)
config.update(overrides)
if created and not (config.get("KINGDEE_ACCESS_TOKEN") or config.get("KINGDEE_TOKEN_URL")):
raise RuntimeError(f"Kingdee config template created at {Path(config_path or '.runtime/kingdee.env').expanduser()}; fill it and rerun")
if args.dry_run_auth:
token = get_kingdee_access_token(config, cache_path=token_cache_path_for_config(config_path))
print(f"✅ Kingdee auth OK: token length {len(token)}")
return 0
records, payload = fetch_kingdee_payload(
api_url=args.api_url,
token=args.token or args.access_token,
since=args.since,
config_path=config_path,
config_overrides=overrides,
filters=build_sales_order_filters(args),
page_size=args.page_size,
max_pages=args.max_pages,
)
result = sync_kingdee_orders(
vault_path,
records,
@@ -523,6 +550,29 @@ def cmd_business_sync_kingdee(args: argparse.Namespace) -> int:
return 0
def cmd_business_config_kingdee(args: argparse.Namespace) -> int:
config_path = Path(args.config or ".runtime/kingdee.env").expanduser()
created = ensure_kingdee_config(config_path)
state = "created" if created else "exists"
print(f"✅ Kingdee config {state}: {config_path}")
return 0
def _kingdee_cli_overrides(args: argparse.Namespace) -> dict[str, str]:
overrides: dict[str, str] = {}
if getattr(args, "api_url", None):
overrides["KINGDEE_SALORDER_API_URL"] = args.api_url
if getattr(args, "token", None):
overrides["KINGDEE_ACCESS_TOKEN"] = args.token
if getattr(args, "access_token", None):
overrides["KINGDEE_ACCESS_TOKEN"] = args.access_token
if getattr(args, "identity", None):
overrides["KINGDEE_ACGW_IDENTITY"] = args.identity
if getattr(args, "page_size", None):
overrides["KINGDEE_PAGE_SIZE"] = str(args.page_size)
return overrides
def cmd_business_query(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
question = " ".join(args.question).strip()
@@ -663,6 +713,13 @@ def build_parser() -> argparse.ArgumentParser:
bp = sub.add_parser("business", help="sync and query structured business facts")
business_sub = bp.add_subparsers(dest="business_command")
bcp = business_sub.add_parser("config", help="create local business integration config files")
business_config_sub = bcp.add_subparsers(dest="business_config_target")
bckp = business_config_sub.add_parser("kingdee", help="create a local Kingdee API config template")
bckp.add_argument("--config", metavar="PATH", help="Kingdee config path (defaults to .runtime/kingdee.env)")
bckp.set_defaults(func=cmd_business_config_kingdee)
bsp = business_sub.add_parser("sync", help="sync business facts from an external system")
business_sync_sub = bsp.add_subparsers(dest="business_sync_source")
@@ -670,9 +727,29 @@ def build_parser() -> argparse.ArgumentParser:
_add_vault_arg(bkp)
bkp.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to <vault>/_meta/business-facts.sqlite)")
bkp.add_argument("--input", metavar="PATH", help="JSON payload exported from or fetched from Kingdee")
bkp.add_argument("--api-url", help="Kingdee-compatible JSON API URL; defaults to KINGDEE_API_URL")
bkp.add_argument("--token", help="API bearer token; defaults to KINGDEE_API_TOKEN")
bkp.add_argument("--config", metavar="PATH", help="Kingdee config path (defaults to .runtime/kingdee.env)")
bkp.add_argument("--api-url", help="Kingdee sales order API URL; defaults to KINGDEE_SALORDER_API_URL")
bkp.add_argument("--token", help="direct access token override; compatibility alias for --access-token")
bkp.add_argument("--access-token", help="direct access token override")
bkp.add_argument("--identity", help="x-acgw-identity override")
bkp.add_argument("--since", help="optional updated_since cursor passed to the API and sync log")
bkp.add_argument("--start-bizdate")
bkp.add_argument("--end-bizdate")
bkp.add_argument("--start-createtime")
bkp.add_argument("--end-createtime")
bkp.add_argument("--start-modifytime")
bkp.add_argument("--end-modifytime")
bkp.add_argument("--start-auditdate")
bkp.add_argument("--end-auditdate")
bkp.add_argument("--id", action="append", help="sales order ID; repeat or comma-separate")
bkp.add_argument("--billno", action="append", help="sales order bill number; repeat or comma-separate")
bkp.add_argument("--billstatus", action="append", help="A/B/C status; repeat or comma-separate")
bkp.add_argument("--org-number", action="append", help="sales org code; repeat or comma-separate")
bkp.add_argument("--customer-number", action="append", help="customer code; repeat or comma-separate")
bkp.add_argument("--customer-createorg-number", action="append", help="customer create org code; repeat or comma-separate")
bkp.add_argument("--page-size", type=int, help="Kingdee pageSize")
bkp.add_argument("--max-pages", type=int, default=100, help="maximum pages to fetch")
bkp.add_argument("--dry-run-auth", action="store_true", help="fetch/refresh access token and exit without syncing orders")
bkp.add_argument("--json", action="store_true", help="print machine-readable sync result")
bkp.set_defaults(func=cmd_business_sync_kingdee)
+310
View File
@@ -0,0 +1,310 @@
"""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()]
+44
View File
@@ -118,6 +118,50 @@ class BusinessFactsTests(unittest.TestCase):
self.assertTrue(result["is_stale"])
self.assertIn("may not include data after 2026-07-03T09:00:00Z", result["answer"])
def test_official_kingdee_sales_order_shape_maps_into_business_facts(self):
payload = {
"data": {
"rows": [
{
"id": "3551625973803228160",
"billno": "SO-REAL-001",
"billstatus": "C",
"orderstatus": "C",
"closestatus": "A",
"bizdate": "2026-06-08",
"modifytime": "2026-06-09 10:30:00",
"customer_number": "CUST-001",
"customer_name": "真实客户",
"totalallamount": "1234.56",
"totalamount": "1000.00",
"settlecurrency_number": "CNY",
"billentry": [
{
"id": "4568544278879012864",
"material_masterid_number": "MAT-001",
"material_masterid_name": "车载连接器",
"qty": "711.97",
"amountandtax": "1234.56",
"amount": "1000.00",
}
],
}
]
}
}
sync_kingdee_orders(
self.vault,
payload["data"]["rows"],
source_payload=payload,
synced_at="2026-07-06T09:00:00Z",
)
count = query_business_question(self.vault, "2026-06 order count")
amount = query_business_question(self.vault, "2026-06 sales amount")
self.assertEqual(count["data"]["order_count"], 1)
self.assertEqual(amount["data"]["sales_amount"], 1234.56)
def test_cli_sync_payload_can_be_loaded_from_json_file(self):
payload_path = self.vault / "kingdee.json"
payload_path.write_text(
+242
View File
@@ -0,0 +1,242 @@
import json
import os
import stat
import tempfile
import unittest
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from obsidian_wiki.kingdee_api import (
DEFAULT_SALORDER_API_URL,
ensure_kingdee_config,
fetch_kingdee_sales_orders,
get_kingdee_access_token,
)
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(self.payload).encode("utf-8")
class RecordingUrlopen:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def __call__(self, request, timeout=60):
self.calls.append({"request": request, "timeout": timeout})
if not self.responses:
raise AssertionError("unexpected HTTP request")
return FakeResponse(self.responses.pop(0))
class KingdeeApiTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
self.root = Path(self.tempdir.name)
def tearDown(self):
self.tempdir.cleanup()
def test_config_command_creates_private_template_without_overwriting(self):
config_path = self.root / ".runtime" / "kingdee.env"
created = ensure_kingdee_config(config_path)
config_path.write_text(config_path.read_text(encoding="utf-8") + "\nKINGDEE_TOKEN_URL=https://token.example\n", encoding="utf-8")
created_again = ensure_kingdee_config(config_path)
self.assertTrue(created)
self.assertFalse(created_again)
self.assertEqual(stat.S_IMODE(config_path.stat().st_mode), 0o600)
text = config_path.read_text(encoding="utf-8")
self.assertIn(f"KINGDEE_SALORDER_API_URL={DEFAULT_SALORDER_API_URL}", text)
self.assertIn("KINGDEE_TOKEN_BODY_JSON=", text)
self.assertIn("KINGDEE_TOKEN_URL=https://token.example", text)
def test_default_config_path_accepts_none(self):
cwd = Path.cwd()
try:
os.chdir(self.root)
created = ensure_kingdee_config(None)
finally:
os.chdir(cwd)
self.assertTrue(created)
self.assertTrue((self.root / ".runtime" / "kingdee.env").is_file())
def test_cli_config_kingdee_writes_template(self):
from obsidian_wiki.cli import main
config_path = self.root / ".runtime" / "kingdee.env"
output = StringIO()
with redirect_stdout(output):
code = main(["business", "config", "kingdee", "--config", str(config_path)])
self.assertEqual(code, 0)
self.assertTrue(config_path.is_file())
self.assertIn("Kingdee config", output.getvalue())
def test_access_token_is_fetched_and_reused_from_private_cache(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
config = {
"KINGDEE_TOKEN_URL": "https://token.example/access",
"KINGDEE_TOKEN_BODY_JSON": '{"app_key":"abc","app_secret":"secret"}',
"KINGDEE_TOKEN_HEADERS_JSON": '{"X-App":"demo"}',
"KINGDEE_TOKEN_FIELD": "data.access_token",
"KINGDEE_TOKEN_EXPIRES_IN_FIELD": "data.expires_in",
"KINGDEE_REFRESH_TOKEN_FIELD": "data.refresh_token",
}
http = RecordingUrlopen(
[
{
"data": {
"access_token": "access-1",
"expires_in": 3600,
"refresh_token": "refresh-1",
}
}
]
)
token = get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1000)
cached = get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1100)
self.assertEqual(token, "access-1")
self.assertEqual(cached, "access-1")
self.assertEqual(len(http.calls), 1)
request = http.calls[0]["request"]
self.assertEqual(request.get_method(), "POST")
self.assertEqual(request.full_url, "https://token.example/access")
self.assertEqual(json.loads(request.data.decode("utf-8"))["app_key"], "abc")
self.assertEqual(request.get_header("X-app"), "demo")
self.assertEqual(stat.S_IMODE(cache_path.stat().st_mode), 0o600)
def test_expired_token_uses_refresh_endpoint(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
cache_path.parent.mkdir(parents=True)
cache_path.write_text(
json.dumps(
{
"access_token": "old-token",
"refresh_token": "refresh-1",
"expires_at": 999,
"obtained_at": 100,
}
),
encoding="utf-8",
)
config = {
"KINGDEE_TOKEN_URL": "https://token.example/access",
"KINGDEE_TOKEN_BODY_JSON": '{"app_key":"abc"}',
"KINGDEE_REFRESH_URL": "https://token.example/refresh",
"KINGDEE_REFRESH_BODY_JSON": '{"refresh_token":"{refresh_token}"}',
"KINGDEE_TOKEN_FIELD": "data.access_token",
"KINGDEE_TOKEN_EXPIRES_IN_FIELD": "data.expires_in",
"KINGDEE_REFRESH_TOKEN_FIELD": "data.refresh_token",
}
http = RecordingUrlopen(
[
{
"data": {
"access_token": "new-token",
"expires_in": 3600,
"refresh_token": "refresh-2",
}
}
]
)
token = get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1000)
self.assertEqual(token, "new-token")
request = http.calls[0]["request"]
self.assertEqual(request.full_url, "https://token.example/refresh")
self.assertEqual(json.loads(request.data.decode("utf-8"))["refresh_token"], "refresh-1")
def test_sales_order_fetch_posts_filters_headers_and_paginates(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
cache_path.parent.mkdir(parents=True)
cache_path.write_text(
json.dumps({"access_token": "access-1", "expires_at": 9999, "obtained_at": 1000}),
encoding="utf-8",
)
config = {
"KINGDEE_SALORDER_API_URL": "https://kingdee.example/querySalOrder",
"KINGDEE_ACGW_IDENTITY": "identity-1",
"KINGDEE_PAGE_SIZE": "2",
}
page_1 = {
"status": True,
"errorCode": "",
"message": "",
"data": {
"pageNo": 1,
"pageSize": 2,
"rows": [{"id": "1", "billno": "SO-001"}, {"id": "2", "billno": "SO-002"}],
"totalCount": 3,
},
}
page_2 = {
"status": True,
"errorCode": "",
"message": "",
"data": {
"pageNo": 2,
"pageSize": 2,
"rows": [{"id": "3", "billno": "SO-003"}],
"totalCount": 3,
},
}
http = RecordingUrlopen([page_1, page_2])
rows, payload = fetch_kingdee_sales_orders(
config,
filters={"start_bizdate": "2026-06-01", "billstatus": ["C"]},
token_cache_path=cache_path,
urlopen=http,
now_epoch=1000,
)
self.assertEqual([row["billno"] for row in rows], ["SO-001", "SO-002", "SO-003"])
self.assertEqual(payload["pages_fetched"], 2)
self.assertEqual(len(http.calls), 2)
first = http.calls[0]["request"]
self.assertEqual(first.get_method(), "POST")
self.assertEqual(first.get_header("Accesstoken"), "access-1")
self.assertEqual(first.get_header("X-acgw-identity"), "identity-1")
body = json.loads(first.data.decode("utf-8"))
self.assertEqual(body["data"]["start_bizdate"], "2026-06-01")
self.assertEqual(body["data"]["billstatus"], ["C"])
self.assertEqual(body["pageNo"], 1)
self.assertEqual(body["pageSize"], 2)
def test_sales_order_api_failure_reports_code_and_message(self):
cache_path = self.root / ".runtime" / "kingdee-token.json"
cache_path.parent.mkdir(parents=True)
cache_path.write_text(
json.dumps({"access_token": "access-1", "expires_at": 9999, "obtained_at": 1000}),
encoding="utf-8",
)
config = {
"KINGDEE_SALORDER_API_URL": "https://kingdee.example/querySalOrder",
"KINGDEE_ACGW_IDENTITY": "identity-1",
}
http = RecordingUrlopen([{"status": False, "errorCode": "E001", "message": "bad filter"}])
with self.assertRaisesRegex(RuntimeError, "E001.*bad filter"):
fetch_kingdee_sales_orders(config, filters={}, token_cache_path=cache_path, urlopen=http, now_epoch=1000)
if __name__ == "__main__":
unittest.main()