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
+17 -1
View File
@@ -333,13 +333,29 @@ obsidian-wiki business sync kingdee \
--end-bizdate 2026-06-30 \
--billstatus C
# Or fetch incrementally by Kingdee modify time.
obsidian-wiki business sync kingdee --incremental
# Ask deterministic statistics questions.
obsidian-wiki business query "2026年6月接了多少单"
obsidian-wiki business query "2026-06 sales amount"
obsidian-wiki business query "2026-06 top customer"
obsidian-wiki business query "徐丽最近一周订单"
obsidian-wiki business query "徐丽最近一周销售额" --all-staff
obsidian-wiki business sql "SELECT COUNT(*) AS order_count FROM orders"
```
V1 uses fixed templates for monthly order count, monthly sales amount, monthly top customers, and a customer trend query. The default order-count basis is distinct Kingdee sales order number by order date. Detailed order rows stay in SQLite; the wiki remains for business context, customer/project knowledge, and later summary pages when you choose to generate them. Real Kingdee credentials live in `.runtime/kingdee.env`, and fetched tokens are cached in `.runtime/kingdee-token.json`; both files are local-only and ignored by git.
V1 uses fixed templates for monthly order count, monthly sales amount, monthly top customers, customer trend, and salesperson order list/count/amount queries. Salesperson queries default to the main Kingdee salesperson and can include secondary/follow-up roles with `--all-staff`. If no template fits, `business sql` and `business query --fallback-sql` allow guarded read-only SQL against structured tables only; raw JSON and write statements are blocked. Detailed order rows stay in SQLite; the wiki remains for business context, customer/project knowledge, and later summary pages when you choose to generate them. Real Kingdee credentials live in `.runtime/kingdee.env`, and fetched tokens are cached in `.runtime/kingdee-token.json`; both files are local-only and ignored by git.
For daily unattended sync on Linux:
```bash
bash scripts/business_sync_service.sh start --vault /path/to/vault
bash scripts/business_sync_service.sh run-now --vault /path/to/vault
bash scripts/business_sync_service.sh status
```
The timer runs as a user-level systemd timer, defaults to `07:10` daily, and uses `--incremental` with a 48-hour lookback window.
### Optional: Source File MCP Server
+7 -1
View File
@@ -169,14 +169,20 @@ obsidian-wiki business sync kingdee \
--start-bizdate 2026-06-01 \
--end-bizdate 2026-06-30 \
--billstatus C
obsidian-wiki business sync kingdee --incremental
obsidian-wiki business query "2026年6月接了多少单"
obsidian-wiki business query "2026-06 sales amount"
obsidian-wiki business query "徐丽最近一周订单"
obsidian-wiki business query "徐丽最近一周销售额" --all-staff
obsidian-wiki business sql "SELECT COUNT(*) AS order_count FROM orders"
bash scripts/business_sync_service.sh start --vault /path/to/vault
```
Without `--input`, the Kingdee sync command creates/reads `.runtime/kingdee.env`, obtains or refreshes an access token, calls the official sales order query API, then stores the fetched JSON response in `<vault>/_meta/business-facts.sqlite`. The private config supports configurable login/token URL, request body, response field paths, refresh URL/body, and required `x-acgw-identity`; fetched tokens are cached in `.runtime/kingdee-token.json`. `.runtime/` is ignored by git.
The answer reports the statistic, counting basis, data source, and last successful sync time. If the latest successful sync is before today, the answer warns that newer data may be missing.
The answer reports the statistic, counting basis, data source, and last successful sync time. If the latest successful sync is before today, the answer warns that newer data may be missing. Fixed templates are tried first; guarded SQL fallback is available for structured tables only. Salesperson queries default to the main salesperson and use `--all-staff` to include sales 2/3/4 and follow-up roles. The Linux timer uses `systemd --user`, runs daily by default, and syncs incrementally with a 48-hour lookback.
## How It Works
+40
View File
@@ -10,6 +10,7 @@ Use `<vault>/_meta/business-facts.sqlite` for questions where markdown is the wr
- monthly sales amount
- top customers for a month
- customer order trend for a fixed period
- sales order counts, lists, and amounts by salesperson
The wiki remains the place for context: customer background, project history, sales notes, email-derived signals, and later human-readable summaries.
@@ -28,13 +29,29 @@ obsidian-wiki business sync kingdee \
--billstatus C
obsidian-wiki business sync kingdee --start-modifytime "2026-07-01 00:00:00"
obsidian-wiki business sync kingdee --incremental
obsidian-wiki business query "2026年6月接了多少单"
obsidian-wiki business query "2026-06 sales amount"
obsidian-wiki business query "2026-06 top customer"
obsidian-wiki business query "徐丽最近一周订单"
obsidian-wiki business query "徐丽最近一周接了多少单"
obsidian-wiki business query "徐丽最近一周销售额" --all-staff
obsidian-wiki business query "按组织列出订单" --fallback-sql "SELECT org_name, COUNT(*) AS order_count FROM orders GROUP BY org_name"
obsidian-wiki business sql "SELECT COUNT(*) AS order_count FROM orders"
```
When `--input` is omitted, sync reads `.runtime/kingdee.env`, gets or refreshes an access token, calls the official Kingdee sales order API, and stores the returned rows. If the file does not exist, `business sync kingdee` creates the template and asks you to fill it before retrying.
For unattended sync, install the user-level systemd timer:
```bash
bash scripts/business_sync_service.sh start --vault /path/to/vault
bash scripts/business_sync_service.sh run-now --vault /path/to/vault
bash scripts/business_sync_service.sh status
```
The timer defaults to daily `07:10` with a 15-minute randomized delay. It runs incremental Kingdee sync with a 48-hour lookback window.
The config template includes:
- Token defaults follow Kingdee's enhanced OpenAPI token authentication doc: `https://vip.kingdee.com/knowledge/specialDetail/226337046514476288?category=239331354741842688&id=489812471545485056&productLineId=29`.
@@ -65,6 +82,8 @@ Tables:
- `orders`: one normalized row per sales order. It keeps the query-critical fields (`order_id`, `order_no`, `order_date`, customer, status, organization, department, operator, currency, total amount, net amount, tax amount, address, comment) plus `raw_json` for the full original order header.
- `order_items`: normalized material detail lines. It keeps material number/name/model, quantity, unit, price, tax-inclusive price, net amount, tax-inclusive amount, tax amount, delivery date, project, warehouse, and `raw_json` for the full original line.
- `order_deliveries`: normalized delivery-plan rows from `billentry[].orderdeliverentry[]`. It keeps planned demand date, planned delivery date, planned quantity, planned base quantity, unit, receive address/place, remark, transport lead time, and `raw_json` for the full original delivery-plan row.
- `order_staff`: sales staff roles extracted from the order header. Roles are `primary_sales`, `sales_2`, `sales_3`, `sales_4`, and `followup`.
- `sync_state`: saved Kingdee incremental cursor.
- `customers`: customer lookup data observed from orders.
- `products`: product lookup data observed from order lines.
@@ -80,9 +99,30 @@ V1 statistics use fixed templates, not free-form SQL generated by an LLM.
- Monthly sales amount: `SUM(total_amount)` by `order_date`.
- Monthly top customers: group by customer name or ID and rank by sales amount.
- Customer trend: group matching customer orders by order month.
- Staff order list/count/amount: join `orders` to `order_staff`, defaulting to `primary_sales`.
Every answer includes the statistic, counting basis, data source, and last successful sync time. If the last successful sync is before the query date, the answer warns that newer data may be missing.
Salesperson queries default to the main Kingdee salesperson (`operator_operatorname`). Use `--all-staff`, or phrases such as "全部角色" or "含跟单", to include sales 2/3/4 and follow-up roles.
If no fixed template matches, callers may provide guarded SQL through `business sql` or `business query --fallback-sql`. The SQL fallback is still restricted:
- only one `SELECT` or `WITH ... SELECT` statement
- only structured tables: `orders`, `order_items`, `order_deliveries`, `order_staff`, `customers`, `products`, `sync_runs`
- no `raw_json` columns and no `raw_snapshots`
- no write, schema, attach, pragma, or vacuum statements
- default result limit is 100 rows, capped at 1000
## Incremental Sync
`obsidian-wiki business sync kingdee --incremental` uses Kingdee `start_modifytime`.
- First run without a cursor: fetches the last 7 days.
- Later runs: starts from the saved cursor minus 48 hours.
- Explicit `--start-modifytime` or `--since` wins over the computed cursor.
The 48-hour lookback intentionally re-fetches recent orders so late edits and boundary cases are updated rather than missed. Upserts keep the database deduplicated by `order_id`.
## Boundary With Wiki Ingest
Do not create one markdown page per order or order line. That makes the wiki noisy and produces unreliable statistics.
+529 -3
View File
@@ -11,7 +11,7 @@ import hashlib
import json
import re
import sqlite3
from datetime import datetime, timezone
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@@ -19,6 +19,7 @@ from obsidian_wiki.file_assets import clean_config_value
from obsidian_wiki.kingdee_api import fetch_kingdee_sales_orders, load_kingdee_config, token_cache_path_for_config
BUSINESS_DB_RELATIVE_PATH = Path("_meta") / "business-facts.sqlite"
KINGDEE_SYNC_STATE_SOURCE = "kingdee_sales_orders_modifytime"
ORDER_COLUMNS = (
"order_id",
@@ -91,6 +92,30 @@ ORDER_DELIVERY_COLUMNS = (
"raw_json",
)
ORDER_STAFF_COLUMNS = (
"order_id",
"role",
"staff_id",
"staff_number",
"staff_name",
)
STAFF_ROLE_FIELDS = (
("primary_sales", "operator_id", "operator_operatornumber", "operator_operatorname"),
("sales_2", "h5a3_basedatafield2_id", "h5a3_basedatafield2_operatornumber", "h5a3_basedatafield2_operatorname"),
("sales_3", "h5a3_basedatafield3_id", "h5a3_basedatafield3_operatornumber", "h5a3_basedatafield3_operatorname"),
("sales_4", "h5a3_basedatafield5_id", "h5a3_basedatafield5_operatornumber", "h5a3_basedatafield5_operatorname"),
("followup", "h5a3_basedatafield4_id", "h5a3_basedatafield4_operatornumber", "h5a3_basedatafield4_operatorname"),
)
ALL_STAFF_ROLES = tuple(role for role, _id_field, _number_field, _name_field in STAFF_ROLE_FIELDS)
PRIMARY_STAFF_ROLES = ("primary_sales",)
ALLOWED_SQL_TABLES = {"orders", "order_items", "order_deliveries", "order_staff", "customers", "products", "sync_runs"}
FORBIDDEN_SQL_COLUMNS = {"raw_json"}
SQL_DEFAULT_LIMIT = 100
SQL_MAX_LIMIT = 1000
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -224,10 +249,28 @@ def init_db(db_path: Path) -> None:
FOREIGN KEY (order_id, line_id) REFERENCES order_items(order_id, line_id)
);
CREATE TABLE IF NOT EXISTS order_staff (
order_id TEXT NOT NULL,
role TEXT NOT NULL,
staff_id TEXT,
staff_number TEXT,
staff_name TEXT,
PRIMARY KEY (order_id, role),
FOREIGN KEY (order_id) REFERENCES orders(order_id)
);
CREATE TABLE IF NOT EXISTS sync_state (
source TEXT PRIMARY KEY,
cursor_value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_orders_order_date ON orders(order_date);
CREATE INDEX IF NOT EXISTS idx_orders_customer_name ON orders(customer_name);
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
CREATE INDEX IF NOT EXISTS idx_order_deliveries_plan_date ON order_deliveries(plan_date);
CREATE INDEX IF NOT EXISTS idx_order_staff_name_role ON order_staff(staff_name, role);
CREATE INDEX IF NOT EXISTS idx_order_staff_number_role ON order_staff(staff_number, role);
"""
)
_ensure_columns(
@@ -341,6 +384,7 @@ def sync_kingdee_orders(
synced_at: str | None = None,
db_path: Path | None = None,
cursor_from: str | None = None,
cursor_to: str | None = None,
) -> dict[str, Any]:
db_path = db_path or business_db_path_for_vault(vault_path)
init_db(db_path)
@@ -348,6 +392,7 @@ def sync_kingdee_orders(
payload = source_payload if source_payload is not None else {"orders": records}
payload_json = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
raw_hash = "sha256:" + hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
cursor_to = cursor_to or synced_at
with connect(db_path) as conn:
cursor = conn.execute(
@@ -377,7 +422,17 @@ def sync_kingdee_orders(
SET status='success', finished_at=?, cursor_to=?, records_seen=?, records_upserted=?
WHERE id=?
""",
(synced_at, synced_at, len(records), upserted, run_id),
(synced_at, cursor_to, len(records), upserted, run_id),
)
conn.execute(
"""
INSERT INTO sync_state (source, cursor_value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(source) DO UPDATE SET
cursor_value=excluded.cursor_value,
updated_at=excluded.updated_at
""",
(KINGDEE_SYNC_STATE_SOURCE, cursor_to, synced_at),
)
except Exception as exc:
conn.execute(
@@ -397,6 +452,8 @@ def sync_kingdee_orders(
"records_upserted": upserted,
"raw_hash": raw_hash,
"synced_at": synced_at,
"cursor_from": cursor_from,
"cursor_to": cursor_to,
}
@@ -446,6 +503,7 @@ def normalize_order(record: dict[str, Any]) -> dict[str, Any]:
"receive_address": _text(_first(record, "receiveaddress", "receive_address")),
"comment": _text(record.get("comment")),
"raw_json": _json_dumps(record),
"staff": _staff_roles(record),
"items": items,
}
@@ -456,12 +514,55 @@ def query_business_question(
*,
db_path: Path | None = None,
now: str | None = None,
all_staff: bool = False,
fallback_sql: str | None = None,
) -> dict[str, Any]:
db_path = db_path or business_db_path_for_vault(vault_path)
init_db(db_path)
q = question.strip()
year, month = parse_month(q, now=now)
lower = q.lower()
staff_name = _extract_staff_name(q)
if staff_name:
include_all_staff = all_staff or _question_requests_all_staff(q)
start, end, range_label = parse_date_range(q, now=now)
if "amount" in lower or "sales" in lower or "销售额" in q or "金额" in q:
return staff_sales_amount(
db_path,
staff_name,
start,
end,
range_label=range_label,
all_staff=include_all_staff,
now=now,
)
if "多少" in q or "几单" in q or "接了" in q or "count" in lower:
return staff_order_count(
db_path,
staff_name,
start,
end,
range_label=range_label,
all_staff=include_all_staff,
now=now,
)
if "订单" in q or "order" in lower:
return staff_order_list(
db_path,
staff_name,
start,
end,
range_label=range_label,
all_staff=include_all_staff,
now=now,
)
try:
year, month = parse_month(q, now=now)
except RuntimeError:
if fallback_sql:
return execute_business_sql(db_path, fallback_sql)
return no_matching_template(q, db_path, now=now)
if "top" in lower and "customer" in lower or "top客户" in q.lower() or "客户排名" in q:
return monthly_top_customers(db_path, year, month, now=now)
if "amount" in lower or "sales" in lower or "销售额" in q or "金额" in q:
@@ -590,6 +691,302 @@ def customer_order_trend(db_path: Path, question: str, year: int, month: int, *,
}
def staff_order_count(
db_path: Path,
staff_name: str,
start: str,
end: str,
*,
range_label: str,
all_staff: bool = False,
now: str | None = None,
) -> dict[str, Any]:
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
with connect(db_path) as conn:
row = conn.execute(
f"""
SELECT COUNT(DISTINCT o.order_no) AS value
FROM orders o
JOIN order_staff s ON s.order_id = o.order_id
WHERE o.order_date >= ? AND o.order_date < ?
AND ({_staff_role_placeholders(roles)})
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
""",
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%"),
).fetchone()
count = int(row["value"] or 0)
last_sync = last_successful_sync(db_path)
is_stale = sync_is_stale(last_sync, now=now)
return {
"template": "staff_order_count",
"data": {
"staff_name": staff_name,
"staff_roles": list(roles),
"start_date": start,
"end_date_exclusive": end,
"range_label": range_label,
"order_count": count,
},
"last_sync": last_sync,
"is_stale": is_stale,
"answer": _answer(
f"Result: {staff_name} has {count} order(s) for {range_label}.",
f"Scope: roles {', '.join(roles)}, deduplicated by sales order number, order date from {start} to {end_exclusive_label(end)}.",
last_sync,
is_stale=is_stale,
),
}
def staff_sales_amount(
db_path: Path,
staff_name: str,
start: str,
end: str,
*,
range_label: str,
all_staff: bool = False,
now: str | None = None,
) -> dict[str, Any]:
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
with connect(db_path) as conn:
row = conn.execute(
f"""
SELECT COALESCE(SUM(total_amount), 0) AS value
FROM (
SELECT DISTINCT o.order_id, o.total_amount
FROM orders o
JOIN order_staff s ON s.order_id = o.order_id
WHERE o.order_date >= ? AND o.order_date < ?
AND ({_staff_role_placeholders(roles)})
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
)
""",
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%"),
).fetchone()
amount = float(row["value"] or 0)
last_sync = last_successful_sync(db_path)
is_stale = sync_is_stale(last_sync, now=now)
return {
"template": "staff_sales_amount",
"data": {
"staff_name": staff_name,
"staff_roles": list(roles),
"start_date": start,
"end_date_exclusive": end,
"range_label": range_label,
"sales_amount": amount,
},
"last_sync": last_sync,
"is_stale": is_stale,
"answer": _answer(
f"Result: {staff_name} sales amount for {range_label} is {amount:.2f}.",
f"Scope: roles {', '.join(roles)}, sum of distinct order total_amount, order date from {start} to {end_exclusive_label(end)}.",
last_sync,
is_stale=is_stale,
),
}
def staff_order_list(
db_path: Path,
staff_name: str,
start: str,
end: str,
*,
range_label: str,
all_staff: bool = False,
now: str | None = None,
limit: int = 100,
) -> dict[str, Any]:
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
with connect(db_path) as conn:
rows = conn.execute(
f"""
SELECT DISTINCT o.order_id, o.order_no, o.order_date, o.customer_name,
o.total_amount, o.total_net_amount, o.total_tax_amount,
o.currency, o.billstatus, o.orderstatus, o.org_name
FROM orders o
JOIN order_staff s ON s.order_id = o.order_id
WHERE o.order_date >= ? AND o.order_date < ?
AND ({_staff_role_placeholders(roles)})
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
ORDER BY o.order_date DESC, o.order_no ASC
LIMIT ?
""",
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%", limit),
).fetchall()
orders = [
{
"order_id": row["order_id"],
"order_no": row["order_no"],
"order_date": row["order_date"],
"customer_name": row["customer_name"],
"total_amount": float(row["total_amount"] or 0),
"total_net_amount": float(row["total_net_amount"] or 0),
"total_tax_amount": float(row["total_tax_amount"] or 0),
"currency": row["currency"],
"billstatus": row["billstatus"],
"orderstatus": row["orderstatus"],
"org_name": row["org_name"],
}
for row in rows
]
last_sync = last_successful_sync(db_path)
is_stale = sync_is_stale(last_sync, now=now)
return {
"template": "staff_order_list",
"data": {
"staff_name": staff_name,
"staff_roles": list(roles),
"start_date": start,
"end_date_exclusive": end,
"range_label": range_label,
"orders": orders,
},
"last_sync": last_sync,
"is_stale": is_stale,
"answer": _answer(
f"Result: {staff_name} has {len(orders)} listed order(s) for {range_label}.",
f"Scope: roles {', '.join(roles)}, order date from {start} to {end_exclusive_label(end)}, limit {limit}.",
last_sync,
is_stale=is_stale,
),
}
def no_matching_template(question: str, db_path: Path, *, now: str | None = None) -> dict[str, Any]:
last_sync = last_successful_sync(db_path)
is_stale = sync_is_stale(last_sync, now=now)
return {
"template": "no_matching_template",
"data": {"question": question},
"last_sync": last_sync,
"is_stale": is_stale,
"answer": _answer(
"Result: no matching fixed business query template.",
"Scope: provide a guarded fallback SQL query to run against structured business tables.",
last_sync,
is_stale=is_stale,
),
}
def execute_business_sql(db_path: Path, sql: str, *, limit: int = SQL_DEFAULT_LIMIT) -> dict[str, Any]:
init_db(db_path)
statement = _validate_business_sql(sql)
safe_limit = max(1, min(int(limit or SQL_DEFAULT_LIMIT), SQL_MAX_LIMIT))
executed_sql = f"SELECT * FROM ({statement}) LIMIT ?"
with connect(db_path) as conn:
conn.set_authorizer(_business_sql_authorizer)
try:
cursor = conn.execute(executed_sql, (safe_limit,))
rows = [dict(row) for row in cursor.fetchall()]
finally:
conn.set_authorizer(None)
columns = list(rows[0].keys()) if rows else []
return {
"template": "sql_fallback",
"sql": statement,
"limit": safe_limit,
"columns": columns,
"rows": rows,
"row_count": len(rows),
"answer": f"Result: SQL fallback returned {len(rows)} row(s).",
}
def _validate_business_sql(sql: str) -> str:
statement = sql.strip()
if not statement:
raise RuntimeError("SQL is empty")
if statement.endswith(";"):
statement = statement[:-1].strip()
if ";" in statement:
raise RuntimeError("SQL fallback accepts one statement only")
first = re.match(r"^\s*([A-Za-z]+)", statement)
keyword = first.group(1).upper() if first else ""
if keyword == "PRAGMA":
raise RuntimeError("PRAGMA is not allowed in SQL fallback")
if keyword not in {"SELECT", "WITH"}:
raise RuntimeError("SQL fallback only SELECT or WITH ... SELECT statements")
if re.search(r"\braw_json\b", statement, re.IGNORECASE):
raise RuntimeError("SQL fallback cannot read raw_json")
if re.search(r"\braw_snapshots\b", statement, re.IGNORECASE):
raise RuntimeError("SQL fallback cannot read raw_snapshots")
forbidden = r"\b(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|ATTACH|DETACH|VACUUM|REINDEX|REPLACE)\b"
if re.search(forbidden, statement, re.IGNORECASE):
raise RuntimeError("SQL fallback only SELECT or WITH ... SELECT statements")
return statement
def _business_sql_authorizer(action: int, arg1: str | None, arg2: str | None, _db: str | None, _trigger: str | None) -> int:
if action == sqlite3.SQLITE_READ:
table = str(arg1 or "")
column = str(arg2 or "")
if table == "raw_snapshots":
return sqlite3.SQLITE_DENY
if column in FORBIDDEN_SQL_COLUMNS:
return sqlite3.SQLITE_DENY
if table and table not in ALLOWED_SQL_TABLES:
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
if action in {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_FUNCTION}:
return sqlite3.SQLITE_OK
return sqlite3.SQLITE_DENY
def _staff_role_placeholders(roles: tuple[str, ...]) -> str:
return " OR ".join("s.role = ?" for _role in roles)
def parse_date_range(text: str, *, now: str | None = None) -> tuple[str, str, str]:
today = _today(now)
if "最近一周" in text or "近一周" in text or "最近7天" in text or "近7天" in text:
start = today - timedelta(days=6)
end = today + timedelta(days=1)
return start.isoformat(), end.isoformat(), "最近一周"
if "今天" in text:
start = today
end = today + timedelta(days=1)
return start.isoformat(), end.isoformat(), "今天"
if "昨天" in text:
start = today - timedelta(days=1)
end = today
return start.isoformat(), end.isoformat(), "昨天"
if "本周" in text or "这周" in text:
start = today - timedelta(days=today.weekday())
end = start + timedelta(days=7)
return start.isoformat(), end.isoformat(), "本周"
if "上周" in text:
end = today - timedelta(days=today.weekday())
start = end - timedelta(days=7)
return start.isoformat(), end.isoformat(), "上周"
if "本月" in text or "这个月" in text:
start = today.replace(day=1)
end = _add_month(start)
return start.isoformat(), end.isoformat(), "本月"
try:
year, month = parse_month(text, now=now)
start, end = month_bounds(year, month)
return start, end, f"{year}-{month:02d}"
except RuntimeError:
start = today - timedelta(days=6)
end = today + timedelta(days=1)
return start.isoformat(), end.isoformat(), "最近一周"
def _today(now: str | None = None) -> date:
raw = now or utc_now()
return datetime.fromisoformat(raw.replace("Z", "+00:00")).date()
def _add_month(value: date) -> date:
if value.month == 12:
return date(value.year + 1, 1, 1)
return date(value.year, value.month + 1, 1)
def parse_month(text: str, *, now: str | None = None) -> tuple[int, int]:
match = re.search(r"(20\d{2})\s*[-/年.]\s*(\d{1,2})", text)
if match:
@@ -633,6 +1030,31 @@ def last_successful_sync(db_path: Path) -> str:
return str(row["finished_at"] or "") if row else ""
def compute_incremental_start_modifytime(
db_path: Path,
*,
now: str | None = None,
lookback_hours: int = 48,
initial_days: int = 7,
) -> str:
init_db(db_path)
cursor = _sync_state_cursor(db_path) or last_successful_sync(db_path)
if cursor:
start = _parse_datetime(cursor) - timedelta(hours=lookback_hours)
else:
start = _parse_datetime(now or utc_now()) - timedelta(days=initial_days)
return _format_kingdee_datetime(start)
def _sync_state_cursor(db_path: Path) -> str:
with connect(db_path) as conn:
row = conn.execute(
"SELECT cursor_value FROM sync_state WHERE source=?",
(KINGDEE_SYNC_STATE_SOURCE,),
).fetchone()
return str(row["cursor_value"] or "") if row else ""
def sync_is_stale(last_sync: str, *, now: str | None = None) -> bool:
if not last_sync:
return True
@@ -678,6 +1100,18 @@ def _upsert_order(conn: sqlite3.Connection, order: dict[str, Any], *, raw_hash:
""",
tuple(order[column] for column in ORDER_COLUMNS) + (raw_hash, synced_at, synced_at),
)
conn.execute("DELETE FROM order_staff WHERE order_id=?", (order["order_id"],))
staff_columns_sql = ", ".join(ORDER_STAFF_COLUMNS)
staff_placeholders = ", ".join("?" for _ in ORDER_STAFF_COLUMNS)
for staff in order["staff"]:
staff_row = {"order_id": order["order_id"], **staff}
conn.execute(
f"""
INSERT INTO order_staff ({staff_columns_sql})
VALUES ({staff_placeholders})
""",
tuple(staff_row[column] for column in ORDER_STAFF_COLUMNS),
)
conn.execute("DELETE FROM order_deliveries WHERE order_id=?", (order["order_id"],))
conn.execute("DELETE FROM order_items WHERE order_id=?", (order["order_id"],))
item_columns_sql = ", ".join(ORDER_ITEM_COLUMNS)
@@ -715,6 +1149,25 @@ def _upsert_order(conn: sqlite3.Connection, order: dict[str, Any], *, raw_hash:
)
def _staff_roles(record: dict[str, Any]) -> list[dict[str, str]]:
staff: list[dict[str, str]] = []
for role, id_field, number_field, name_field in STAFF_ROLE_FIELDS:
staff_id = _text(record.get(id_field))
staff_number = _text(record.get(number_field))
staff_name = _text(record.get(name_field))
if not staff_id and not staff_number and not staff_name:
continue
staff.append(
{
"role": role,
"staff_id": staff_id,
"staff_number": staff_number,
"staff_name": staff_name,
}
)
return staff
def _items(record: dict[str, Any]) -> list[dict[str, Any]]:
raw_items = None
for key in ("items", "billentry", "entries", "order_items", "FEntity"):
@@ -810,6 +1263,23 @@ def _date_time_text(value: Any) -> str:
return _text(value)
def _parse_datetime(value: str) -> datetime:
text = _text(value)
if not text:
return datetime.now(timezone.utc).replace(microsecond=0)
normalized = text.replace("Z", "+00:00")
if " " in normalized and "T" not in normalized:
normalized = normalized.replace(" ", "T", 1)
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).replace(tzinfo=None, microsecond=0)
def _format_kingdee_datetime(value: datetime) -> str:
return value.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
def _float(value: Any) -> float:
if value in (None, ""):
return 0.0
@@ -837,6 +1307,62 @@ def _extract_customer_name(question: str) -> str:
return match.group(1).strip() if match else ""
def _extract_staff_name(question: str) -> str:
if not any(token in question for token in ("销售员", "销售额", "订单", "接了", "多少单")) and not re.search(
r"\border(s)?\b|\bsales\b|\bstaff\b", question, re.IGNORECASE
):
return ""
text = question.strip()
text = re.sub(r"(20\d{2})\s*[-/年.]\s*\d{1,2}\s*月?", " ", text)
remove_patterns = (
"最近一周",
"近一周",
"最近7天",
"近7天",
"今天",
"昨天",
"本周",
"这周",
"上周",
"本月",
"这个月",
"接了多少单",
"接多少单",
"多少单",
"几单",
"销售额",
"金额",
"订单列表",
"订单",
"全部角色",
"所有销售角色",
"所有角色",
"含销售员2",
"含跟单",
"包括跟单",
"包含跟单",
"主销售员",
"销售员",
)
for pattern in remove_patterns:
text = text.replace(pattern, " ")
text = re.sub(r"\b(order|orders|sales|amount|count|staff|all roles|primary)\b", " ", text, flags=re.IGNORECASE)
text = re.sub(r"[\s,,。::??]+", " ", text).strip()
candidate = text.split()[0] if text else ""
if candidate.startswith(("按", "列出", "统计", "查询")):
return ""
if re.fullmatch(r"[\u4e00-\u9fff]+", candidate) and len(candidate) > 4:
return ""
return candidate
def _question_requests_all_staff(question: str) -> bool:
return any(
token in question
for token in ("全部角色", "所有销售角色", "所有角色", "含销售员2", "含跟单", "包括跟单", "包含跟单")
)
def _status_text(record: dict[str, Any]) -> str:
parts = []
for key in ("billstatus", "orderstatus", "closestatus", "status", "document_status", "FDocumentStatus"):
+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")
+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())
+292
View File
@@ -5,9 +5,12 @@ import unittest
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from obsidian_wiki.business_facts import (
business_db_path_for_vault,
compute_incremental_start_modifytime,
execute_business_sql,
query_business_question,
sync_kingdee_orders,
)
@@ -246,6 +249,295 @@ class BusinessFactsTests(unittest.TestCase):
self.assertEqual(delivery["transport_lead_time"], 2)
self.assertEqual(json.loads(delivery["raw_json"])["d_remark"], "首批")
def test_sync_extracts_all_kingdee_staff_roles(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "SO-STAFF-001",
"bizdate": "2026-07-01",
"customer_name": "Customer A",
"totalallamount": "100.00",
"operator_id": "S1",
"operator_operatornumber": "XS19",
"operator_operatorname": "徐丽",
"h5a3_basedatafield2_id": "S2",
"h5a3_basedatafield2_operatornumber": "XS20",
"h5a3_basedatafield2_operatorname": "李二",
"h5a3_basedatafield3_id": "S3",
"h5a3_basedatafield3_operatornumber": "XS21",
"h5a3_basedatafield3_operatorname": "王三",
"h5a3_basedatafield5_id": "S4",
"h5a3_basedatafield5_operatornumber": "XS22",
"h5a3_basedatafield5_operatorname": "赵四",
"h5a3_basedatafield4_id": "F1",
"h5a3_basedatafield4_operatornumber": "GD01",
"h5a3_basedatafield4_operatorname": "跟单甲",
}
],
synced_at="2026-07-06T09:00:00Z",
)
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT role, staff_id, staff_number, staff_name
FROM order_staff
WHERE order_id=?
ORDER BY role
""",
("1001",),
).fetchall()
self.assertEqual(
[(row["role"], row["staff_id"], row["staff_number"], row["staff_name"]) for row in rows],
[
("followup", "F1", "GD01", "跟单甲"),
("primary_sales", "S1", "XS19", "徐丽"),
("sales_2", "S2", "XS20", "李二"),
("sales_3", "S3", "XS21", "王三"),
("sales_4", "S4", "XS22", "赵四"),
],
)
def test_staff_query_defaults_to_primary_sales_and_can_include_all_roles(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "SO-PRIMARY",
"bizdate": "2026-07-03",
"customer_name": "Customer A",
"totalallamount": "100.00",
"operator_operatorname": "徐丽",
"operator_operatornumber": "XS19",
},
{
"id": "1002",
"billno": "SO-SECONDARY",
"bizdate": "2026-07-03",
"customer_name": "Customer B",
"totalallamount": "200.00",
"operator_operatorname": "其他销售",
"operator_operatornumber": "XS99",
"h5a3_basedatafield2_operatorname": "徐丽",
"h5a3_basedatafield2_operatornumber": "XS19",
},
],
synced_at="2026-07-06T09:00:00Z",
)
primary = query_business_question(
self.vault,
"徐丽最近一周接了多少单",
now="2026-07-06T12:00:00Z",
all_staff=False,
)
all_roles = query_business_question(
self.vault,
"徐丽最近一周接了多少单 全部角色",
now="2026-07-06T12:00:00Z",
)
amount = query_business_question(
self.vault,
"徐丽最近一周销售额",
now="2026-07-06T12:00:00Z",
all_staff=True,
)
self.assertEqual(primary["template"], "staff_order_count")
self.assertEqual(primary["data"]["order_count"], 1)
self.assertEqual(primary["data"]["staff_roles"], ["primary_sales"])
self.assertEqual(all_roles["data"]["order_count"], 2)
self.assertIn("sales_2", all_roles["data"]["staff_roles"])
self.assertEqual(amount["template"], "staff_sales_amount")
self.assertEqual(amount["data"]["sales_amount"], 300.0)
def test_staff_order_list_uses_date_range_and_returns_orders(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "SO-IN-RANGE",
"bizdate": "2026-07-03",
"customer_name": "Customer A",
"totalallamount": "100.00",
"operator_operatorname": "徐丽",
"operator_operatornumber": "XS19",
},
{
"id": "1002",
"billno": "SO-OUT-RANGE",
"bizdate": "2026-06-20",
"customer_name": "Customer B",
"totalallamount": "200.00",
"operator_operatorname": "徐丽",
"operator_operatornumber": "XS19",
},
],
synced_at="2026-07-06T09:00:00Z",
)
result = query_business_question(self.vault, "徐丽最近一周订单", now="2026-07-06T12:00:00Z")
self.assertEqual(result["template"], "staff_order_list")
self.assertEqual(result["data"]["start_date"], "2026-06-30")
self.assertEqual(result["data"]["end_date_exclusive"], "2026-07-07")
self.assertEqual([order["order_no"] for order in result["data"]["orders"]], ["SO-IN-RANGE"])
def test_sql_fallback_allows_readonly_structured_queries_and_blocks_raw_or_writes(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "SO-001",
"bizdate": "2026-07-03",
"customer_name": "Customer A",
"totalallamount": "100.00",
"operator_operatorname": "徐丽",
},
{
"id": "1002",
"billno": "SO-002",
"bizdate": "2026-07-04",
"customer_name": "Customer B",
"totalallamount": "200.00",
"operator_operatorname": "徐丽",
},
],
synced_at="2026-07-06T09:00:00Z",
)
result = execute_business_sql(
self.db_path,
"""
SELECT s.staff_name, COUNT(DISTINCT o.order_no) AS order_count
FROM orders o
JOIN order_staff s ON s.order_id = o.order_id
WHERE s.staff_name = '徐丽'
GROUP BY s.staff_name
""",
)
self.assertEqual(result["template"], "sql_fallback")
self.assertEqual(result["rows"], [{"staff_name": "徐丽", "order_count": 2}])
with self.assertRaisesRegex(RuntimeError, "only SELECT"):
execute_business_sql(self.db_path, "DELETE FROM orders")
with self.assertRaisesRegex(RuntimeError, "raw_json"):
execute_business_sql(self.db_path, "SELECT raw_json FROM orders")
with self.assertRaisesRegex(RuntimeError, "raw_snapshots"):
execute_business_sql(self.db_path, "SELECT payload_json FROM raw_snapshots")
with self.assertRaisesRegex(RuntimeError, "PRAGMA"):
execute_business_sql(self.db_path, "PRAGMA table_info(orders)")
def test_query_can_use_guarded_fallback_sql_when_no_template_matches(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "SO-001",
"bizdate": "2026-07-03",
"customer_name": "Customer A",
"totalallamount": "100.00",
}
],
synced_at="2026-07-06T09:00:00Z",
)
missing = query_business_question(self.vault, "按组织列出订单")
fallback = query_business_question(
self.vault,
"按组织列出订单",
fallback_sql="SELECT order_no, customer_name FROM orders ORDER BY order_no",
)
self.assertEqual(missing["template"], "no_matching_template")
self.assertEqual(fallback["template"], "sql_fallback")
self.assertEqual(fallback["rows"], [{"order_no": "SO-001", "customer_name": "Customer A"}])
def test_incremental_start_modifytime_uses_cursor_with_lookback_or_initial_days(self):
initial = compute_incremental_start_modifytime(
self.db_path,
now="2026-07-06T12:00:00Z",
lookback_hours=48,
initial_days=7,
)
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "SO-001",
"bizdate": "2026-07-03",
"customer_name": "Customer A",
"totalallamount": "100.00",
}
],
synced_at="2026-07-06T09:00:00Z",
)
next_start = compute_incremental_start_modifytime(
self.db_path,
now="2026-07-07T12:00:00Z",
lookback_hours=48,
initial_days=7,
)
self.assertEqual(initial, "2026-06-29 12:00:00")
self.assertEqual(next_start, "2026-07-04 09:00:00")
def test_cli_incremental_sync_passes_computed_modifytime_filter(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "SO-001",
"bizdate": "2026-07-03",
"customer_name": "Customer A",
"totalallamount": "100.00",
}
],
synced_at="2026-07-06T09:00:00Z",
)
from obsidian_wiki import cli
fetched_record = {
"id": "1002",
"billno": "SO-002",
"bizdate": "2026-07-04",
"customer_name": "Customer B",
"totalallamount": "200.00",
}
with (
patch.object(cli, "ensure_kingdee_config", return_value=False),
patch.object(cli, "load_kingdee_config", return_value={}),
patch.object(cli, "fetch_kingdee_payload", return_value=([fetched_record], {"rows": [fetched_record]})) as fetch_payload,
redirect_stdout(StringIO()),
):
status = cli.main(
[
"business",
"sync",
"kingdee",
"--vault",
str(self.vault),
"--config",
str(self.vault / "kingdee.env"),
"--incremental",
"--lookback-hours",
"48",
]
)
self.assertEqual(status, 0)
self.assertEqual(fetch_payload.call_args.kwargs["filters"]["start_modifytime"], "2026-07-04 09:00:00")
def test_cli_sync_payload_can_be_loaded_from_json_file(self):
payload_path = self.vault / "kingdee.json"
payload_path.write_text(
+84
View File
@@ -0,0 +1,84 @@
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import call, patch
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import manage_business_sync
class BusinessSyncServiceTests(unittest.TestCase):
def test_render_service_and_timer_units_for_incremental_sync(self):
service = manage_business_sync.render_service_unit(
project_root=Path("/opt/obsidian-wiki"),
vault_path=Path("/data/bjd-vault"),
config_path=Path("/opt/obsidian-wiki/.runtime/kingdee.env"),
python_path=Path("/usr/bin/python3"),
lookback_hours=48,
initial_days=7,
)
timer = manage_business_sync.render_timer_unit(
on_calendar="*-*-* 07:10:00",
randomized_delay_sec="15m",
)
self.assertIn("WorkingDirectory=/opt/obsidian-wiki", service)
self.assertIn("EnvironmentFile=/opt/obsidian-wiki/.runtime/kingdee.env", service)
self.assertIn("/usr/bin/python3 -m obsidian_wiki business sync kingdee", service)
self.assertIn("--vault /data/bjd-vault", service)
self.assertIn("--config /opt/obsidian-wiki/.runtime/kingdee.env", service)
self.assertIn("--incremental", service)
self.assertIn("--lookback-hours 48", service)
self.assertIn("--initial-days 7", service)
self.assertNotIn("KINGDEE_CLIENT_SECRET=", service)
self.assertNotIn("KINGDEE_ACCESS_TOKEN=", service)
self.assertIn("OnCalendar=*-*-* 07:10:00", timer)
self.assertIn("Persistent=true", timer)
self.assertIn("RandomizedDelaySec=15m", timer)
def test_start_timer_writes_units_and_enables_timer(self):
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
vault = Path(tmp) / "vault"
config_home = Path(tmp) / "config"
project_root.mkdir()
vault.mkdir()
(project_root / ".runtime").mkdir()
(project_root / ".runtime" / "kingdee.env").write_text("KINGDEE_ACGW_IDENTITY=test\n", encoding="utf-8")
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_business_sync, "run_systemctl") as run_systemctl,
):
output = manage_business_sync.start_timer(
project_root=project_root,
vault_path=vault,
python_path=Path("/usr/bin/python3"),
)
paths = manage_business_sync.resolve_service_paths(project_root, config_home=config_home)
self.assertTrue(paths.service_unit_path.is_file())
self.assertTrue(paths.timer_unit_path.is_file())
self.assertIn("Business sync timer is enabled.", output)
run_systemctl.assert_has_calls(
[
call(["daemon-reload"]),
call(["enable", "--now", manage_business_sync.TIMER_NAME]),
]
)
def test_run_now_starts_service_unit(self):
with patch.object(manage_business_sync, "run_systemctl") as run_systemctl:
manage_business_sync.run_now()
run_systemctl.assert_called_once_with(["start", manage_business_sync.SERVICE_NAME])
if __name__ == "__main__":
unittest.main()