Store expanded Kingdee sales order header, line, and delivery-plan fields in the business facts database while preserving raw JSON at each level. Add migration-safe columns, a delivery-plan table, coverage for the official response shape, and database documentation. Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests; git diff --check.
847 lines
32 KiB
Python
847 lines
32 KiB
Python
"""Structured business facts for deterministic operational queries.
|
|
|
|
The markdown wiki is for distilled knowledge. This module keeps normalized
|
|
business facts, such as Kingdee sales orders, in SQLite so count and amount
|
|
questions do not depend on LLM-generated SQL or markdown scraping.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from obsidian_wiki.file_assets import clean_config_value
|
|
from obsidian_wiki.kingdee_api import fetch_kingdee_sales_orders, load_kingdee_config, token_cache_path_for_config
|
|
|
|
BUSINESS_DB_RELATIVE_PATH = Path("_meta") / "business-facts.sqlite"
|
|
|
|
ORDER_COLUMNS = (
|
|
"order_id",
|
|
"order_no",
|
|
"order_date",
|
|
"customer_id",
|
|
"customer_name",
|
|
"total_amount",
|
|
"currency",
|
|
"status",
|
|
"updated_at",
|
|
"billstatus",
|
|
"orderstatus",
|
|
"closestatus",
|
|
"org_id",
|
|
"org_number",
|
|
"org_name",
|
|
"dept_id",
|
|
"dept_number",
|
|
"dept_name",
|
|
"operator_id",
|
|
"operator_number",
|
|
"operator_name",
|
|
"total_net_amount",
|
|
"total_tax_amount",
|
|
"total_all_amount",
|
|
"receive_address",
|
|
"comment",
|
|
"raw_json",
|
|
)
|
|
|
|
ORDER_ITEM_COLUMNS = (
|
|
"order_id",
|
|
"line_id",
|
|
"product_id",
|
|
"product_name",
|
|
"quantity",
|
|
"amount",
|
|
"seq",
|
|
"material_model",
|
|
"unit_number",
|
|
"unit_name",
|
|
"price",
|
|
"price_and_tax",
|
|
"amount_and_tax",
|
|
"tax_amount",
|
|
"delivery_date",
|
|
"project_number",
|
|
"project_name",
|
|
"warehouse_number",
|
|
"warehouse_name",
|
|
"raw_json",
|
|
)
|
|
|
|
ORDER_DELIVERY_COLUMNS = (
|
|
"order_id",
|
|
"line_id",
|
|
"delivery_id",
|
|
"plan_date",
|
|
"plan_delivery_date",
|
|
"plan_qty",
|
|
"plan_base_qty",
|
|
"plan_unit_number",
|
|
"plan_unit_name",
|
|
"receive_address",
|
|
"receive_place_number",
|
|
"receive_place_name",
|
|
"remark",
|
|
"transport_lead_time",
|
|
"raw_json",
|
|
)
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def business_db_path_for_vault(vault_path: str | Path) -> Path:
|
|
vault = Path(clean_config_value(str(vault_path))).expanduser()
|
|
return vault / BUSINESS_DB_RELATIVE_PATH
|
|
|
|
|
|
def connect(db_path: Path) -> sqlite3.Connection:
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
return conn
|
|
|
|
|
|
def init_db(db_path: Path) -> None:
|
|
with connect(db_path) as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT,
|
|
cursor_from TEXT,
|
|
cursor_to TEXT,
|
|
records_seen INTEGER NOT NULL DEFAULT 0,
|
|
records_upserted INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS raw_snapshots (
|
|
content_hash TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL,
|
|
retrieved_at TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS customers (
|
|
customer_id TEXT PRIMARY KEY,
|
|
customer_name TEXT,
|
|
updated_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
product_id TEXT PRIMARY KEY,
|
|
product_name TEXT,
|
|
updated_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS orders (
|
|
order_id TEXT PRIMARY KEY,
|
|
order_no TEXT NOT NULL UNIQUE,
|
|
order_date TEXT NOT NULL,
|
|
customer_id TEXT,
|
|
customer_name TEXT,
|
|
total_amount REAL NOT NULL DEFAULT 0,
|
|
currency TEXT,
|
|
status TEXT,
|
|
updated_at TEXT,
|
|
billstatus TEXT,
|
|
orderstatus TEXT,
|
|
closestatus TEXT,
|
|
org_id TEXT,
|
|
org_number TEXT,
|
|
org_name TEXT,
|
|
dept_id TEXT,
|
|
dept_number TEXT,
|
|
dept_name TEXT,
|
|
operator_id TEXT,
|
|
operator_number TEXT,
|
|
operator_name TEXT,
|
|
total_net_amount REAL NOT NULL DEFAULT 0,
|
|
total_tax_amount REAL NOT NULL DEFAULT 0,
|
|
total_all_amount REAL NOT NULL DEFAULT 0,
|
|
receive_address TEXT,
|
|
comment TEXT,
|
|
raw_json TEXT,
|
|
raw_hash TEXT,
|
|
created_at TEXT NOT NULL,
|
|
synced_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS order_items (
|
|
order_id TEXT NOT NULL,
|
|
line_id TEXT NOT NULL,
|
|
product_id TEXT,
|
|
product_name TEXT,
|
|
quantity REAL,
|
|
amount REAL,
|
|
seq INTEGER,
|
|
material_model TEXT,
|
|
unit_number TEXT,
|
|
unit_name TEXT,
|
|
price REAL,
|
|
price_and_tax REAL,
|
|
amount_and_tax REAL,
|
|
tax_amount REAL,
|
|
delivery_date TEXT,
|
|
project_number TEXT,
|
|
project_name TEXT,
|
|
warehouse_number TEXT,
|
|
warehouse_name TEXT,
|
|
raw_json TEXT,
|
|
PRIMARY KEY (order_id, line_id),
|
|
FOREIGN KEY (order_id) REFERENCES orders(order_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS order_deliveries (
|
|
order_id TEXT NOT NULL,
|
|
line_id TEXT NOT NULL,
|
|
delivery_id TEXT NOT NULL,
|
|
plan_date TEXT,
|
|
plan_delivery_date TEXT,
|
|
plan_qty REAL,
|
|
plan_base_qty REAL,
|
|
plan_unit_number TEXT,
|
|
plan_unit_name TEXT,
|
|
receive_address TEXT,
|
|
receive_place_number TEXT,
|
|
receive_place_name TEXT,
|
|
remark TEXT,
|
|
transport_lead_time INTEGER,
|
|
raw_json TEXT,
|
|
PRIMARY KEY (order_id, line_id, delivery_id),
|
|
FOREIGN KEY (order_id, line_id) REFERENCES order_items(order_id, line_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_orders_order_date ON orders(order_date);
|
|
CREATE INDEX IF NOT EXISTS idx_orders_customer_name ON orders(customer_name);
|
|
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
|
|
CREATE INDEX IF NOT EXISTS idx_order_deliveries_plan_date ON order_deliveries(plan_date);
|
|
"""
|
|
)
|
|
_ensure_columns(
|
|
conn,
|
|
"orders",
|
|
{
|
|
"billstatus": "TEXT",
|
|
"orderstatus": "TEXT",
|
|
"closestatus": "TEXT",
|
|
"org_id": "TEXT",
|
|
"org_number": "TEXT",
|
|
"org_name": "TEXT",
|
|
"dept_id": "TEXT",
|
|
"dept_number": "TEXT",
|
|
"dept_name": "TEXT",
|
|
"operator_id": "TEXT",
|
|
"operator_number": "TEXT",
|
|
"operator_name": "TEXT",
|
|
"total_net_amount": "REAL NOT NULL DEFAULT 0",
|
|
"total_tax_amount": "REAL NOT NULL DEFAULT 0",
|
|
"total_all_amount": "REAL NOT NULL DEFAULT 0",
|
|
"receive_address": "TEXT",
|
|
"comment": "TEXT",
|
|
"raw_json": "TEXT",
|
|
},
|
|
)
|
|
_ensure_columns(
|
|
conn,
|
|
"order_items",
|
|
{
|
|
"seq": "INTEGER",
|
|
"material_model": "TEXT",
|
|
"unit_number": "TEXT",
|
|
"unit_name": "TEXT",
|
|
"price": "REAL",
|
|
"price_and_tax": "REAL",
|
|
"amount_and_tax": "REAL",
|
|
"tax_amount": "REAL",
|
|
"delivery_date": "TEXT",
|
|
"project_number": "TEXT",
|
|
"project_name": "TEXT",
|
|
"warehouse_number": "TEXT",
|
|
"warehouse_name": "TEXT",
|
|
"raw_json": "TEXT",
|
|
},
|
|
)
|
|
|
|
|
|
def _ensure_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None:
|
|
existing = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
|
for name, column_type in columns.items():
|
|
if name not in existing:
|
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {column_type}")
|
|
|
|
|
|
def load_kingdee_payload_from_json(path: str | Path) -> tuple[list[dict[str, Any]], Any]:
|
|
payload = json.loads(Path(path).expanduser().read_text(encoding="utf-8"))
|
|
return extract_orders_from_payload(payload), payload
|
|
|
|
|
|
def fetch_kingdee_payload(
|
|
*,
|
|
api_url: str | None = None,
|
|
token: str | None = None,
|
|
since: str | None = None,
|
|
config_path: str | Path | None = None,
|
|
config_overrides: dict[str, str] | None = None,
|
|
filters: dict[str, Any] | None = None,
|
|
page_size: int | None = None,
|
|
max_pages: int = 100,
|
|
) -> tuple[list[dict[str, Any]], Any]:
|
|
config = load_kingdee_config(config_path)
|
|
config.update(config_overrides or {})
|
|
if api_url:
|
|
config["KINGDEE_SALORDER_API_URL"] = api_url
|
|
if token:
|
|
config["KINGDEE_ACCESS_TOKEN"] = token
|
|
safe_filters = dict(filters or {})
|
|
if since and not safe_filters.get("start_modifytime"):
|
|
safe_filters["start_modifytime"] = since
|
|
return fetch_kingdee_sales_orders(
|
|
config,
|
|
filters=safe_filters,
|
|
token_cache_path=token_cache_path_for_config(config_path),
|
|
page_size=page_size,
|
|
max_pages=max_pages,
|
|
)
|
|
|
|
|
|
def extract_orders_from_payload(payload: Any) -> list[dict[str, Any]]:
|
|
if isinstance(payload, list):
|
|
return [item for item in payload if isinstance(item, dict)]
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("Kingdee payload must be a JSON object or array")
|
|
for key in ("orders", "rows", "data", "records", "items"):
|
|
value = payload.get(key)
|
|
if isinstance(value, list):
|
|
return [item for item in value if isinstance(item, dict)]
|
|
if isinstance(value, dict):
|
|
nested = extract_orders_from_payload(value)
|
|
if nested:
|
|
return nested
|
|
return []
|
|
|
|
|
|
def sync_kingdee_orders(
|
|
vault_path: str | Path,
|
|
records: list[dict[str, Any]],
|
|
*,
|
|
source_payload: Any | None = None,
|
|
synced_at: str | None = None,
|
|
db_path: Path | None = None,
|
|
cursor_from: str | None = None,
|
|
) -> dict[str, Any]:
|
|
db_path = db_path or business_db_path_for_vault(vault_path)
|
|
init_db(db_path)
|
|
synced_at = synced_at or utc_now()
|
|
payload = source_payload if source_payload is not None else {"orders": records}
|
|
payload_json = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
raw_hash = "sha256:" + hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
|
|
|
|
with connect(db_path) as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO sync_runs (source, status, started_at, cursor_from)
|
|
VALUES (?, 'running', ?, ?)
|
|
""",
|
|
("kingdee", synced_at, cursor_from),
|
|
)
|
|
run_id = int(cursor.lastrowid)
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO raw_snapshots (content_hash, source, retrieved_at, payload_json)
|
|
VALUES (?, 'kingdee', ?, ?)
|
|
""",
|
|
(raw_hash, synced_at, payload_json),
|
|
)
|
|
upserted = 0
|
|
for record in records:
|
|
normalized = normalize_order(record)
|
|
_upsert_order(conn, normalized, raw_hash=raw_hash, synced_at=synced_at)
|
|
upserted += 1
|
|
conn.execute(
|
|
"""
|
|
UPDATE sync_runs
|
|
SET status='success', finished_at=?, cursor_to=?, records_seen=?, records_upserted=?
|
|
WHERE id=?
|
|
""",
|
|
(synced_at, synced_at, len(records), upserted, run_id),
|
|
)
|
|
except Exception as exc:
|
|
conn.execute(
|
|
"""
|
|
UPDATE sync_runs
|
|
SET status='failed', finished_at=?, records_seen=?, error=?
|
|
WHERE id=?
|
|
""",
|
|
(synced_at, len(records), str(exc), run_id),
|
|
)
|
|
raise
|
|
|
|
return {
|
|
"db_path": str(db_path),
|
|
"run_id": run_id,
|
|
"records_seen": len(records),
|
|
"records_upserted": upserted,
|
|
"raw_hash": raw_hash,
|
|
"synced_at": synced_at,
|
|
}
|
|
|
|
|
|
def normalize_order(record: dict[str, Any]) -> dict[str, Any]:
|
|
order_id = _text(_first(record, "order_id", "id", "fid", "FID", "FId", "f_id"))
|
|
order_no = _text(_first(record, "order_no", "billno", "bill_no", "billNo", "FBillNo", "f_bill_no", "number"))
|
|
if not order_id:
|
|
order_id = order_no
|
|
if not order_no:
|
|
order_no = order_id
|
|
order_date = _date_text(_first(record, "order_date", "bizdate", "date", "FDate", "orderDate", "bill_date"))
|
|
if not order_no or not order_date:
|
|
raise RuntimeError("Kingdee order record must include order_no/order_id and order_date")
|
|
|
|
customer = record.get("customer") if isinstance(record.get("customer"), dict) else {}
|
|
customer_id = _text(_first(record, "customer_number", "customer_id", "customerId", "FCustomerId") or _first(customer, "number", "id"))
|
|
customer_name = _text(_first(record, "customer_name", "customerName", "FCustomerName") or _first(customer, "name"))
|
|
items = _items(record)
|
|
total_amount_value = _first(record, "total_amount", "totalallamount", "totalamount", "amount", "amount_total", "FAllAmount", "FTotalAmount")
|
|
total_all_amount = _float(total_amount_value)
|
|
explicit_total_all_amount = _first(record, "totalallamount", "total_all_amount")
|
|
return {
|
|
"order_id": order_id,
|
|
"order_no": order_no,
|
|
"order_date": order_date,
|
|
"customer_id": customer_id,
|
|
"customer_name": customer_name,
|
|
"total_amount": total_all_amount,
|
|
"currency": _text(_first(record, "settlecurrency_number", "currency_number", "currency", "FCurrency")),
|
|
"status": _status_text(record),
|
|
"updated_at": _date_time_text(_first(record, "updated_at", "modifytime", "updatedAt", "FModifyDate", "modify_date")),
|
|
"billstatus": _text(record.get("billstatus")),
|
|
"orderstatus": _text(record.get("orderstatus")),
|
|
"closestatus": _text(record.get("closestatus")),
|
|
"org_id": _text(record.get("org_id")),
|
|
"org_number": _text(record.get("org_number")),
|
|
"org_name": _text(record.get("org_name")),
|
|
"dept_id": _text(record.get("dept_id")),
|
|
"dept_number": _text(record.get("dept_number")),
|
|
"dept_name": _text(record.get("dept_name")),
|
|
"operator_id": _text(record.get("operator_id")),
|
|
"operator_number": _text(_first(record, "operator_operatornumber", "operator_number")),
|
|
"operator_name": _text(_first(record, "operator_operatorname", "operator_name")),
|
|
"total_net_amount": _float(_first(record, "totalamount", "total_net_amount")),
|
|
"total_tax_amount": _float(_first(record, "totaltaxamount", "total_tax_amount")),
|
|
"total_all_amount": _float(explicit_total_all_amount) if explicit_total_all_amount is not None else total_all_amount,
|
|
"receive_address": _text(_first(record, "receiveaddress", "receive_address")),
|
|
"comment": _text(record.get("comment")),
|
|
"raw_json": _json_dumps(record),
|
|
"items": items,
|
|
}
|
|
|
|
|
|
def query_business_question(
|
|
vault_path: str | Path,
|
|
question: str,
|
|
*,
|
|
db_path: Path | None = None,
|
|
now: str | None = None,
|
|
) -> dict[str, Any]:
|
|
db_path = db_path or business_db_path_for_vault(vault_path)
|
|
init_db(db_path)
|
|
q = question.strip()
|
|
year, month = parse_month(q, now=now)
|
|
lower = q.lower()
|
|
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)
|
|
if "amount" in lower or "sales" in lower or "销售额" in q or "金额" in q:
|
|
return monthly_sales_amount(db_path, year, month, now=now)
|
|
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)
|
|
|
|
|
|
def monthly_order_count(db_path: Path, year: int, month: int, *, now: str | None = None) -> dict[str, Any]:
|
|
start, end = month_bounds(year, month)
|
|
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),
|
|
).fetchone()
|
|
count = int(row["value"] or 0)
|
|
last_sync = last_successful_sync(db_path)
|
|
is_stale = sync_is_stale(last_sync, now=now)
|
|
return {
|
|
"template": "monthly_order_count",
|
|
"data": {"year": year, "month": month, "order_count": count},
|
|
"last_sync": last_sync,
|
|
"is_stale": is_stale,
|
|
"answer": _answer(
|
|
f"Result: {year}-{month:02d} has {count} order(s).",
|
|
f"Scope: deduplicated by sales order number, order date from {start} to {end_exclusive_label(end)}.",
|
|
last_sync,
|
|
is_stale=is_stale,
|
|
),
|
|
}
|
|
|
|
|
|
def monthly_sales_amount(db_path: Path, year: int, month: int, *, now: str | None = None) -> dict[str, Any]:
|
|
start, end = month_bounds(year, month)
|
|
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),
|
|
).fetchone()
|
|
amount = float(row["value"] or 0)
|
|
last_sync = last_successful_sync(db_path)
|
|
is_stale = sync_is_stale(last_sync, now=now)
|
|
return {
|
|
"template": "monthly_sales_amount",
|
|
"data": {"year": year, "month": month, "sales_amount": amount},
|
|
"last_sync": last_sync,
|
|
"is_stale": is_stale,
|
|
"answer": _answer(
|
|
f"Result: {year}-{month:02d} sales amount is {amount:.2f}.",
|
|
f"Scope: sum of order total_amount, order date from {start} to {end_exclusive_label(end)}.",
|
|
last_sync,
|
|
is_stale=is_stale,
|
|
),
|
|
}
|
|
|
|
|
|
def monthly_top_customers(db_path: Path, year: int, month: int, *, limit: int = 5, now: str | None = None) -> dict[str, Any]:
|
|
start, end = month_bounds(year, month)
|
|
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 < ?
|
|
GROUP BY customer
|
|
ORDER BY sales_amount DESC, order_count DESC, customer ASC
|
|
LIMIT ?
|
|
""",
|
|
(start, end, limit),
|
|
).fetchall()
|
|
customers = [
|
|
{"customer": row["customer"], "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)
|
|
is_stale = sync_is_stale(last_sync, now=now)
|
|
lines = ", ".join(f"{item['customer']} ({item['order_count']} orders, {item['sales_amount']:.2f})" for item in customers) or "no customers"
|
|
return {
|
|
"template": "monthly_top_customers",
|
|
"data": {"year": year, "month": month, "customers": customers},
|
|
"last_sync": last_sync,
|
|
"is_stale": is_stale,
|
|
"answer": _answer(
|
|
f"Result: {year}-{month:02d} top customers: {lines}.",
|
|
f"Scope: ranked by sales amount, order date from {start} to {end_exclusive_label(end)}.",
|
|
last_sync,
|
|
is_stale=is_stale,
|
|
),
|
|
}
|
|
|
|
|
|
def customer_order_trend(db_path: Path, question: str, year: int, month: int, *, now: str | None = None) -> dict[str, Any]:
|
|
customer = _extract_customer_name(question)
|
|
start, end = month_bounds(year, month)
|
|
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 < ?
|
|
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}%"),
|
|
).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)
|
|
is_stale = sync_is_stale(last_sync, now=now)
|
|
return {
|
|
"template": "customer_order_trend",
|
|
"data": {"customer": customer, "year": year, "month": month, "trend": trend},
|
|
"last_sync": last_sync,
|
|
"is_stale": is_stale,
|
|
"answer": _answer(
|
|
f"Result: customer trend for {customer or 'all customers'} in {year}-{month:02d}: {trend}.",
|
|
f"Scope: grouped by order month, order date from {start} to {end_exclusive_label(end)}.",
|
|
last_sync,
|
|
is_stale=is_stale,
|
|
),
|
|
}
|
|
|
|
|
|
def parse_month(text: str, *, now: str | None = None) -> tuple[int, int]:
|
|
match = re.search(r"(20\d{2})\s*[-/年.]\s*(\d{1,2})", text)
|
|
if match:
|
|
year = int(match.group(1))
|
|
month = int(match.group(2))
|
|
else:
|
|
month_match = re.search(r"(?<!\d)(\d{1,2})\s*月", text)
|
|
if not month_match:
|
|
raise RuntimeError("question must include a month, such as 2026-06 or 2026年6月")
|
|
year = int((now or utc_now())[:4])
|
|
month = int(month_match.group(1))
|
|
if month < 1 or month > 12:
|
|
raise RuntimeError(f"invalid month: {month}")
|
|
return year, month
|
|
|
|
|
|
def month_bounds(year: int, month: int) -> tuple[str, str]:
|
|
start = f"{year:04d}-{month:02d}-01"
|
|
if month == 12:
|
|
end = f"{year + 1:04d}-01-01"
|
|
else:
|
|
end = f"{year:04d}-{month + 1:02d}-01"
|
|
return start, end
|
|
|
|
|
|
def end_exclusive_label(end: str) -> str:
|
|
return f"before {end}"
|
|
|
|
|
|
def last_successful_sync(db_path: Path) -> str:
|
|
with connect(db_path) as conn:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT finished_at
|
|
FROM sync_runs
|
|
WHERE source='kingdee' AND status='success'
|
|
ORDER BY finished_at DESC, id DESC
|
|
LIMIT 1
|
|
"""
|
|
).fetchone()
|
|
return str(row["finished_at"] or "") if row else ""
|
|
|
|
|
|
def sync_is_stale(last_sync: str, *, now: str | None = None) -> bool:
|
|
if not last_sync:
|
|
return True
|
|
return last_sync[:10] < (now or utc_now())[:10]
|
|
|
|
|
|
def _answer(result: str, scope: str, last_sync: str, *, is_stale: bool) -> str:
|
|
parts = [result, scope, "Data source: Kingdee API."]
|
|
if last_sync:
|
|
parts.append(f"Last sync: {last_sync}.")
|
|
if is_stale:
|
|
parts.append(f"Note: result may not include data after {last_sync}.")
|
|
else:
|
|
parts.append("Last sync: never.")
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _upsert_order(conn: sqlite3.Connection, order: dict[str, Any], *, raw_hash: str, synced_at: str) -> None:
|
|
customer_id = order["customer_id"]
|
|
product_seen_at = order["updated_at"] or synced_at
|
|
if customer_id:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO customers (customer_id, customer_name, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(customer_id) DO UPDATE SET
|
|
customer_name=excluded.customer_name,
|
|
updated_at=excluded.updated_at
|
|
""",
|
|
(customer_id, order["customer_name"], product_seen_at),
|
|
)
|
|
order_columns_sql = ", ".join(ORDER_COLUMNS)
|
|
order_placeholders = ", ".join("?" for _ in (*ORDER_COLUMNS, "raw_hash", "created_at", "synced_at"))
|
|
order_updates = ",\n ".join(f"{column}=excluded.{column}" for column in ORDER_COLUMNS if column != "order_id")
|
|
conn.execute(
|
|
f"""
|
|
INSERT INTO orders ({order_columns_sql}, raw_hash, created_at, synced_at)
|
|
VALUES ({order_placeholders})
|
|
ON CONFLICT(order_id) DO UPDATE SET
|
|
{order_updates},
|
|
raw_hash=excluded.raw_hash,
|
|
synced_at=excluded.synced_at
|
|
""",
|
|
tuple(order[column] for column in ORDER_COLUMNS) + (raw_hash, synced_at, synced_at),
|
|
)
|
|
conn.execute("DELETE FROM order_deliveries WHERE order_id=?", (order["order_id"],))
|
|
conn.execute("DELETE FROM order_items WHERE order_id=?", (order["order_id"],))
|
|
item_columns_sql = ", ".join(ORDER_ITEM_COLUMNS)
|
|
item_placeholders = ", ".join("?" for _ in ORDER_ITEM_COLUMNS)
|
|
delivery_columns_sql = ", ".join(ORDER_DELIVERY_COLUMNS)
|
|
delivery_placeholders = ", ".join("?" for _ in ORDER_DELIVERY_COLUMNS)
|
|
for item in order["items"]:
|
|
if item["product_id"]:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO products (product_id, product_name, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(product_id) DO UPDATE SET
|
|
product_name=excluded.product_name,
|
|
updated_at=excluded.updated_at
|
|
""",
|
|
(item["product_id"], item["product_name"], product_seen_at),
|
|
)
|
|
item_row = {"order_id": order["order_id"], **item}
|
|
conn.execute(
|
|
f"""
|
|
INSERT INTO order_items ({item_columns_sql})
|
|
VALUES ({item_placeholders})
|
|
""",
|
|
tuple(item_row[column] for column in ORDER_ITEM_COLUMNS),
|
|
)
|
|
for delivery in item["deliveries"]:
|
|
delivery_row = {"order_id": order["order_id"], "line_id": item["line_id"], **delivery}
|
|
conn.execute(
|
|
f"""
|
|
INSERT INTO order_deliveries ({delivery_columns_sql})
|
|
VALUES ({delivery_placeholders})
|
|
""",
|
|
tuple(delivery_row[column] for column in ORDER_DELIVERY_COLUMNS),
|
|
)
|
|
|
|
|
|
def _items(record: dict[str, Any]) -> list[dict[str, Any]]:
|
|
raw_items = None
|
|
for key in ("items", "billentry", "entries", "order_items", "FEntity"):
|
|
if isinstance(record.get(key), list):
|
|
raw_items = record[key]
|
|
break
|
|
if raw_items is None:
|
|
raw_items = []
|
|
items = []
|
|
for index, raw in enumerate(raw_items, start=1):
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
product = raw.get("product") if isinstance(raw.get("product"), dict) else {}
|
|
product_id = _text(_first(raw, "material_masterid_number", "product_id", "productId", "material_id", "FMaterialId") or _first(product, "number", "id"))
|
|
product_name = _text(_first(raw, "material_masterid_name", "product_name", "productName", "material_name", "FMaterialName") or _first(product, "name"))
|
|
line_id = _text(_first(raw, "line_id", "id", "line_no", "entry_id", "FEntryID", "seq")) or str(index)
|
|
items.append(
|
|
{
|
|
"line_id": line_id,
|
|
"product_id": product_id,
|
|
"product_name": product_name,
|
|
"quantity": _float(_first(raw, "quantity", "qty", "FQty")),
|
|
"amount": _float(_first(raw, "amount", "line_amount", "FAmount", "amountandtax")),
|
|
"seq": _int(_first(raw, "seq", "line_no")),
|
|
"material_model": _text(_first(raw, "material_masterid_modelnum", "material_model", "model")),
|
|
"unit_number": _text(_first(raw, "unit_number", "unitNumber", "FUnitNumber")),
|
|
"unit_name": _text(_first(raw, "unit_name", "unitName", "FUnitName")),
|
|
"price": _float(_first(raw, "price", "FPrice")),
|
|
"price_and_tax": _float(_first(raw, "priceandtax", "price_and_tax")),
|
|
"amount_and_tax": _float(_first(raw, "amountandtax", "amount_and_tax")),
|
|
"tax_amount": _float(_first(raw, "taxamount", "tax_amount")),
|
|
"delivery_date": _date_text(_first(raw, "deliverydate", "delivery_date")),
|
|
"project_number": _text(_first(raw, "project_number", "projectNumber")),
|
|
"project_name": _text(_first(raw, "project_name", "projectName")),
|
|
"warehouse_number": _text(_first(raw, "warehouse_number", "warehouseNumber")),
|
|
"warehouse_name": _text(_first(raw, "warehouse_name", "warehouseName")),
|
|
"raw_json": _json_dumps(raw),
|
|
"deliveries": _deliveries(raw),
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def _deliveries(item: dict[str, Any]) -> list[dict[str, Any]]:
|
|
raw_deliveries = None
|
|
for key in ("orderdeliverentry", "deliveries", "delivery_entries", "plan_entries"):
|
|
if isinstance(item.get(key), list):
|
|
raw_deliveries = item[key]
|
|
break
|
|
if raw_deliveries is None:
|
|
raw_deliveries = []
|
|
deliveries = []
|
|
for index, raw in enumerate(raw_deliveries, start=1):
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
deliveries.append(
|
|
{
|
|
"delivery_id": _text(_first(raw, "id", "delivery_id", "entry_id")) or str(index),
|
|
"plan_date": _date_text(_first(raw, "d_plandate", "plan_date")),
|
|
"plan_delivery_date": _date_text(_first(raw, "d_plandeliverydate", "plan_delivery_date")),
|
|
"plan_qty": _float(_first(raw, "d_planqty", "plan_qty")),
|
|
"plan_base_qty": _float(_first(raw, "d_planbaseqty", "plan_base_qty")),
|
|
"plan_unit_number": _text(_first(raw, "d_planunit_number", "plan_unit_number")),
|
|
"plan_unit_name": _text(_first(raw, "d_planunit_name", "plan_unit_name")),
|
|
"receive_address": _text(_first(raw, "d_receiveaddress", "receive_address")),
|
|
"receive_place_number": _text(_first(raw, "d_receiveaddressf7_number", "receive_place_number")),
|
|
"receive_place_name": _text(_first(raw, "d_receiveaddressf7_name", "receive_place_name")),
|
|
"remark": _text(_first(raw, "d_remark", "remark")),
|
|
"transport_lead_time": _int(_first(raw, "d_transportleadtime", "transport_lead_time")),
|
|
"raw_json": _json_dumps(raw),
|
|
}
|
|
)
|
|
return deliveries
|
|
|
|
|
|
def _first(record: dict[str, Any], *keys: str) -> Any:
|
|
for key in keys:
|
|
if key in record and record[key] not in (None, ""):
|
|
return record[key]
|
|
return None
|
|
|
|
|
|
def _text(value: Any) -> str:
|
|
return "" if value is None else str(value).strip()
|
|
|
|
|
|
def _date_text(value: Any) -> str:
|
|
text = _text(value)
|
|
return text[:10] if text else ""
|
|
|
|
|
|
def _date_time_text(value: Any) -> str:
|
|
return _text(value)
|
|
|
|
|
|
def _float(value: Any) -> float:
|
|
if value in (None, ""):
|
|
return 0.0
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def _int(value: Any) -> int | None:
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _json_dumps(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
|
|
|
|
|
def _extract_customer_name(question: str) -> str:
|
|
match = re.search(r"customer\s+(.+?)\s+trend", question, re.IGNORECASE)
|
|
return match.group(1).strip() if match else ""
|
|
|
|
|
|
def _status_text(record: dict[str, Any]) -> str:
|
|
parts = []
|
|
for key in ("billstatus", "orderstatus", "closestatus", "status", "document_status", "FDocumentStatus"):
|
|
value = _text(record.get(key))
|
|
if value:
|
|
parts.append(f"{key}={value}")
|
|
return ";".join(parts)
|