Files
obsidian-wiki/obsidian_wiki/business_facts.py
T
Codex dae51ab570 feat(business): preserve full Kingdee order structure
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.
2026-07-06 20:31:39 +00:00

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)