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:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user