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
+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()