Add order_staff normalization, salesperson query templates, guarded read-only SQL fallback, and incremental Kingdee sync cursor handling. Add a user-level systemd timer manager for daily Kingdee business sync and document the new workflow. Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check.
290 lines
10 KiB
Python
290 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Manage the user-level Kingdee business fact sync timer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Sequence
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from obsidian_wiki.config import resolve_vault_path
|
|
|
|
|
|
SERVICE_NAME = "obsidian-wiki-business-sync.service"
|
|
TIMER_NAME = "obsidian-wiki-business-sync.timer"
|
|
DEFAULT_CONFIG_RELATIVE_PATH = Path(".runtime") / "kingdee.env"
|
|
DEFAULT_ON_CALENDAR = "*-*-* 07:10:00"
|
|
DEFAULT_RANDOMIZED_DELAY_SEC = "15m"
|
|
DEFAULT_LOOKBACK_HOURS = 48
|
|
DEFAULT_INITIAL_DAYS = 7
|
|
PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BusinessSyncServicePaths:
|
|
project_root: Path
|
|
service_unit_path: Path
|
|
timer_unit_path: Path
|
|
config_path: Path
|
|
|
|
|
|
def systemd_quote(value: str | Path) -> str:
|
|
return shlex.quote(str(value))
|
|
|
|
|
|
def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> BusinessSyncServicePaths:
|
|
root = project_root.expanduser().resolve()
|
|
base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
unit_dir = base_config.expanduser() / "systemd" / "user"
|
|
return BusinessSyncServicePaths(
|
|
project_root=root,
|
|
service_unit_path=unit_dir / SERVICE_NAME,
|
|
timer_unit_path=unit_dir / TIMER_NAME,
|
|
config_path=root / DEFAULT_CONFIG_RELATIVE_PATH,
|
|
)
|
|
|
|
|
|
def resolve_vault_for_service(project_root: Path, vault_path: Path | str | None = None) -> Path:
|
|
if vault_path is not None:
|
|
return Path(vault_path).expanduser().resolve()
|
|
configured = resolve_vault_path(None, start=project_root)
|
|
if configured:
|
|
return Path(configured).expanduser().resolve()
|
|
return project_root.expanduser().resolve()
|
|
|
|
|
|
def render_service_unit(
|
|
*,
|
|
project_root: Path,
|
|
vault_path: Path,
|
|
config_path: Path,
|
|
python_path: Path | None = None,
|
|
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
|
|
initial_days: int = DEFAULT_INITIAL_DAYS,
|
|
) -> str:
|
|
resolved_python = python_path or Path(sys.executable)
|
|
command = " ".join(
|
|
[
|
|
systemd_quote(resolved_python),
|
|
"-m",
|
|
"obsidian_wiki",
|
|
"business",
|
|
"sync",
|
|
"kingdee",
|
|
"--vault",
|
|
systemd_quote(vault_path),
|
|
"--config",
|
|
systemd_quote(config_path),
|
|
"--incremental",
|
|
"--lookback-hours",
|
|
str(lookback_hours),
|
|
"--initial-days",
|
|
str(initial_days),
|
|
]
|
|
)
|
|
return (
|
|
"[Unit]\n"
|
|
"Description=obsidian-wiki Kingdee business fact sync\n"
|
|
"After=network-online.target\n"
|
|
"Wants=network-online.target\n"
|
|
"\n"
|
|
"[Service]\n"
|
|
"Type=oneshot\n"
|
|
f"WorkingDirectory={systemd_quote(project_root)}\n"
|
|
f"EnvironmentFile={systemd_quote(config_path)}\n"
|
|
f"PassEnvironment={' '.join(PROXY_ENV_NAMES)}\n"
|
|
f"ExecStart={command}\n"
|
|
)
|
|
|
|
|
|
def render_timer_unit(
|
|
*,
|
|
on_calendar: str = DEFAULT_ON_CALENDAR,
|
|
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
|
|
) -> str:
|
|
return (
|
|
"[Unit]\n"
|
|
"Description=Run obsidian-wiki Kingdee business sync on a schedule\n"
|
|
"\n"
|
|
"[Timer]\n"
|
|
f"OnCalendar={on_calendar}\n"
|
|
f"RandomizedDelaySec={randomized_delay_sec}\n"
|
|
"Persistent=true\n"
|
|
f"Unit={SERVICE_NAME}\n"
|
|
"\n"
|
|
"[Install]\n"
|
|
"WantedBy=timers.target\n"
|
|
)
|
|
|
|
|
|
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(["systemctl", "--user", *args], text=True, check=check)
|
|
|
|
|
|
def write_units(
|
|
paths: BusinessSyncServicePaths,
|
|
*,
|
|
vault_path: Path,
|
|
python_path: Path | None = None,
|
|
on_calendar: str = DEFAULT_ON_CALENDAR,
|
|
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
|
|
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
|
|
initial_days: int = DEFAULT_INITIAL_DAYS,
|
|
) -> None:
|
|
paths.service_unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
paths.service_unit_path.write_text(
|
|
render_service_unit(
|
|
project_root=paths.project_root,
|
|
vault_path=vault_path,
|
|
config_path=paths.config_path,
|
|
python_path=python_path,
|
|
lookback_hours=lookback_hours,
|
|
initial_days=initial_days,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
paths.timer_unit_path.write_text(
|
|
render_timer_unit(on_calendar=on_calendar, randomized_delay_sec=randomized_delay_sec),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def start_timer(
|
|
*,
|
|
project_root: Path,
|
|
vault_path: Path | str | None = None,
|
|
python_path: Path | None = None,
|
|
on_calendar: str = DEFAULT_ON_CALENDAR,
|
|
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
|
|
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
|
|
initial_days: int = DEFAULT_INITIAL_DAYS,
|
|
) -> str:
|
|
paths = resolve_service_paths(project_root)
|
|
if not paths.config_path.exists():
|
|
raise RuntimeError(f"Missing Kingdee config file: {paths.config_path}. Run `obsidian-wiki business config kingdee` first.")
|
|
resolved_vault = resolve_vault_for_service(paths.project_root, vault_path=vault_path)
|
|
write_units(
|
|
paths,
|
|
vault_path=resolved_vault,
|
|
python_path=python_path,
|
|
on_calendar=on_calendar,
|
|
randomized_delay_sec=randomized_delay_sec,
|
|
lookback_hours=lookback_hours,
|
|
initial_days=initial_days,
|
|
)
|
|
run_systemctl(["daemon-reload"])
|
|
run_systemctl(["enable", "--now", TIMER_NAME])
|
|
return (
|
|
"Business sync timer is enabled.\n"
|
|
f"Service: {paths.service_unit_path}\n"
|
|
f"Timer: {paths.timer_unit_path}\n"
|
|
f"Vault: {resolved_vault}\n"
|
|
f"Config: {paths.config_path}\n"
|
|
f"Schedule: {on_calendar} randomized by {randomized_delay_sec}\n"
|
|
)
|
|
|
|
|
|
def run_now() -> None:
|
|
run_systemctl(["start", SERVICE_NAME])
|
|
|
|
|
|
def stop_timer() -> None:
|
|
run_systemctl(["disable", "--now", TIMER_NAME], check=False)
|
|
|
|
|
|
def uninstall_timer(project_root: Path) -> None:
|
|
paths = resolve_service_paths(project_root)
|
|
stop_timer()
|
|
if paths.service_unit_path.exists():
|
|
paths.service_unit_path.unlink()
|
|
if paths.timer_unit_path.exists():
|
|
paths.timer_unit_path.unlink()
|
|
run_systemctl(["daemon-reload"], check=False)
|
|
run_systemctl(["reset-failed", SERVICE_NAME], check=False)
|
|
run_systemctl(["reset-failed", TIMER_NAME], check=False)
|
|
|
|
|
|
def timer_status() -> None:
|
|
run_systemctl(["status", TIMER_NAME], check=False)
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Manage the Kingdee business sync user timer.")
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
start_parser = subparsers.add_parser("start", help="Install or update the user timer.")
|
|
start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
|
start_parser.add_argument("--vault", dest="vault_path", type=Path, help="Vault path to sync into.")
|
|
start_parser.add_argument("--python", dest="python_path", type=Path, help="Python executable for the systemd service.")
|
|
start_parser.add_argument("--on-calendar", default=DEFAULT_ON_CALENDAR)
|
|
start_parser.add_argument("--randomized-delay-sec", default=DEFAULT_RANDOMIZED_DELAY_SEC)
|
|
start_parser.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS)
|
|
start_parser.add_argument("--initial-days", type=int, default=DEFAULT_INITIAL_DAYS)
|
|
|
|
run_now_parser = subparsers.add_parser("run-now", help="Run the sync service immediately.")
|
|
run_now_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
|
run_now_parser.add_argument("--vault", dest="vault_path", type=Path)
|
|
run_now_parser.add_argument("--python", dest="python_path", type=Path)
|
|
run_now_parser.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS)
|
|
run_now_parser.add_argument("--initial-days", type=int, default=DEFAULT_INITIAL_DAYS)
|
|
|
|
subparsers.add_parser("status", help="Show timer status.")
|
|
subparsers.add_parser("stop", help="Disable and stop the timer.")
|
|
|
|
uninstall_parser = subparsers.add_parser("uninstall", help="Remove managed timer/service unit files.")
|
|
uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
|
|
|
args = parser.parse_args(argv)
|
|
if args.command == "start":
|
|
print(
|
|
start_timer(
|
|
project_root=args.project_root,
|
|
vault_path=args.vault_path,
|
|
python_path=args.python_path,
|
|
on_calendar=args.on_calendar,
|
|
randomized_delay_sec=args.randomized_delay_sec,
|
|
lookback_hours=args.lookback_hours,
|
|
initial_days=args.initial_days,
|
|
),
|
|
end="",
|
|
)
|
|
return 0
|
|
if args.command == "run-now":
|
|
paths = resolve_service_paths(args.project_root)
|
|
if not paths.service_unit_path.exists():
|
|
start_timer(
|
|
project_root=args.project_root,
|
|
vault_path=args.vault_path,
|
|
python_path=args.python_path,
|
|
lookback_hours=args.lookback_hours,
|
|
initial_days=args.initial_days,
|
|
)
|
|
run_now()
|
|
print(f"Started {SERVICE_NAME}.")
|
|
return 0
|
|
if args.command == "status":
|
|
timer_status()
|
|
return 0
|
|
if args.command == "stop":
|
|
stop_timer()
|
|
print(f"Stopped {TIMER_NAME}.")
|
|
return 0
|
|
if args.command == "uninstall":
|
|
uninstall_timer(args.project_root)
|
|
print(f"Uninstalled {TIMER_NAME}.")
|
|
return 0
|
|
raise AssertionError(f"unhandled command: {args.command}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|