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_schema, business_db_path_for_vault, compute_incremental_start_modifytime, execute_business_sql, execute_business_template, 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_business_schema_describes_templates_fields_and_sql_guardrails(self): schema = business_schema() template_names = {template["name"] for template in schema["templates"]} templates = {template["name"]: template for template in schema["templates"]} self.assertTrue( { "order_count", "sales_amount", "customer_count", "product_count", "quantity_sum", "avg_order_amount", "order_trend", "sales_trend", "quantity_trend", "top_customers", "top_products", "top_staff", "top_orgs", "top_departments", "top_projects", "top_warehouses", "breakdown_by_customer", "breakdown_by_product", "breakdown_by_staff", "breakdown_by_org", "breakdown_by_department", "breakdown_by_status", "breakdown_by_currency", "order_list", "customer_order_list", "product_order_list", "staff_order_list", "customer_summary", "product_summary", "staff_summary", "delivery_plan_count", "delivery_plan_quantity", "delivery_plan_list", "order_status_breakdown", "billstatus_breakdown", "close_status_breakdown", }.issubset(template_names) ) table_names = {table["name"] for table in schema["tables"]} self.assertIn("orders", table_names) self.assertIn("order_items", table_names) self.assertIn("order_staff", table_names) self.assertIn("order_deliveries", table_names) self.assertIn("COUNT(DISTINCT order_no)", schema["metric_guidance"]["order_count"]) self.assertEqual(templates["customer_count"]["required_parameters"], []) self.assertEqual(templates["product_count"]["required_parameters"], []) self.assertEqual(templates["customer_summary"]["required_parameters"], ["customer"]) self.assertEqual(templates["product_summary"]["required_parameters"], ["product"]) self.assertEqual(templates["staff_summary"]["required_parameters"], ["staff"]) self.assertIn("raw_json", schema["sql_guardrails"]["forbidden_columns"]) self.assertIn("raw_snapshots", schema["sql_guardrails"]["forbidden_tables"]) def test_execute_business_templates_cover_core_totals_rankings_lists_and_delivery_plans(self): sync_kingdee_orders( self.vault, [ { "id": "1001", "billno": "SO-001", "bizdate": "2026-06-03", "customer_number": "C01", "customer_name": "Customer A", "totalallamount": "100.00", "settlecurrency_number": "CNY", "billstatus": "C", "orderstatus": "D", "closestatus": "A", "org_name": "Org A", "dept_name": "Dept A", "operator_operatorname": "徐丽", "billentry": [ { "id": "L1", "material_masterid_number": "P01", "material_masterid_name": "Part 1", "qty": "2", "amountandtax": "60", "project_name": "Project A", "warehouse_name": "Warehouse A", "orderdeliverentry": [ { "id": "D1", "d_plandate": "2026-06-10", "d_plandeliverydate": "2026-06-08", "d_planqty": "2", } ], }, { "id": "L2", "material_masterid_number": "P02", "material_masterid_name": "Part 2", "qty": "1", "amountandtax": "40", "project_name": "Project B", "warehouse_name": "Warehouse B", }, ], }, { "id": "1002", "billno": "SO-002", "bizdate": "2026-06-04", "customer_number": "C02", "customer_name": "Customer B", "totalallamount": "300.00", "settlecurrency_number": "CNY", "billstatus": "B", "orderstatus": "C", "closestatus": "A", "org_name": "Org B", "dept_name": "Dept B", "operator_operatorname": "王五", "h5a3_basedatafield2_operatorname": "徐丽", "billentry": [ { "id": "L1", "material_masterid_number": "P01", "material_masterid_name": "Part 1", "qty": "3", "amountandtax": "300", "project_name": "Project A", "warehouse_name": "Warehouse A", "orderdeliverentry": [ { "id": "D2", "d_plandate": "2026-06-11", "d_planqty": "3", } ], } ], }, ], synced_at="2026-07-06T09:00:00Z", ) count = execute_business_template(self.db_path, "order_count", start_date="2026-06-01", end_date="2026-07-01") sales = execute_business_template(self.db_path, "sales_amount", year=2026, month=6) customers = execute_business_template(self.db_path, "customer_count", year=2026, month=6) products = execute_business_template(self.db_path, "product_count", year=2026, month=6) quantity = execute_business_template(self.db_path, "quantity_sum", year=2026, month=6) average = execute_business_template(self.db_path, "avg_order_amount", year=2026, month=6) top_products = execute_business_template(self.db_path, "top_products", year=2026, month=6, limit=1) staff = execute_business_template( self.db_path, "staff_summary", year=2026, month=6, staff="徐丽", staff_role_scope="all", ) delivery_qty = execute_business_template(self.db_path, "delivery_plan_quantity", year=2026, month=6) status = execute_business_template(self.db_path, "billstatus_breakdown", year=2026, month=6) self.assertEqual(count["data"]["order_count"], 2) self.assertEqual(sales["data"]["sales_amount"], 400.0) self.assertEqual(customers["data"]["customer_count"], 2) self.assertEqual(products["data"]["product_count"], 2) self.assertEqual(quantity["data"]["quantity_sum"], 6.0) self.assertEqual(average["data"]["avg_order_amount"], 200.0) self.assertEqual(top_products["rows"][0]["product_id"], "P01") self.assertEqual(top_products["rows"][0]["quantity"], 5.0) self.assertEqual(staff["data"]["order_count"], 2) self.assertEqual(staff["data"]["sales_amount"], 400.0) self.assertEqual(delivery_qty["data"]["delivery_plan_quantity"], 5.0) self.assertEqual( [(row["status"], row["order_count"]) for row in status["rows"]], [("B", 1), ("C", 1)], ) def test_execute_business_template_rejects_unknown_or_missing_parameters(self): with self.assertRaisesRegex(RuntimeError, "unknown business template"): execute_business_template(self.db_path, "not_a_template", year=2026, month=6) with self.assertRaisesRegex(RuntimeError, "requires"): execute_business_template(self.db_path, "staff_summary", year=2026, month=6) def test_cli_business_schema_and_template_print_json(self): sync_kingdee_orders( self.vault, [ { "id": "1001", "billno": "SO-001", "bizdate": "2026-06-03", "customer_name": "Customer A", "totalallamount": "100.00", } ], synced_at="2026-07-06T09:00:00Z", ) from obsidian_wiki.cli import main schema_output = StringIO() with redirect_stdout(schema_output): schema_code = main(["business", "schema", "--json"]) template_output = StringIO() with redirect_stdout(template_output): template_code = main( [ "business", "template", "order_count", "--vault", str(self.vault), "--year", "2026", "--month", "6", "--json", ] ) self.assertEqual(schema_code, 0) self.assertEqual(template_code, 0) self.assertIn("order_count", {template["name"] for template in json.loads(schema_output.getvalue())["templates"]}) self.assertEqual(json.loads(template_output.getvalue())["data"]["order_count"], 1) 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()