Add order_staff normalization, salesperson query templates, guarded read-only SQL fallback, and incremental Kingdee sync cursor handling. Add a user-level systemd timer manager for daily Kingdee business sync and document the new workflow. Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check.
1373 lines
51 KiB
Python
1373 lines
51 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 date, datetime, timedelta, 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"
|
||
KINGDEE_SYNC_STATE_SOURCE = "kingdee_sales_orders_modifytime"
|
||
|
||
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",
|
||
)
|
||
|
||
ORDER_STAFF_COLUMNS = (
|
||
"order_id",
|
||
"role",
|
||
"staff_id",
|
||
"staff_number",
|
||
"staff_name",
|
||
)
|
||
|
||
STAFF_ROLE_FIELDS = (
|
||
("primary_sales", "operator_id", "operator_operatornumber", "operator_operatorname"),
|
||
("sales_2", "h5a3_basedatafield2_id", "h5a3_basedatafield2_operatornumber", "h5a3_basedatafield2_operatorname"),
|
||
("sales_3", "h5a3_basedatafield3_id", "h5a3_basedatafield3_operatornumber", "h5a3_basedatafield3_operatorname"),
|
||
("sales_4", "h5a3_basedatafield5_id", "h5a3_basedatafield5_operatornumber", "h5a3_basedatafield5_operatorname"),
|
||
("followup", "h5a3_basedatafield4_id", "h5a3_basedatafield4_operatornumber", "h5a3_basedatafield4_operatorname"),
|
||
)
|
||
|
||
ALL_STAFF_ROLES = tuple(role for role, _id_field, _number_field, _name_field in STAFF_ROLE_FIELDS)
|
||
PRIMARY_STAFF_ROLES = ("primary_sales",)
|
||
|
||
ALLOWED_SQL_TABLES = {"orders", "order_items", "order_deliveries", "order_staff", "customers", "products", "sync_runs"}
|
||
FORBIDDEN_SQL_COLUMNS = {"raw_json"}
|
||
SQL_DEFAULT_LIMIT = 100
|
||
SQL_MAX_LIMIT = 1000
|
||
|
||
|
||
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 TABLE IF NOT EXISTS order_staff (
|
||
order_id TEXT NOT NULL,
|
||
role TEXT NOT NULL,
|
||
staff_id TEXT,
|
||
staff_number TEXT,
|
||
staff_name TEXT,
|
||
PRIMARY KEY (order_id, role),
|
||
FOREIGN KEY (order_id) REFERENCES orders(order_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS sync_state (
|
||
source TEXT PRIMARY KEY,
|
||
cursor_value TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
|
||
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);
|
||
CREATE INDEX IF NOT EXISTS idx_order_staff_name_role ON order_staff(staff_name, role);
|
||
CREATE INDEX IF NOT EXISTS idx_order_staff_number_role ON order_staff(staff_number, role);
|
||
"""
|
||
)
|
||
_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,
|
||
cursor_to: 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()
|
||
cursor_to = cursor_to or synced_at
|
||
|
||
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, cursor_to, len(records), upserted, run_id),
|
||
)
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO sync_state (source, cursor_value, updated_at)
|
||
VALUES (?, ?, ?)
|
||
ON CONFLICT(source) DO UPDATE SET
|
||
cursor_value=excluded.cursor_value,
|
||
updated_at=excluded.updated_at
|
||
""",
|
||
(KINGDEE_SYNC_STATE_SOURCE, cursor_to, synced_at),
|
||
)
|
||
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,
|
||
"cursor_from": cursor_from,
|
||
"cursor_to": cursor_to,
|
||
}
|
||
|
||
|
||
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),
|
||
"staff": _staff_roles(record),
|
||
"items": items,
|
||
}
|
||
|
||
|
||
def query_business_question(
|
||
vault_path: str | Path,
|
||
question: str,
|
||
*,
|
||
db_path: Path | None = None,
|
||
now: str | None = None,
|
||
all_staff: bool = False,
|
||
fallback_sql: 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()
|
||
lower = q.lower()
|
||
staff_name = _extract_staff_name(q)
|
||
if staff_name:
|
||
include_all_staff = all_staff or _question_requests_all_staff(q)
|
||
start, end, range_label = parse_date_range(q, now=now)
|
||
if "amount" in lower or "sales" in lower or "销售额" in q or "金额" in q:
|
||
return staff_sales_amount(
|
||
db_path,
|
||
staff_name,
|
||
start,
|
||
end,
|
||
range_label=range_label,
|
||
all_staff=include_all_staff,
|
||
now=now,
|
||
)
|
||
if "多少" in q or "几单" in q or "接了" in q or "count" in lower:
|
||
return staff_order_count(
|
||
db_path,
|
||
staff_name,
|
||
start,
|
||
end,
|
||
range_label=range_label,
|
||
all_staff=include_all_staff,
|
||
now=now,
|
||
)
|
||
if "订单" in q or "order" in lower:
|
||
return staff_order_list(
|
||
db_path,
|
||
staff_name,
|
||
start,
|
||
end,
|
||
range_label=range_label,
|
||
all_staff=include_all_staff,
|
||
now=now,
|
||
)
|
||
|
||
try:
|
||
year, month = parse_month(q, now=now)
|
||
except RuntimeError:
|
||
if fallback_sql:
|
||
return execute_business_sql(db_path, fallback_sql)
|
||
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)
|
||
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 staff_order_count(
|
||
db_path: Path,
|
||
staff_name: str,
|
||
start: str,
|
||
end: str,
|
||
*,
|
||
range_label: str,
|
||
all_staff: bool = False,
|
||
now: str | None = None,
|
||
) -> dict[str, Any]:
|
||
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
|
||
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 < ?
|
||
AND ({_staff_role_placeholders(roles)})
|
||
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
|
||
""",
|
||
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%"),
|
||
).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": "staff_order_count",
|
||
"data": {
|
||
"staff_name": staff_name,
|
||
"staff_roles": list(roles),
|
||
"start_date": start,
|
||
"end_date_exclusive": end,
|
||
"range_label": range_label,
|
||
"order_count": count,
|
||
},
|
||
"last_sync": last_sync,
|
||
"is_stale": is_stale,
|
||
"answer": _answer(
|
||
f"Result: {staff_name} has {count} order(s) for {range_label}.",
|
||
f"Scope: roles {', '.join(roles)}, deduplicated by sales order number, order date from {start} to {end_exclusive_label(end)}.",
|
||
last_sync,
|
||
is_stale=is_stale,
|
||
),
|
||
}
|
||
|
||
|
||
def staff_sales_amount(
|
||
db_path: Path,
|
||
staff_name: str,
|
||
start: str,
|
||
end: str,
|
||
*,
|
||
range_label: str,
|
||
all_staff: bool = False,
|
||
now: str | None = None,
|
||
) -> dict[str, Any]:
|
||
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
|
||
with connect(db_path) as conn:
|
||
row = conn.execute(
|
||
f"""
|
||
SELECT COALESCE(SUM(total_amount), 0) AS value
|
||
FROM (
|
||
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 < ?
|
||
AND ({_staff_role_placeholders(roles)})
|
||
AND (s.staff_name LIKE ? OR s.staff_number LIKE ?)
|
||
)
|
||
""",
|
||
(start, end, *roles, f"%{staff_name}%", f"%{staff_name}%"),
|
||
).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": "staff_sales_amount",
|
||
"data": {
|
||
"staff_name": staff_name,
|
||
"staff_roles": list(roles),
|
||
"start_date": start,
|
||
"end_date_exclusive": end,
|
||
"range_label": range_label,
|
||
"sales_amount": amount,
|
||
},
|
||
"last_sync": last_sync,
|
||
"is_stale": is_stale,
|
||
"answer": _answer(
|
||
f"Result: {staff_name} sales amount for {range_label} is {amount:.2f}.",
|
||
f"Scope: roles {', '.join(roles)}, sum of distinct order total_amount, order date from {start} to {end_exclusive_label(end)}.",
|
||
last_sync,
|
||
is_stale=is_stale,
|
||
),
|
||
}
|
||
|
||
|
||
def staff_order_list(
|
||
db_path: Path,
|
||
staff_name: str,
|
||
start: str,
|
||
end: str,
|
||
*,
|
||
range_label: str,
|
||
all_staff: bool = False,
|
||
now: str | None = None,
|
||
limit: int = 100,
|
||
) -> dict[str, Any]:
|
||
roles = ALL_STAFF_ROLES if all_staff else PRIMARY_STAFF_ROLES
|
||
with connect(db_path) as conn:
|
||
rows = conn.execute(
|
||
f"""
|
||
SELECT DISTINCT o.order_id, o.order_no, o.order_date, o.customer_name,
|
||
o.total_amount, o.total_net_amount, o.total_tax_amount,
|
||
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 < ?
|
||
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),
|
||
).fetchall()
|
||
orders = [
|
||
{
|
||
"order_id": row["order_id"],
|
||
"order_no": row["order_no"],
|
||
"order_date": row["order_date"],
|
||
"customer_name": row["customer_name"],
|
||
"total_amount": float(row["total_amount"] or 0),
|
||
"total_net_amount": float(row["total_net_amount"] or 0),
|
||
"total_tax_amount": float(row["total_tax_amount"] or 0),
|
||
"currency": row["currency"],
|
||
"billstatus": row["billstatus"],
|
||
"orderstatus": row["orderstatus"],
|
||
"org_name": row["org_name"],
|
||
}
|
||
for row in rows
|
||
]
|
||
last_sync = last_successful_sync(db_path)
|
||
is_stale = sync_is_stale(last_sync, now=now)
|
||
return {
|
||
"template": "staff_order_list",
|
||
"data": {
|
||
"staff_name": staff_name,
|
||
"staff_roles": list(roles),
|
||
"start_date": start,
|
||
"end_date_exclusive": end,
|
||
"range_label": range_label,
|
||
"orders": orders,
|
||
},
|
||
"last_sync": last_sync,
|
||
"is_stale": is_stale,
|
||
"answer": _answer(
|
||
f"Result: {staff_name} has {len(orders)} listed order(s) for {range_label}.",
|
||
f"Scope: roles {', '.join(roles)}, order date from {start} to {end_exclusive_label(end)}, limit {limit}.",
|
||
last_sync,
|
||
is_stale=is_stale,
|
||
),
|
||
}
|
||
|
||
|
||
def no_matching_template(question: str, db_path: Path, *, now: str | None = None) -> dict[str, Any]:
|
||
last_sync = last_successful_sync(db_path)
|
||
is_stale = sync_is_stale(last_sync, now=now)
|
||
return {
|
||
"template": "no_matching_template",
|
||
"data": {"question": question},
|
||
"last_sync": last_sync,
|
||
"is_stale": is_stale,
|
||
"answer": _answer(
|
||
"Result: no matching fixed business query template.",
|
||
"Scope: provide a guarded fallback SQL query to run against structured business tables.",
|
||
last_sync,
|
||
is_stale=is_stale,
|
||
),
|
||
}
|
||
|
||
|
||
def execute_business_sql(db_path: Path, sql: str, *, limit: int = SQL_DEFAULT_LIMIT) -> dict[str, Any]:
|
||
init_db(db_path)
|
||
statement = _validate_business_sql(sql)
|
||
safe_limit = max(1, min(int(limit or SQL_DEFAULT_LIMIT), SQL_MAX_LIMIT))
|
||
executed_sql = f"SELECT * FROM ({statement}) LIMIT ?"
|
||
with connect(db_path) as conn:
|
||
conn.set_authorizer(_business_sql_authorizer)
|
||
try:
|
||
cursor = conn.execute(executed_sql, (safe_limit,))
|
||
rows = [dict(row) for row in cursor.fetchall()]
|
||
finally:
|
||
conn.set_authorizer(None)
|
||
columns = list(rows[0].keys()) if rows else []
|
||
return {
|
||
"template": "sql_fallback",
|
||
"sql": statement,
|
||
"limit": safe_limit,
|
||
"columns": columns,
|
||
"rows": rows,
|
||
"row_count": len(rows),
|
||
"answer": f"Result: SQL fallback returned {len(rows)} row(s).",
|
||
}
|
||
|
||
|
||
def _validate_business_sql(sql: str) -> str:
|
||
statement = sql.strip()
|
||
if not statement:
|
||
raise RuntimeError("SQL is empty")
|
||
if statement.endswith(";"):
|
||
statement = statement[:-1].strip()
|
||
if ";" in statement:
|
||
raise RuntimeError("SQL fallback accepts one statement only")
|
||
first = re.match(r"^\s*([A-Za-z]+)", statement)
|
||
keyword = first.group(1).upper() if first else ""
|
||
if keyword == "PRAGMA":
|
||
raise RuntimeError("PRAGMA is not allowed in SQL fallback")
|
||
if keyword not in {"SELECT", "WITH"}:
|
||
raise RuntimeError("SQL fallback only SELECT or WITH ... SELECT statements")
|
||
if re.search(r"\braw_json\b", statement, re.IGNORECASE):
|
||
raise RuntimeError("SQL fallback cannot read raw_json")
|
||
if re.search(r"\braw_snapshots\b", statement, re.IGNORECASE):
|
||
raise RuntimeError("SQL fallback cannot read raw_snapshots")
|
||
forbidden = r"\b(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|ATTACH|DETACH|VACUUM|REINDEX|REPLACE)\b"
|
||
if re.search(forbidden, statement, re.IGNORECASE):
|
||
raise RuntimeError("SQL fallback only SELECT or WITH ... SELECT statements")
|
||
return statement
|
||
|
||
|
||
def _business_sql_authorizer(action: int, arg1: str | None, arg2: str | None, _db: str | None, _trigger: str | None) -> int:
|
||
if action == sqlite3.SQLITE_READ:
|
||
table = str(arg1 or "")
|
||
column = str(arg2 or "")
|
||
if table == "raw_snapshots":
|
||
return sqlite3.SQLITE_DENY
|
||
if column in FORBIDDEN_SQL_COLUMNS:
|
||
return sqlite3.SQLITE_DENY
|
||
if table and table not in ALLOWED_SQL_TABLES:
|
||
return sqlite3.SQLITE_DENY
|
||
return sqlite3.SQLITE_OK
|
||
if action in {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_FUNCTION}:
|
||
return sqlite3.SQLITE_OK
|
||
return sqlite3.SQLITE_DENY
|
||
|
||
|
||
def _staff_role_placeholders(roles: tuple[str, ...]) -> str:
|
||
return " OR ".join("s.role = ?" for _role in roles)
|
||
|
||
|
||
def parse_date_range(text: str, *, now: str | None = None) -> tuple[str, str, str]:
|
||
today = _today(now)
|
||
if "最近一周" in text or "近一周" in text or "最近7天" in text or "近7天" in text:
|
||
start = today - timedelta(days=6)
|
||
end = today + timedelta(days=1)
|
||
return start.isoformat(), end.isoformat(), "最近一周"
|
||
if "今天" in text:
|
||
start = today
|
||
end = today + timedelta(days=1)
|
||
return start.isoformat(), end.isoformat(), "今天"
|
||
if "昨天" in text:
|
||
start = today - timedelta(days=1)
|
||
end = today
|
||
return start.isoformat(), end.isoformat(), "昨天"
|
||
if "本周" in text or "这周" in text:
|
||
start = today - timedelta(days=today.weekday())
|
||
end = start + timedelta(days=7)
|
||
return start.isoformat(), end.isoformat(), "本周"
|
||
if "上周" in text:
|
||
end = today - timedelta(days=today.weekday())
|
||
start = end - timedelta(days=7)
|
||
return start.isoformat(), end.isoformat(), "上周"
|
||
if "本月" in text or "这个月" in text:
|
||
start = today.replace(day=1)
|
||
end = _add_month(start)
|
||
return start.isoformat(), end.isoformat(), "本月"
|
||
try:
|
||
year, month = parse_month(text, now=now)
|
||
start, end = month_bounds(year, month)
|
||
return start, end, f"{year}-{month:02d}"
|
||
except RuntimeError:
|
||
start = today - timedelta(days=6)
|
||
end = today + timedelta(days=1)
|
||
return start.isoformat(), end.isoformat(), "最近一周"
|
||
|
||
|
||
def _today(now: str | None = None) -> date:
|
||
raw = now or utc_now()
|
||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).date()
|
||
|
||
|
||
def _add_month(value: date) -> date:
|
||
if value.month == 12:
|
||
return date(value.year + 1, 1, 1)
|
||
return date(value.year, value.month + 1, 1)
|
||
|
||
|
||
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 compute_incremental_start_modifytime(
|
||
db_path: Path,
|
||
*,
|
||
now: str | None = None,
|
||
lookback_hours: int = 48,
|
||
initial_days: int = 7,
|
||
) -> str:
|
||
init_db(db_path)
|
||
cursor = _sync_state_cursor(db_path) or last_successful_sync(db_path)
|
||
if cursor:
|
||
start = _parse_datetime(cursor) - timedelta(hours=lookback_hours)
|
||
else:
|
||
start = _parse_datetime(now or utc_now()) - timedelta(days=initial_days)
|
||
return _format_kingdee_datetime(start)
|
||
|
||
|
||
def _sync_state_cursor(db_path: Path) -> str:
|
||
with connect(db_path) as conn:
|
||
row = conn.execute(
|
||
"SELECT cursor_value FROM sync_state WHERE source=?",
|
||
(KINGDEE_SYNC_STATE_SOURCE,),
|
||
).fetchone()
|
||
return str(row["cursor_value"] 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_staff WHERE order_id=?", (order["order_id"],))
|
||
staff_columns_sql = ", ".join(ORDER_STAFF_COLUMNS)
|
||
staff_placeholders = ", ".join("?" for _ in ORDER_STAFF_COLUMNS)
|
||
for staff in order["staff"]:
|
||
staff_row = {"order_id": order["order_id"], **staff}
|
||
conn.execute(
|
||
f"""
|
||
INSERT INTO order_staff ({staff_columns_sql})
|
||
VALUES ({staff_placeholders})
|
||
""",
|
||
tuple(staff_row[column] for column in ORDER_STAFF_COLUMNS),
|
||
)
|
||
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 _staff_roles(record: dict[str, Any]) -> list[dict[str, str]]:
|
||
staff: list[dict[str, str]] = []
|
||
for role, id_field, number_field, name_field in STAFF_ROLE_FIELDS:
|
||
staff_id = _text(record.get(id_field))
|
||
staff_number = _text(record.get(number_field))
|
||
staff_name = _text(record.get(name_field))
|
||
if not staff_id and not staff_number and not staff_name:
|
||
continue
|
||
staff.append(
|
||
{
|
||
"role": role,
|
||
"staff_id": staff_id,
|
||
"staff_number": staff_number,
|
||
"staff_name": staff_name,
|
||
}
|
||
)
|
||
return staff
|
||
|
||
|
||
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 _parse_datetime(value: str) -> datetime:
|
||
text = _text(value)
|
||
if not text:
|
||
return datetime.now(timezone.utc).replace(microsecond=0)
|
||
normalized = text.replace("Z", "+00:00")
|
||
if " " in normalized and "T" not in normalized:
|
||
normalized = normalized.replace(" ", "T", 1)
|
||
parsed = datetime.fromisoformat(normalized)
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||
return parsed.astimezone(timezone.utc).replace(tzinfo=None, microsecond=0)
|
||
|
||
|
||
def _format_kingdee_datetime(value: datetime) -> str:
|
||
return value.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
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 _extract_staff_name(question: str) -> str:
|
||
if not any(token in question for token in ("销售员", "销售额", "订单", "接了", "多少单")) and not re.search(
|
||
r"\border(s)?\b|\bsales\b|\bstaff\b", question, re.IGNORECASE
|
||
):
|
||
return ""
|
||
text = question.strip()
|
||
text = re.sub(r"(20\d{2})\s*[-/年.]\s*\d{1,2}\s*月?", " ", text)
|
||
remove_patterns = (
|
||
"最近一周",
|
||
"近一周",
|
||
"最近7天",
|
||
"近7天",
|
||
"今天",
|
||
"昨天",
|
||
"本周",
|
||
"这周",
|
||
"上周",
|
||
"本月",
|
||
"这个月",
|
||
"接了多少单",
|
||
"接多少单",
|
||
"多少单",
|
||
"几单",
|
||
"销售额",
|
||
"金额",
|
||
"订单列表",
|
||
"订单",
|
||
"全部角色",
|
||
"所有销售角色",
|
||
"所有角色",
|
||
"含销售员2",
|
||
"含跟单",
|
||
"包括跟单",
|
||
"包含跟单",
|
||
"主销售员",
|
||
"销售员",
|
||
)
|
||
for pattern in remove_patterns:
|
||
text = text.replace(pattern, " ")
|
||
text = re.sub(r"\b(order|orders|sales|amount|count|staff|all roles|primary)\b", " ", text, flags=re.IGNORECASE)
|
||
text = re.sub(r"[\s,,。::??]+", " ", text).strip()
|
||
candidate = text.split()[0] if text else ""
|
||
if candidate.startswith(("按", "列出", "统计", "查询")):
|
||
return ""
|
||
if re.fullmatch(r"[\u4e00-\u9fff]+", candidate) and len(candidate) > 4:
|
||
return ""
|
||
return candidate
|
||
|
||
|
||
def _question_requests_all_staff(question: str) -> bool:
|
||
return any(
|
||
token in question
|
||
for token in ("全部角色", "所有销售角色", "所有角色", "含销售员2", "含跟单", "包括跟单", "包含跟单")
|
||
)
|
||
|
||
|
||
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)
|