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
+51 -3
View File
@@ -20,6 +20,8 @@ from obsidian_wiki import __version__
from obsidian_wiki.access_control import audit_profile, export_profile, list_profiles
from obsidian_wiki.business_facts import (
business_db_path_for_vault,
compute_incremental_start_modifytime,
execute_business_sql,
fetch_kingdee_payload,
load_kingdee_payload_from_json,
query_business_question,
@@ -510,6 +512,9 @@ def cmd_file_index_stage(args: argparse.Namespace) -> int:
def cmd_business_sync_kingdee(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
db_path = _resolve_business_db(args, vault_path)
filters: dict[str, object] = {}
cursor_from = args.since
if args.input:
records, payload = load_kingdee_payload_from_json(args.input)
else:
@@ -527,13 +532,21 @@ def cmd_business_sync_kingdee(args: argparse.Namespace) -> int:
token = get_kingdee_access_token(config, cache_path=token_cache_path_for_config(config_path))
print(f"✅ Kingdee auth OK: token length {len(token)}")
return 0
filters = build_sales_order_filters(args)
if getattr(args, "incremental", False) and not filters.get("start_modifytime"):
filters["start_modifytime"] = compute_incremental_start_modifytime(
db_path,
lookback_hours=args.lookback_hours,
initial_days=args.initial_days,
)
cursor_from = str(filters.get("start_modifytime") or args.since or "")
records, payload = fetch_kingdee_payload(
api_url=args.api_url,
token=args.token or args.access_token,
since=args.since,
config_path=config_path,
config_overrides=overrides,
filters=build_sales_order_filters(args),
filters=filters,
page_size=args.page_size,
max_pages=args.max_pages,
)
@@ -541,15 +554,18 @@ def cmd_business_sync_kingdee(args: argparse.Namespace) -> int:
vault_path,
records,
source_payload=payload,
db_path=_resolve_business_db(args, vault_path),
cursor_from=args.since,
db_path=db_path,
cursor_from=cursor_from,
)
result["filters"] = filters
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(f"✅ Synced Kingdee orders: {result['records_upserted']} record(s)")
print(f" DB: {result['db_path']}")
print(f" Last sync: {result['synced_at']}")
if filters:
print(f" Filters: {json.dumps(filters, ensure_ascii=False, sort_keys=True)}")
return 0
@@ -583,6 +599,8 @@ def cmd_business_query(args: argparse.Namespace) -> int:
vault_path,
question,
db_path=_resolve_business_db(args, vault_path),
all_staff=args.all_staff,
fallback_sql=args.fallback_sql,
)
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -591,6 +609,23 @@ def cmd_business_query(args: argparse.Namespace) -> int:
return 0
def cmd_business_sql(args: argparse.Namespace) -> int:
vault_path = _require_vault(args)
sql = " ".join(args.sql).strip()
result = execute_business_sql(
_resolve_business_db(args, vault_path),
sql,
limit=args.limit,
)
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(result["answer"])
for row in result["rows"]:
print(json.dumps(row, ensure_ascii=False, sort_keys=True))
return 0
def cmd_mcp_server(args: argparse.Namespace) -> int:
from obsidian_wiki.mcp_server import main as mcp_main
@@ -752,6 +787,9 @@ def build_parser() -> argparse.ArgumentParser:
bkp.add_argument("--customer-createorg-number", action="append", help="customer create org code; repeat or comma-separate")
bkp.add_argument("--page-size", type=int, help="Kingdee pageSize")
bkp.add_argument("--max-pages", type=int, default=100, help="maximum pages to fetch")
bkp.add_argument("--incremental", action="store_true", help="use saved modifytime cursor with a lookback window")
bkp.add_argument("--lookback-hours", type=int, default=48, help="hours to subtract from the saved incremental cursor")
bkp.add_argument("--initial-days", type=int, default=7, help="days to fetch on the first incremental sync")
bkp.add_argument("--dry-run-auth", action="store_true", help="fetch/refresh access token and exit without syncing orders")
bkp.add_argument("--json", action="store_true", help="print machine-readable sync result")
bkp.set_defaults(func=cmd_business_sync_kingdee)
@@ -759,10 +797,20 @@ def build_parser() -> argparse.ArgumentParser:
bqp = business_sub.add_parser("query", help="answer fixed-template business statistics questions")
_add_vault_arg(bqp)
bqp.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to <vault>/_meta/business-facts.sqlite)")
bqp.add_argument("--all-staff", action="store_true", help="include secondary sales and follow-up roles in staff queries")
bqp.add_argument("--fallback-sql", help="guarded read-only SQL to run only when no fixed template matches")
bqp.add_argument("--json", action="store_true", help="print machine-readable query result")
bqp.add_argument("question", nargs="+", help="question, e.g. '2026-06 order count'")
bqp.set_defaults(func=cmd_business_query)
bsp_sql = business_sub.add_parser("sql", help="run guarded read-only SQL against structured business facts")
_add_vault_arg(bsp_sql)
bsp_sql.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to <vault>/_meta/business-facts.sqlite)")
bsp_sql.add_argument("--limit", type=int, default=100, help="maximum rows to return, capped at 1000")
bsp_sql.add_argument("--json", action="store_true", help="print machine-readable SQL result")
bsp_sql.add_argument("sql", nargs="+", help="single SELECT or WITH ... SELECT statement")
bsp_sql.set_defaults(func=cmd_business_sql)
mp = sub.add_parser("mcp-server", help="run the MCP server for safe source-file lookup")
_add_file_index_storage_args(mp)
mp.add_argument("--profile", help="bot profile name for ACL checks")