feat(business): add staff queries and incremental sync

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.
This commit is contained in:
Codex
2026-07-06 21:08:00 +00:00
parent dae51ab570
commit 317b0e6feb
9 changed files with 1314 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$SCRIPT_DIR/manage_business_sync.py" "$@"
+289
View File
@@ -0,0 +1,289 @@
#!/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())