Files
AM516/capabilities/am516-delivery-prediction/scripts/refresh_material_names.py

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