feat: refresh material names in background every two days
This commit is contained in:
67
shared/runtime/install_material_name_refresh.py
Normal file
67
shared/runtime/install_material_name_refresh.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Install the native macOS background material-name refresher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
LABEL = "com.am516.material-name-refresh"
|
||||
|
||||
|
||||
def launch_agent(project: Path, python: Path, log_dir: Path, client: Path | None = None) -> dict:
|
||||
command = [str(python), str(project / "capabilities/am516-delivery-prediction/scripts/refresh_material_names.py"), "--due", "--interval-hours", "48"]
|
||||
if client is not None:
|
||||
command.extend(["--kingdee-client", str(client)])
|
||||
return {
|
||||
"Label": LABEL, "ProgramArguments": command, "WorkingDirectory": str(project),
|
||||
"RunAtLoad": True, "StartInterval": 3600, "ProcessType": "Background",
|
||||
"EnvironmentVariables": {"PYTHONDONTWRITEBYTECODE": "1", "PYTHONUNBUFFERED": "1"},
|
||||
"StandardOutPath": str(log_dir / "material_name_refresh.log"),
|
||||
"StandardErrorPath": str(log_dir / "material_name_refresh.error.log"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=("install", "status", "uninstall", "preview"))
|
||||
parser.add_argument("--python", type=Path, default=Path(sys.executable))
|
||||
parser.add_argument("--kingdee-client", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.kingdee_client is not None:
|
||||
args.kingdee_client = args.kingdee_client.resolve()
|
||||
destination = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
|
||||
logs = Path.home() / "Library/Logs/AM516_Feishu_Bot"
|
||||
domain = f"gui/{os.getuid()}"
|
||||
service = f"{domain}/{LABEL}"
|
||||
if args.action == "preview":
|
||||
print(plistlib.dumps(launch_agent(PROJECT_ROOT, args.python.resolve(), logs, args.kingdee_client)).decode())
|
||||
return 0
|
||||
if args.action == "status":
|
||||
return subprocess.run(["launchctl", "print", service]).returncode
|
||||
if args.action in ("install", "uninstall"):
|
||||
exists = subprocess.run(["launchctl", "print", service], capture_output=True).returncode == 0
|
||||
if exists:
|
||||
subprocess.run(["launchctl", "bootout", service], check=True)
|
||||
if args.action == "uninstall":
|
||||
destination.unlink(missing_ok=True)
|
||||
print("已移除物料名称后台刷新服务。")
|
||||
return 0
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
(PROJECT_ROOT / "logs").mkdir(exist_ok=True)
|
||||
with destination.open("wb") as handle:
|
||||
plistlib.dump(launch_agent(PROJECT_ROOT, args.python.resolve(), logs, args.kingdee_client), handle)
|
||||
subprocess.run(["launchctl", "bootstrap", domain, str(destination)], check=True)
|
||||
print("已安装物料名称后台刷新:每小时检查,成功刷新后间隔48小时再次查询 ERP。")
|
||||
print(f"服务:{service}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
110
shared/runtime/kingdee_materials.py
Normal file
110
shared/runtime/kingdee_materials.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Standalone Kingdee reader restricted to material codes and names."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE_URL = "https://kingdeeapi.zeroerr.com"
|
||||
LOCAL_CREDENTIALS_PATH = Path(__file__).resolve().parents[2] / "configs/material_cache_erp.local"
|
||||
LOGIN_PATH = "/Kingdee.BOS.WebApi.ServicesStub.AuthService.ValidateUser.common.kdsvc"
|
||||
QUERY_PATH = "/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.ExecuteBillQuery.common.kdsvc"
|
||||
|
||||
|
||||
class KingdeeMaterialError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def credentials() -> dict[str, str]:
|
||||
result = {key: os.environ.get(env, "") for key, env in (
|
||||
("acct_id", "KINGDEE_ACCT_ID"), ("username", "KINGDEE_USERNAME"), ("password", "KINGDEE_PASSWORD"),
|
||||
)}
|
||||
if any(result.values()):
|
||||
if not all(result.values()):
|
||||
raise KingdeeMaterialError("金蝶运行环境凭证配置不完整。")
|
||||
return result
|
||||
if LOCAL_CREDENTIALS_PATH.exists():
|
||||
try:
|
||||
data = json.loads(LOCAL_CREDENTIALS_PATH.read_text(encoding="utf-8"))
|
||||
result = {key: data.get(key) for key in result}
|
||||
if not all(isinstance(value, str) and value for value in result.values()):
|
||||
raise ValueError
|
||||
return result
|
||||
except (OSError, ValueError, AttributeError) as exc:
|
||||
raise KingdeeMaterialError("本机金蝶凭证文件配置无效。") from exc
|
||||
account = getpass.getuser()
|
||||
for key, service in (
|
||||
("acct_id", "TH51_KINGDEE_ACCT_ID"), ("username", "TH51_KINGDEE_USERNAME"), ("password", "TH51_KINGDEE_PASSWORD"),
|
||||
):
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["security", "find-generic-password", "-a", account, "-s", service, "-w"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise KingdeeMaterialError("本机金蝶只读凭证不可用。") from exc
|
||||
if completed.returncode:
|
||||
raise KingdeeMaterialError("本机未配置金蝶只读凭证。")
|
||||
result[key] = completed.stdout.rstrip("\r\n")
|
||||
# macOS may return non-ASCII Keychain text as UTF-8 hexadecimal bytes.
|
||||
for key, value in result.items():
|
||||
try:
|
||||
decoded = bytes.fromhex(value).decode("utf-8")
|
||||
except (ValueError, UnicodeError):
|
||||
continue
|
||||
if any(ord(char) > 127 for char in decoded):
|
||||
result[key] = decoded
|
||||
return result
|
||||
|
||||
|
||||
def build_opener() -> urllib.request.OpenerDirector:
|
||||
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||
|
||||
|
||||
def post(opener: Any, path: str, payload: Any) -> Any:
|
||||
request = urllib.request.Request(
|
||||
BASE_URL + path, data=json.dumps(payload, ensure_ascii=True).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json; charset=utf-8"}, method="POST",
|
||||
)
|
||||
try:
|
||||
with opener.open(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise KingdeeMaterialError(f"金蝶只读接口返回 HTTP {exc.code}。") from exc
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
raise KingdeeMaterialError("金蝶只读接口网络或 TLS 连接失败。") from exc
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise KingdeeMaterialError("金蝶只读接口响应格式无效。") from exc
|
||||
|
||||
|
||||
def login(opener: Any) -> None:
|
||||
values = credentials()
|
||||
result = post(opener, LOGIN_PATH, {
|
||||
"acctID": values["acct_id"], "username": values["username"],
|
||||
"password": values["password"], "lcid": 2052,
|
||||
})
|
||||
if not isinstance(result, dict) or not (result.get("IsSuccess") is True or result.get("LoginResultType") == 1):
|
||||
raise KingdeeMaterialError("金蝶登录失败,请检查本机只读账号和凭证。")
|
||||
|
||||
|
||||
def execute_list_query(opener: Any, args: Any) -> list[Any]:
|
||||
if args.form_id != "BD_MATERIAL" or args.fields != "FNumber,FName" or args.filter:
|
||||
raise KingdeeMaterialError("物料名称客户端只允许未筛选的 BD_MATERIAL 编码和名称查询。")
|
||||
parameters = {
|
||||
"FormId": "BD_MATERIAL", "FieldKeys": "FNumber,FName", "FilterString": "",
|
||||
"OrderString": "FNumber ASC,FMaterialId ASC", "TopRowCount": 0,
|
||||
"StartRow": args.start_row, "Limit": args.limit,
|
||||
}
|
||||
for payload in (parameters, {"data": parameters}):
|
||||
result = post(opener, QUERY_PATH, payload)
|
||||
if isinstance(result, list) and all(isinstance(row, list) and len(row) == 2 for row in result):
|
||||
return result
|
||||
raise KingdeeMaterialError("金蝶物料名称查询未返回有效数据,请核对只读查询权限。")
|
||||
@@ -39,9 +39,19 @@ def material_names_from_records(records: list[dict[str, Any]]) -> dict[str, str]
|
||||
return dict(sorted(materials.items()))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def load_material_names(path: Path = MATERIAL_NAME_CACHE_FILE) -> dict[str, str]:
|
||||
"""Load once per process; a Git update takes effect after the bot restarts."""
|
||||
"""Reuse memory until the local file changes; never access ERP."""
|
||||
try:
|
||||
stat = path.stat()
|
||||
except FileNotFoundError as exc:
|
||||
raise MaterialNameCacheError("本地物料名称缓存文件缺失") from exc
|
||||
except OSError as exc:
|
||||
raise MaterialNameCacheError("本地物料名称缓存文件不可读取") from exc
|
||||
return _load_material_names(path, (stat.st_mtime_ns, stat.st_size, stat.st_ino))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_material_names(path: Path, version: tuple[int, int, int]) -> dict[str, str]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
@@ -62,3 +72,6 @@ def load_material_names(path: Path = MATERIAL_NAME_CACHE_FILE) -> dict[str, str]
|
||||
if payload.get("material_count") != len(materials) or payload.get("content_sha256") != material_names_hash(materials):
|
||||
raise MaterialNameCacheError("本地物料名称缓存计数或完整性校验失败")
|
||||
return materials
|
||||
|
||||
|
||||
load_material_names.cache_clear = _load_material_names.cache_clear
|
||||
|
||||
Reference in New Issue
Block a user