Expose a business schema catalog and explicit template runner for Kingdee sales-order facts. Update wiki-query guidance so the LLM routes operational questions through templates first and guarded SQL only when needed. Refresh user docs and ops aliases, and add coverage for schema metadata, template execution, and CLI JSON output. Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check
802 lines
33 KiB
Python
802 lines
33 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_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()
|