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.
575 lines
23 KiB
Python
575 lines
23 KiB
Python
import json
|
|
import sqlite3
|
|
import tempfile
|
|
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,
|
|
)
|
|
|
|
|
|
class BusinessFactsTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tempdir = tempfile.TemporaryDirectory()
|
|
self.vault = Path(self.tempdir.name)
|
|
(self.vault / "_meta").mkdir()
|
|
self.db_path = business_db_path_for_vault(self.vault)
|
|
|
|
def tearDown(self):
|
|
self.tempdir.cleanup()
|
|
|
|
def test_monthly_order_count_deduplicates_order_lines(self):
|
|
sync_kingdee_orders(
|
|
self.vault,
|
|
[
|
|
{
|
|
"order_id": "1001",
|
|
"order_no": "SO-001",
|
|
"order_date": "2026-06-03",
|
|
"customer_id": "C01",
|
|
"customer_name": "Customer A",
|
|
"total_amount": 1200,
|
|
"updated_at": "2026-06-03T08:00:00Z",
|
|
"items": [
|
|
{"line_id": "1", "product_id": "P01", "product_name": "Part 1", "quantity": 10, "amount": 500},
|
|
{"line_id": "2", "product_id": "P02", "product_name": "Part 2", "quantity": 20, "amount": 700},
|
|
],
|
|
},
|
|
{
|
|
"order_id": "1002",
|
|
"order_no": "SO-002",
|
|
"order_date": "2026-06-15",
|
|
"customer_id": "C02",
|
|
"customer_name": "Customer B",
|
|
"total_amount": 900,
|
|
"updated_at": "2026-06-15T08:00:00Z",
|
|
"items": [
|
|
{"line_id": "1", "product_id": "P03", "product_name": "Part 3", "quantity": 5, "amount": 900},
|
|
],
|
|
},
|
|
{
|
|
"order_id": "1003",
|
|
"order_no": "SO-003",
|
|
"order_date": "2026-07-01",
|
|
"customer_id": "C01",
|
|
"customer_name": "Customer A",
|
|
"total_amount": 300,
|
|
"updated_at": "2026-07-01T08:00:00Z",
|
|
},
|
|
],
|
|
source_payload={"orders": 3},
|
|
synced_at="2026-07-06T09:00:00Z",
|
|
)
|
|
|
|
result = query_business_question(self.vault, "2026-06 order count")
|
|
|
|
self.assertEqual(result["template"], "monthly_order_count")
|
|
self.assertEqual(result["data"]["order_count"], 2)
|
|
self.assertIn("2", result["answer"])
|
|
self.assertIn("sales order number", result["answer"])
|
|
self.assertIn("2026-07-06T09:00:00Z", result["answer"])
|
|
|
|
def test_repeated_sync_updates_existing_orders_without_duplicates(self):
|
|
first = [
|
|
{
|
|
"order_id": "1001",
|
|
"order_no": "SO-001",
|
|
"order_date": "2026-06-03",
|
|
"customer_id": "C01",
|
|
"customer_name": "Customer A",
|
|
"total_amount": 1200,
|
|
"updated_at": "2026-06-03T08:00:00Z",
|
|
}
|
|
]
|
|
second = [{**first[0], "total_amount": 1500, "updated_at": "2026-06-04T08:00:00Z"}]
|
|
|
|
sync_kingdee_orders(self.vault, first, source_payload={"orders": first}, synced_at="2026-07-06T09:00:00Z")
|
|
sync_kingdee_orders(self.vault, second, source_payload={"orders": second}, synced_at="2026-07-06T10:00:00Z")
|
|
|
|
count = query_business_question(self.vault, "2026-06 order count")
|
|
amount = query_business_question(self.vault, "2026-06 sales amount")
|
|
|
|
self.assertEqual(count["data"]["order_count"], 1)
|
|
self.assertEqual(amount["data"]["sales_amount"], 1500.0)
|
|
self.assertIn("2026-07-06T10:00:00Z", amount["answer"])
|
|
|
|
def test_query_warns_when_last_sync_is_before_today(self):
|
|
sync_kingdee_orders(
|
|
self.vault,
|
|
[
|
|
{
|
|
"order_id": "1001",
|
|
"order_no": "SO-001",
|
|
"order_date": "2026-06-03",
|
|
"customer_name": "Customer A",
|
|
"total_amount": 1200,
|
|
}
|
|
],
|
|
source_payload={"orders": 1},
|
|
synced_at="2026-07-03T09:00:00Z",
|
|
)
|
|
|
|
result = query_business_question(self.vault, "2026-06 order count", now="2026-07-06T00:00:00Z")
|
|
|
|
self.assertTrue(result["is_stale"])
|
|
self.assertIn("may not include data after 2026-07-03T09:00:00Z", result["answer"])
|
|
|
|
def test_official_kingdee_sales_order_shape_maps_into_business_facts(self):
|
|
payload = {
|
|
"data": {
|
|
"rows": [
|
|
{
|
|
"id": "3551625973803228160",
|
|
"billno": "SO-REAL-001",
|
|
"billstatus": "C",
|
|
"orderstatus": "C",
|
|
"closestatus": "A",
|
|
"bizdate": "2026-06-08",
|
|
"modifytime": "2026-06-09 10:30:00",
|
|
"customer_number": "CUST-001",
|
|
"customer_name": "真实客户",
|
|
"totalallamount": "1234.56",
|
|
"totalamount": "1000.00",
|
|
"totaltaxamount": "234.56",
|
|
"org_number": "ORG-001",
|
|
"org_name": "销售组织",
|
|
"dept_number": "DEPT-001",
|
|
"dept_name": "销售部",
|
|
"operator_operatornumber": "OP-001",
|
|
"operator_operatorname": "张三",
|
|
"settlecurrency_number": "CNY",
|
|
"billentry": [
|
|
{
|
|
"id": "4568544278879012864",
|
|
"seq": 1,
|
|
"material_masterid_number": "MAT-001",
|
|
"material_masterid_name": "车载连接器",
|
|
"material_masterid_modelnum": "MODEL-A",
|
|
"qty": "711.97",
|
|
"priceandtax": "1.73",
|
|
"amountandtax": "1234.56",
|
|
"amount": "1000.00",
|
|
"unit_number": "PCS",
|
|
"unit_name": "个",
|
|
"deliverydate": "2026-06-20",
|
|
"project_number": "PRJ-001",
|
|
"project_name": "重点项目",
|
|
"warehouse_number": "WH-001",
|
|
"warehouse_name": "成品仓",
|
|
"orderdeliverentry": [
|
|
{
|
|
"id": "7690495588165568512",
|
|
"d_plandate": "2026-06-18",
|
|
"d_plandeliverydate": "2026-06-16",
|
|
"d_planqty": "500.00",
|
|
"d_planbaseqty": "500.00",
|
|
"d_planunit_number": "PCS",
|
|
"d_planunit_name": "个",
|
|
"d_receiveaddress": "测试地址",
|
|
"d_receiveaddressf7_number": "ADDR-001",
|
|
"d_receiveaddressf7_name": "测试收货点",
|
|
"d_remark": "首批",
|
|
"d_transportleadtime": 2,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
sync_kingdee_orders(
|
|
self.vault,
|
|
payload["data"]["rows"],
|
|
source_payload=payload,
|
|
synced_at="2026-07-06T09:00:00Z",
|
|
)
|
|
count = query_business_question(self.vault, "2026-06 order count")
|
|
amount = query_business_question(self.vault, "2026-06 sales amount")
|
|
|
|
self.assertEqual(count["data"]["order_count"], 1)
|
|
self.assertEqual(amount["data"]["sales_amount"], 1234.56)
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
order = conn.execute(
|
|
"""
|
|
SELECT billstatus, orderstatus, closestatus, org_number, org_name,
|
|
dept_number, dept_name, operator_number, operator_name,
|
|
total_net_amount, total_tax_amount, total_all_amount, raw_json
|
|
FROM orders
|
|
WHERE order_id=?
|
|
""",
|
|
("3551625973803228160",),
|
|
).fetchone()
|
|
item = conn.execute(
|
|
"""
|
|
SELECT seq, product_id, product_name, material_model, unit_number, unit_name,
|
|
price_and_tax, amount, amount_and_tax, delivery_date,
|
|
project_number, project_name, warehouse_number, warehouse_name, raw_json
|
|
FROM order_items
|
|
WHERE order_id=? AND line_id=?
|
|
""",
|
|
("3551625973803228160", "4568544278879012864"),
|
|
).fetchone()
|
|
delivery = conn.execute(
|
|
"""
|
|
SELECT delivery_id, plan_date, plan_delivery_date, plan_qty, plan_base_qty,
|
|
plan_unit_number, plan_unit_name, receive_address,
|
|
receive_place_number, receive_place_name, remark, transport_lead_time, raw_json
|
|
FROM order_deliveries
|
|
WHERE order_id=? AND line_id=?
|
|
""",
|
|
("3551625973803228160", "4568544278879012864"),
|
|
).fetchone()
|
|
|
|
self.assertEqual(order["billstatus"], "C")
|
|
self.assertEqual(order["org_number"], "ORG-001")
|
|
self.assertEqual(order["operator_name"], "张三")
|
|
self.assertEqual(order["total_net_amount"], 1000.0)
|
|
self.assertEqual(order["total_tax_amount"], 234.56)
|
|
self.assertEqual(order["total_all_amount"], 1234.56)
|
|
self.assertEqual(json.loads(order["raw_json"])["billno"], "SO-REAL-001")
|
|
self.assertEqual(item["seq"], 1)
|
|
self.assertEqual(item["material_model"], "MODEL-A")
|
|
self.assertEqual(item["price_and_tax"], 1.73)
|
|
self.assertEqual(item["amount_and_tax"], 1234.56)
|
|
self.assertEqual(json.loads(item["raw_json"])["material_masterid_number"], "MAT-001")
|
|
self.assertEqual(delivery["delivery_id"], "7690495588165568512")
|
|
self.assertEqual(delivery["plan_date"], "2026-06-18")
|
|
self.assertEqual(delivery["plan_qty"], 500.0)
|
|
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(
|
|
json.dumps(
|
|
{
|
|
"orders": [
|
|
{
|
|
"order_id": "1001",
|
|
"order_no": "SO-001",
|
|
"order_date": "2026-06-03",
|
|
"customer_name": "Customer A",
|
|
"total_amount": 1200,
|
|
}
|
|
]
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from obsidian_wiki.cli import main
|
|
|
|
with redirect_stdout(StringIO()):
|
|
sync_code = main(["business", "sync", "kingdee", "--vault", str(self.vault), "--input", str(payload_path)])
|
|
query_output = StringIO()
|
|
with redirect_stdout(query_output):
|
|
query_code = main(["business", "query", "2026-06 order count", "--vault", str(self.vault), "--json"])
|
|
|
|
self.assertEqual(sync_code, 0)
|
|
self.assertEqual(query_code, 0)
|
|
self.assertEqual(json.loads(query_output.getvalue())["data"]["order_count"], 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|