145 lines
6.2 KiB
Python
145 lines
6.2 KiB
Python
"""Refresh the Git-synchronized code/name cache outside the Feishu runtime."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from shared.runtime.material_name_cache import (
|
|
MATERIAL_NAME_CACHE_FILE,
|
|
MaterialNameCacheError,
|
|
material_names_from_records,
|
|
material_names_hash,
|
|
)
|
|
|
|
|
|
def read_reference(path: Path) -> tuple[list[dict[str, Any]], str]:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(payload, dict) or payload.get("form_id") != "BD_MATERIAL" or payload.get("filter") != "":
|
|
raise MaterialNameCacheError("来源必须为未筛选的 BD_MATERIAL 全量参考缓存。")
|
|
rows = payload.get("records")
|
|
if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
|
|
raise MaterialNameCacheError("来源缓存记录结构无效。")
|
|
digest = hashlib.sha256(
|
|
json.dumps(rows, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
).hexdigest()
|
|
if payload.get("record_count") != len(rows) or payload.get("content_sha256") != digest:
|
|
raise MaterialNameCacheError("来源缓存记录计数或完整性校验失败。")
|
|
snapshot_at = payload.get("generated_at")
|
|
if not isinstance(snapshot_at, str):
|
|
raise MaterialNameCacheError("来源缓存缺少快照时间。")
|
|
datetime.fromisoformat(snapshot_at)
|
|
return rows, snapshot_at
|
|
|
|
|
|
def query_materials(client_path: Path) -> tuple[list[dict[str, Any]], str]:
|
|
"""Use the existing approved read-only Kingdee client, querying only two fields."""
|
|
spec = importlib.util.spec_from_file_location("am516_kingdee_query", client_path)
|
|
if spec is None or spec.loader is None:
|
|
raise MaterialNameCacheError("无法加载金蝶只读查询器。")
|
|
client = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(client)
|
|
# Resolve the client's existing local credential fallback without copying it.
|
|
client.LOCAL_CREDENTIALS_PATH = client_path.resolve().parents[2] / client.LOCAL_CREDENTIALS_PATH
|
|
snapshot_at = datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds")
|
|
opener = client.build_opener()
|
|
client.login(opener)
|
|
rows: list[dict[str, Any]] = []
|
|
seen_pages: set[str] = set()
|
|
start_row = 0
|
|
while True:
|
|
args = SimpleNamespace(
|
|
command="bill-query", no_audit_log=True, form_id="BD_MATERIAL",
|
|
fields="FNumber,FName", filter="", order="FNumber ASC,FMaterialId ASC",
|
|
limit=2000, start_row=start_row,
|
|
)
|
|
page = client.execute_list_query(opener, args)
|
|
if not isinstance(page, list) or any(not isinstance(row, list) or len(row) != 2 for row in page):
|
|
raise MaterialNameCacheError("金蝶返回的物料分页结构无效,保留原缓存。")
|
|
if not page:
|
|
break
|
|
page_hash = hashlib.sha256(json.dumps(page, ensure_ascii=False).encode()).hexdigest()
|
|
if page_hash in seen_pages:
|
|
raise MaterialNameCacheError("金蝶分页重复,无法确认全量完成,保留原缓存。")
|
|
seen_pages.add(page_hash)
|
|
rows.extend({"FNumber": row[0], "FName": row[1]} for row in page)
|
|
start_row += len(page)
|
|
if len(page) < args.limit:
|
|
break
|
|
print(f"已读取物料主数据:{len(rows)}行", flush=True)
|
|
return rows, snapshot_at
|
|
|
|
|
|
def write_cache(records: list[dict[str, Any]], snapshot_at: str, output: Path) -> dict[str, Any]:
|
|
materials = material_names_from_records(records)
|
|
payload = {
|
|
"schema_version": 1,
|
|
"source": {
|
|
"system": "Kingdee K3 Cloud", "form_id": "BD_MATERIAL",
|
|
"field_keys": ["FNumber", "FName"], "filter": "",
|
|
"snapshot_at": snapshot_at, "record_count": len(records),
|
|
},
|
|
"generated_at": datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds"),
|
|
"material_count": len(materials),
|
|
"content_sha256": material_names_hash(materials),
|
|
"materials": materials,
|
|
}
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
temp_path = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=output.parent, delete=False) as handle:
|
|
temp_path = Path(handle.name)
|
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
handle.write("\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temp_path, output)
|
|
finally:
|
|
if temp_path is not None:
|
|
temp_path.unlink(missing_ok=True)
|
|
return payload
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
source = parser.add_mutually_exclusive_group(required=True)
|
|
source.add_argument("--reference-json", type=Path, help="Existing verified full material-master cache")
|
|
source.add_argument("--kingdee-client", type=Path, help="Approved kingdee_query.py for a live read-only refresh")
|
|
parser.add_argument("--output", type=Path, default=MATERIAL_NAME_CACHE_FILE)
|
|
args = parser.parse_args()
|
|
try:
|
|
records, snapshot_at = (
|
|
read_reference(args.reference_json)
|
|
if args.reference_json is not None
|
|
else query_materials(args.kingdee_client)
|
|
)
|
|
payload = write_cache(records, snapshot_at, args.output)
|
|
except Exception as exc:
|
|
# Kingdee exceptions may contain server text: do not print arbitrary content.
|
|
reason = str(exc) if isinstance(exc, MaterialNameCacheError) else f"读取物料数据失败({type(exc).__name__}),请检查只读连接和凭证配置。"
|
|
print(json.dumps({"ok": False, "error": reason, "previous_cache_preserved": True}, ensure_ascii=False))
|
|
return 1
|
|
print(json.dumps({
|
|
"ok": True, "source_record_count": payload["source"]["record_count"],
|
|
"material_count": payload["material_count"], "snapshot_at": snapshot_at,
|
|
"output": str(args.output),
|
|
}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|