From 4c5454da90fcc69a2add804a02353d07c8742e54 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 05:40:54 +0000 Subject: [PATCH] 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) --- .env.example | 11 ++ .skills/wiki-query/SKILL.md | 2 + README.md | 2 + docs/business-facts.md | 5 + obsidian_wiki/business_facts.py | 181 +++++++++++++++++++++++------ obsidian_wiki/cli.py | 55 ++++++++- tests/test_business_facts.py | 194 ++++++++++++++++++++++++++++++++ 7 files changed, 416 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index d13259f..878a7ba 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.skills/wiki-query/SKILL.md b/.skills/wiki-query/SKILL.md index cda79dd..c2e4126 100644 --- a/.skills/wiki-query/SKILL.md +++ b/.skills/wiki-query/SKILL.md @@ -69,10 +69,12 @@ If it is a business facts query: obsidian-wiki business template --vault "$OBSIDIAN_VAULT_PATH" --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" "" --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. diff --git a/README.md b/README.md index fb62cf2..df2d6ea 100644 --- a/README.md +++ b/README.md @@ -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 `/_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 diff --git a/docs/business-facts.md b/docs/business-facts.md index fe225d2..e12b7b1 100644 --- a/docs/business-facts.md +++ b/docs/business-facts.md @@ -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`. diff --git a/obsidian_wiki/business_facts.py b/obsidian_wiki/business_facts.py index 9b16519..c1a3273 100644 --- a/obsidian_wiki/business_facts.py +++ b/obsidian_wiki/business_facts.py @@ -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 = [ { diff --git a/obsidian_wiki/cli.py b/obsidian_wiki/cli.py index 290cb6f..7a64101 100644 --- a/obsidian_wiki/cli.py +++ b/obsidian_wiki/cli.py @@ -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 /_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) diff --git a/tests/test_business_facts.py b/tests/test_business_facts.py index 54e2c82..daebd89 100644 --- a/tests/test_business_facts.py +++ b/tests/test_business_facts.py @@ -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,