feat(business): exclude intercompany transfer orders

Allow operators to configure internal company names and identifiers, then apply the filter consistently across business templates and query routing. Preserve direct external-customer orders and provide explicit CLI and environment overrides for internal-flow audits.

Validation: python -m unittest tests.test_business_facts tests.test_wiki_skills tests.test_user_ops -v (25 tests)
This commit is contained in:
Codex
2026-07-10 05:40:54 +00:00
parent 7eb9b987ab
commit 4c5454da90
7 changed files with 416 additions and 34 deletions
+11
View File
@@ -36,6 +36,17 @@ OBSIDIAN_FILE_ASSET_DB=
# Do not put real app secrets or access tokens in this example file.
OBSIDIAN_BUSINESS_FACTS_DB=
# Optional intercompany dedupe for multi-company sales data. When configured,
# business templates exclude orders where both the sales organization and the
# ordering customer match internal parties. This removes internal transfer
# orders while preserving direct external-customer orders to factories.
# Comma-separated name substrings, e.g. 北极大,捷康,众创.
OBSIDIAN_BUSINESS_INTERNAL_PARTIES=
# Optional exact org/customer codes for internal parties, comma-separated.
OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS=
# Set true only when auditing internal transfer orders.
OBSIDIAN_BUSINESS_INCLUDE_INTERNAL_ORDERS=false
# --- Source File MCP Server (optional) ---
#
# Run with: obsidian-wiki-mcp
+2
View File
@@ -69,10 +69,12 @@ If it is a business facts query:
obsidian-wiki business template <template_name> --vault "$OBSIDIAN_VAULT_PATH" <parameters> --json
```
Common parameters include `--customer`, `--product`, `--staff`, `--org`, `--department`, `--project`, `--warehouse`, `--billstatus`, `--orderstatus`, `--closestatus`, `--currency`, `--limit`, `--granularity day|month`, and `--staff-role-scope primary|all`. Salesperson questions default to `primary`; choose `all` only when the user asks to include secondary sales/follow-up roles.
If `OBSIDIAN_BUSINESS_INTERNAL_PARTIES` or `OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS` is configured, templates automatically exclude intercompany transfer orders where both the sales organization and ordering customer are internal parties. Do not add `--include-internal-orders` unless the user is explicitly auditing internal transfer flow.
3. If no template fits, generate one guarded read-only SQL statement from the schema. Use only `SELECT` or `WITH ... SELECT`, only the documented business tables, never `raw_json` or `raw_snapshots`, and never write/DDL/PRAGMA. Then run:
```bash
obsidian-wiki business sql --vault "$OBSIDIAN_VAULT_PATH" "<SQL>" --json
```
For normal customer-facing sales metrics, apply the same intercompany dedupe rule in SQL unless the question explicitly asks about internal transfer flow.
4. If the template or guarded SQL returns an answer, answer from that JSON result as structured business facts. Do not cite wiki pages unless you also used wiki pages for business context. Mention stale sync warnings when `is_stale` is true.
5. If the CLI/database is unavailable or the question is not a business facts query, continue with the normal wiki retrieval protocol below.
+2
View File
@@ -329,6 +329,8 @@ Staging is for upload/intake only. The normal ingest flow should still classify
Operational counts and amounts belong in a structured fact database, not in markdown pages. The `business` CLI stores Kingdee sales orders in `<vault>/_meta/business-facts.sqlite`. Normal users should ask through `wiki-query`; the skill decides when to use structured business facts, prefers templates, and only falls back to guarded read-only SQL when no template fits. For mixed business-and-context questions, `wiki-query` runs a hybrid path: one track gathers wiki context and citations, another gathers read-only business metrics, and the final answer keeps the two sources clearly separated.
For multi-company data, set `OBSIDIAN_BUSINESS_INTERNAL_PARTIES` and/or `OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS` in `.env`. Built-in business templates then exclude intercompany transfer orders by default, but only when both the sales organization and ordering customer match internal parties. Direct external-customer orders to a factory are still counted. Use `--include-internal-orders` only when auditing internal transfer flow.
```bash
# Sync from an exported or API-fetched Kingdee JSON payload.
scripts/obsidian-wiki-ops.py kingdee sync --input ./kingdee-orders.json
+5
View File
@@ -97,6 +97,8 @@ Order upsert key: `order_id`, falling back to `order_no` when no explicit ID is
`wiki-query` decides semantically whether a user question needs business facts. When it does, it loads `obsidian-wiki business schema --json`, chooses a template if one fits, and only then uses guarded SQL for gaps. For hybrid questions about customers, products, salespeople, projects, delivery risk, anomalies, rankings, or quality impact, it runs two tracks: ordinary wiki retrieval for context/citations and business facts retrieval for structured metrics, then fuses the results in one answer.
For multi-company sales architectures, configure `OBSIDIAN_BUSINESS_INTERNAL_PARTIES` and/or `OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS` in `.env`. Templates then exclude intercompany transfer orders by default: an order is excluded only when both the sales organization and the ordering customer match configured internal parties. This keeps true external orders placed directly with a factory, while removing internal flow such as a sales company ordering from a factory. Set `OBSIDIAN_BUSINESS_INCLUDE_INTERNAL_ORDERS=true` or pass `--include-internal-orders` only when auditing internal transfers.
Template families include:
- Order totals: `order_count`, `sales_amount`, `customer_count`, `product_count`, `quantity_sum`, `avg_order_amount`.
@@ -113,6 +115,7 @@ Core counting basis:
- Ordered quantity: `SUM(order_items.quantity)` by `orders.order_date`.
- Delivery-plan quantity: `SUM(order_deliveries.plan_qty)` by `order_deliveries.plan_date`.
- Staff templates join `orders` to `order_staff`, defaulting to `primary_sales`.
- When internal parties are configured, all built-in templates apply the intercompany dedupe filter before counting, summing, ranking, listing, or joining staff/delivery rows.
Every answer includes the statistic, counting basis, data source, and last successful sync time. If the last successful sync is before the query date, the answer warns that newer data may be missing.
@@ -126,6 +129,8 @@ If no template matches, `wiki-query` may generate guarded SQL and execute it thr
- no write, schema, attach, pragma, or vacuum statements
- default result limit is 100 rows, capped at 1000
When using guarded SQL for normal customer-facing sales metrics, apply the same intercompany dedupe rule manually unless the question is explicitly about internal transfer flow.
## Incremental Sync
`scripts/obsidian-wiki-ops.py kingdee sync --incremental` uses Kingdee `start_modifytime`.
+148 -33
View File
@@ -429,10 +429,11 @@ def business_schema() -> dict[str, Any]:
],
"metric_guidance": {
"order_count": "Use COUNT(DISTINCT order_no) for sales-order counts.",
"sales_amount": "Use orders.total_amount for tax-inclusive order amount; total_net_amount and total_tax_amount are available when that split matters.",
"sales_amount": "Use orders.total_amount for tax-inclusive order amount; when internal_parties/internal_party_ids are configured, templates exclude internal company transfer orders by default. Set include_internal_orders=true only for intercompany-flow audits. total_net_amount and total_tax_amount are available when that split matters.",
"quantity": "Use order_items.quantity for ordered material quantity; use order_deliveries.plan_qty only for delivery-plan questions.",
"date": "Use orders.order_date for order statistics and order_deliveries.plan_date for planned demand/delivery-plan questions.",
"staff": "Use order_staff.role='primary_sales' by default; use staff_role_scope=all when the user asks to include secondary sales or follow-up roles.",
"intercompany_dedupe": "For multi-company architectures, pass internal_parties name substrings and/or internal_party_ids. Templates exclude an order only when both the sales organization and ordering customer match configured internal parties, so direct external customer orders to a factory are still counted.",
},
"templates": [_template_schema(name) for name in BUSINESS_TEMPLATE_NAMES],
"sql_guardrails": {
@@ -512,6 +513,9 @@ def _template_schema(name: str) -> dict[str, Any]:
"orderstatus",
"closestatus",
"currency",
"internal_parties",
"internal_party_ids",
"include_internal_orders",
],
}
@@ -640,6 +644,69 @@ def _where_date(column: str, start: str, end: str) -> tuple[list[str], list[Any]
return [f"{column} >= ?", f"{column} < ?"], [start, end]
def _coerce_string_list(value: Any) -> tuple[str, ...]:
if value in (None, ""):
return ()
if isinstance(value, str):
values = re.split(r"[,,\n]", value)
elif isinstance(value, (list, tuple, set)):
values = []
for item in value:
values.extend(re.split(r"[,,\n]", str(item)))
else:
values = [value]
return tuple(str(item).strip() for item in values if str(item).strip())
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
def _internal_party_match_sql(
*,
name_fields: tuple[str, ...],
id_fields: tuple[str, ...],
party_terms: tuple[str, ...],
party_ids: tuple[str, ...],
) -> tuple[str, list[Any]]:
clauses: list[str] = []
args: list[Any] = []
for term in party_terms:
for field in name_fields:
clauses.append(f"{field} LIKE ?")
args.append(f"%{term}%")
for party_id in party_ids:
for field in id_fields:
clauses.append(f"{field} = ?")
args.append(party_id)
return "(" + " OR ".join(clauses) + ")", args
def _internal_transfer_filter_sql(params: dict[str, Any], alias: str = "o") -> tuple[list[str], list[Any]]:
if _truthy(params.get("include_internal_orders")):
return [], []
party_terms = _coerce_string_list(params.get("internal_parties"))
party_ids = _coerce_string_list(params.get("internal_party_ids"))
if not party_terms and not party_ids:
return [], []
org_clause, org_args = _internal_party_match_sql(
name_fields=(f"{alias}.org_name",),
id_fields=(f"{alias}.org_id", f"{alias}.org_number"),
party_terms=party_terms,
party_ids=party_ids,
)
customer_clause, customer_args = _internal_party_match_sql(
name_fields=(f"{alias}.customer_name",),
id_fields=(f"{alias}.customer_id",),
party_terms=party_terms,
party_ids=party_ids,
)
return [f"NOT ({org_clause} AND {customer_clause})"], [*org_args, *customer_args]
def _order_filter_sql(params: dict[str, Any], alias: str = "o") -> tuple[list[str], list[Any]]:
clauses: list[str] = []
args: list[Any] = []
@@ -662,6 +729,9 @@ def _order_filter_sql(params: dict[str, Any], alias: str = "o") -> tuple[list[st
if params.get(param):
clauses.append(f"{field} = ?")
args.append(params[param])
internal_clauses, internal_args = _internal_transfer_filter_sql(params, alias=alias)
clauses.extend(internal_clauses)
args.extend(internal_args)
return clauses, args
@@ -1435,9 +1505,11 @@ def query_business_question(
now: str | None = None,
all_staff: bool = False,
fallback_sql: str | None = None,
business_params: dict[str, Any] | None = None,
) -> dict[str, Any]:
db_path = db_path or business_db_path_for_vault(vault_path)
init_db(db_path)
params = dict(business_params or {})
q = question.strip()
lower = q.lower()
staff_name = _extract_staff_name(q)
@@ -1453,6 +1525,7 @@ def query_business_question(
range_label=range_label,
all_staff=include_all_staff,
now=now,
params=params,
)
if "多少" in q or "几单" in q or "接了" in q or "count" in lower:
return staff_order_count(
@@ -1463,6 +1536,7 @@ def query_business_question(
range_label=range_label,
all_staff=include_all_staff,
now=now,
params=params,
)
if "订单" in q or "order" in lower:
return staff_order_list(
@@ -1473,6 +1547,7 @@ def query_business_question(
range_label=range_label,
all_staff=include_all_staff,
now=now,
params=params,
)
try:
@@ -1483,20 +1558,28 @@ def query_business_question(
return no_matching_template(q, db_path, now=now)
if "top" in lower and "customer" in lower or "top客户" in q.lower() or "客户排名" in q:
return monthly_top_customers(db_path, year, month, now=now)
return monthly_top_customers(db_path, year, month, now=now, params=params)
if "amount" in lower or "sales" in lower or "销售额" in q or "金额" in q:
return monthly_sales_amount(db_path, year, month, now=now)
return monthly_sales_amount(db_path, year, month, now=now, params=params)
if "trend" in lower or "趋势" in q:
return customer_order_trend(db_path, q, year, month, now=now)
return monthly_order_count(db_path, year, month, now=now)
return customer_order_trend(db_path, q, year, month, now=now, params=params)
return monthly_order_count(db_path, year, month, now=now, params=params)
def monthly_order_count(db_path: Path, year: int, month: int, *, now: str | None = None) -> dict[str, Any]:
def monthly_order_count(
db_path: Path,
year: int,
month: int,
*,
now: str | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
start, end = month_bounds(year, month)
where, args = _order_where(params or {}, start, end)
with connect(db_path) as conn:
row = conn.execute(
"SELECT COUNT(DISTINCT order_no) AS value FROM orders WHERE order_date >= ? AND order_date < ?",
(start, end),
f"SELECT COUNT(DISTINCT o.order_no) AS value FROM orders o WHERE {where}",
args,
).fetchone()
count = int(row["value"] or 0)
last_sync = last_successful_sync(db_path)
@@ -1515,12 +1598,20 @@ def monthly_order_count(db_path: Path, year: int, month: int, *, now: str | None
}
def monthly_sales_amount(db_path: Path, year: int, month: int, *, now: str | None = None) -> dict[str, Any]:
def monthly_sales_amount(
db_path: Path,
year: int,
month: int,
*,
now: str | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
start, end = month_bounds(year, month)
where, args = _order_where(params or {}, start, end)
with connect(db_path) as conn:
row = conn.execute(
"SELECT COALESCE(SUM(total_amount), 0) AS value FROM orders WHERE order_date >= ? AND order_date < ?",
(start, end),
f"SELECT COALESCE(SUM(o.total_amount), 0) AS value FROM orders o WHERE {where}",
args,
).fetchone()
amount = float(row["value"] or 0)
last_sync = last_successful_sync(db_path)
@@ -1539,21 +1630,30 @@ def monthly_sales_amount(db_path: Path, year: int, month: int, *, now: str | Non
}
def monthly_top_customers(db_path: Path, year: int, month: int, *, limit: int = 5, now: str | None = None) -> dict[str, Any]:
def monthly_top_customers(
db_path: Path,
year: int,
month: int,
*,
limit: int = 5,
now: str | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
start, end = month_bounds(year, month)
where, args = _order_where(params or {}, start, end)
with connect(db_path) as conn:
rows = conn.execute(
"""
SELECT COALESCE(NULLIF(customer_name, ''), NULLIF(customer_id, ''), 'Unknown') AS customer,
COUNT(DISTINCT order_no) AS order_count,
COALESCE(SUM(total_amount), 0) AS sales_amount
FROM orders
WHERE order_date >= ? AND order_date < ?
SELECT COALESCE(NULLIF(o.customer_name, ''), NULLIF(o.customer_id, ''), 'Unknown') AS customer,
COUNT(DISTINCT o.order_no) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS sales_amount
FROM orders o
WHERE {where}
GROUP BY customer
ORDER BY sales_amount DESC, order_count DESC, customer ASC
LIMIT ?
""",
(start, end, limit),
""".format(where=where),
(*args, limit),
).fetchall()
customers = [
{"customer": row["customer"], "order_count": int(row["order_count"] or 0), "sales_amount": float(row["sales_amount"] or 0)}
@@ -1576,22 +1676,31 @@ def monthly_top_customers(db_path: Path, year: int, month: int, *, limit: int =
}
def customer_order_trend(db_path: Path, question: str, year: int, month: int, *, now: str | None = None) -> dict[str, Any]:
def customer_order_trend(
db_path: Path,
question: str,
year: int,
month: int,
*,
now: str | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
customer = _extract_customer_name(question)
start, end = month_bounds(year, month)
where, args = _order_where(params or {}, start, end)
with connect(db_path) as conn:
rows = conn.execute(
"""
SELECT substr(order_date, 1, 7) AS month,
COUNT(DISTINCT order_no) AS order_count,
COALESCE(SUM(total_amount), 0) AS sales_amount
FROM orders
WHERE order_date >= ? AND order_date < ?
SELECT substr(o.order_date, 1, 7) AS month,
COUNT(DISTINCT o.order_no) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS sales_amount
FROM orders o
WHERE {where}
AND (? = '' OR customer_name LIKE ? OR customer_id LIKE ?)
GROUP BY substr(order_date, 1, 7)
ORDER BY month ASC
""",
(start, end, customer, f"%{customer}%", f"%{customer}%"),
""".format(where=where),
(*args, customer, f"%{customer}%", f"%{customer}%"),
).fetchall()
trend = [{"month": row["month"], "order_count": int(row["order_count"] or 0), "sales_amount": float(row["sales_amount"] or 0)} for row in rows]
last_sync = last_successful_sync(db_path)
@@ -1619,19 +1728,21 @@ def staff_order_count(
range_label: str,
all_staff: bool = False,
now: str | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
where, args = _order_where(params or {}, start, end)
with connect(db_path) as conn:
row = conn.execute(
f"""
SELECT COUNT(DISTINCT o.order_no) AS value
FROM orders o
JOIN order_staff s ON s.order_id = o.order_id
WHERE o.order_date >= ? AND o.order_date < ?
WHERE {where}
AND ({_staff_role_placeholders(roles)})
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
""",
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%"),
(*args, *roles, f"%{staff_name}%", f"%{staff_name}%"),
).fetchone()
count = int(row["value"] or 0)
last_sync = last_successful_sync(db_path)
@@ -1666,8 +1777,10 @@ def staff_sales_amount(
range_label: str,
all_staff: bool = False,
now: str | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
where, args = _order_where(params or {}, start, end)
with connect(db_path) as conn:
row = conn.execute(
f"""
@@ -1676,12 +1789,12 @@ def staff_sales_amount(
SELECT DISTINCT o.order_id, o.total_amount
FROM orders o
JOIN order_staff s ON s.order_id = o.order_id
WHERE o.order_date >= ? AND o.order_date < ?
WHERE {where}
AND ({_staff_role_placeholders(roles)})
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
)
""",
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%"),
(*args, *roles, f"%{staff_name}%", f"%{staff_name}%"),
).fetchone()
amount = float(row["value"] or 0)
last_sync = last_successful_sync(db_path)
@@ -1717,8 +1830,10 @@ def staff_order_list(
all_staff: bool = False,
now: str | None = None,
limit: int = 100,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
where, args = _order_where(params or {}, start, end)
with connect(db_path) as conn:
rows = conn.execute(
f"""
@@ -1727,13 +1842,13 @@ def staff_order_list(
o.currency, o.billstatus, o.orderstatus, o.org_name
FROM orders o
JOIN order_staff s ON s.order_id = o.order_id
WHERE o.order_date >= ? AND o.order_date < ?
WHERE {where}
AND ({_staff_role_placeholders(roles)})
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
ORDER BY o.order_date DESC, o.order_no ASC
LIMIT ?
""",
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%", limit),
(*args, *roles, f"%{staff_name}%", f"%{staff_name}%", limit),
).fetchall()
orders = [
{
+54 -1
View File
@@ -29,6 +29,7 @@ from obsidian_wiki.business_facts import (
query_business_question,
sync_kingdee_orders,
)
from obsidian_wiki.config import load_config
from obsidian_wiki.file_assets import (
clean_config_value,
db_path_for_vault,
@@ -603,6 +604,7 @@ def cmd_business_query(args: argparse.Namespace) -> int:
db_path=_resolve_business_db(args, vault_path),
all_staff=args.all_staff,
fallback_sql=args.fallback_sql,
business_params=_business_internal_params(args),
)
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -666,7 +668,32 @@ def _business_template_params(args: argparse.Namespace) -> dict[str, object]:
"closestatus",
"currency",
)
return {key: getattr(args, key) for key in keys if getattr(args, key, None) not in (None, "")}
params = {key: getattr(args, key) for key in keys if getattr(args, key, None) not in (None, "", [])}
params.update({key: value for key, value in _business_internal_params(args).items() if key not in params})
return params
def _business_internal_params(args: argparse.Namespace) -> dict[str, object]:
params: dict[str, object] = {}
if getattr(args, "internal_parties", None):
params["internal_parties"] = getattr(args, "internal_parties")
if getattr(args, "internal_party_ids", None):
params["internal_party_ids"] = getattr(args, "internal_party_ids")
if getattr(args, "include_internal_orders", False):
params["include_internal_orders"] = True
config = load_config(Path.cwd())
if not params.get("internal_parties"):
parties = os.environ.get("OBSIDIAN_BUSINESS_INTERNAL_PARTIES") or config.get("OBSIDIAN_BUSINESS_INTERNAL_PARTIES")
if parties:
params["internal_parties"] = parties
if not params.get("internal_party_ids"):
party_ids = os.environ.get("OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS") or config.get("OBSIDIAN_BUSINESS_INTERNAL_PARTY_IDS")
if party_ids:
params["internal_party_ids"] = party_ids
include_internal = os.environ.get("OBSIDIAN_BUSINESS_INCLUDE_INTERNAL_ORDERS") or config.get("OBSIDIAN_BUSINESS_INCLUDE_INTERNAL_ORDERS")
if include_internal and not params.get("include_internal_orders"):
params["include_internal_orders"] = include_internal
return params
def cmd_business_sql(args: argparse.Namespace) -> int:
@@ -859,6 +886,19 @@ def build_parser() -> argparse.ArgumentParser:
bqp.add_argument("--db", metavar="PATH", help="SQLite DB path (defaults to <vault>/_meta/business-facts.sqlite)")
bqp.add_argument("--all-staff", action="store_true", help="include secondary sales and follow-up roles in staff queries")
bqp.add_argument("--fallback-sql", help="guarded read-only SQL to run only when no fixed template matches")
bqp.add_argument(
"--internal-party",
dest="internal_parties",
action="append",
help="internal company name substring to exclude intercompany transfer orders; repeat or comma-separate",
)
bqp.add_argument(
"--internal-party-id",
dest="internal_party_ids",
action="append",
help="internal org/customer code to exclude intercompany transfer orders; repeat or comma-separate",
)
bqp.add_argument("--include-internal-orders", action="store_true", help="include configured internal transfer orders")
bqp.add_argument("--json", action="store_true", help="print machine-readable query result")
bqp.add_argument("question", nargs="+", help="question, e.g. '2026-06 order count'")
bqp.set_defaults(func=cmd_business_query)
@@ -894,6 +934,19 @@ def build_parser() -> argparse.ArgumentParser:
btp.add_argument("--orderstatus", help="Kingdee orderstatus filter")
btp.add_argument("--closestatus", help="Kingdee closestatus filter")
btp.add_argument("--currency", help="currency code filter")
btp.add_argument(
"--internal-party",
dest="internal_parties",
action="append",
help="internal company name substring to exclude intercompany transfer orders; repeat or comma-separate",
)
btp.add_argument(
"--internal-party-id",
dest="internal_party_ids",
action="append",
help="internal org/customer code to exclude intercompany transfer orders; repeat or comma-separate",
)
btp.add_argument("--include-internal-orders", action="store_true", help="include configured internal transfer orders")
btp.add_argument("--json", action="store_true", help="print machine-readable template result")
btp.add_argument("template_name", help="template name from `business schema`")
btp.set_defaults(func=cmd_business_template)
+194
View File
@@ -1,4 +1,5 @@
import json
import os
import sqlite3
import tempfile
import unittest
@@ -599,6 +600,9 @@ class BusinessFactsTests(unittest.TestCase):
self.assertIn("order_staff", table_names)
self.assertIn("order_deliveries", table_names)
self.assertIn("COUNT(DISTINCT order_no)", schema["metric_guidance"]["order_count"])
self.assertIn("internal company transfer", schema["metric_guidance"]["sales_amount"])
self.assertIn("internal_parties", templates["sales_amount"]["optional_parameters"])
self.assertIn("include_internal_orders", templates["sales_amount"]["optional_parameters"])
self.assertEqual(templates["customer_count"]["required_parameters"], [])
self.assertEqual(templates["product_count"]["required_parameters"], [])
self.assertEqual(templates["customer_summary"]["required_parameters"], ["customer"])
@@ -644,6 +648,108 @@ class BusinessFactsTests(unittest.TestCase):
[("B", 1), ("C", 1)],
)
def test_business_templates_exclude_configured_internal_company_transfer_orders(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "EXT-BJD-001",
"bizdate": "2026-06-03",
"customer_number": "C01",
"customer_name": "外部客户 A",
"totalallamount": "100.00",
"org_name": "深圳市北极大电子科技有限公司",
"billentry": [
{
"id": "L1",
"material_masterid_number": "P01",
"material_masterid_name": "Part 1",
"qty": "2",
"amountandtax": "100.00",
"orderdeliverentry": [{"id": "D1", "d_plandate": "2026-06-10", "d_planqty": "2"}],
}
],
},
{
"id": "1002",
"billno": "INT-JK-BJD-001",
"bizdate": "2026-06-04",
"customer_number": "NB000001",
"customer_name": "深圳市北极大电子科技有限公司",
"totalallamount": "80.00",
"org_name": "东莞市捷康电子科技有限公司",
"billentry": [
{
"id": "L1",
"material_masterid_number": "P01",
"material_masterid_name": "Part 1",
"qty": "4",
"amountandtax": "80.00",
"orderdeliverentry": [{"id": "D2", "d_plandate": "2026-06-11", "d_planqty": "4"}],
}
],
},
{
"id": "1003",
"billno": "EXT-JK-001",
"bizdate": "2026-06-05",
"customer_number": "C02",
"customer_name": "外部客户 B",
"totalallamount": "40.00",
"org_name": "东莞市捷康电子科技有限公司",
"billentry": [
{
"id": "L1",
"material_masterid_number": "P02",
"material_masterid_name": "Part 2",
"qty": "1",
"amountandtax": "40.00",
"orderdeliverentry": [{"id": "D3", "d_plandate": "2026-06-12", "d_planqty": "1"}],
}
],
},
{
"id": "1004",
"billno": "INT-ZC-JK-001",
"bizdate": "2026-06-06",
"customer_number": "NB000002",
"customer_name": "东莞市捷康电子科技有限公司",
"totalallamount": "20.00",
"org_name": "东莞市众创机电有限公司",
"billentry": [
{
"id": "L1",
"material_masterid_number": "P03",
"material_masterid_name": "Part 3",
"qty": "5",
"amountandtax": "20.00",
"orderdeliverentry": [{"id": "D4", "d_plandate": "2026-06-13", "d_planqty": "5"}],
}
],
},
],
synced_at="2026-07-06T09:00:00Z",
)
dedupe_params = {"year": 2026, "month": 6, "internal_parties": ["北极大", "捷康", "众创"]}
count = execute_business_template(self.db_path, "order_count", **dedupe_params)
sales = execute_business_template(self.db_path, "sales_amount", **dedupe_params)
quantity = execute_business_template(self.db_path, "quantity_sum", **dedupe_params)
delivery_qty = execute_business_template(self.db_path, "delivery_plan_quantity", **dedupe_params)
included = execute_business_template(
self.db_path,
"sales_amount",
**dedupe_params,
include_internal_orders=True,
)
self.assertEqual(count["data"]["order_count"], 2)
self.assertEqual(sales["data"]["sales_amount"], 140.0)
self.assertEqual(quantity["data"]["quantity_sum"], 3.0)
self.assertEqual(delivery_qty["data"]["delivery_plan_quantity"], 3.0)
self.assertEqual(included["data"]["sales_amount"], 240.0)
def test_all_business_templates_execute_with_fixture_data(self):
self.seed_template_fixture()
required_values = {
@@ -725,6 +831,94 @@ class BusinessFactsTests(unittest.TestCase):
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_cli_business_template_uses_internal_party_environment_configuration(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "EXT-BJD-001",
"bizdate": "2026-06-03",
"customer_name": "外部客户 A",
"totalallamount": "100.00",
"org_name": "深圳市北极大电子科技有限公司",
},
{
"id": "1002",
"billno": "INT-JK-BJD-001",
"bizdate": "2026-06-04",
"customer_name": "深圳市北极大电子科技有限公司",
"totalallamount": "80.00",
"org_name": "东莞市捷康电子科技有限公司",
},
],
synced_at="2026-07-06T09:00:00Z",
)
from obsidian_wiki.cli import main
template_output = StringIO()
with patch.dict(os.environ, {"OBSIDIAN_BUSINESS_INTERNAL_PARTIES": "北极大,捷康"}):
with redirect_stdout(template_output):
template_code = main(
[
"business",
"template",
"sales_amount",
"--vault",
str(self.vault),
"--year",
"2026",
"--month",
"6",
"--json",
]
)
self.assertEqual(template_code, 0)
self.assertEqual(json.loads(template_output.getvalue())["data"]["sales_amount"], 100.0)
def test_cli_business_query_uses_internal_party_environment_configuration(self):
sync_kingdee_orders(
self.vault,
[
{
"id": "1001",
"billno": "EXT-BJD-001",
"bizdate": "2026-06-03",
"customer_name": "外部客户 A",
"totalallamount": "100.00",
"org_name": "深圳市北极大电子科技有限公司",
},
{
"id": "1002",
"billno": "INT-JK-BJD-001",
"bizdate": "2026-06-04",
"customer_name": "深圳市北极大电子科技有限公司",
"totalallamount": "80.00",
"org_name": "东莞市捷康电子科技有限公司",
},
],
synced_at="2026-07-06T09:00:00Z",
)
from obsidian_wiki.cli import main
query_output = StringIO()
with patch.dict(os.environ, {"OBSIDIAN_BUSINESS_INTERNAL_PARTIES": "北极大,捷康"}):
with redirect_stdout(query_output):
query_code = main(
[
"business",
"query",
"2026-06 sales amount",
"--vault",
str(self.vault),
"--json",
]
)
self.assertEqual(query_code, 0)
self.assertEqual(json.loads(query_output.getvalue())["data"]["sales_amount"], 100.0)
def test_incremental_start_modifytime_uses_cursor_with_lookback_or_initial_days(self):
initial = compute_incremental_start_modifytime(
self.db_path,