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:
@@ -36,6 +36,17 @@ OBSIDIAN_FILE_ASSET_DB=
|
|||||||
# Do not put real app secrets or access tokens in this example file.
|
# Do not put real app secrets or access tokens in this example file.
|
||||||
OBSIDIAN_BUSINESS_FACTS_DB=
|
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) ---
|
# --- Source File MCP Server (optional) ---
|
||||||
#
|
#
|
||||||
# Run with: obsidian-wiki-mcp
|
# Run with: obsidian-wiki-mcp
|
||||||
|
|||||||
@@ -69,10 +69,12 @@ If it is a business facts query:
|
|||||||
obsidian-wiki business template <template_name> --vault "$OBSIDIAN_VAULT_PATH" <parameters> --json
|
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.
|
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:
|
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
|
```bash
|
||||||
obsidian-wiki business sql --vault "$OBSIDIAN_VAULT_PATH" "<SQL>" --json
|
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.
|
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.
|
5. If the CLI/database is unavailable or the question is not a business facts query, continue with the normal wiki retrieval protocol below.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
```bash
|
||||||
# Sync from an exported or API-fetched Kingdee JSON payload.
|
# Sync from an exported or API-fetched Kingdee JSON payload.
|
||||||
scripts/obsidian-wiki-ops.py kingdee sync --input ./kingdee-orders.json
|
scripts/obsidian-wiki-ops.py kingdee sync --input ./kingdee-orders.json
|
||||||
|
|||||||
@@ -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.
|
`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:
|
Template families include:
|
||||||
|
|
||||||
- Order totals: `order_count`, `sales_amount`, `customer_count`, `product_count`, `quantity_sum`, `avg_order_amount`.
|
- 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`.
|
- Ordered quantity: `SUM(order_items.quantity)` by `orders.order_date`.
|
||||||
- Delivery-plan quantity: `SUM(order_deliveries.plan_qty)` by `order_deliveries.plan_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`.
|
- 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.
|
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
|
- no write, schema, attach, pragma, or vacuum statements
|
||||||
- default result limit is 100 rows, capped at 1000
|
- 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
|
## Incremental Sync
|
||||||
|
|
||||||
`scripts/obsidian-wiki-ops.py kingdee sync --incremental` uses Kingdee `start_modifytime`.
|
`scripts/obsidian-wiki-ops.py kingdee sync --incremental` uses Kingdee `start_modifytime`.
|
||||||
|
|||||||
+148
-33
@@ -429,10 +429,11 @@ def business_schema() -> dict[str, Any]:
|
|||||||
],
|
],
|
||||||
"metric_guidance": {
|
"metric_guidance": {
|
||||||
"order_count": "Use COUNT(DISTINCT order_no) for sales-order counts.",
|
"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.",
|
"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.",
|
"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.",
|
"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],
|
"templates": [_template_schema(name) for name in BUSINESS_TEMPLATE_NAMES],
|
||||||
"sql_guardrails": {
|
"sql_guardrails": {
|
||||||
@@ -512,6 +513,9 @@ def _template_schema(name: str) -> dict[str, Any]:
|
|||||||
"orderstatus",
|
"orderstatus",
|
||||||
"closestatus",
|
"closestatus",
|
||||||
"currency",
|
"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]
|
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]]:
|
def _order_filter_sql(params: dict[str, Any], alias: str = "o") -> tuple[list[str], list[Any]]:
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
args: list[Any] = []
|
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):
|
if params.get(param):
|
||||||
clauses.append(f"{field} = ?")
|
clauses.append(f"{field} = ?")
|
||||||
args.append(params[param])
|
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
|
return clauses, args
|
||||||
|
|
||||||
|
|
||||||
@@ -1435,9 +1505,11 @@ def query_business_question(
|
|||||||
now: str | None = None,
|
now: str | None = None,
|
||||||
all_staff: bool = False,
|
all_staff: bool = False,
|
||||||
fallback_sql: str | None = None,
|
fallback_sql: str | None = None,
|
||||||
|
business_params: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
db_path = db_path or business_db_path_for_vault(vault_path)
|
db_path = db_path or business_db_path_for_vault(vault_path)
|
||||||
init_db(db_path)
|
init_db(db_path)
|
||||||
|
params = dict(business_params or {})
|
||||||
q = question.strip()
|
q = question.strip()
|
||||||
lower = q.lower()
|
lower = q.lower()
|
||||||
staff_name = _extract_staff_name(q)
|
staff_name = _extract_staff_name(q)
|
||||||
@@ -1453,6 +1525,7 @@ def query_business_question(
|
|||||||
range_label=range_label,
|
range_label=range_label,
|
||||||
all_staff=include_all_staff,
|
all_staff=include_all_staff,
|
||||||
now=now,
|
now=now,
|
||||||
|
params=params,
|
||||||
)
|
)
|
||||||
if "多少" in q or "几单" in q or "接了" in q or "count" in lower:
|
if "多少" in q or "几单" in q or "接了" in q or "count" in lower:
|
||||||
return staff_order_count(
|
return staff_order_count(
|
||||||
@@ -1463,6 +1536,7 @@ def query_business_question(
|
|||||||
range_label=range_label,
|
range_label=range_label,
|
||||||
all_staff=include_all_staff,
|
all_staff=include_all_staff,
|
||||||
now=now,
|
now=now,
|
||||||
|
params=params,
|
||||||
)
|
)
|
||||||
if "订单" in q or "order" in lower:
|
if "订单" in q or "order" in lower:
|
||||||
return staff_order_list(
|
return staff_order_list(
|
||||||
@@ -1473,6 +1547,7 @@ def query_business_question(
|
|||||||
range_label=range_label,
|
range_label=range_label,
|
||||||
all_staff=include_all_staff,
|
all_staff=include_all_staff,
|
||||||
now=now,
|
now=now,
|
||||||
|
params=params,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1483,20 +1558,28 @@ def query_business_question(
|
|||||||
return no_matching_template(q, db_path, now=now)
|
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:
|
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:
|
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:
|
if "trend" in lower or "趋势" in q:
|
||||||
return customer_order_trend(db_path, q, 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)
|
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)
|
start, end = month_bounds(year, month)
|
||||||
|
where, args = _order_where(params or {}, start, end)
|
||||||
with connect(db_path) as conn:
|
with connect(db_path) as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT COUNT(DISTINCT order_no) AS value FROM orders WHERE order_date >= ? AND order_date < ?",
|
f"SELECT COUNT(DISTINCT o.order_no) AS value FROM orders o WHERE {where}",
|
||||||
(start, end),
|
args,
|
||||||
).fetchone()
|
).fetchone()
|
||||||
count = int(row["value"] or 0)
|
count = int(row["value"] or 0)
|
||||||
last_sync = last_successful_sync(db_path)
|
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)
|
start, end = month_bounds(year, month)
|
||||||
|
where, args = _order_where(params or {}, start, end)
|
||||||
with connect(db_path) as conn:
|
with connect(db_path) as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT COALESCE(SUM(total_amount), 0) AS value FROM orders WHERE order_date >= ? AND order_date < ?",
|
f"SELECT COALESCE(SUM(o.total_amount), 0) AS value FROM orders o WHERE {where}",
|
||||||
(start, end),
|
args,
|
||||||
).fetchone()
|
).fetchone()
|
||||||
amount = float(row["value"] or 0)
|
amount = float(row["value"] or 0)
|
||||||
last_sync = last_successful_sync(db_path)
|
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)
|
start, end = month_bounds(year, month)
|
||||||
|
where, args = _order_where(params or {}, start, end)
|
||||||
with connect(db_path) as conn:
|
with connect(db_path) as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(NULLIF(customer_name, ''), NULLIF(customer_id, ''), 'Unknown') AS customer,
|
SELECT COALESCE(NULLIF(o.customer_name, ''), NULLIF(o.customer_id, ''), 'Unknown') AS customer,
|
||||||
COUNT(DISTINCT order_no) AS order_count,
|
COUNT(DISTINCT o.order_no) AS order_count,
|
||||||
COALESCE(SUM(total_amount), 0) AS sales_amount
|
COALESCE(SUM(o.total_amount), 0) AS sales_amount
|
||||||
FROM orders
|
FROM orders o
|
||||||
WHERE order_date >= ? AND order_date < ?
|
WHERE {where}
|
||||||
GROUP BY customer
|
GROUP BY customer
|
||||||
ORDER BY sales_amount DESC, order_count DESC, customer ASC
|
ORDER BY sales_amount DESC, order_count DESC, customer ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""".format(where=where),
|
||||||
(start, end, limit),
|
(*args, limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
customers = [
|
customers = [
|
||||||
{"customer": row["customer"], "order_count": int(row["order_count"] or 0), "sales_amount": float(row["sales_amount"] or 0)}
|
{"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)
|
customer = _extract_customer_name(question)
|
||||||
start, end = month_bounds(year, month)
|
start, end = month_bounds(year, month)
|
||||||
|
where, args = _order_where(params or {}, start, end)
|
||||||
with connect(db_path) as conn:
|
with connect(db_path) as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT substr(order_date, 1, 7) AS month,
|
SELECT substr(o.order_date, 1, 7) AS month,
|
||||||
COUNT(DISTINCT order_no) AS order_count,
|
COUNT(DISTINCT o.order_no) AS order_count,
|
||||||
COALESCE(SUM(total_amount), 0) AS sales_amount
|
COALESCE(SUM(o.total_amount), 0) AS sales_amount
|
||||||
FROM orders
|
FROM orders o
|
||||||
WHERE order_date >= ? AND order_date < ?
|
WHERE {where}
|
||||||
AND (? = '' OR customer_name LIKE ? OR customer_id LIKE ?)
|
AND (? = '' OR customer_name LIKE ? OR customer_id LIKE ?)
|
||||||
GROUP BY substr(order_date, 1, 7)
|
GROUP BY substr(order_date, 1, 7)
|
||||||
ORDER BY month ASC
|
ORDER BY month ASC
|
||||||
""",
|
""".format(where=where),
|
||||||
(start, end, customer, f"%{customer}%", f"%{customer}%"),
|
(*args, customer, f"%{customer}%", f"%{customer}%"),
|
||||||
).fetchall()
|
).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]
|
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)
|
last_sync = last_successful_sync(db_path)
|
||||||
@@ -1619,19 +1728,21 @@ def staff_order_count(
|
|||||||
range_label: str,
|
range_label: str,
|
||||||
all_staff: bool = False,
|
all_staff: bool = False,
|
||||||
now: str | None = None,
|
now: str | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
|
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:
|
with connect(db_path) as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT COUNT(DISTINCT o.order_no) AS value
|
SELECT COUNT(DISTINCT o.order_no) AS value
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN order_staff s ON s.order_id = o.order_id
|
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 ({_staff_role_placeholders(roles)})
|
||||||
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
|
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()
|
).fetchone()
|
||||||
count = int(row["value"] or 0)
|
count = int(row["value"] or 0)
|
||||||
last_sync = last_successful_sync(db_path)
|
last_sync = last_successful_sync(db_path)
|
||||||
@@ -1666,8 +1777,10 @@ def staff_sales_amount(
|
|||||||
range_label: str,
|
range_label: str,
|
||||||
all_staff: bool = False,
|
all_staff: bool = False,
|
||||||
now: str | None = None,
|
now: str | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
|
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:
|
with connect(db_path) as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -1676,12 +1789,12 @@ def staff_sales_amount(
|
|||||||
SELECT DISTINCT o.order_id, o.total_amount
|
SELECT DISTINCT o.order_id, o.total_amount
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN order_staff s ON s.order_id = o.order_id
|
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 ({_staff_role_placeholders(roles)})
|
||||||
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
|
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()
|
).fetchone()
|
||||||
amount = float(row["value"] or 0)
|
amount = float(row["value"] or 0)
|
||||||
last_sync = last_successful_sync(db_path)
|
last_sync = last_successful_sync(db_path)
|
||||||
@@ -1717,8 +1830,10 @@ def staff_order_list(
|
|||||||
all_staff: bool = False,
|
all_staff: bool = False,
|
||||||
now: str | None = None,
|
now: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
|
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:
|
with connect(db_path) as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -1727,13 +1842,13 @@ def staff_order_list(
|
|||||||
o.currency, o.billstatus, o.orderstatus, o.org_name
|
o.currency, o.billstatus, o.orderstatus, o.org_name
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN order_staff s ON s.order_id = o.order_id
|
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 ({_staff_role_placeholders(roles)})
|
||||||
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
|
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
|
||||||
ORDER BY o.order_date DESC, o.order_no ASC
|
ORDER BY o.order_date DESC, o.order_no ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%", limit),
|
(*args, *roles, f"%{staff_name}%", f"%{staff_name}%", limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
orders = [
|
orders = [
|
||||||
{
|
{
|
||||||
|
|||||||
+54
-1
@@ -29,6 +29,7 @@ from obsidian_wiki.business_facts import (
|
|||||||
query_business_question,
|
query_business_question,
|
||||||
sync_kingdee_orders,
|
sync_kingdee_orders,
|
||||||
)
|
)
|
||||||
|
from obsidian_wiki.config import load_config
|
||||||
from obsidian_wiki.file_assets import (
|
from obsidian_wiki.file_assets import (
|
||||||
clean_config_value,
|
clean_config_value,
|
||||||
db_path_for_vault,
|
db_path_for_vault,
|
||||||
@@ -603,6 +604,7 @@ def cmd_business_query(args: argparse.Namespace) -> int:
|
|||||||
db_path=_resolve_business_db(args, vault_path),
|
db_path=_resolve_business_db(args, vault_path),
|
||||||
all_staff=args.all_staff,
|
all_staff=args.all_staff,
|
||||||
fallback_sql=args.fallback_sql,
|
fallback_sql=args.fallback_sql,
|
||||||
|
business_params=_business_internal_params(args),
|
||||||
)
|
)
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
@@ -666,7 +668,32 @@ def _business_template_params(args: argparse.Namespace) -> dict[str, object]:
|
|||||||
"closestatus",
|
"closestatus",
|
||||||
"currency",
|
"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:
|
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("--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("--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("--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("--json", action="store_true", help="print machine-readable query result")
|
||||||
bqp.add_argument("question", nargs="+", help="question, e.g. '2026-06 order count'")
|
bqp.add_argument("question", nargs="+", help="question, e.g. '2026-06 order count'")
|
||||||
bqp.set_defaults(func=cmd_business_query)
|
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("--orderstatus", help="Kingdee orderstatus filter")
|
||||||
btp.add_argument("--closestatus", help="Kingdee closestatus filter")
|
btp.add_argument("--closestatus", help="Kingdee closestatus filter")
|
||||||
btp.add_argument("--currency", help="currency code 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("--json", action="store_true", help="print machine-readable template result")
|
||||||
btp.add_argument("template_name", help="template name from `business schema`")
|
btp.add_argument("template_name", help="template name from `business schema`")
|
||||||
btp.set_defaults(func=cmd_business_template)
|
btp.set_defaults(func=cmd_business_template)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -599,6 +600,9 @@ class BusinessFactsTests(unittest.TestCase):
|
|||||||
self.assertIn("order_staff", table_names)
|
self.assertIn("order_staff", table_names)
|
||||||
self.assertIn("order_deliveries", table_names)
|
self.assertIn("order_deliveries", table_names)
|
||||||
self.assertIn("COUNT(DISTINCT order_no)", schema["metric_guidance"]["order_count"])
|
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["customer_count"]["required_parameters"], [])
|
||||||
self.assertEqual(templates["product_count"]["required_parameters"], [])
|
self.assertEqual(templates["product_count"]["required_parameters"], [])
|
||||||
self.assertEqual(templates["customer_summary"]["required_parameters"], ["customer"])
|
self.assertEqual(templates["customer_summary"]["required_parameters"], ["customer"])
|
||||||
@@ -644,6 +648,108 @@ class BusinessFactsTests(unittest.TestCase):
|
|||||||
[("B", 1), ("C", 1)],
|
[("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):
|
def test_all_business_templates_execute_with_fixture_data(self):
|
||||||
self.seed_template_fixture()
|
self.seed_template_fixture()
|
||||||
required_values = {
|
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.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)
|
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):
|
def test_incremental_start_modifytime_uses_cursor_with_lookback_or_initial_days(self):
|
||||||
initial = compute_incremental_start_modifytime(
|
initial = compute_incremental_start_modifytime(
|
||||||
self.db_path,
|
self.db_path,
|
||||||
|
|||||||
Reference in New Issue
Block a user