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