211 lines
9.8 KiB
Python
211 lines
9.8 KiB
Python
"""Refresh the Git-synchronized code/name cache outside the Feishu runtime."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fcntl
|
|
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,
|
|
load_material_names,
|
|
material_names_from_records,
|
|
material_names_hash,
|
|
)
|
|
|
|
BUNDLED_CLIENT = PROJECT_ROOT / "shared/runtime/kingdee_materials.py"
|
|
DEFAULT_STATE = PROJECT_ROOT / "logs/material_names_refresh_state.json"
|
|
|
|
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_json_atomic(output: Path, payload: dict[str, Any]) -> None:
|
|
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)
|
|
|
|
|
|
def write_cache(
|
|
records: list[dict[str, Any]], snapshot_at: str, output: Path, *, merge_existing: bool = False,
|
|
) -> dict[str, Any]:
|
|
materials = material_names_from_records(records)
|
|
retained_count = 0
|
|
if merge_existing and output.exists():
|
|
previous = load_material_names(output)
|
|
retained_count = len(previous.keys() - materials.keys())
|
|
materials = dict(sorted({**previous, **materials}.items()))
|
|
if materials == previous:
|
|
return json.loads(output.read_text(encoding="utf-8"))
|
|
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,
|
|
}
|
|
if retained_count:
|
|
payload["source"]["retained_missing_count"] = retained_count
|
|
write_json_atomic(output, payload)
|
|
return payload
|
|
|
|
|
|
def refresh_once(
|
|
*, output: Path, state_file: Path, due_only: bool, interval_hours: int,
|
|
client_path: Path, reference_json: Path | None = None, current_time: datetime | None = None,
|
|
) -> dict[str, Any]:
|
|
now = current_time or datetime.now(timezone(timedelta(hours=8)))
|
|
if due_only and state_file.exists():
|
|
try:
|
|
state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
last = datetime.fromisoformat(state["last_success_at"])
|
|
if last.tzinfo is None:
|
|
raise ValueError
|
|
next_due = last + timedelta(hours=interval_hours)
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
next_due = now # Rebuild invalid local state only after a successful refresh.
|
|
if now < next_due:
|
|
return {"ok": True, "status": "NOT_DUE", "next_due_at": next_due.isoformat()}
|
|
previous = load_material_names(output) if output.exists() else {}
|
|
records, snapshot_at = read_reference(reference_json) if reference_json else query_materials(client_path)
|
|
incoming = material_names_from_records(records)
|
|
added_count = len(incoming.keys() - previous.keys())
|
|
renamed_count = sum(previous[code] != name for code, name in incoming.items() if code in previous)
|
|
payload = write_cache(records, snapshot_at, output, merge_existing=due_only)
|
|
completed_at = current_time or datetime.now(timezone(timedelta(hours=8)))
|
|
result = {
|
|
"ok": True, "status": "UPDATED" if added_count or renamed_count else "UNCHANGED",
|
|
"source_record_count": len(records), "material_count": payload["material_count"],
|
|
"added_count": added_count, "renamed_count": renamed_count,
|
|
"retained_missing_count": len(previous.keys() - incoming.keys()) if due_only else 0,
|
|
"snapshot_at": snapshot_at, "output": str(output),
|
|
}
|
|
if due_only:
|
|
write_json_atomic(state_file, {
|
|
"last_success_at": completed_at.isoformat(timespec="seconds"),
|
|
"last_result": result,
|
|
})
|
|
return result
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
source = parser.add_mutually_exclusive_group()
|
|
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)
|
|
parser.add_argument("--due", action="store_true", help="Query only after the successful refresh interval")
|
|
parser.add_argument("--interval-hours", type=int, default=48)
|
|
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE)
|
|
args = parser.parse_args()
|
|
if args.interval_hours <= 0 or (args.due and args.reference_json):
|
|
parser.error("--interval-hours 必须为正数;--due 只允许实时 ERP 刷新。")
|
|
args.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
before = hashlib.sha256(args.output.read_bytes()).hexdigest() if args.output.exists() else None
|
|
try:
|
|
with args.state_file.with_suffix(".lock").open("a") as lock:
|
|
try:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
print(json.dumps({"ok": True, "status": "ALREADY_RUNNING"}))
|
|
return 0
|
|
result = refresh_once(
|
|
output=args.output, state_file=args.state_file, due_only=args.due,
|
|
interval_hours=args.interval_hours, client_path=args.kingdee_client or BUNDLED_CLIENT,
|
|
reference_json=args.reference_json,
|
|
)
|
|
except Exception as exc:
|
|
# Kingdee exceptions may contain server text: do not print arbitrary content.
|
|
reason = str(exc) if isinstance(exc, MaterialNameCacheError) or type(exc).__name__ == "KingdeeMaterialError" else f"读取物料数据失败({type(exc).__name__}),请检查只读连接和凭证配置。"
|
|
after = hashlib.sha256(args.output.read_bytes()).hexdigest() if args.output.exists() else None
|
|
print(json.dumps({"ok": False, "error": reason, "previous_cache_preserved": before == after}, ensure_ascii=False))
|
|
return 1
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|