#!/usr/bin/env python3 import argparse import json import os import subprocess import sys import time import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Dict, List, Tuple DEFAULT_APP_TOKEN = "SZ4ebCKU3a9OdfspetxcSeg1n4g" DEFAULT_TABLE_ID = "tblzuMy85WiRAUX5" DEFAULT_TABLE_LINK = ( "https://acnuoqgha8fk.feishu.cn/wiki/" "RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI" ) OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json" REQUIRED_FIELDS = [ "记录类型", "批次ID", "报表时间", "统计周期天数", "补货提前期天数", "预警阈值天数", "在售SKU数", "风险SKU数", "紧急SKU数", "预警SKU数", "SKU编码", "SKU名称", "SPU", "风险等级", "预计缺货天数", "建议补货件数", "可用库存", "近7天发货件数", "近30天发货件数", "近30天日均发货", "渠道", "店铺", "采购", "运营", "工厂", "仓库", ] def _http_json(method: str, url: str, headers: Dict[str, str], payload: Dict = None) -> Dict: data = None req_headers = dict(headers) if payload is not None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req_headers["Content-Type"] = "application/json; charset=utf-8" req = urllib.request.Request(url, data=data, headers=req_headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read().decode("utf-8") if not raw: return {} return json.loads(raw) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="ignore") raise RuntimeError(f"HTTP {e.code} {url}: {body[:1000]}") from e except Exception as e: raise RuntimeError(f"request failed {method} {url}: {e}") from e def _load_feishu_app() -> Tuple[str, str]: app_id = os.getenv("FEISHU_APP_ID", "").strip() app_secret = os.getenv("FEISHU_APP_SECRET", "").strip() if app_id and app_secret: return app_id, app_secret cfg_path = Path(OPENCLAW_CONFIG) if not cfg_path.exists(): raise RuntimeError("missing feishu credentials (env and openclaw.json both unavailable)") cfg = json.loads(cfg_path.read_text(encoding="utf-8")) ch = (cfg.get("channels") or {}).get("feishu") or {} app_id = (ch.get("appId") or "").strip() app_secret = (ch.get("appSecret") or "").strip() if not app_id or not app_secret: raise RuntimeError("feishu appId/appSecret is empty in openclaw.json") return app_id, app_secret def _tenant_token(app_id: str, app_secret: str) -> str: url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" res = _http_json( "POST", url, headers={}, payload={"app_id": app_id, "app_secret": app_secret}, ) if res.get("code") != 0: raise RuntimeError(f"feishu auth failed: {res}") token = res.get("tenant_access_token") if not token: raise RuntimeError("feishu auth succeeded but tenant_access_token missing") return token def _parse_company_rows(output: str) -> Dict[int, Dict]: result_sets: Dict[int, Dict] = {} current_idx = None current_cols: List[str] = [] current_rows: List[Dict] = [] def flush(): nonlocal current_idx, current_cols, current_rows if current_idx is not None: result_sets[current_idx] = {"columns": current_cols, "rows": current_rows} current_idx = None current_cols = [] current_rows = [] for raw_line in output.splitlines(): line = raw_line.strip() if not line: continue if line.startswith("RESULT_SET "): flush() current_idx = int(line.split()[1]) continue if line.startswith("COLUMNS:"): cols = line.split(":", 1)[1].strip() current_cols = [c.strip() for c in cols.split(",")] continue if line.startswith("{") and line.endswith("}"): try: current_rows.append(json.loads(line)) except Exception: pass continue flush() return result_sets def _run_stock_risk(days: int, lead_days: int, warn_days: int, limit: int) -> Tuple[Dict, List[Dict]]: cmd = [ "stock-risk-alerts", "--days", str(days), "--lead-days", str(lead_days), "--warn-days", str(warn_days), "--limit", str(limit), ] proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: err = (proc.stderr or proc.stdout or "stock-risk-alerts failed").strip() raise RuntimeError(err) parsed = _parse_company_rows(proc.stdout or "") summary_rows = parsed.get(1, {}).get("rows", []) detail_rows = parsed.get(2, {}).get("rows", []) if not summary_rows: raise RuntimeError("stock-risk-alerts returned no summary rows") return summary_rows[0], detail_rows def _list_fields(token: str, app_token: str, table_id: str) -> Dict[str, str]: url = ( f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" "?page_size=500" ) res = _http_json("GET", url, headers={"Authorization": f"Bearer {token}"}) if res.get("code") != 0: raise RuntimeError(f"list fields failed: {res}") items = (((res.get("data") or {}).get("items")) or []) out: Dict[str, str] = {} for it in items: name = it.get("field_name") fid = it.get("field_id") if name and fid: out[name] = fid return out def _create_field(token: str, app_token: str, table_id: str, field_name: str) -> None: url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" payload = {"field_name": field_name, "type": 1} res = _http_json("POST", url, headers={"Authorization": f"Bearer {token}"}, payload=payload) if res.get("code") != 0: raise RuntimeError(f"create field failed ({field_name}): {res}") def _ensure_fields(token: str, app_token: str, table_id: str) -> None: fields = _list_fields(token, app_token, table_id) for name in REQUIRED_FIELDS: if name not in fields: _create_field(token, app_token, table_id, name) # wait a bit for schema consistency time.sleep(0.5) def _create_record(token: str, app_token: str, table_id: str, fields: Dict[str, str]) -> str: url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" payload = {"fields": fields} res = _http_json("POST", url, headers={"Authorization": f"Bearer {token}"}, payload=payload) if res.get("code") != 0: raise RuntimeError(f"create record failed: {res}") rec = (res.get("data") or {}).get("record") or {} return rec.get("record_id") or rec.get("id") or "" def _fmt(v) -> str: if v is None: return "" if isinstance(v, float): return f"{v:.2f}".rstrip("0").rstrip(".") return str(v) def main() -> int: parser = argparse.ArgumentParser(description="Push stock risk alerts to Feishu as structured rows") parser.add_argument("--days", type=int, default=30) parser.add_argument("--lead-days", type=int, default=10) parser.add_argument("--warn-days", type=int, default=15) parser.add_argument("--top", type=int, default=50, help="top N detail rows to push") parser.add_argument("--app-token", default=DEFAULT_APP_TOKEN) parser.add_argument("--table-id", default=DEFAULT_TABLE_ID) parser.add_argument("--table-link", default=DEFAULT_TABLE_LINK) args = parser.parse_args() days = max(7, min(args.days, 180)) lead_days = max(1, min(args.lead_days, 60)) warn_days = max(lead_days, min(args.warn_days, 90)) top = max(1, min(args.top, 500)) summary, details = _run_stock_risk(days, lead_days, warn_days, limit=max(top, 50)) app_id, app_secret = _load_feishu_app() token = _tenant_token(app_id, app_secret) _ensure_fields(token, args.app_token, args.table_id) batch_id = time.strftime("%Y%m%d%H%M%S") now_text = time.strftime("%Y-%m-%d %H:%M:%S") summary_record = { "记录类型": "汇总", "批次ID": batch_id, "报表时间": now_text, "统计周期天数": _fmt(summary.get("lookback_days", days)), "补货提前期天数": _fmt(summary.get("replenishment_lead_days", lead_days)), "预警阈值天数": _fmt(summary.get("warning_days", warn_days)), "在售SKU数": _fmt(summary.get("sold_sku_count_30d")), "风险SKU数": _fmt(summary.get("stock_risk_sku_count")), "紧急SKU数": _fmt(summary.get("urgent_sku_count")), "预警SKU数": _fmt(summary.get("warning_sku_count")), } summary_record_id = _create_record(token, args.app_token, args.table_id, summary_record) detail_record_ids: List[str] = [] for row in details[:top]: fields = { "记录类型": "明细", "批次ID": batch_id, "报表时间": now_text, "统计周期天数": _fmt(days), "补货提前期天数": _fmt(lead_days), "预警阈值天数": _fmt(warn_days), "SKU编码": _fmt(row.get("sku_id")), "SKU名称": _fmt(row.get("sku_name")), "SPU": _fmt(row.get("spu")), "风险等级": _fmt(row.get("stock_risk_level")), "预计缺货天数": _fmt(row.get("estimated_stockout_days")), "建议补货件数": _fmt(row.get("suggest_replenish_qty_to_lead_days")), "可用库存": _fmt(row.get("available_qty")), "近7天发货件数": _fmt(row.get("shipped_qty_7d")), "近30天发货件数": _fmt(row.get("shipped_qty_30d")), "近30天日均发货": _fmt(row.get("avg_daily_shipped_30d")), "渠道": _fmt(row.get("top_shop_channel")), "店铺": _fmt(row.get("top_shop_name")), "采购": _fmt(row.get("purchaser_name")), "运营": _fmt(row.get("operator_name")), "工厂": _fmt(row.get("factory_name")), "仓库": _fmt(row.get("warehouse_name")), } rec_id = _create_record(token, args.app_token, args.table_id, fields) detail_record_ids.append(rec_id) print("PUSH_STATUS: success") print(f"TABLE_LINK: {args.table_link}") print(f"BATCH_ID: {batch_id}") print(f"SUMMARY_RECORD_ID: {summary_record_id}") print(f"DETAIL_RECORD_COUNT: {len(detail_record_ids)}") if detail_record_ids: print(f"FIRST_DETAIL_RECORD_ID: {detail_record_ids[0]}") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as e: print(f"PUSH_STATUS: failed\nERROR: {e}", file=sys.stderr) raise