diff --git a/.env.example b/.env.example index de37a5d..796d4f2 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ LLM_FALLBACK_MODELS=openai/kimi-k2.6,gemini/gemini-2.5-flash # ── 供應商金鑰(依你選的模型填對應的)── # OPENAI_API_BASE=https://opencode.ai/zen/go/v1 # OPENAI_API_KEY=replace_with_key +# OpenCode Go 端點(/zen/go/v1)每個請求都要帶 x-opencode-session,否則回 MissingSessionID。 +# JSON 物件;其他端點不需要時留空。 +# LLM_EXTRA_HEADERS={"x-opencode-session": "erp-inventory"} +# 單次 LLM 請求逾時秒數(預設 120);超時視為該模型失敗,改打 LLM_FALLBACK_MODELS +# LLM_TIMEOUT=120 GEMINI_API_KEY=replace_with_gemini_api_key # ── 新聞來源(供應鏈風險頁)── diff --git a/backend/access_control.py b/backend/access_control.py index e76536d..5f99378 100644 --- a/backend/access_control.py +++ b/backend/access_control.py @@ -14,6 +14,8 @@ RISK_OVERVIEW_READ = "risk.overview.read" +# L1 告警的已讀/處理中/通知 L2:監控狀態,不是 ERP 資料,L1 唯讀原則不受影響 +RISK_ALERT_ACK = "risk.alert.ack" RISK_ANALYSIS_READ = "risk.analysis.read" RISK_WHAT_IF_RUN = "risk.what_if.run" RISK_WORKSPACE_WRITE = "risk.workspace.write" @@ -32,6 +34,7 @@ _CAPABILITY_ENTITLEMENT = { RISK_OVERVIEW_READ: L1_MONITOR, + RISK_ALERT_ACK: L1_MONITOR, RISK_ANALYSIS_READ: L2_DECISION, RISK_WHAT_IF_RUN: L2_DECISION, RISK_WORKSPACE_WRITE: L2_DECISION, @@ -49,10 +52,11 @@ _ROLE_CAPABILITIES = { - "risk_viewer": frozenset({RISK_OVERVIEW_READ}), + "risk_viewer": frozenset({RISK_OVERVIEW_READ, RISK_ALERT_ACK}), "supply_planner": frozenset( { RISK_OVERVIEW_READ, + RISK_ALERT_ACK, RISK_ANALYSIS_READ, RISK_WHAT_IF_RUN, RISK_WORKSPACE_WRITE, @@ -62,6 +66,7 @@ "procurement_approver": frozenset( { RISK_OVERVIEW_READ, + RISK_ALERT_ACK, PROPOSAL_EVIDENCE_READ, APPROVAL_QUEUE_READ, APPROVAL_DECIDE, @@ -74,6 +79,7 @@ "warehouse": frozenset( { RISK_OVERVIEW_READ, + RISK_ALERT_ACK, RISK_ANALYSIS_READ, RISK_WHAT_IF_RUN, RISK_WORKSPACE_WRITE, diff --git a/backend/agent_logger.py b/backend/agent_logger.py index 9cb1cd3..0d9f02e 100644 --- a/backend/agent_logger.py +++ b/backend/agent_logger.py @@ -6,6 +6,8 @@ import hmac import json from datetime import datetime +import sqlite3 +from backend import database as _database from backend.database import run_query, transaction, tx_run from backend.log_checksum import _get_prev_checksum, compute_checksum @@ -413,3 +415,28 @@ def reject_action(approval_id: str, reason: str, approver: str) -> dict: from backend.tool_gateway import gateway res = gateway.reject_action(approval_id, reason, approver=approver) return res.to_dict() + + +def get_reversal_record(approval_id: str, *, conn=None) -> dict | None: + """某審批單是否已成功沖銷過;回 {"timestamp", "result", "caller"} 或 None。 + + 沖銷是補償交易,重按會再扣一次庫存/再取消一次訂單,所以前端要先查這裡。 + """ + if not approval_id: + return None + needle = json.dumps({"approval_id": approval_id}, ensure_ascii=False)[1:-1] # "approval_id": "…" + query = """ + SELECT timestamp, result, caller FROM agent_action_logs + WHERE tool_name = 'retry_approval' AND success = 1 AND parameters LIKE ? + ORDER BY id DESC LIMIT 1 + """ + owned = conn is None + conn = conn or sqlite3.connect(_database.DB_FILE) # 動態讀,測試可改路徑 + try: + row = conn.execute(query, (f"%{needle}%",)).fetchone() + finally: + if owned: + conn.close() + if not row: + return None + return {"timestamp": row[0], "result": row[1], "caller": row[2]} diff --git a/backend/agent_orchestrator.py b/backend/agent_orchestrator.py index c4f3a9d..33a7d99 100644 --- a/backend/agent_orchestrator.py +++ b/backend/agent_orchestrator.py @@ -55,6 +55,42 @@ ] +# ── 額外 HTTP header(選填,JSON 物件):部分 OpenAI 相容端點要求自報身分 ── +# 例:OpenCode Go 每個請求都要帶 x-opencode-session,否則回 MissingSessionID。 +# 格式錯誤時視為未設定(啟動時印警告),不讓 .env 打錯字把整個 LLM 層拖垮。 +def _load_extra_headers() -> dict[str, str]: + raw = os.getenv("LLM_EXTRA_HEADERS", "").strip() + if not raw: + return {} + try: + headers = json.loads(raw) + if not isinstance(headers, dict): + raise ValueError("must be a JSON object") + return {str(k): str(v) for k, v in headers.items()} + except ValueError as e: + print(f"[llm] ignoring LLM_EXTRA_HEADERS: {e}") + return {} + + +_EXTRA_HEADERS = _load_extra_headers() + + +# ── 單次請求逾時(秒):上游卡住時及早放棄、讓 fallback 接手 ────────────── +# litellm 預設 600 秒;OpenCode 這類代理端點偶爾會吞掉請求不回應, +# 現場等 10 分鐘不如 2 分鐘換一家。非法值視為預設。 +def _load_timeout(default: float = 120.0) -> float: + raw = os.getenv("LLM_TIMEOUT", "").strip() + try: + value = float(raw) if raw else default + except ValueError: + print(f"[llm] ignoring LLM_TIMEOUT={raw!r}: not a number") + return default + return value if value > 0 else default + + +_LLM_TIMEOUT = _load_timeout() + + # ════════════════════════════════════════════════════════════════════════ # 0) 各 Agent 的 system prompt(由 registry 組出,DRY + 單一真實來源) # ════════════════════════════════════════════════════════════════════════ @@ -201,7 +237,7 @@ def _llm(messages, model=None, tools=None, temperature=0.2, json_mode=False, 用量記帳:每次成功呼叫記一列 llm_usage_logs(tokens + 成本), usage_tag 標記用途(route / agent: / aggregate / smalltalk)供歸因。 """ - kw = {"messages": messages, "temperature": temperature} + kw = {"messages": messages, "temperature": temperature, "timeout": _LLM_TIMEOUT} if tools: kw["tools"] = tools kw["tool_choice"] = "auto" @@ -211,6 +247,8 @@ def _llm(messages, model=None, tools=None, temperature=0.2, json_mode=False, kw["api_key"] = api_key if api_base: kw["api_base"] = api_base + if _EXTRA_HEADERS: + kw["extra_headers"] = dict(_EXTRA_HEADERS) explicit = bool(model or api_key or api_base) chain = [model or DEFAULT_MODEL] diff --git a/backend/database.py b/backend/database.py index 4af17ec..af28a87 100644 --- a/backend/database.py +++ b/backend/database.py @@ -52,6 +52,44 @@ def _ensure_db_dir(): os.makedirs(d, exist_ok=True) +def is_demo_seed_enabled() -> bool: + """合成示範資料(採購單等)只能由環境變數明確啟用,避免污染真實資料。""" + return os.getenv("ERP_ENABLE_DEMO_SEED", "").strip().lower() in {"1", "true", "yes", "on"} + + +def _seed_demo_purchase_orders(c) -> int: + """為每家正式供應商建一張進行中採購單(含明細)。表非空或無商品時不動作。""" + if c.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] > 0: + return 0 + products = c.execute( + "SELECT product_id, COALESCE(cost, price, 1000) FROM inventory ORDER BY product_id" + ).fetchall() + suppliers = c.execute( + "SELECT supplier_id FROM suppliers WHERE is_official = 1 ORDER BY supplier_id" + ).fetchall() + if not products or not suppliers: + return 0 + statuses = ("已下單", "生產中", "運送中") + created = 0 + for idx, (supplier_id,) in enumerate(suppliers): + product_id, unit_cost = products[idx % len(products)] + qty = 20 + (idx % 5) * 10 + unit_price = round(float(unit_cost or 1000), 2) + po_id = f"PO-DEMO-{idx + 1:03d}" + order_date = (datetime.now() - timedelta(days=3 + idx % 12)).strftime("%Y-%m-%d") + c.execute( + "INSERT OR IGNORE INTO purchase_orders (po_id, supplier_id, order_date, status, total_amount, note) " + "VALUES (?,?,?,?,?,?)", + (po_id, supplier_id, order_date, statuses[idx % len(statuses)], qty * unit_price, "demo seed"), + ) + c.execute( + "INSERT INTO purchase_order_items (po_id, product_id, qty, unit_price) VALUES (?,?,?,?)", + (po_id, product_id, qty, unit_price), + ) + created += 1 + return created + + def init_db(): _ensure_db_dir() conn = sqlite3.connect(DB_FILE) @@ -170,6 +208,27 @@ def init_db(): ai_summary TEXT, updated_at TEXT )''') + c.execute('''CREATE TABLE IF NOT EXISTS risk_alert_states ( + alert_key TEXT PRIMARY KEY, + kind TEXT NOT NULL, + ref_id INTEGER NOT NULL, + status TEXT NOT NULL, + note TEXT, + updated_by TEXT, + updated_at TEXT NOT NULL + )''') + c.execute('''CREATE TABLE IF NOT EXISTS risk_ai_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + actor TEXT, + reference_date TEXT, + summary TEXT NOT NULL, + updates_json TEXT NOT NULL, + events_json TEXT NOT NULL, + audit_json TEXT NOT NULL, + news_count INTEGER DEFAULT 0, + event_count INTEGER DEFAULT 0 + )''') c.execute('''CREATE TABLE IF NOT EXISTS esg_targets (id INTEGER PRIMARY KEY AUTOINCREMENT, target_year INTEGER, scope INTEGER, baseline_kg_co2 REAL, target_kg_co2 REAL, note TEXT)''') # 永續 ESG:風險管理係數(地區/事件類型/供應商類別 → 風險分數 0–100、權重) c.execute('''CREATE TABLE IF NOT EXISTS esg_risk_factors (id INTEGER PRIMARY KEY AUTOINCREMENT, risk_type TEXT, risk_key TEXT, risk_score REAL, weight REAL, note TEXT, updated_at TEXT, UNIQUE(risk_type, risk_key))''') @@ -740,6 +799,12 @@ def init_db(): for row in top20: c.execute("UPDATE suppliers SET is_official = 1 WHERE supplier_id=?", (row[0],)) + # Demo 曝險資料:供應鏈風險卡片的「曝險金額」算的是未結採購單,demo 供應商 + # 原本沒有任何採購單,所有據點永遠 $0。只在明確 opt-in(ERP_ENABLE_DEMO_SEED) + # 且採購單表為空時,替正式供應商各建一張進行中的採購單。 + if is_demo_mode_enabled() and is_demo_seed_enabled(): + _seed_demo_purchase_orders(c) + # N3:既有 DB 的 legacy 明文密碼一次性升級為 salted hash(自我修復式遷移) try: from backend.passwords import hash_password, is_hashed diff --git a/backend/l1_monitoring.py b/backend/l1_monitoring.py index 98af2a1..e2a27cc 100644 --- a/backend/l1_monitoring.py +++ b/backend/l1_monitoring.py @@ -2,6 +2,21 @@ from __future__ import annotations +from datetime import datetime, timedelta +import sqlite3 + +from backend import database +from backend.access_control import RISK_ALERT_ACK, RISK_ANALYSIS_READ, RISK_OVERVIEW_READ, require_capability + + +# 告警嚴重度依預估延遲天數分級;L1 只讀不寫,分級規則放在後端以便 LINE / Web 共用。 +ALERT_SEVERITY_HIGH_DAYS = 14 +ALERT_SEVERITY_MEDIUM_DAYS = 7 + +ALERT_SOURCE_NEWS = "新聞登錄" +ALERT_SOURCE_MANUAL = "人工登錄" +CANDIDATE_STATUS = "AI 偵測待確認" + def _text(value) -> str: if value is None: @@ -143,3 +158,353 @@ def map_purchase_rows_to_events( mapped_rows.append(row) return mapped_rows + + +# ── 最新事件告警(唯讀 feed) ────────────────────────────────────────── + + +def classify_alert_severity(impact_days) -> str: + """依預估延遲天數回傳「高/中/低/無」。""" + days = _impact_days({"impact_days": impact_days}) + if days >= ALERT_SEVERITY_HIGH_DAYS: + return "高" + if days >= ALERT_SEVERITY_MEDIUM_DAYS: + return "中" + if days >= 1: + return "低" + return "無" + + +def _window_start(since_days: int, *, now: datetime | None = None) -> str: + days = max(0, int(since_days or 0)) + reference = now or datetime.now() + return (reference - timedelta(days=days)).strftime("%Y-%m-%d") + + +def _load_confirmed_alerts(conn: sqlite3.Connection, *, since: str, limit: int) -> list[dict]: + rows = conn.execute( + """ + SELECT e.id, e.event_type, e.region, e.country, e.impact_days, + e.description, e.created_at, e.news_id, + n.title AS news_title, n.url AS news_url, n.source AS news_source + FROM supply_chain_events e + LEFT JOIN supply_chain_news n ON n.id = e.news_id + WHERE substr(COALESCE(e.created_at, ''), 1, 10) >= ? + ORDER BY COALESCE(e.created_at, '') DESC, e.id DESC + LIMIT ? + """, + (since, limit), + ).fetchall() + alerts = [] + for row in rows: + ( + event_id, event_type, region, country, impact_days, + description, created_at, news_id, news_title, news_url, news_source, + ) = row + alerts.append( + { + "id": event_id, + "event_type": _text(event_type) or "未分類", + "country": _text(country), + "region": _text(region), + "impact_days": _impact_days({"impact_days": impact_days}), + "severity": classify_alert_severity(impact_days), + "description": _text(description), + "created_at": _text(created_at), + "news_id": news_id, + "source": ALERT_SOURCE_NEWS if news_id is not None else ALERT_SOURCE_MANUAL, + "news_title": _text(news_title), + "news_url": _text(news_url), + "news_source": _text(news_source), + } + ) + return alerts + + +def _load_candidate_alerts(conn: sqlite3.Connection, *, since: str, limit: int) -> list[dict]: + """尚未登錄為正式事件、但 AI 判定有實質延遲的新聞。 + + 這層讓 L1 在 L2 尚未按「登錄」之前就能看到新偵測到的風險;資料只來自 + 排程/L2 已寫入的 supply_chain_news,本函式不觸發抓取也不寫入。 + """ + rows = conn.execute( + """ + SELECT n.id, n.category, n.region, n.country, n.estimated_delay, + n.title, n.summary, n.url, n.source, n.published_at, n.fetched_at + FROM supply_chain_news n + WHERE COALESCE(n.is_relevant, 1) = 1 + AND COALESCE(n.estimated_delay, 0) > 0 + AND COALESCE(date(n.published_at), date(n.fetched_at), '') >= ? + AND NOT EXISTS ( + SELECT 1 FROM supply_chain_events e WHERE e.news_id = n.id + ) + ORDER BY COALESCE(date(n.published_at), date(n.fetched_at), '') DESC, + n.estimated_delay DESC, n.id DESC + """, + (since,), + ).fetchall() + candidates = [] + seen: set[tuple[str, str]] = set() + for row in rows: + ( + news_id, category, region, country, estimated_delay, + title, summary, url, source, published_at, fetched_at, + ) = row + dedupe_key = (_text(title)[:200], _text(url)) + if dedupe_key in seen or dedupe_key == ("", ""): + continue + seen.add(dedupe_key) + candidates.append( + { + "news_id": news_id, + "event_type": _text(category) or "其他", + "country": _text(country), + "region": _text(region), + "impact_days": _impact_days({"impact_days": estimated_delay}), + "severity": classify_alert_severity(estimated_delay), + "title": _text(title), + "summary": _text(summary), + "url": _text(url), + "news_source": _text(source), + "observed_at": _text(published_at) or _text(fetched_at), + "status": CANDIDATE_STATUS, + } + ) + if len(candidates) >= limit: + break + return candidates + + +# ── 告警狀態(已讀/處理中/已通知 L2) ──────────────────────────────── +# 監控狀態獨立一張表,不碰事件與新聞本體;L1 每次重整仍直接讀 DB,但狀態會留下來。 + +ALERT_KIND_CONFIRMED = "confirmed" +ALERT_KIND_CANDIDATE = "candidate" +ALERT_STATUS_UNREAD = "未讀" +ALERT_STATUS_READ = "已讀" +ALERT_STATUS_IN_PROGRESS = "處理中" +ALERT_STATUS_NOTIFIED_L2 = "已通知L2" +CONFIRMED_STATUS_OPTIONS = (ALERT_STATUS_UNREAD, ALERT_STATUS_READ, ALERT_STATUS_IN_PROGRESS) +CANDIDATE_STATUS_OPTIONS = (ALERT_STATUS_UNREAD, ALERT_STATUS_READ, ALERT_STATUS_NOTIFIED_L2) +_STATUS_OPTIONS = { + ALERT_KIND_CONFIRMED: CONFIRMED_STATUS_OPTIONS, + ALERT_KIND_CANDIDATE: CANDIDATE_STATUS_OPTIONS, +} + + +def _ensure_alert_state_table(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS risk_alert_states ( + alert_key TEXT PRIMARY KEY, kind TEXT NOT NULL, ref_id INTEGER NOT NULL, + status TEXT NOT NULL, note TEXT, updated_by TEXT, updated_at TEXT NOT NULL)""" + ) + + +def _alert_key(kind: str, ref_id) -> str: + return f"{kind}:{int(ref_id)}" + + +def set_alert_status(kind: str, ref_id, status: str, *, actor: str | None, note: str = "", + conn: sqlite3.Connection | None = None, now: datetime | None = None) -> dict: + """L1 標記告警狀態。authorization 先於任何寫入;狀態值必須是該類別允許的選項。""" + require_capability(actor, RISK_ALERT_ACK, conn=conn) + if kind not in _STATUS_OPTIONS: + raise ValueError(f"不支援的告警類別:{kind}") + status = _text(status) + if status not in _STATUS_OPTIONS[kind]: + raise ValueError(f"{kind} 告警不支援狀態「{status}」") + record = { + "alert_key": _alert_key(kind, ref_id), + "kind": kind, + "ref_id": int(ref_id), + "status": status, + "note": _text(note)[:500], + "updated_by": actor, + "updated_at": (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + } + + def _write(active_conn: sqlite3.Connection) -> None: + _ensure_alert_state_table(active_conn) + active_conn.execute( + """INSERT INTO risk_alert_states (alert_key, kind, ref_id, status, note, updated_by, updated_at) + VALUES (:alert_key, :kind, :ref_id, :status, :note, :updated_by, :updated_at) + ON CONFLICT(alert_key) DO UPDATE SET status=excluded.status, note=excluded.note, + updated_by=excluded.updated_by, updated_at=excluded.updated_at""", + record, + ) + active_conn.commit() + + if conn is not None: + _write(conn) + else: + with sqlite3.connect(database.DB_FILE) as owned_conn: + _write(owned_conn) + return record + + +def get_alert_states(kind: str, ref_ids, *, conn: sqlite3.Connection | None = None) -> dict[int, dict]: + ids = sorted({int(i) for i in (ref_ids or []) if i is not None}) + if not ids: + return {} + + def _load(active_conn: sqlite3.Connection) -> dict[int, dict]: + _ensure_alert_state_table(active_conn) + placeholders = ",".join("?" for _ in ids) + rows = active_conn.execute( + f"""SELECT ref_id, status, note, updated_by, updated_at FROM risk_alert_states + WHERE kind = ? AND ref_id IN ({placeholders})""", + (kind, *ids), + ).fetchall() + return { + int(ref_id): {"status": status, "note": _text(note), "updated_by": _text(by), "updated_at": _text(at)} + for ref_id, status, note, by, at in rows + } + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) + + +def list_l1_notifications_for_l2(*, actor: str | None, conn: sqlite3.Connection | None = None) -> list[dict]: + """L1 標成「已通知L2」、而 L2 還沒登錄成事件的情報。L2 頁面頂端提醒用。""" + require_capability(actor, RISK_ANALYSIS_READ, conn=conn) + + def _load(active_conn: sqlite3.Connection) -> list[dict]: + _ensure_alert_state_table(active_conn) + rows = active_conn.execute( + """ + SELECT s.ref_id, s.note, s.updated_by, s.updated_at, + n.title, n.country, n.region, n.category, n.estimated_delay, n.url + FROM risk_alert_states s + JOIN supply_chain_news n ON n.id = s.ref_id + WHERE s.kind = ? AND s.status = ? + AND NOT EXISTS (SELECT 1 FROM supply_chain_events e WHERE e.news_id = n.id) + ORDER BY s.updated_at DESC + """, + (ALERT_KIND_CANDIDATE, ALERT_STATUS_NOTIFIED_L2), + ).fetchall() + return [ + { + "news_id": int(ref_id), "note": _text(note), "notified_by": _text(by), "notified_at": _text(at), + "title": _text(title), "country": _text(country), "region": _text(region), + "event_type": _text(category) or "其他", "impact_days": _impact_days({"impact_days": delay}), + "url": _text(url), + } + for ref_id, note, by, at, title, country, region, category, delay, url in rows + ] + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) + + +def load_open_purchase_rows(*, actor: str | None, conn: sqlite3.Connection | None = None) -> list[dict]: + """系統內未結採購單(一列一品項),格式與 CSV 範本相同,供 L1 對映事件。唯讀。""" + require_capability(actor, RISK_OVERVIEW_READ, conn=conn) + + def _load(active_conn: sqlite3.Connection) -> list[dict]: + rows = active_conn.execute( + """ + SELECT p.po_id, p.supplier_id, i.product_id, i.qty, p.status, p.order_date, p.total_amount + FROM purchase_orders p + LEFT JOIN purchase_order_items i ON i.po_id = p.po_id + WHERE (p.status IS NULL OR p.status NOT IN ('已完成', '已取消')) + ORDER BY p.po_id, i.id + """ + ).fetchall() + return [ + { + "external_id": po_id, "po_id": po_id, "supplier_id": _text(supplier_id), + "product_id": _text(product_id), "qty": int(qty or 0), "status": _text(status), + "order_date": _text(order_date), "total_amount": float(total_amount or 0), + } + for po_id, supplier_id, product_id, qty, status, order_date, total_amount in rows + ] + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) + + +def _attach_ack_and_proposals(conn: sqlite3.Connection, confirmed: list[dict], candidates: list[dict]) -> None: + """把 L1 標記狀態與 L3 提案計數併進告警列(唯讀)。""" + from backend.purchase_proposals import proposal_status_summary_by_event + + confirmed_states = get_alert_states(ALERT_KIND_CONFIRMED, [item["id"] for item in confirmed], conn=conn) + proposal_counts = proposal_status_summary_by_event([item["id"] for item in confirmed], conn=conn) + for item in confirmed: + state = confirmed_states.get(int(item["id"]), {}) + item["ack_status"] = state.get("status") or ALERT_STATUS_UNREAD + item["ack_note"] = state.get("note", "") + item["ack_by"] = state.get("updated_by", "") + item["ack_at"] = state.get("updated_at", "") + item["proposals"] = proposal_counts.get(int(item["id"]), {"pending": 0, "approved": 0, "rejected": 0, "unsubmitted": 0}) + candidate_states = get_alert_states(ALERT_KIND_CANDIDATE, [item["news_id"] for item in candidates], conn=conn) + for item in candidates: + state = candidate_states.get(int(item["news_id"]), {}) + item["ack_status"] = state.get("status") or ALERT_STATUS_UNREAD + item["ack_note"] = state.get("note", "") + item["ack_by"] = state.get("updated_by", "") + item["ack_at"] = state.get("updated_at", "") + + +def get_latest_event_alerts( + *, + actor: str | None, + since_days: int = 30, + limit: int = 10, + conn: sqlite3.Connection | None = None, + now: datetime | None = None, +) -> dict: + """L1 告警 feed:已確認事件 + AI 偵測待確認候選,皆為唯讀。 + + authorization 先於任何資料讀取;缺少 RISK_OVERVIEW_READ 直接拒絕。 + 每次呼叫都重新查詢資料庫,所以排程或 L2 寫入新聞/事件後, + L1 下一次 rerun 就會看到更新,不依賴 session state。 + """ + require_capability(actor, RISK_OVERVIEW_READ, conn=conn) + limit = max(1, int(limit or 1)) + since = _window_start(since_days, now=now) + + def _load(active_conn: sqlite3.Connection) -> dict: + confirmed = _load_confirmed_alerts(active_conn, since=since, limit=limit) + candidates = _load_candidate_alerts(active_conn, since=since, limit=limit) + _attach_ack_and_proposals(active_conn, confirmed, candidates) + severities = [item["severity"] for item in confirmed + candidates] + highest = "無" + for level in ("高", "中", "低"): + if level in severities: + highest = level + break + return { + "since": since, + "since_days": max(0, int(since_days or 0)), + "generated_at": (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + "confirmed": confirmed, + "candidates": candidates, + "confirmed_count": len(confirmed), + "candidate_count": len(candidates), + "highest_severity": highest, + } + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) + + +# ── 最新 AI 風險摘要(唯讀) ────────────────────────────────────────── + + +def get_latest_risk_summary(*, actor: str | None, conn: sqlite3.Connection | None = None) -> dict | None: + """L2/排程最近一次產生並落地的 AI 風險摘要;L1 只讀、不觸發任何模型呼叫。 + + authorization 先於任何資料讀取;缺少 RISK_OVERVIEW_READ 直接拒絕。 + """ + require_capability(actor, RISK_OVERVIEW_READ, conn=conn) + from backend.supply_chain_risk import get_latest_ai_risk_summary + + return get_latest_ai_risk_summary(conn=conn) diff --git a/backend/prompts.py b/backend/prompts.py index 0afa71a..90ae72d 100644 --- a/backend/prompts.py +++ b/backend/prompts.py @@ -36,6 +36,7 @@ 2. 「更新」與「事件」中的地區名稱請從上方合法區域清單挑選;新聞若只提到國家(如「台灣」),請展開為清單內對應的完整名稱。不在清單上的地點可略過(系統會自動過濾)。 3. 延遲天數參考:戰爭 30-90、罷工 7-21、氣候 3-14、政策 7-30、交通 1-7。 4. 摘要中每提到一個受影響區域,「更新」與「事件」就各對應一筆,天數需與摘要一致。 +5. 只能根據上方「已登錄事件」與「採集新聞」下判斷;資料裡沒有出現的地區、事件類型或延遲天數不要自行推測(系統會依證據過濾)。 【輸出格式】只輸出以下 JSON 物件(頂層必須是物件、不要其他文字): {{ diff --git a/backend/purchase_proposals.py b/backend/purchase_proposals.py index e040a71..a848388 100644 --- a/backend/purchase_proposals.py +++ b/backend/purchase_proposals.py @@ -811,6 +811,7 @@ def list_impacted_purchase_options(*, actor: str) -> list[dict]: ORDER BY COALESCE(p.estimated_delay_days, 0) DESC, p.po_id, i.id """ ).fetchall() + status_by_line = _proposal_status_by_po_line(conn) result = [] for row in rows: item = dict(row) @@ -820,10 +821,121 @@ def list_impacted_purchase_options(*, actor: str) -> list[dict]: item["product_id"], item["source_po_item_id"], ) + item["proposal"] = status_by_line.get((item["po_id"], int(item["source_po_item_id"]))) result.append(item) return result +_PROPOSAL_STATUS_LABELS = { + "pending": "待 L3 核准", + "approved": "已核准", + "rejected": "已拒絕", + "unsubmitted": "草稿未送審", +} + + +def _proposal_status_by_po_line(conn) -> dict[tuple[str, int], dict]: + """每條受影響採購明細最新一筆提案的狀態(pending / approved / rejected / unsubmitted)。 + + 提案本身不存審批狀態(設計如此);狀態由 operation_id 對回 pending_approvals。 + 同一明細多次提案時取最新建立者。 + """ + rows = conn.execute( + """ + SELECT pr.proposal_id, pr.affected_po_id, pr.source_po_item_id, pr.proposed_po_id, + pr.alternative_supplier_id, pr.source_event_id, pr.created_at, + pa.status, pa.approver, pa.updated_at, pa.reason + FROM purchase_proposals pr + LEFT JOIN pending_approvals pa + ON pa.operation_id = ? || pr.proposal_id || ? + ORDER BY pr.created_at DESC, pr.rowid DESC + """, + (_OPERATION_PREFIX, f":{EXECUTION_CONTRACT_VERSION}"), + ).fetchall() + out: dict[tuple[str, int], dict] = {} + for row in rows: + (proposal_id, po_id, item_id, proposed_po_id, alt_supplier, event_id, + created_at, status, approver, decided_at, reason) = tuple(row) + key = (str(po_id), int(item_id)) + if key in out: + continue + status = str(status or "unsubmitted") + out[key] = { + "proposal_id": proposal_id, + "status": status, + "label": _PROPOSAL_STATUS_LABELS.get(status, status), + "proposed_po_id": proposed_po_id, + "alternative_supplier_id": alt_supplier, + "source_event_id": event_id, + "created_at": created_at, + "approver": approver, + "decided_at": decided_at if status in {"approved", "rejected"} else None, + "reason": reason or "", + } + return out + + +def proposal_status_summary_by_event(event_ids, *, conn=None) -> dict[int, dict]: + """各風險事件底下替代採購提案的狀態計數(L1 告警用;只回計數,不含提案內容)。""" + ids = sorted({int(i) for i in (event_ids or []) if i is not None}) + if not ids: + return {} + placeholders = ",".join("?" for _ in ids) + query = f""" + SELECT pr.source_event_id, COALESCE(pa.status, 'unsubmitted'), COUNT(*) + FROM purchase_proposals pr + LEFT JOIN pending_approvals pa ON pa.operation_id = ? || pr.proposal_id || ? + WHERE pr.source_event_id IN ({placeholders}) + GROUP BY pr.source_event_id, COALESCE(pa.status, 'unsubmitted') + """ + params = (_OPERATION_PREFIX, f":{EXECUTION_CONTRACT_VERSION}", *ids) + owned = conn is None + conn = conn or sqlite3.connect(database.DB_FILE) + try: + rows = conn.execute(query, params).fetchall() + finally: + if owned: + conn.close() + out: dict[int, dict] = {} + for event_id, status, count in rows: + entry = out.setdefault(int(event_id), {"pending": 0, "approved": 0, "rejected": 0, "unsubmitted": 0}) + entry[str(status)] = entry.get(str(status), 0) + int(count) + return out + + +def get_purchase_proposal_context(proposal: PurchaseProposal, *, actor: str) -> dict: + """審批頁的 L2 證據脈絡:提案所依據的風險事件 + 受影響採購單上的延遲/替代建議註記。 + + 只讀;需 PROPOSAL_EVIDENCE_READ。找不到事件時 event 為 None(舊提案沒綁事件)。 + """ + require_capability(actor, PROPOSAL_EVIDENCE_READ) + with sqlite3.connect(database.DB_FILE) as conn: + conn.row_factory = sqlite3.Row + event = None + if proposal.source_event_id is not None: + row = conn.execute( + """ + SELECT e.id, e.event_type, e.region, e.country, e.impact_days, e.description, + e.created_at, e.news_id, n.title AS news_title, n.url AS news_url + FROM supply_chain_events e + LEFT JOIN supply_chain_news n ON n.id = e.news_id + WHERE e.id = ? + """, + (int(proposal.source_event_id),), + ).fetchone() + event = dict(row) if row else None + po = conn.execute( + """ + SELECT p.po_id, p.status, p.total_amount, p.estimated_delay_days, p.alternative_suggestion, + s.name AS supplier_name, s.country, s.region + FROM purchase_orders p LEFT JOIN suppliers s ON s.supplier_id = p.supplier_id + WHERE p.po_id = ? + """, + (proposal.affected_po_id,), + ).fetchone() + return {"event": event, "affected_po": dict(po) if po else None} + + def list_alternative_suppliers( *, affected_po_id: str, diff --git a/backend/supply_chain_news.py b/backend/supply_chain_news.py index 9634c59..be79bd9 100644 --- a/backend/supply_chain_news.py +++ b/backend/supply_chain_news.py @@ -10,9 +10,9 @@ from datetime import datetime, timedelta import email.utils from typing import List, Optional -from urllib.parse import quote_plus - -from .access_control import RISK_WORKSPACE_WRITE, require_capability +from urllib.parse import quote_plus + +from .access_control import RISK_WORKSPACE_WRITE, require_capability # 國家名稱 → 英文搜尋用 / 雙碼(給 GNews API 用) COUNTRY_MAP = { @@ -245,20 +245,20 @@ def get_news_from_db( return [dict(r) for r in rows] -def refresh_news_for_countries( - countries: List[str], - gemini_api_key: Optional[str] = None, - gnews_api_key: Optional[str] = None, - max_per_country: int = 15, - within_days: int = 7, - gemini_model: str = "gemini-2.5-flash", - *, - actor: str | None = None, -) -> dict: - """ - 為多個國家平行抓取新聞,並使用批量 AI 歸類以極大化提升效能。 - """ - require_capability(actor, RISK_WORKSPACE_WRITE) +def refresh_news_for_countries( + countries: List[str], + gemini_api_key: Optional[str] = None, + gnews_api_key: Optional[str] = None, + max_per_country: int = 15, + within_days: int = 7, + gemini_model: str = "gemini-2.5-flash", + *, + actor: str | None = None, +) -> dict: + """ + 為多個國家平行抓取新聞,並使用批量 AI 歸類以極大化提升效能。 + """ + require_capability(actor, RISK_WORKSPACE_WRITE) import concurrent.futures from .supply_chain_risk import batch_infer_affected_region_from_news from .llm_client import llm_available @@ -323,18 +323,18 @@ def fetch_job(c): try: from .supply_chain_risk import get_heatmap_ai_summary, apply_heatmap_updates all_news = get_news_from_db(limit=25, order_by_latest=True, within_days=30) - news_context = "\n".join([ - f"{(n.get('title') or '')} {(n.get('summary') or '')[:150]} [{n.get('published_at') or n.get('fetched_at') or ''}]" - for n in all_news - ]) ref_date = datetime.now().strftime("%Y-%m-%d") - summary_text, updates, _ = get_heatmap_ai_summary(news_context=news_context, reference_date=ref_date) + # news_items 讓摘要同時拿到證據(國家/類別/天數);actor 讓結果落地到 + # risk_ai_summaries,排程產生的建議事件不再被丟掉,L1/L2 重開頁面都看得到。 + summary_text, updates, _ = get_heatmap_ai_summary( + news_context="", reference_date=ref_date, news_items=all_news, actor=actor, + ) if updates: - apply_heatmap_updates(updates, summary_text, actor=actor) - except PermissionError: - raise - except Exception: - pass + apply_heatmap_updates(updates, summary_text, actor=actor) + except PermissionError: + raise + except Exception: + pass return { "updated": total_saved, diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index bcbf9a3..6e0f627 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -153,7 +153,8 @@ def get_recent_events_for_delay(limit=50): """取得近期供應鏈事件,供地圖判定出貨延遲狀況。""" conn = sqlite3.connect(DB_FILE) df = __pd_read( - "SELECT event_type, region, country, impact_days FROM supply_chain_events ORDER BY id DESC LIMIT ?", + "SELECT event_type, region, country, impact_days, created_at, news_id " + "FROM supply_chain_events ORDER BY COALESCE(created_at, '') DESC, id DESC LIMIT ?", conn, params=(limit,), ) @@ -161,6 +162,94 @@ def get_recent_events_for_delay(limit=50): return df +# ── 熱圖事件加權 ────────────────────────────────────────────────────── +# 原本「只要有任何事件就 +40」會讓每個有事件的據點都停在 60%,看不出差異。 +# 改為:依「最嚴重事件的延遲天數」給分,多筆事件再加成,逾期事件減半。 +HEATMAP_BASE_RISK = 20.0 +HEATMAP_EVENT_POINTS = ((30, 50), (14, 40), (7, 30), (1, 20), (0, 10)) # (延遲天數下限, 加權) +HEATMAP_EXTRA_EVENT_BONUS = 5 # 每多一筆事件 +HEATMAP_EXTRA_EVENT_CAP = 15 +HEATMAP_EVENT_STALE_DAYS = 30 # 登錄超過此天數的事件加權減半 +HEATMAP_EVENT_LOOKBACK = 200 # 參與計算的事件筆數上限(依登錄時間新→舊) + + +def _event_points(impact_days) -> float: + try: + days = max(0, int(impact_days or 0)) + except (TypeError, ValueError): + days = 0 + for floor, points in HEATMAP_EVENT_POINTS: + if days >= floor: + return float(points) + return 0.0 + + +def _clean_text(value) -> str: + """DataFrame 的 NaN/None 一律視為空字串(str(nan) 會變成 "nan" 而誤判為有值)。""" + if value is None: + return "" + try: + if pd.isna(value): + return "" + except (TypeError, ValueError): + pass + text = str(value).strip() + return "" if text.casefold() in {"nan", "none"} else text + + +def _event_matches_location(ev: dict, country: str, region: str) -> bool: + """事件有國家 → 只比對國家;沒有國家(純大區域事件,如「中東」)→ 才比對地區。 + + 原本「國家或地區任一命中」會讓「台灣/亞洲」的事件同時點亮越南、日本等所有亞洲據點。 + """ + ev_country = _clean_text(ev.get("country")) + ev_region = _clean_text(ev.get("region")) + if ev_country: + return bool(country) and ev_country in country + return bool(ev_region and region and ev_region in region) + + +def score_region_events(country: str, region: str, events, *, now=None) -> dict: + """算出單一據點的事件加權與可讀理由。 + + 回傳 {"points", "count", "max_days", "reason"};events 可為 DataFrame 或 list[dict]。 + """ + if events is None: + rows = [] + elif hasattr(events, "iterrows"): + rows = [r.to_dict() for _, r in events.iterrows()] + else: + rows = list(events) + matched = [ev for ev in rows if _event_matches_location(ev, country or "", region or "")] + if not matched: + return {"points": 0.0, "count": 0, "max_days": 0, "reason": "近期無登錄事件"} + + reference = now or datetime.now() + best = 0.0 + max_days = 0 + stale = 0 + for ev in matched: + points = _event_points(ev.get("impact_days")) + try: + max_days = max(max_days, int(ev.get("impact_days") or 0)) + except (TypeError, ValueError): + pass + created = str(ev.get("created_at") or "")[:10] + try: + age = (reference - datetime.strptime(created, "%Y-%m-%d")).days + except ValueError: + age = 0 + if age > HEATMAP_EVENT_STALE_DAYS: + points *= 0.5 + stale += 1 + best = max(best, points) + bonus = min(HEATMAP_EXTRA_EVENT_CAP, HEATMAP_EXTRA_EVENT_BONUS * (len(matched) - 1)) + reason = f"{len(matched)} 則事件・最長延遲 {max_days} 天" + if stale: + reason += f"({stale} 則已逾 {HEATMAP_EVENT_STALE_DAYS} 天)" + return {"points": best + bonus, "count": len(matched), "max_days": max_days, "reason": reason} + + def get_region_procurement_share(): """依地區彙總採購金額,計算各地區採購佔比(該地區供應商之採購額 / 全公司採購額)。 回傳 list of dict: region_key, display_name, procurement_ratio (0~1), total_amount, supplier_count。 @@ -236,10 +325,9 @@ def get_risk_heatmap_data(): suppliers = get_suppliers_for_map() if suppliers is None or suppliers.empty: return [] - events = get_recent_events_for_delay(20) + events = get_recent_events_for_delay(HEATMAP_EVENT_LOOKBACK) region_scores = get_region_risk_scores() procurement_by_region = get_region_procurement_share() - default_risk = 20.0 seen = set() default_rows = [] for _, s in suppliers.iterrows(): @@ -249,24 +337,26 @@ def get_risk_heatmap_data(): if key in seen: continue seen.add(key) - risk = default_risk - if events is not None and not events.empty: - for _, ev in events.iterrows(): - if (ev.get("country") and ev["country"] in country) or (ev.get("region") and ev["region"] in region): - risk = min(100, risk + 40) - break + event_score = score_region_events(country, region, events) + risk = min(100.0, HEATMAP_BASE_RISK + event_score["points"]) + reasons = [event_score["reason"]] for k, v in region_scores.items(): if k in region or k in country: + if v > risk: + reasons.append(f"地區係數 {k} {v:.0f}%") risk = max(risk, min(100, v)) break if key in procurement_by_region: ratio = procurement_by_region[key]["procurement_ratio"] if ratio >= 0.35: - risk = max(risk, 70) + floor = 70 elif ratio >= 0.15: - risk = max(risk, 45) + floor = 45 else: - risk = max(risk, min(35, 20 + ratio * 100)) + floor = min(35, 20 + ratio * 100) + if floor > risk: + reasons.append(f"採購集中度 {ratio:.0%}") + risk = max(risk, floor) lat, lon = s.get("latitude"), s.get("longitude") if lat is None or lon is None: continue @@ -276,6 +366,9 @@ def get_risk_heatmap_data(): "latitude": float(lat), "longitude": float(lon), "risk_pct": round(risk, 1), + "risk_reason": ";".join(reasons), + "event_count": event_score["count"], + "event_max_days": event_score["max_days"], "ai_summary": None, "updated_at": None, }) @@ -304,12 +397,17 @@ def get_risk_heatmap_data(): rk = row["region_key"] if rk in overrides: o = overrides[rk] + overridden = o.get("risk_pct") is not None out.append({ "region_key": rk, "display_name": row["display_name"], "latitude": o.get("latitude") if o.get("latitude") is not None else row["latitude"], "longitude": o.get("longitude") if o.get("longitude") is not None else row["longitude"], - "risk_pct": o.get("risk_pct") if o.get("risk_pct") is not None else row["risk_pct"], + "risk_pct": o.get("risk_pct") if overridden else row["risk_pct"], + "risk_reason": (f"AI/人工設定({o.get('updated_at') or '時間未記錄'})" if overridden + else row["risk_reason"]), + "event_count": row["event_count"], + "event_max_days": row["event_max_days"], "ai_summary": o.get("ai_summary"), "updated_at": o.get("updated_at"), }) @@ -397,16 +495,257 @@ def _coerce_heatmap_events(raw_events) -> list[dict]: return out -def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_date: str = "2026-04-11", model: str | None = None) -> tuple[str, list[dict], list[dict]]: +# ── AI 風險摘要:證據閘門 + 持久化 ─────────────────────────────────── +# 摘要原本只活在 session_state:重新整理就消失、L1 看不到、排程產生的建議事件直接丟掉。 +# 現在每次產生都寫進 risk_ai_summaries,L2 重開頁面與 L1 總覽都讀最新一筆。 + +AI_SUMMARY_TABLE = "risk_ai_summaries" +EVIDENCE_DAYS_MULTIPLIER = 2 # AI 建議延遲天數上限 = 證據最長天數 × 此倍率 +EVIDENCE_DAYS_FLOOR = 7 # …但至少允許到這個天數(證據只有 1-2 天時仍可合理外推) + + +def _ensure_summary_table(conn: sqlite3.Connection) -> None: + conn.execute( + f"""CREATE TABLE IF NOT EXISTS {AI_SUMMARY_TABLE} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + actor TEXT, + reference_date TEXT, + summary TEXT NOT NULL, + updates_json TEXT NOT NULL, + events_json TEXT NOT NULL, + audit_json TEXT NOT NULL, + news_count INTEGER DEFAULT 0, + event_count INTEGER DEFAULT 0 + )""" + ) + + +def save_ai_risk_summary(result: dict, *, actor=None, conn=None) -> int: + """把一次 AI 摘要結果落地;回傳新列 id。寫入需 RISK_WORKSPACE_WRITE。""" + import json + require_capability(actor, RISK_WORKSPACE_WRITE, conn=conn) + owned = conn is None + conn = conn or sqlite3.connect(DB_FILE) + try: + _ensure_summary_table(conn) + cur = conn.execute( + f"""INSERT INTO {AI_SUMMARY_TABLE} + (created_at, actor, reference_date, summary, updates_json, events_json, + audit_json, news_count, event_count) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + result.get("generated_at") or datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + actor, + result.get("reference_date"), + result.get("summary") or "", + json.dumps(result.get("updates") or [], ensure_ascii=False), + json.dumps(result.get("events") or [], ensure_ascii=False), + json.dumps(result.get("audit") or [], ensure_ascii=False), + int(result.get("news_count") or 0), + int(result.get("event_count") or 0), + ), + ) + conn.commit() + return int(cur.lastrowid) + finally: + if owned: + conn.close() + + +def get_latest_ai_risk_summary(conn=None) -> dict | None: + """最新一筆已落地的 AI 摘要;沒有回 None。回傳鍵與 analyze_heatmap_risk 一致。""" + import json + owned = conn is None + conn = conn or sqlite3.connect(DB_FILE) + try: + _ensure_summary_table(conn) + row = conn.execute( + f"""SELECT id, created_at, actor, reference_date, summary, updates_json, + events_json, audit_json, news_count, event_count + FROM {AI_SUMMARY_TABLE} ORDER BY id DESC LIMIT 1""" + ).fetchone() + finally: + if owned: + conn.close() + if not row: + return None + (row_id, created_at, actor, reference_date, summary, updates_json, + events_json, audit_json, news_count, event_count) = row + + def _load(raw): + try: + value = json.loads(raw or "[]") + return value if isinstance(value, list) else [] + except ValueError: + return [] + + return { + "summary_id": row_id, + "generated_at": created_at, + "actor": actor, + "reference_date": reference_date, + "summary": summary or "", + "updates": _load(updates_json), + "events": _load(events_json), + "audit": _load(audit_json), + "news_count": int(news_count or 0), + "event_count": int(event_count or 0), + "error": False, + } + + +def _norm_loc(value) -> str: + return "".join(str(value or "").strip().casefold().split()) + + +def _loc_hits(name: str, known: set[str]) -> bool: + """AI 給的地區名(如「台灣 北區」「美國」)是否被任一證據地點涵蓋。""" + target = _norm_loc(name) + if not target: + return False + parts = [_norm_loc(part) for part in str(name).split() if part.strip()] + for loc in known: + if not loc or len(loc) < 2: + continue + if loc == target or loc in target or target in loc: + return True + if any(part and (loc == part or loc in part or part in loc) for part in parts): + return True + return False + + +def build_risk_evidence(news_items, events) -> dict: + """把新聞(含抓取時 AI 標的國家/地區/類別/天數)與已登錄事件整理成證據表。 + + 回傳 {"locations": {地點: {"types": set, "max_days": int}}};地點以正規化字串為鍵。 """ - 獲取 AI 熱圖摘要,並整合現有的正式事件,確保「情報 -> 摘要 -> 應變」流程連貫。 + locations: dict[str, dict] = {} + + def _add(country, region, etype, days): + try: + days = max(0, int(days or 0)) + except (TypeError, ValueError): + days = 0 + for raw in (country, region): + key = _norm_loc(raw) + if not key: + continue + entry = locations.setdefault(key, {"types": set(), "max_days": 0}) + if etype: + entry["types"].add(str(etype).strip()) + entry["max_days"] = max(entry["max_days"], days) + + for n in news_items or []: + _add(n.get("country"), n.get("region"), n.get("category"), n.get("estimated_delay")) + if events is not None: + rows = [r.to_dict() for _, r in events.iterrows()] if hasattr(events, "iterrows") else list(events) + for ev in rows: + _add(ev.get("country"), ev.get("region"), ev.get("event_type"), ev.get("impact_days")) + return {"locations": locations} + + +def _evidence_for(name: str, evidence: dict) -> dict | None: + """回傳最能對應 name 的證據項(合併所有命中的地點)。""" + merged = None + for loc, entry in (evidence.get("locations") or {}).items(): + if _loc_hits(name, {loc}): + if merged is None: + merged = {"types": set(), "max_days": 0} + merged["types"] |= set(entry["types"]) + merged["max_days"] = max(merged["max_days"], entry["max_days"]) + return merged + + +def gate_by_evidence(updates: list[dict], events: list[dict], evidence: dict) -> tuple[list, list, list]: + """證據閘門:AI 提到但資料裡沒有的地區一律略過;事件類型/天數超出證據則調整。 + + 回傳 (kept_updates, kept_events, audit)。audit 每筆: + {"kind": "更新"|"事件", "name", "action": "略過"|"調整", "reason"} + 證據表為空(例如無新聞也無事件)時不做閘門、原樣放行——沒有依據可比就不該猜。 """ + if not (evidence.get("locations") or {}): + return list(updates or []), list(events or []), [] + audit: list[dict] = [] + kept_updates = [] + for u in updates or []: + name = str(u.get("display_name") or "").strip() + if _evidence_for(name, evidence) is None: + audit.append({"kind": "更新", "name": name, "action": "略過", + "reason": "新聞與已登錄事件中都沒有此地區"}) + continue + kept_updates.append(u) + + kept_events = [] + for e in events or []: + country = str(e.get("country") or "").strip() + region = str(e.get("region") or "").strip() + # AI 常把「地區」填成完整節點名(如「美國 北美洲」),避免重複成「美國 美國 北美洲」 + name = region if (country and region.startswith(country)) else " ".join(p for p in (country, region) if p) + ev = _evidence_for(name or region or country, evidence) + if ev is None: + audit.append({"kind": "事件", "name": name, "action": "略過", + "reason": "新聞與已登錄事件中都沒有此地區"}) + continue + item = dict(e) + notes = [] + cap = max(EVIDENCE_DAYS_FLOOR, ev["max_days"] * EVIDENCE_DAYS_MULTIPLIER) + try: + days = int(item.get("impact_days") or 0) + except (TypeError, ValueError): + days = 0 + if days > cap: + notes.append(f"延遲 {days} 天超出證據上限({cap} 天),已調整") + item["impact_days"] = cap + etype = str(item.get("event_type") or "其他").strip() + specific_types = {t for t in ev["types"] if t and t != "其他"} + if etype != "其他": + if specific_types and etype not in specific_types: + notes.append(f"類型「{etype}」無新聞依據(證據類型:{'、'.join(sorted(specific_types))}),改為「其他」") + item["event_type"] = "其他" + elif not specific_types: + # 證據只有未分類(其他)新聞:不改寫,但提醒人工確認類型 + audit.append({"kind": "事件", "name": name, "action": "提醒", + "reason": f"類型「{etype}」僅由未分類新聞推得,請人工確認"}) + if notes: + audit.append({"kind": "事件", "name": name, "action": "調整", "reason": ";".join(notes)}) + kept_events.append(item) + return kept_updates, kept_events, audit + + +def _summary_news_context(news_items) -> str: + return "\n".join( + (n.get("title") or "") + " " + (n.get("summary") or "")[:200] + + f" [{n.get('published_at') or n.get('fetched_at') or ''}, 預估延遲: {n.get('estimated_delay') or 0}天]" + for n in (news_items or []) + ) + + +def analyze_heatmap_risk( + news_items=None, *, news_context: str = "", reference_date: str | None = None, + actor=None, persist: bool = True, +) -> dict: + """AI 熱圖摘要(結構化)。 + + - news_items:新聞列(含 country/region/category/estimated_delay)→ 同時當 prompt 素材與證據 + - news_context:舊介面的純文字素材(沒有 news_items 時使用;證據只剩已登錄事件) + - actor 有給且 persist=True 時把結果寫進 risk_ai_summaries(需 RISK_WORKSPACE_WRITE) + 回傳 dict:summary / updates / events / audit / evidence_locations / generated_at / + reference_date / news_count / event_count / summary_id / error + """ + import json + from backend.llm_client import complete_text + + reference_date = reference_date or datetime.now().strftime("%Y-%m-%d") events_df = get_active_risk_events() events_text = "目前尚無已登錄事件。" if events_df is not None and not events_df.empty: # 只列出最近的 15 筆事件作為背景 + # region 為 NaN 時 pandas 值為 truthy,原本會把字面 "nan" 餵給模型(模型真的回了「地區欄位為 nan」) events_text = "\n".join([ - f"- 【{row['event_type']}】區域:{row['region'] or row['country']} (預計延遲:{row['impact_days']}天)" + f"- 【{_clean_text(row['event_type']) or '其他'}】區域:" + f"{' '.join(p for p in (_clean_text(row['country']), _clean_text(row['region'])) if p) or '未填'}" + f" (預計延遲:{row['impact_days']}天)" for _, row in events_df.head(15).iterrows() ]) @@ -428,6 +767,8 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ finally: conn.close() + if news_items: + news_context = _summary_news_context(news_items) prompt = HEATMAP_AI_SUMMARY_PROMPT_V2.format( reference_date=reference_date, events_text=events_text, @@ -449,26 +790,56 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ if v not in name_expansions[r]: name_expansions[r].append(v) + evidence = build_risk_evidence(news_items, events_df) + result = { + "summary": "", + "updates": [], + "events": [], + "audit": [], + "evidence_locations": sorted(evidence["locations"]), + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "reference_date": reference_date, + "news_count": len(news_items or []), + "event_count": 0 if events_df is None else int(len(events_df)), + "summary_id": None, + "error": False, + } try: # issue #27/#47:統一 LLM 入口 + 結構化輸出(JSON)。 - # 合法區域檢核由 _gate_heatmap_updates 執行,prompt 只做平述引導; - # 原本的 UPDATE:/EVENT: 行解析與正文回填 regex 全數移除。 - import json - from backend.llm_client import complete_text + # 合法區域檢核由 _gate_heatmap_updates 執行、證據檢核由 gate_by_evidence 執行, + # prompt 只做平述引導。 raw = (complete_text(prompt, temperature=0.3, json_mode=True, tag="analysis:heatmap") or "").strip() if not raw: - return "AI 摘要失敗:模型未回傳內容。", [], [] + result.update({"summary": "AI 摘要失敗:模型未回傳內容。", "error": True}) + return result payload = json.loads(re.sub(r"```json\s*|```\s*", "", raw)) summary = str(payload.get("摘要") or "").strip() or "(AI 未提供摘要內容)" updates = _gate_heatmap_updates(payload.get("更新"), valid_list, name_expansions) suggested_events = _coerce_heatmap_events(payload.get("事件")) - return summary, updates, suggested_events + updates, suggested_events, audit = gate_by_evidence(updates, suggested_events, evidence) + result.update({"summary": summary, "updates": updates, "events": suggested_events, "audit": audit, + # 模型可能想 1~2 分鐘,「產生時間」以回覆完成為準 + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}) except Exception as e: import traceback traceback.print_exc() - return f"AI 摘要解析失敗:{e}", [], [] + result.update({"summary": f"AI 摘要解析失敗:{e}", "error": True}) + return result + + if persist and actor: + result["summary_id"] = save_ai_risk_summary(result, actor=actor) + return result + + +def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_date: str = "2026-04-11", + model: str | None = None, *, news_items=None, actor=None) -> tuple[str, list[dict], list[dict]]: + """舊介面:回傳 (摘要, 更新, 事件) 三元組。新程式請用 analyze_heatmap_risk 拿完整結果。""" + result = analyze_heatmap_risk( + news_items, news_context=news_context, reference_date=reference_date, actor=actor, + ) + return result["summary"], result["updates"], result["events"] def apply_heatmap_updates(updates, ai_summary=None, *, actor=None): @@ -584,6 +955,45 @@ def generate_communication_draft(api_key: str = "", context: str = "", target_ty +def get_region_exposure(region_key) -> dict: + """單一據點的曝險資訊:未結採購單金額/張數 + 該區供應商數。 + + 「曝險金額」只算 status 不在 (已完成, 已取消) 的採購單;沒有採購單時金額為 0, + 前端應改顯示供應商家數而不是誤導的 $0。 + """ + conn = sqlite3.connect(DB_FILE) + try: + if "|" in str(region_key): + # 熱圖 region_key「國家|地區」→ 精準對到該據點,不展開大區域 + country, region = [part.strip() for part in str(region_key).split("|", 1)] + where_sub = ["COALESCE(s.country, '') = ? AND COALESCE(s.region, '') = ?"] + params_sub = [country, region] + else: + where_sub, params_sub = _get_expanded_region_where(region_key, None, prefix="s.") + supplier_where = " AND ".join(where_sub) or "1=1" + sup_row = conn.execute( + f"""SELECT COUNT(*), COALESCE(SUM(CASE WHEN s.is_official=1 THEN 1 ELSE 0 END), 0) + FROM suppliers s WHERE {supplier_where}""", + tuple(params_sub), + ).fetchone() + po_row = conn.execute( + f"""SELECT COUNT(p.po_id), COALESCE(SUM(p.total_amount), 0) + FROM purchase_orders p + JOIN suppliers s ON p.supplier_id = s.supplier_id + WHERE (p.status IS NULL OR p.status NOT IN ('已完成','已取消')) + AND {supplier_where}""", + tuple(params_sub), + ).fetchone() + finally: + conn.close() + return { + "supplier_count": int(sup_row[0] or 0), + "official_supplier_count": int(sup_row[1] or 0), + "open_po_count": int(po_row[0] or 0), + "open_po_amount": float(po_row[1] or 0), + } + + def get_total_impact_amount(region_key): """計算特定地區受波及的採購總金額 (美元)。""" conn = sqlite3.connect(DB_FILE) @@ -693,7 +1103,7 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): params.extend(params_sub) q = """ SELECT p.po_id, p.supplier_id, s.name as supplier_name, s.country, s.region, - p.estimated_delay_days, p.alternative_suggestion + p.estimated_delay_days, p.alternative_suggestion, p.total_amount, p.status FROM purchase_orders p JOIN suppliers s ON p.supplier_id = s.supplier_id WHERE """ + " AND ".join(where) @@ -732,10 +1142,17 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): alt = str(alt_raw).strip() if (_pd.notna(alt_raw) and alt_raw) else "—" out.append({ "po_id": row["po_id"], + "supplier_id": row["supplier_id"], "supplier_name": row["supplier_name"], + "country": _clean_text(row.get("country")), + "region": _clean_text(row.get("region")), "key_materials": key_materials, "estimated_delay": delay_str, + "estimated_delay_days": int(delay) if (delay is not None and delay == delay) else None, "alternative_suggestion": alt, + "alternative_suggestion_raw": alt if alt != "—" else "", + "total_amount": float(row.get("total_amount") or 0) if row.get("total_amount") == row.get("total_amount") else 0.0, + "status": _clean_text(row.get("status")), }) conn.close() return out @@ -744,8 +1161,12 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): def update_po_impact( po_id, estimated_delay_days=None, alternative_suggestion=None, *, actor=None ): - """更新採購單的預計延遲天數與替代建議。""" - require_capability(actor, ERP_POLICY_WRITE) + """更新採購單的預計延遲天數與替代建議(風險評估註記)。 + + 這兩欄是給步驟 5/L3 決策看的證據,不動採購單本體(供應商、金額、狀態), + 所以歸 RISK_WORKSPACE_WRITE:L2 標記證據、L3 才在提案核准時真正改採購。 + """ + require_capability(actor, RISK_WORKSPACE_WRITE) conn = sqlite3.connect(DB_FILE) if estimated_delay_days is not None: conn.execute("UPDATE purchase_orders SET estimated_delay_days = ? WHERE po_id = ?", (estimated_delay_days, po_id)) @@ -873,10 +1294,15 @@ def what_if_simulation( # ── 風險事件與交期 ──────────────────────────────────────────────────── def get_risk_events_list(limit=20): - """取得風險事件列表(id, event_type, region, country, impact_days, description, created_at)。""" + """取得風險事件列表(id, event_type, region, country, impact_days, description, created_at)。 + + add_risk_event 對同一地區是覆寫(id 不變、created_at 更新),所以「最新」 + 必須依 created_at 排序,否則被更新的舊事件永遠排在後面。 + """ conn = sqlite3.connect(DB_FILE) df = __pd_read( - "SELECT id, event_type, region, country, impact_days, description, created_at, news_id FROM supply_chain_events ORDER BY id DESC LIMIT ?", + "SELECT id, event_type, region, country, impact_days, description, created_at, news_id " + "FROM supply_chain_events ORDER BY COALESCE(created_at, '') DESC, id DESC LIMIT ?", conn, params=(limit,), ) @@ -939,26 +1365,31 @@ def get_historical_event_precedents(): def add_risk_event( event_type, region, country, impact_days, description, news_id=None, *, actor=None ): - """新增或更新風險事件(如果該區域已存在事件則覆蓋)。""" + """新增風險事件;同一 (國家, 地區, 事件類型, 來源新聞) 已存在時更新該筆。 + + 原本只比對 (國家, 地區),導致同一地區先後登錄「罷工」再登錄「地震」時 + 前者被靜默覆蓋、歷史消失。不同類型現在會各自保留,熱圖加權也會依筆數加成。 + 要修改既有事件請用 update_risk_event(event_id, …)。 + """ require_capability(actor, RISK_WORKSPACE_WRITE) conn = sqlite3.connect(DB_FILE) c = conn.cursor() - - # 核心優化:直接覆寫同區域的正式事件 (news_id 為空者) + c.execute( - """SELECT id FROM supply_chain_events - WHERE COALESCE(country, '') = ? AND COALESCE(region, '') = ? AND news_id IS ?""", - (country or "", region or "", news_id) + """SELECT id FROM supply_chain_events + WHERE COALESCE(country, '') = ? AND COALESCE(region, '') = ? + AND COALESCE(event_type, '') = ? AND news_id IS ?""", + (country or "", region or "", event_type or "", news_id) ) existing = c.fetchone() - + if existing: event_id = existing[0] c.execute( - """UPDATE supply_chain_events - SET event_type=?, impact_days=?, description=?, created_at=? + """UPDATE supply_chain_events + SET impact_days=?, description=?, created_at=? WHERE id=?""", - (event_type, impact_days, description or None, datetime.now().strftime("%Y-%m-%d %H:%M"), event_id) + (impact_days, description or None, datetime.now().strftime("%Y-%m-%d %H:%M"), event_id) ) conn.commit() conn.close() @@ -974,6 +1405,37 @@ def add_risk_event( return new_id +def update_risk_event( + event_id, *, event_type=None, impact_days=None, description=None, actor=None +): + """就地修改一筆既有事件(AI 建議天數異動、人工修正)。只更新有給的欄位。 + + 回傳 True 表示有更新到資料;找不到 event_id 回 False。 + """ + require_capability(actor, RISK_WORKSPACE_WRITE) + fields, params = [], [] + if event_type is not None: + fields.append("event_type=?"); params.append(event_type) + if impact_days is not None: + fields.append("impact_days=?"); params.append(int(impact_days)) + if description is not None: + fields.append("description=?"); params.append(description or None) + if not fields: + return False + fields.append("created_at=?") + params.append(datetime.now().strftime("%Y-%m-%d %H:%M")) + params.append(int(event_id)) + conn = sqlite3.connect(DB_FILE) + try: + cur = conn.execute( + f"UPDATE supply_chain_events SET {', '.join(fields)} WHERE id=?", tuple(params) + ) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + def delete_risk_event(event_id, *, actor=None): """刪除一筆風險事件。""" require_capability(actor, RISK_WORKSPACE_WRITE) @@ -1502,3 +1964,39 @@ def __empty_df(): def __pd_concat(a, b): import pandas as pd return pd.concat([a, b], ignore_index=True) + + +def events_for_location(country: str, region: str, events=None) -> list[dict]: + """某據點命中的事件(含新聞登錄與人工登錄),依延遲天數→登錄時間新到舊排序。 + + 前端卡片用它判斷「已有情報/已有應變計畫」,避免每張卡各自重查資料庫。 + """ + if events is None: + events = get_active_risk_events(limit=HEATMAP_EVENT_LOOKBACK) + if events is None: + rows = [] + elif hasattr(events, "iterrows"): + rows = [r.to_dict() for _, r in events.iterrows()] + else: + rows = list(events) + matched = [ev for ev in rows if _event_matches_location(ev, country or "", region or "")] + + def _days(ev): + try: + return int(ev.get("impact_days") or 0) + except (TypeError, ValueError): + return 0 + + matched.sort(key=lambda ev: (_days(ev), str(ev.get("created_at") or "")), reverse=True) + return matched + + +def is_news_event(ev: dict) -> bool: + """news_id 非空 → 由新聞一鍵登錄;否則為人工/AI 建議建立的正式應變事件。""" + news_id = ev.get("news_id") + if news_id is None: + return False + try: + return not pd.isna(news_id) + except (TypeError, ValueError): + return True diff --git a/frontend/components/purchase_proposal_workbench.py b/frontend/components/purchase_proposal_workbench.py index b9b9e00..7b2c2c0 100644 --- a/frontend/components/purchase_proposal_workbench.py +++ b/frontend/components/purchase_proposal_workbench.py @@ -83,6 +83,35 @@ def _render_submission_state() -> bool: return True +_BADGES = {"pending": "⏳ ", "approved": "✅ ", "rejected": "❌ "} + + +def _proposal_badge(item: dict) -> str: + return _BADGES.get((item.get("proposal") or {}).get("status"), "") + + +def _resolve_source_event(selected: dict) -> dict | None: + """步驟 3 選中的事件優先;否則依受影響採購單的供應商地區找最嚴重的正式事件。""" + from backend.supply_chain_risk import events_for_location, get_risk_events_list, is_news_event + + active_id = st.session_state.get("active_risk_event_id") + try: + events = get_risk_events_list(limit=200) + except Exception: + return None + if events is None or events.empty: + return None + if active_id is not None: + hit = events[events["id"] == active_id] + if not hit.empty: + return hit.iloc[0].to_dict() + matched = [ + ev for ev in events_for_location(selected.get("country") or "", selected.get("region") or "", events) + if not is_news_event(ev) + ] + return matched[0] if matched else None + + def render_purchase_proposal_workbench(*, actor: str) -> None: """Render affected PO evidence, candidate suppliers, and proposal submit.""" st.subheader("🧾 替代採購決策提案") @@ -101,11 +130,23 @@ def render_purchase_proposal_workbench(*, actor: str) -> None: st.info("目前沒有含延遲或替代建議的受影響採購單。") return + # 閉環:已核准/待審的明細標出來,避免同一條明細重複提案 + counts = {"pending": 0, "approved": 0, "rejected": 0} + for item in impacted: + status = (item.get("proposal") or {}).get("status") + if status in counts: + counts[status] += 1 + if any(counts.values()): + st.caption( + f"提案狀態:待核准 {counts['pending']} ・ 已核准 {counts['approved']} ・ 已拒絕 {counts['rejected']}" + ) + option_keys = list(range(len(impacted))) selected_index = st.selectbox( "選擇受影響採購品項", option_keys, format_func=lambda index: ( + f"{_proposal_badge(impacted[index])}" f"{impacted[index]['po_id']}|" f"{impacted[index]['product_id']} {impacted[index].get('product_name') or ''}|" f"明細 #{impacted[index]['source_po_item_id']} × {impacted[index]['qty']}|" @@ -115,6 +156,24 @@ def render_purchase_proposal_workbench(*, actor: str) -> None: key="purchase_proposal_affected_line", ) selected = impacted[selected_index] + existing = selected.get("proposal") + if existing and existing.get("status") == "approved": + st.success( + f"此明細的替代採購提案 `{existing['proposal_id']}` 已由 `{existing.get('approver') or 'L3'}` " + f"於 {existing.get('decided_at') or '—'} 核准,替代採購單 `{existing.get('proposed_po_id')}` 已建立。" + "如需再次提案請先確認原因。" + ) + elif existing and existing.get("status") == "pending": + st.info(f"此明細已有提案 `{existing['proposal_id']}` 待 L3 核准;再送一筆會成為新的提案。") + elif existing and existing.get("status") == "rejected": + st.warning( + f"上一筆提案 `{existing['proposal_id']}` 已被拒絕" + + (f":{existing.get('reason')}" if existing.get("reason") else "") + + "。可修正後重新提案。" + ) + + # 提案綁定風險事件:優先用步驟 3 正在分析的事件,否則依供應商地區找最嚴重的正式事件 + source_event = _resolve_source_event(selected) st.dataframe( pd.DataFrame( [ @@ -173,6 +232,14 @@ def render_purchase_proposal_workbench(*, actor: str) -> None: value=int(selected.get("estimated_delay_days") or 0), step=1, ) + if source_event: + st.caption( + f"依據事件 #{source_event['id']}:{source_event.get('event_type')}|" + f"{source_event.get('country') or ''} {source_event.get('region') or ''}|" + f"預估延遲 {source_event.get('impact_days') or 0} 天(L3 審批頁會看到)" + ) + else: + st.caption("找不到對應的正式風險事件;提案仍可送出,但 L3 看不到事件依據。") st.caption(f"提案識別碼:`{proposal_id}`") if st.form_submit_button( "送交 L3 人工核准", type="primary", use_container_width=True @@ -190,6 +257,7 @@ def render_purchase_proposal_workbench(*, actor: str) -> None: ], reason=reason, estimated_delay_days=int(delay_days), + source_event_id=int(source_event["id"]) if source_event else None, actor=actor, ) result = submit_purchase_proposal(proposal, actor=actor) diff --git a/frontend/components/risk_dashboard.py b/frontend/components/risk_dashboard.py index 1731a53..3eb7032 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -1,4 +1,5 @@ import streamlit as st +from frontend.ui_utils import show_error import re import pandas as pd from backend.access_control import ERP_POLICY_WRITE, has_capability @@ -29,24 +30,31 @@ def can_write_erp_policy(actor: str) -> bool: return has_capability(actor, ERP_POLICY_WRITE) -def _auto_refresh_heatmap_ai(api_key, gemini_model): - from backend.supply_chain_risk import get_heatmap_ai_summary - from datetime import datetime - import streamlit as st - news_list = get_news_from_db(limit=10, order_by_latest=True, within_days=30) - news_context = "" - if news_list: - news_context = "\n".join([ - (n.get("title") or "") + " " + (n.get("summary") or "")[:200] - for n in news_list - ]) - ref_date = datetime.now().strftime("%Y-%m-%d") - s, u, evs = get_heatmap_ai_summary(api_key, news_context, reference_date=ref_date, model=gemini_model) - st.session_state["heatmap_ai_summary"] = s - st.session_state["heatmap_updates"] = u - st.session_state["suggested_events"] = evs - if "heatmap_needs_refresh" in st.session_state: - del st.session_state["heatmap_needs_refresh"] +def _render_l1_handoff_notices(*, actor: str) -> None: + """L1 勾「通知 L2」的待確認情報;登錄成事件後自動消失(唯讀提示)。""" + from backend.l1_monitoring import list_l1_notifications_for_l2 + + try: + notices = list_l1_notifications_for_l2(actor=actor) + except PermissionError: + return + except Exception as exc: + show_error("L1 通知讀取失敗", exc) + return + if not notices: + return + with st.container(border=True): + st.markdown(f"**📨 L1 轉來 {len(notices)} 則待確認情報**(在下方「當前全球情報分析」選取後一鍵登錄即可結案)") + for n in notices[:8]: + location = " ".join(part for part in (n["country"], n["region"]) if part) or "未填地區" + line = (f"- 【{n['event_type']}|預估 {n['impact_days']} 天】{n['title'] or '(無標題)'} — {location}" + f"|{n['notified_by'] or 'L1'} 於 {n['notified_at'] or '—'} 通知") + if n.get("note"): + line += f"|備註:{n['note']}" + st.markdown(line) + if len(notices) > 8: + st.caption(f"…另有 {len(notices) - 8} 則") + def render_intelligence_gathering( api_key: str = "", @@ -61,6 +69,7 @@ def render_intelligence_gathering( """ st.subheader("🔍 即時全球情報與事件登錄") st.caption("透過 GNews/RSS 抓取全球供應鏈相關新聞,並利用 AI 自動偵測受影響國家、地區與事件類型(戰爭、氣候、罷工等)。") + _render_l1_handoff_notices(actor=actor) # 更新即時新聞:依供應商國家從 GNews/RSS 抓取並寫入 DB _suppliers = get_suppliers_for_map() @@ -268,6 +277,109 @@ def render_intelligence_gathering( st.success("手動事件已登錄!記得至地圖區更新 AI 摘要。") st.rerun() +def _render_impacted_po_marking(*, active_ev_id: int, region: str, country: str, impact_days: int, actor: str) -> None: + """受影響採購單標記:情報 → 事件 → 【這裡】→ 步驟 5 提案 → L3 核准。 + + 步驟 5 只列「已標上預估延遲或替代建議」的採購單;原本沒有任何畫面會寫這兩欄, + 所以整條鏈在這裡斷掉。這裡用事件地區找出未結採購單,AI 建議延遲/替代來源, + 使用者審核後寫回(RISK_WORKSPACE_WRITE,不動採購單本體)。 + """ + from backend.supply_chain_risk import ( + get_ai_alternative_suggestions, + get_impacted_pos, + update_po_impact, + ) + + with st.expander("🧾 0. 受影響採購單標記 → 送交步驟 5 提案 (Mark Impacted POs)", expanded=True): + try: + impacted = get_impacted_pos(region_key=region or None, country=country or None) + except Exception as exc: + show_error("受影響採購單讀取失敗", exc) + return + if not impacted: + st.info("此事件地區的供應商目前沒有未結採購單,步驟 5 不會有可提案項目。") + return + + marked = [x for x in impacted if x.get("estimated_delay_days") is not None or x.get("alternative_suggestion_raw")] + total_amount = sum(x.get("total_amount") or 0 for x in impacted) + st.caption( + f"事件地區命中 **{len(impacted)}** 張未結採購單(合計 ${total_amount:,.0f})," + f"其中 **{len(marked)}** 張已標記。標記後會出現在下方「步驟 5」供建立替代採購提案。" + ) + + hotspot = " ".join(part for part in (country, region) if part) or "受災地區" + ai_key = f"po_ai_suggest_{active_ev_id}" + col_ai, col_hint = st.columns([1, 2]) + with col_ai: + if st.button("🤖 AI 評估延遲與替代來源", key=f"po_ai_btn_{active_ev_id}", use_container_width=True): + with st.spinner("AI 正在依熱點、供應商與物料庫存評估每張採購單..."): + suggestions = get_ai_alternative_suggestions(impacted_list=impacted, hotspot_name=hotspot) + if suggestions: + st.session_state[ai_key] = {x["po_id"]: x for x in suggestions} + st.toast(f"AI 已為 {len(suggestions)} 張採購單提出建議", icon="🤖") + else: + st.session_state.pop(ai_key, None) + st.warning("AI 未回傳可用建議;你仍可手動填延遲天數與替代建議後標記。") + st.rerun() + with col_hint: + st.caption("沒按 AI 也能標:預設延遲=事件預估天數,替代建議可留空。表格可直接修改。") + + ai_suggestions = st.session_state.get(ai_key, {}) + table_rows = [] + for x in impacted: + sug = ai_suggestions.get(x["po_id"], {}) + default_days = sug.get("estimated_delay_days") or x.get("estimated_delay_days") or impact_days + default_alt = sug.get("alternative_suggestion") or x.get("alternative_suggestion_raw") or "" + table_rows.append({ + "標記": True, + "採購單": x["po_id"], + "供應商": x["supplier_name"], + "關鍵物料": x["key_materials"], + "金額": float(x.get("total_amount") or 0), + "目前": x["estimated_delay"], + "預估延遲 (天)": int(default_days), + "替代建議": default_alt, + }) + edited = st.data_editor( + pd.DataFrame(table_rows), + column_config={ + "標記": st.column_config.CheckboxColumn("標記", default=True), + "採購單": st.column_config.TextColumn("採購單", disabled=True), + "供應商": st.column_config.TextColumn("供應商", disabled=True), + "關鍵物料": st.column_config.TextColumn("關鍵物料(庫存)", disabled=True), + "金額": st.column_config.NumberColumn("金額", format="$%d", disabled=True), + "目前": st.column_config.TextColumn("目前延遲", disabled=True), + "預估延遲 (天)": st.column_config.NumberColumn("預估延遲 (天)", min_value=0, max_value=365, step=1), + "替代建議": st.column_config.TextColumn("替代建議(從哪裡調貨)", width="large"), + }, + hide_index=True, + use_container_width=True, + key=f"po_mark_editor_{active_ev_id}", + ) + selected = edited[edited["標記"] == True] + if st.button( + f"📌 標記 {len(selected)} 張為受影響採購單(寫入延遲與建議)", + key=f"po_mark_btn_{active_ev_id}", type="primary", disabled=len(selected) == 0, + ): + try: + for _, row in selected.iterrows(): + update_po_impact( + row["採購單"], + estimated_delay_days=int(row["預估延遲 (天)"]), + alternative_suggestion=(str(row["替代建議"]).strip() or None), + actor=actor, + ) + except PermissionError: + st.error("此帳號沒有標記受影響採購單的權限。") + return + except Exception as exc: + show_error("標記受影響採購單失敗", exc) + return + st.session_state.pop(ai_key, None) + st.toast(f"✅ 已標記 {len(selected)} 張採購單,步驟 5 可建立提案", icon="🧾") + st.rerun() + + def render_response_execution( api_key: str = "", gnews_api_key: str = "", @@ -381,6 +493,11 @@ def get_ai_safety_multiplier(etype): return mapping.get(etype, 1.0) # 執行與分析細節 (用摺疊式選單以省空間) + _render_impacted_po_marking( + active_ev_id=int(active_ev["id"]), region=region, country=country, + impact_days=impact_days, actor=actor, + ) + with st.expander("🚚 1. 斷鏈庫存預警與應變 (Increase Safety Stock)", expanded=True): if stock_alerts: etype = active_ev.get('event_type', '其他') diff --git a/frontend/components/risk_overview.py b/frontend/components/risk_overview.py index 148e732..ee9357c 100644 --- a/frontend/components/risk_overview.py +++ b/frontend/components/risk_overview.py @@ -8,7 +8,18 @@ build_purchase_order_template_csv, parse_purchase_order_csv, ) -from backend.l1_monitoring import map_purchase_rows_to_events +from backend.l1_monitoring import ( + ALERT_KIND_CANDIDATE, + ALERT_KIND_CONFIRMED, + ALERT_STATUS_NOTIFIED_L2, + CANDIDATE_STATUS_OPTIONS, + CONFIRMED_STATUS_OPTIONS, + get_latest_event_alerts, + get_latest_risk_summary, + load_open_purchase_rows, + map_purchase_rows_to_events, + set_alert_status, +) from backend.supply_chain_risk import ( get_risk_events_list, get_supply_chain_summary_kpis, @@ -17,6 +28,11 @@ from frontend.ui_utils import show_error +_ALERT_WINDOW_OPTIONS = {"近 7 天": 7, "近 30 天": 30, "近 90 天": 90} +_ALERT_LIMIT = 10 +_SEVERITY_ICONS = {"高": "🔴 高", "中": "🟠 中", "低": "🟡 低", "無": "⚪ 無"} + + _L1_DISPLAY_COLUMNS = { "po_id": "採購單", "supplier_id": "供應商", @@ -44,50 +60,262 @@ def _load_supplier_context(supplier_ids: set[str]) -> dict[str, dict]: return {row["supplier_id"]: dict(row) for row in rows} -def _render_latest_event_alerts(events: list[dict]) -> None: +def _location_label(item: dict) -> str: + parts = [part for part in (item.get("country"), item.get("region")) if part] + return "/".join(parts) or "未設定" + + +def _render_latest_event_alerts(*, actor: str) -> None: st.markdown("#### 🚨 最新事件告警") - if not events: - st.info("目前尚無已登錄的供應鏈風險事件。") + header_left, header_right = st.columns([3, 1]) + with header_left: + st.caption( + "已確認事件來自 L2 登錄;「AI 偵測待確認」來自排程或 L2 更新新聞後、" + "尚未登錄為正式事件的情報。每次重新整理都會直接讀取最新資料。" + ) + with header_right: + window_label = st.selectbox( + "告警時間範圍", + list(_ALERT_WINDOW_OPTIONS), + index=1, + key="l1_alert_window", + label_visibility="collapsed", + ) + since_days = _ALERT_WINDOW_OPTIONS[window_label] + + try: + feed = get_latest_event_alerts( + actor=actor, since_days=since_days, limit=_ALERT_LIMIT + ) + except PermissionError: + st.error("此帳號沒有讀取事件告警的權限。") + return + except sqlite3.Error as exc: + show_error("事件告警讀取失敗", exc) return - event_rows = [] - for event in events[:5]: - event_rows.append( + metric_a, metric_b, metric_c = st.columns(3) + metric_a.metric("已確認事件", f"{feed['confirmed_count']} 筆") + metric_b.metric("AI 偵測待確認", f"{feed['candidate_count']} 筆") + metric_c.metric("最高嚴重度", _SEVERITY_ICONS.get(feed["highest_severity"], feed["highest_severity"])) + st.caption(f"統計區間自 {feed['since']} 起 ・ 更新時間 {feed['generated_at']}") + + st.markdown("**已確認事件**") + if not feed["confirmed"]: + st.info("此區間內尚無已登錄的供應鏈風險事件。") + else: + unread = sum(1 for item in feed["confirmed"] if item["ack_status"] == "未讀") + st.caption(f"未讀 {unread} 筆 ・ 狀態改完按「儲存狀態」,重新整理不會歸零。「替代提案」為 L3 對此事件提案的核准進度。") + confirmed_rows = [ + { + "處理狀態": item["ack_status"], + "嚴重度": _SEVERITY_ICONS.get(item["severity"], item["severity"]), + "事件": item["event_type"], + "國家/地區": _location_label(item), + "預估延遲": f"{item['impact_days']} 天", + "替代提案": _proposal_label(item.get("proposals") or {}), + "登錄時間": item["created_at"] or "未記錄", + "來源": item["source"], + "來源新聞": item["news_title"] or "—", + "原文連結": item["news_url"] or "", + "事件說明": item["description"] or "未提供", + "備註": item.get("ack_note") or "", + "_id": item["id"], + } + for item in feed["confirmed"] + ] + edited = st.data_editor( + pd.DataFrame(confirmed_rows), + width="stretch", + hide_index=True, + key=f"l1_confirmed_editor_{since_days}", + disabled=[c for c in confirmed_rows[0] if c not in ("處理狀態", "備註")], + column_config={ + "處理狀態": st.column_config.SelectboxColumn("處理狀態", options=list(CONFIRMED_STATUS_OPTIONS), required=True), + "備註": st.column_config.TextColumn("備註", width="medium"), + "原文連結": st.column_config.LinkColumn("原文連結", display_text="開啟"), + "_id": None, + }, + ) + changed = [ + (int(row["_id"]), row["處理狀態"], row["備註"]) + for (_, row), original in zip(edited.iterrows(), confirmed_rows) + if row["處理狀態"] != original["處理狀態"] or (row["備註"] or "") != (original["備註"] or "") + ] + if st.button(f"💾 儲存狀態({len(changed)} 筆異動)", key="l1_save_confirmed", disabled=not changed): + try: + for event_id, status, note in changed: + set_alert_status(ALERT_KIND_CONFIRMED, event_id, status, actor=actor, note=note or "") + except PermissionError: + st.error("此帳號沒有標記告警狀態的權限。") + except ValueError as exc: + st.error(str(exc)) + else: + st.toast(f"已更新 {len(changed)} 筆告警狀態", icon="💾") + st.rerun() + + st.markdown("**AI 偵測待確認**") + if not feed["candidates"]: + st.success("此區間內沒有尚未登錄的高風險情報。") + else: + notified = sum(1 for item in feed["candidates"] if item["ack_status"] == ALERT_STATUS_NOTIFIED_L2) + st.caption(f"已通知 L2 {notified} 筆。勾選後按「通知 L2」,L2「情報與決策」頁頂端會列出這些情報;L2 登錄成事件後自動從這裡消失。") + candidate_rows = [ { - "事件": event.get("event_type") or "未分類", - "地區": event.get("region") or event.get("country") or "未設定", - "預估延遲": f"{int(event.get('impact_days') or 0)} 天", - "事件說明": event.get("description") or "未提供", + "通知 L2": item["ack_status"] == ALERT_STATUS_NOTIFIED_L2, + "處理狀態": item["ack_status"], + "嚴重度": _SEVERITY_ICONS.get(item["severity"], item["severity"]), + "類型": item["event_type"], + "國家/地區": _location_label(item), + "預估延遲": f"{item['impact_days']} 天", + "情報時間": item["observed_at"] or "未記錄", + "新聞標題": item["title"] or "(無標題)", + "原文連結": item["url"] or "", + "狀態": item["status"], + "備註": item.get("ack_note") or "", + "_news_id": item["news_id"], } + for item in feed["candidates"] + ] + edited = st.data_editor( + pd.DataFrame(candidate_rows), + width="stretch", + hide_index=True, + key=f"l1_candidate_editor_{since_days}", + disabled=[c for c in candidate_rows[0] if c not in ("通知 L2", "處理狀態", "備註")], + column_config={ + "通知 L2": st.column_config.CheckboxColumn("通知 L2"), + "處理狀態": st.column_config.SelectboxColumn("處理狀態", options=list(CANDIDATE_STATUS_OPTIONS), required=True), + "備註": st.column_config.TextColumn("備註", width="medium"), + "原文連結": st.column_config.LinkColumn("原文連結", display_text="開啟"), + "_news_id": None, + }, ) - st.dataframe(pd.DataFrame(event_rows), width="stretch", hide_index=True) + changed = [] + for (_, row), original in zip(edited.iterrows(), candidate_rows): + status = ALERT_STATUS_NOTIFIED_L2 if bool(row["通知 L2"]) else row["處理狀態"] + if status == ALERT_STATUS_NOTIFIED_L2 and not bool(row["通知 L2"]): + status = "已讀" # 取消勾選 → 退回已讀 + if status != original["處理狀態"] or (row["備註"] or "") != (original["備註"] or ""): + changed.append((int(row["_news_id"]), status, row["備註"])) + notify_count = sum(1 for _, status, _ in changed if status == ALERT_STATUS_NOTIFIED_L2) + label = f"📨 通知 L2({notify_count} 則)" if notify_count else f"💾 儲存狀態({len(changed)} 筆異動)" + if st.button(label, key="l1_save_candidates", disabled=not changed): + try: + for news_id, status, note in changed: + set_alert_status(ALERT_KIND_CANDIDATE, news_id, status, actor=actor, note=note or "") + except PermissionError: + st.error("此帳號沒有標記告警狀態的權限。") + except ValueError as exc: + st.error(str(exc)) + else: + st.toast(f"已更新 {len(changed)} 筆情報狀態", icon="📨") + st.rerun() + st.caption("待確認情報需由具 L2 權限的人員在「情報與決策」頁登錄後,才會成為正式事件並進入對映。") -def _render_read_only_mapping(events: list[dict]) -> None: - st.markdown("#### 🔔 L1 告警與通知中心") +def _render_latest_ai_summary(*, actor: str) -> None: + """L2/排程最近一次產生的 AI 風險摘要(唯讀)。""" + st.markdown("#### 🤖 最新 AI 風險摘要") + try: + latest = get_latest_risk_summary(actor=actor) + except PermissionError: + st.error("此帳號沒有讀取 AI 風險摘要的權限。") + return + except sqlite3.Error as exc: + show_error("AI 風險摘要讀取失敗", exc) + return + if not latest: + st.info("尚未產生 AI 風險摘要;由 L2 在「情報與決策」按「產生/更新即時風險摘要」或排程更新新聞後產生。") + return st.caption( - "上傳資料只會在記憶體中進行格式驗證、事件對映與通知預覽," - "不會寫入 ERP 或提案暫存區。Excel 資料請先另存為 UTF-8 CSV。" + f"產生時間 {latest['generated_at']} ・ 由 {latest.get('actor') or '排程'} 產生 ・ " + f"依據 {latest['news_count']} 則新聞、{latest['event_count']} 筆已登錄事件" ) - st.download_button( - "下載唯讀對映 CSV 範本", - data=build_purchase_order_template_csv(), - file_name="l1_purchase_order_monitoring_template.csv", - mime="text/csv", - key="l1_monitor_download_template", + with st.container(border=True): + st.markdown(latest["summary"]) + if latest["events"]: + st.markdown("**AI 建議事件(待 L2 確認)**") + st.dataframe( + pd.DataFrame([ + { + "類型": e.get("event_type") or "其他", + "國家/地區": _location_label(e), + "預估延遲": f"{e.get('impact_days') or 0} 天", + "說明": e.get("description") or "", + } + for e in latest["events"] + ]), + width="stretch", + hide_index=True, + ) + if latest["audit"]: + with st.expander(f"證據檢核:{len(latest['audit'])} 項 AI 建議被略過或調整"): + for item in latest["audit"]: + st.caption(f"{item.get('kind')}「{item.get('name')}」{item.get('action')}:{item.get('reason')}") + + +def _proposal_label(counts: dict) -> str: + parts = [] + if counts.get("approved"): + parts.append(f"✅ 核准 {counts['approved']}") + if counts.get("pending"): + parts.append(f"⏳ 待審 {counts['pending']}") + if counts.get("rejected"): + parts.append(f"❌ 拒絕 {counts['rejected']}") + return "、".join(parts) or "—" + + +def _render_read_only_mapping(events: list[dict], *, actor: str) -> None: + st.markdown("#### 🔔 L1 告警與通知中心") + st.caption( + "對映只在記憶體中進行:把採購單依供應商地區比對已確認事件,產生通知預覽," + "不會寫入 ERP 或提案暫存區。" ) - uploaded = st.file_uploader( - "上傳採購資料 CSV", - type=["csv"], - key="l1_monitor_csv_upload", - help="檔案必須為 UTF-8;上傳與對映均不會修改 ERP。", + source = st.radio( + "採購資料來源", + ("系統內未結採購單", "上傳 CSV"), + horizontal=True, + key="l1_monitor_source", ) - if uploaded is None: - st.info("可下載範本後匯入採購資料,以預覽事件對映與通知結果。") - return + purchase_rows: list[dict] = [] + if source == "系統內未結採購單": + try: + purchase_rows = load_open_purchase_rows(actor=actor) + except PermissionError: + st.error("此帳號沒有讀取採購單的權限。") + return + except sqlite3.Error as exc: + show_error("採購單讀取失敗", exc) + return + if not purchase_rows: + st.info("系統內目前沒有未結採購單;可改用上傳 CSV 預覽對映。") + return + st.caption(f"讀取 {len(purchase_rows)} 條未結採購明細(即時,不需上傳)。") + else: + st.download_button( + "下載唯讀對映 CSV 範本", + data=build_purchase_order_template_csv(), + file_name="l1_purchase_order_monitoring_template.csv", + mime="text/csv", + key="l1_monitor_download_template", + ) + uploaded = st.file_uploader( + "上傳採購資料 CSV", + type=["csv"], + key="l1_monitor_csv_upload", + help="檔案必須為 UTF-8;上傳與對映均不會修改 ERP。", + ) + if uploaded is None: + st.info("可下載範本後匯入採購資料,以預覽事件對映與通知結果。") + return + try: + purchase_rows = parse_purchase_order_csv(uploaded.getvalue()) + except ValueError as exc: + st.error(f"CSV 驗證失敗:{exc}") + return try: - purchase_rows = parse_purchase_order_csv(uploaded.getvalue()) supplier_context = _load_supplier_context( {row["supplier_id"] for row in purchase_rows} ) @@ -143,7 +371,7 @@ def _render_read_only_mapping(events: list[dict]) -> None: key="l1_monitor_download_alerts", ) -def render_risk_overview(): +def render_risk_overview(*, actor: str): """渲染 L1 唯讀閉環:事件告警、熱圖、資料對映與通知預覽。""" st.markdown("#### 📊 供應鏈風險總覽 (Risk Overview)") @@ -177,6 +405,12 @@ def render_risk_overview(): render_risk_heatmap(key="overview_heatmap") st.markdown("
", unsafe_allow_html=True) + _render_latest_event_alerts(actor=actor) + + st.markdown("
", unsafe_allow_html=True) + _render_latest_ai_summary(actor=actor) + + # CSV 對映只比對「已確認」事件;候選情報尚未登錄,不參與對映。 try: event_frame = get_risk_events_list(limit=30) events = [] if event_frame is None or event_frame.empty else event_frame.to_dict("records") @@ -184,6 +418,5 @@ def render_risk_overview(): show_error("風險事件讀取失敗", exc) events = [] - _render_latest_event_alerts(events) st.markdown("
", unsafe_allow_html=True) - _render_read_only_mapping(events) + _render_read_only_mapping(events, actor=actor) diff --git a/frontend/components/supply_map.py b/frontend/components/supply_map.py index 0511191..a6e9318 100644 --- a/frontend/components/supply_map.py +++ b/frontend/components/supply_map.py @@ -4,7 +4,8 @@ from backend.supply_chain_news import get_news_from_db from backend.supply_chain_risk import ( get_risk_heatmap_data, - get_heatmap_ai_summary, + analyze_heatmap_risk, + get_latest_ai_risk_summary, apply_heatmap_updates, upsert_risk_heatmap, reset_risk_heatmap_to_initial, @@ -31,6 +32,22 @@ def _wrap_text(text, width=40): return "
".join(lines) +def _store_summary_result(result: dict) -> None: + """analyze_heatmap_risk / get_latest_ai_risk_summary 的結果 → session_state(產生與載入共用)。""" + st.session_state["heatmap_ai_summary"] = result.get("summary") or "" + st.session_state["heatmap_updates"] = list(result.get("updates") or []) + st.session_state["suggested_events"] = [dict(e) for e in (result.get("events") or [])] + st.session_state["heatmap_ai_meta"] = { + "generated_at": result.get("generated_at"), + "actor": result.get("actor"), + "news_count": result.get("news_count", 0), + "event_count": result.get("event_count", 0), + "audit": list(result.get("audit") or []), + "summary_id": result.get("summary_id"), + "error": bool(result.get("error")), + } + + def render_risk_heatmap(key: str = "risk_heatmap", heatmap_rows=None): """僅渲染風險熱圖 (Plotly Chart)。""" if heatmap_rows is None: @@ -70,196 +87,197 @@ def render_risk_heatmap(key: str = "risk_heatmap", heatmap_rows=None): ) st.plotly_chart(fig, use_container_width=True, key=key) +_BADGE_STYLE = "font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; display: inline-block; margin-bottom: 5px;" +_CARD_BADGES = { + "view": ("#D1FAE5", "#065F46", "✅ 應變執行中"), + "update": ("#FEF3C7", "#92400E", "⚠️ 數據異動(建議更新)"), + "ready": ("#DBEAFE", "#1E40AF", "⚡ AI 建議啟動應變"), + "news": ("#EDE9FE", "#5B21B6", "📰 已登錄情報"), + "add": ("#F3F4F6", "#374151", "🔍 待評估"), +} + + +def _split_display_name(display_name: str) -> tuple[str, str]: + parts = (display_name or "").split(" ", 1) + return parts[0].strip(), (parts[1].strip() if len(parts) > 1 else "") + + +def _find_suggestion(display_name: str, suggested_events) -> dict | None: + """AI 建議事件裡是否有對應此據點的(沿用寬鬆的包含比對)。""" + target = (display_name or "").strip().lower() + for sev in suggested_events or []: + s_r = (sev.get("region") or "").strip().lower() + s_c = (sev.get("country") or "").strip().lower() + if (s_r and s_r in target) or (s_c and s_c in target) or (target and (target in s_r or target in s_c)): + return sev + return None + + +def _exposure_line(exposure: dict) -> str: + """曝險金額只在真的有未結採購單時才顯示金額;否則說清楚為什麼沒有數字。""" + if exposure.get("open_po_count"): + return (f"曝險金額: ${exposure['open_po_amount']:,.0f}" + f"({exposure['open_po_count']} 張未結採購單)") + return (f"無未結採購單 ・ 供應商 {exposure.get('supplier_count', 0)} 家" + f"(正式 {exposure.get('official_supplier_count', 0)} 家)") + + +def _card_state(found_ev, match_suggest, news_events) -> str: + if found_ev is not None: + suggested_days = int(match_suggest.get("impact_days", 0)) if match_suggest else None + actual_days = int(found_ev.get("impact_days") or 0) + # 只有當 AI 建議的天數與現有計畫「不一致」時,才顯示「更新應變建議」 + return "update" if (suggested_days is not None and suggested_days != actual_days) else "view" + if match_suggest: + return "ready" + if news_events: + return "news" + return "add" + + def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): """區域風險快速分析小卡。""" + from backend.supply_chain_risk import ( + add_risk_event, + events_for_location, + get_active_risk_events, + get_region_exposure, + is_news_event, + update_risk_event, + ) + if heatmap_rows is None: heatmap_rows = get_risk_heatmap_data() - if heatmap_rows: - # 如果有新情報登錄且尚未重新摘要,給予提示 - if st.session_state.get("heatmap_needs_refresh"): - st.warning("⚠️ 偵測到新的風險登錄,請點擊下方「產生分析」以更新地圖與摘要。") - - high_risk_regions = [r for r in heatmap_rows if (r.get("risk_pct") or 0) > 20] - # 依風險百分比由高至低排序 - high_risk_regions.sort(key=lambda x: x.get("risk_pct") or 0, reverse=True) - - if high_risk_regions: - st.markdown("#### ⚡ 區域風險快速分析") - st.caption("點擊下方區域即可快速登錄事件或查看現有應變計畫。點擊下方的「分析衝擊」會自動帶您進入詳細應變區。") - - # 建立 3 列的小卡片 - cols = st.columns(3) - from backend.supply_chain_risk import get_active_risk_events - active_events = get_active_risk_events() - - for i, reg in enumerate(high_risk_regions[:6]): # 最多顯示 6 個 - with cols[i % 3]: - color = "#EF4444" if reg['risk_pct'] > 60 else "#F59E0B" - - # 1. 取得總結名稱並計算曝險金額 - reg_display = reg.get('display_name') or "" - from backend.supply_chain_risk import get_total_impact_amount - impact_amt = get_total_impact_amount(reg_display) - impact_display = f"${impact_amt:,.0f}" - - # 2. 尋找現有正式事件 (非新聞初篩登錄) - from backend.supply_chain_risk import get_active_risk_events - active_events = get_active_risk_events() - found_ev = None - if active_events is not None and not active_events.empty: - # 分別對應國家與地區 - dn_parts = (reg.get('display_name') or "").split(" ", 1) - c_name = dn_parts[0].strip().lower() - r_name = dn_parts[1].strip().lower() if len(dn_parts) > 1 else "" - - for _, ev in active_events.iterrows(): - # 只比對正式事件 (news_id 為空) - if pd.isna(ev.get('news_id')): - ev_c = (ev.get('country') or "").strip().lower() - ev_r = (ev.get('region') or "").strip().lower() - if ev_c == c_name and ev_r == r_name: - found_ev = ev - break - - # 3. 比對 AI 最新建議 (檢查是否天數有更新) - s_events = st.session_state.get("suggested_events", []) - match_suggest = None - for sev in s_events: - s_r, s_c = (sev.get('region') or "").strip().lower(), (sev.get('country') or "").strip().lower() - if (s_r and s_r in reg_display.lower()) or (s_c and s_c in reg_display.lower()): - match_suggest = sev - break - - # 判定按鈕狀態 - btn_state = "add" # 待登錄 - if found_ev is not None: - # 只有當 AI 建議的天數與現有計畫「不一致」時,才顯示「更新應變建議」 - # 這樣一鍵更新後,兩者數據一致,狀態就會自動變回綠色的「查看分析」 - suggested_days = int(match_suggest.get('impact_days', 0)) if match_suggest else None - actual_days = int(found_ev.get('impact_days', 0)) - - if suggested_days is not None and suggested_days != actual_days: - btn_state = "update" - else: - btn_state = "view" # 執行中 - elif match_suggest: - btn_state = "ready" # 建議啟動 - - status_badge = "" - if btn_state == "view": - status_badge = '
✅ 應變執行中
' - elif btn_state == "update": - status_badge = '
⚠️ 數據異動(建議更新)
' - elif btn_state == "ready": - status_badge = '
⚡ AI 建議啟動應變
' - else: - status_badge = '
🔍 待評估
' - - st.markdown(f""" -
- {status_badge} -
{reg['display_name']}
-
{reg['risk_pct']:.0f}% 風險
-
曝險金額: {impact_display}
-
- """, unsafe_allow_html=True) - - if btn_state == "view": - if st.button(f"📊 查看分析", key=f"{key}_quick_anal_{reg.get('region_key') or i}_{i}", use_container_width=True, type="secondary"): - st.session_state["active_risk_event_id"] = found_ev["id"] - st.rerun() - elif btn_state == "update": - if st.button(f"🔄 更新應變建議", key=f"{key}_upd_{reg.get('region_key') or i}", use_container_width=True, type="primary"): - # 執行覆寫更新 - impact_days = match_suggest.get('impact_days', 7) - etype = match_suggest.get('event_type', '其他') - desc = f"【AI 建議更新】{match_suggest.get('description', '')}" - dn_parts = reg_display.split(" ", 1) - ev_c = dn_parts[0].strip() - ev_r = dn_parts[1].strip() if len(dn_parts) > 1 else "" - - from backend.supply_chain_risk import add_risk_event - add_risk_event( - etype, ev_r, ev_c, impact_days, desc, actor=actor - ) - st.toast(f"✅ 已將 {reg_display} 的數據更新", icon="🔄") - st.rerun() - elif btn_state == "ready": - if st.button("⚡ 啟動 AI 建議應變", key=f"{key}_heat_ana_ready_{i}_{reg_display}", use_container_width=True, type="primary"): - st.session_state["selected_region_for_response"] = reg_display - impact_days = match_suggest.get('impact_days', 7) - etype = match_suggest.get('event_type', '其他') - desc = f"AI 熱圖分析:{match_suggest.get('description', '')}" - dn_parts = reg_display.split(" ", 1) - ev_c = dn_parts[0].strip() - ev_r = dn_parts[1].strip() if len(dn_parts) > 1 else "" - from backend.supply_chain_risk import add_risk_event - add_risk_event( - etype, ev_r, ev_c, impact_days, desc, actor=actor - ) - st.session_state["heatmap_needs_refresh"] = True - st.toast(f"📍 已啟動 {reg_display} 應變計畫", icon="🤖") - st.rerun() + if not heatmap_rows: + return + # 如果有新情報登錄且尚未重新摘要,給予提示 + if st.session_state.get("heatmap_needs_refresh"): + st.warning("⚠️ 偵測到新的風險登錄,請點擊上方「產生/更新即時風險摘要」以更新地圖與摘要。") + + high_risk_regions = [r for r in heatmap_rows if (r.get("risk_pct") or 0) > 20] + # 依風險百分比由高至低排序 + high_risk_regions.sort(key=lambda x: x.get("risk_pct") or 0, reverse=True) + if not high_risk_regions: + return + + st.markdown("#### ⚡ 區域風險快速分析") + st.caption("卡片依風險由高至低。已有情報的據點可一鍵建立應變計畫;已有計畫的據點可查看分析或套用 AI 更新。") + + # 事件只查一次,每張卡片再從記憶體篩選(原本每張卡各查一次資料庫) + all_events = get_active_risk_events(limit=200) + s_events = st.session_state.get("suggested_events", []) + + cols = st.columns(3) + for i, reg in enumerate(high_risk_regions[:6]): # 最多顯示 6 個 + with cols[i % 3]: + color = "#EF4444" if reg["risk_pct"] > 60 else "#F59E0B" + reg_display = reg.get("display_name") or "" + region_key = reg.get("region_key") or reg_display + ev_country, ev_region = _split_display_name(reg_display) + + exposure = get_region_exposure(region_key) + matched = events_for_location(ev_country, ev_region, all_events) + news_events = [ev for ev in matched if is_news_event(ev)] + formal_events = [ev for ev in matched if not is_news_event(ev)] + found_ev = formal_events[0] if formal_events else None # 已依天數排序,取最嚴重者 + match_suggest = _find_suggestion(reg_display, s_events) + btn_state = _card_state(found_ev, match_suggest, news_events) + + bg, fg, label = _CARD_BADGES[btn_state] + if btn_state == "news": + label = f"📰 已登錄 {len(news_events)} 則情報" + status_badge = f'
{label}
' + reason = reg.get("risk_reason") or "" + + st.markdown(f""" +
+ {status_badge} +
{reg_display}
+
{reg['risk_pct']:.0f}% 風險
+
依據:{reason}
+
{_exposure_line(exposure)}
+
+ """, unsafe_allow_html=True) + + if btn_state == "view": + if st.button("📊 查看分析", key=f"{key}_quick_anal_{region_key}_{i}", use_container_width=True, type="secondary"): + st.session_state["active_risk_event_id"] = found_ev["id"] + st.rerun() + elif btn_state == "update": + if st.button("🔄 更新應變建議", key=f"{key}_upd_{region_key}", use_container_width=True, type="primary"): + # 就地更新同一筆事件,不再靠「同區域覆寫」的副作用 + update_risk_event( + found_ev["id"], + event_type=match_suggest.get("event_type", found_ev.get("event_type")), + impact_days=match_suggest.get("impact_days", 7), + description=f"【AI 建議更新】{match_suggest.get('description', '')}", + actor=actor, + ) + st.toast(f"✅ 已將 {reg_display} 的數據更新", icon="🔄") + st.rerun() + elif btn_state == "ready": + if st.button("⚡ 啟動 AI 建議應變", key=f"{key}_heat_ana_ready_{i}_{reg_display}", use_container_width=True, type="primary"): + st.session_state["selected_region_for_response"] = reg_display + add_risk_event( + match_suggest.get("event_type", "其他"), ev_region, ev_country, + match_suggest.get("impact_days", 7), + f"AI 熱圖分析:{match_suggest.get('description', '')}", + actor=actor, + ) + st.session_state["heatmap_needs_refresh"] = True + st.toast(f"📍 已啟動 {reg_display} 應變計畫", icon="🤖") + st.rerun() + elif btn_state == "news": + # 用最嚴重那則情報的類型/天數建立正式應變事件,不再硬塞「其他/7 天」 + top = news_events[0] + top_days = int(top.get("impact_days") or 0) or 7 + top_type = top.get("event_type") or "其他" + if st.button(f"🏗️ 建立應變計畫({top_type}・{top_days} 天)", key=f"{key}_from_news_{i}_{region_key}", use_container_width=True, type="primary"): + st.session_state["selected_region_for_response"] = reg_display + add_risk_event( + top_type, ev_region, ev_country, top_days, + f"依 {len(news_events)} 則已登錄情報建立:{(top.get('description') or '')[:80]}", + actor=actor, + ) + st.session_state["heatmap_needs_refresh"] = True + st.toast(f"📍 已依情報建立 {reg_display} 應變計畫", icon="📰") + st.rerun() + else: + if st.button("🏗️ 加入應變計畫", key=f"{key}_heat_ana_manual_{i}_{reg_display}", use_container_width=True): + st.session_state["selected_region_for_response"] = reg_display + add_risk_event( + "其他", ev_region, ev_country, 7, f"手動加入:偵測到 {reg_display} 高風險。", actor=actor + ) + st.session_state["heatmap_needs_refresh"] = True + st.rerun() + + if len(high_risk_regions) > 6: + st.markdown("
", unsafe_allow_html=True) + with st.expander(f"➕ 查看並登錄其他 {len(high_risk_regions) - 6} 個高風險區域", expanded=False): + other_regs = high_risk_regions[6:] + c1, c2, c3 = st.columns([2, 1, 1]) + with c1: + opt_names = [f"{r['display_name']} ({r['risk_pct']}%)" for r in other_regs] + sel_idx = st.selectbox("選擇其他高風險區域", range(len(opt_names)), format_func=lambda i: opt_names[i], key=f"{key}_other_reg_sel", label_visibility="collapsed") + selected_r = other_regs[sel_idx] + with c2: + exposure = get_region_exposure(selected_r.get("region_key") or selected_r.get("display_name")) + st.markdown(f"
{_exposure_line(exposure)}
", unsafe_allow_html=True) + with c3: + if st.button("🏗️ 加入應變計畫", key=f"{key}_other_reg_btn", use_container_width=True, type="secondary"): + sel_country, sel_region = _split_display_name(selected_r["display_name"]) + match = _find_suggestion(selected_r["display_name"], s_events) + if match: + impact_days = match.get("impact_days", 7) + etype = match.get("event_type", "其他") + desc = f"AI 熱圖分析建議:{match.get('description', '建議登錄應變計畫')}" else: - if st.button("🏗️ 加入應變計畫", key=f"{key}_heat_ana_manual_{i}_{reg_display}", use_container_width=True): - st.session_state["selected_region_for_response"] = reg_display - impact_days, etype, desc = 7, "其他", f"手動加入:偵測到 {reg_display} 高風險。" - dn_parts = reg_display.split(" ", 1) - ev_c = dn_parts[0].strip() - ev_r = dn_parts[1].strip() if len(dn_parts) > 1 else "" - from backend.supply_chain_risk import add_risk_event - add_risk_event( - etype, ev_r, ev_c, impact_days, desc, actor=actor - ) - st.session_state["heatmap_needs_refresh"] = True - st.rerun() - - if len(high_risk_regions) > 6: - st.markdown("
", unsafe_allow_html=True) - with st.expander(f"➕ 查看並登錄其他 {len(high_risk_regions) - 6} 個高風險區域", expanded=False): - other_regs = high_risk_regions[6:] - c1, c2, c3 = st.columns([2, 1, 1]) - with c1: - opt_names = [f"{r['display_name']} ({r['risk_pct']}%)" for r in other_regs] - sel_idx = st.selectbox("選擇其他高風險區域", range(len(opt_names)), format_func=lambda i: opt_names[i], key=f"{key}_other_reg_sel", label_visibility="collapsed") - selected_r = other_regs[sel_idx] - with c2: - # 顯示曝險金額 - from backend.supply_chain_risk import get_total_impact_amount - impact_amt = get_total_impact_amount(selected_r.get('display_name')) - st.markdown(f"
曝險金額: ${impact_amt:,.0f}
", unsafe_allow_html=True) - with c3: - if st.button("🏗️ 加入應變計畫", key=f"{key}_other_reg_btn", use_container_width=True, type="secondary"): - import re - clean_loc = re.sub(r'[\(\d\.%\)]', '', selected_r['display_name']).strip() - s_events = st.session_state.get("suggested_events", []) - # 更加寬容的匹配 - def find_match(r_name, evs): - for e in evs: - sr, sc = (e.get('region') or "").strip(), (e.get('country') or "").strip() - if (sr and sr in r_name) or (sc and sc in r_name) or (r_name in sr) or (r_name in sc): - return e - return None - - match = find_match(selected_r['display_name'], s_events) - if match: - impact_days = match.get('impact_days', 7) - etype = match.get('event_type', '其他') - desc = f"AI 熱圖分析建議:{match.get('description', '建議登錄應變計畫')}" - else: - impact_days, etype, desc = 7, "其他", f"快速登錄:AI 偵測到 {selected_r['display_name']} 之 {selected_r['risk_pct']}% 地理風險。" - from backend.supply_chain_risk import add_risk_event - new_id = add_risk_event( - etype, - clean_loc, - clean_loc, - impact_days, - desc, - actor=actor, - ) - st.session_state["heatmap_needs_refresh"] = True - if match: st.toast(f"📍 已採用 AI 建議之 {impact_days} 天延遲 (類型: {etype})", icon="🤖") - st.rerun() + impact_days, etype, desc = 7, "其他", f"快速登錄:AI 偵測到 {selected_r['display_name']} 之 {selected_r['risk_pct']}% 地理風險。" + add_risk_event(etype, sel_region, sel_country, impact_days, desc, actor=actor) + st.session_state["heatmap_needs_refresh"] = True + if match: + st.toast(f"📍 已採用 AI 建議之 {impact_days} 天延遲 (類型: {etype})", icon="🤖") + st.rerun() def render_supply_chain_map( api_key: str, @@ -279,55 +297,69 @@ def render_supply_chain_map( # AI 摘要(使用最近最新新聞) st.markdown("**AI 摘要**") - news_context = "" + news_items = [] try: - news_list = get_news_from_db(limit=10, order_by_latest=True, within_days=30) - if news_list: - news_context = "\\n".join([ - (n.get("title") or "") + " " + (n.get("summary") or "")[:200] + - f" [{n.get('published_at') or n.get('fetched_at') or ''}, 預估延遲: {n.get('estimated_delay') or 0}天]" - for n in news_list - ]) + news_items = get_news_from_db(limit=10, order_by_latest=True, within_days=30) or [] except Exception: pass from datetime import datetime ref_date = datetime.now().strftime("%Y-%m-%d") + + # 頁面剛開或重新整理:session 沒有摘要就載入最近一次落地的結果(L2 換頁不會再遺失) + if "heatmap_ai_summary" not in st.session_state and not st.session_state.get("heatmap_summary_dismissed"): + latest = get_latest_ai_risk_summary() + if latest: + _store_summary_result(latest) + col_ai_btn, col_reset = st.columns(2) with col_ai_btn: if st.button("🔄 產生/更新即時風險摘要", key="heatmap_ai_btn"): - with st.spinner("AI 正在分析情報並偵測風險等級..."): - summary_text, updates, suggested_events = get_heatmap_ai_summary(api_key, news_context, reference_date=ref_date, model=gemini_model) - st.session_state["heatmap_ai_summary"] = summary_text - st.session_state["heatmap_updates"] = updates - st.session_state["suggested_events"] = suggested_events + with st.spinner("AI 正在分析情報並偵測風險等級(思考型模型約需 1~2 分鐘)..."): + result = analyze_heatmap_risk(news_items, reference_date=ref_date, actor=actor) + _store_summary_result(result) + st.session_state.pop("heatmap_summary_dismissed", None) if "heatmap_needs_refresh" in st.session_state: del st.session_state["heatmap_needs_refresh"] st.rerun() with col_reset: if st.button("🔄 重置為初始熱圖", key="reset_heatmap_btn"): reset_risk_heatmap_to_initial(actor=actor) - for key in ["heatmap_ai_summary", "heatmap_updates", "suggested_events"]: + for key in ["heatmap_ai_summary", "heatmap_updates", "suggested_events", "heatmap_ai_meta"]: if key in st.session_state: del st.session_state[key] + st.session_state["heatmap_summary_dismissed"] = True st.success("已重置為初始熱圖。") st.rerun() if "heatmap_ai_summary" in st.session_state: # issue #47 P1-1:摘要已由後端以結構化 JSON 產出(純敘事 markdown), # 原本剝離 UPDATE:/EVENT: 技術指令行的 regex 邏輯不再需要。 + meta = st.session_state.get("heatmap_ai_meta") or {} with st.container(border=True): st.markdown("### 🤖 AI 供應鏈與地理風險深度分析") + if meta.get("generated_at"): + st.caption( + f"產生時間 {meta['generated_at']} ・ 依據 {meta.get('news_count', 0)} 則新聞、" + f"{meta.get('event_count', 0)} 筆已登錄事件" + + (f" ・ 由 {meta['actor']} 產生" if meta.get("actor") else "") + ) st.markdown(st.session_state["heatmap_ai_summary"]) - + audit = meta.get("audit") or [] + if audit: + with st.expander(f"🔎 證據檢核:{len(audit)} 項 AI 建議被略過或調整", expanded=False): + st.caption("AI 提到但新聞與已登錄事件裡都沒有的地區會被略過;延遲天數或事件類型超出證據範圍的會被調整。") + for item in audit: + st.markdown(f"- {item.get('kind')}「**{item.get('name')}**」{item.get('action')}:{item.get('reason')}") + # --- 選擇性帶入:風險建議值 (Selective Apply Risk Updates) --- # 核心策略:完全使用熱圖節點清單(供應商產生),而不依賴 AI 的名稱自由發揮 # AI 的更新建議只用來「查詢風險百分比」,最後對應到正確的熱圖節點名稱 heatmap_rows_for_update = get_risk_heatmap_data() h_updates_raw = st.session_state.get("heatmap_updates", []) - + if heatmap_rows_for_update: st.markdown("##### 🎯 審核並套用 AI 風險建議") st.caption("下表依照您的供應商據點清單產生,AI 的建議風險值已對應至每個確切節點。") - + # 建立 AI 更新字典:key 為國家名(或完整節點名),value 為風險百分比 ai_risk_by_name: dict = {} for u in h_updates_raw: @@ -335,34 +367,33 @@ def render_supply_chain_map( pct = u.get("risk_pct") if name and pct is not None: ai_risk_by_name[name] = pct - + # 為每個熱圖節點找出 AI 建議的風險值與延遲天數 table_rows = [] s_events = st.session_state.get("suggested_events", []) - + for row in heatmap_rows_for_update: node_name = row.get("display_name", "") node_country = node_name.split(" ")[0] if " " in node_name else node_name - + # 1. 匹配風險百分比 risk_val = ai_risk_by_name.get(node_name) or ai_risk_by_name.get(node_country) - + # 2. 匹配建議延遲天數 (從 suggested_events 找) suggested_days = 7 - for sev in s_events: - s_reg, s_cnt = (sev.get('region') or "").strip(), (sev.get('country') or "").strip() - if (s_reg and s_reg in node_name) or (s_cnt and s_cnt in node_name) or (node_name in s_reg) or (node_name in s_cnt): - suggested_days = sev.get('impact_days', 7) - break - + sev = _find_suggestion(node_name, s_events) + if sev: + suggested_days = sev.get("impact_days", 7) + if risk_val is not None: table_rows.append({ - "套用": True, - "地區": node_name, + "套用": True, + "地區": node_name, + "目前 (%)": float(row.get("risk_pct") or 0), "預估風險 (%)": float(risk_val), "預估延遲 (天)": int(suggested_days) }) - + if table_rows: df_upd = pd.DataFrame(table_rows) edited_risk_df = st.data_editor( @@ -370,29 +401,25 @@ def render_supply_chain_map( column_config={ "套用": st.column_config.CheckboxColumn("是否套用", default=True), "地區": st.column_config.TextColumn("熱點名稱", disabled=True), - "預估風險 (%)": st.column_config.NumberColumn("影響 %", min_value=0, max_value=100, step=1), + "目前 (%)": st.column_config.NumberColumn("目前 %", disabled=True), + "預估風險 (%)": st.column_config.NumberColumn("AI 建議 %", min_value=0, max_value=100, step=1), "預估延遲 (天)": st.column_config.NumberColumn("延遲天數", min_value=0, max_value=365, step=1) }, hide_index=True, use_container_width=True, key="ai_risk_editor" ) - + sel_risks = edited_risk_df[edited_risk_df["套用"] == True] if st.button(f"📥 套用打勾的 {len(sel_risks)} 個地區風險至地圖", key="apply_ai_risk_btn", type="primary", disabled=len(sel_risks)==0): - from backend.supply_chain_risk import apply_heatmap_updates final_updates = [{"display_name": r["地區"], "risk_pct": r["預估風險 (%)"]} for _, r in sel_risks.iterrows()] - + # 🧪 關鍵同步:將使用者手動修改的天數寫回 suggested_events current_suggested = st.session_state.get("suggested_events", []) for _, edited_row in sel_risks.iterrows(): - reg_name = edited_row["地區"] - new_days = edited_row["預估延遲 (天)"] - for sev in current_suggested: - s_reg, s_cnt = (sev.get('region') or "").strip(), (sev.get('country') or "").strip() - if (s_reg and s_reg in reg_name) or (s_cnt and s_cnt in reg_name) or (reg_name in s_reg) or (reg_name in s_cnt): - sev["impact_days"] = int(new_days) - break + sev = _find_suggestion(edited_row["地區"], current_suggested) + if sev: + sev["impact_days"] = int(edited_row["預估延遲 (天)"]) st.session_state["suggested_events"] = current_suggested cnt = apply_heatmap_updates( @@ -411,9 +438,6 @@ def render_supply_chain_map( st.rerun() else: st.info("AI 本次分析未偵測到與您供應商節點直接相關的變動建議。") - - # 移除原有的「審核 AI 偵測到之新事件」區塊(依需求隱藏) - pass else: st.caption("提示:點擊「即時全球情報」區塊的「更新即時新聞」後,系統會自動同步更新此熱圖與 AI 摘要。") diff --git a/frontend/page_agent_dashboard.py b/frontend/page_agent_dashboard.py index b9d1e21..1227d45 100644 --- a/frontend/page_agent_dashboard.py +++ b/frontend/page_agent_dashboard.py @@ -12,6 +12,7 @@ from backend.access_control import load_principal from backend.agent_registry import AGENTS, get_tools_for_agent, get_agent_for_tool from backend.agent_logger import ( + get_reversal_record, get_pending_list, get_action_logs, approve_action, @@ -21,6 +22,7 @@ ) from backend.database import run_query from backend.purchase_proposals import ( + get_purchase_proposal_context, ApprovalDecision, decide_purchase_proposal, get_purchase_operation_timeline, @@ -182,8 +184,40 @@ def format_parameters_to_chinese(tool_name: str, args) -> str: return ", ".join(parts) -def _render_domain_proposal_evidence(proposal) -> None: +def _render_proposal_context(proposal, principal) -> None: + """L2 的事件依據與採購單註記:讓 L3 不用回 L2 頁翻就能判斷。""" + try: + context = get_purchase_proposal_context(proposal, actor=principal.username) + except (PermissionError, ValueError) as exc: + st.caption(f"(無法讀取事件依據:{exc})") + return + event = context.get("event") + if event: + location = " ".join(part for part in (event.get("country"), event.get("region")) if part) or "未填地區" + st.markdown( + f"**風險事件依據**:#{event['id']} `{event.get('event_type') or '未分類'}`|{location}|" + f"預估延遲 {event.get('impact_days') or 0} 天|登錄於 {event.get('created_at') or '—'}" + ) + if event.get("description"): + st.caption(f"事件說明:{event['description'][:160]}") + if event.get("news_url"): + st.caption(f"來源新聞:[{event.get('news_title') or '開啟'}]({event['news_url']})") + else: + st.caption("此提案未綁定風險事件(舊提案或 L2 未選事件)。") + po = context.get("affected_po") + if po: + st.caption( + f"受影響採購單註記:{po.get('supplier_name') or po.get('po_id')}({po.get('country') or ''} {po.get('region') or ''})|" + f"狀態 {po.get('status') or '—'}|金額 ${float(po.get('total_amount') or 0):,.0f}|" + f"L2 標記延遲 {po.get('estimated_delay_days') if po.get('estimated_delay_days') is not None else '—'} 天" + + (f"|建議:{po['alternative_suggestion'][:80]}" if po.get("alternative_suggestion") else "") + ) + + +def _render_domain_proposal_evidence(proposal, principal=None) -> None: """Show the immutable business evidence separately from approval state.""" + if principal is not None: + _render_proposal_context(proposal, principal) st.markdown(f"**受影響採購單**:`{proposal.affected_po_id}`") st.markdown( f"**供應來源變更**:`{proposal.original_supplier_id}` → " @@ -267,7 +301,7 @@ def _render_purchase_approval_dashboard(principal, pending_list, approval_histor st.error(f"提案證據驗證失敗,已停止決策:{evidence_error}") continue if domain_proposal is not None: - _render_domain_proposal_evidence(domain_proposal) + _render_domain_proposal_evidence(domain_proposal, principal) _render_operation_timeline(item["operation_id"], principal) if item.get("requester_username") == principal.username: @@ -364,7 +398,7 @@ def _render_purchase_approval_dashboard(principal, pending_list, approval_histor if evidence_error: st.error(f"提案證據驗證失敗:{evidence_error}") elif domain_proposal is not None: - _render_domain_proposal_evidence(domain_proposal) + _render_domain_proposal_evidence(domain_proposal, principal) _render_operation_timeline(item["operation_id"], principal) if item["reason"]: st.markdown(f"**拒絕原因**:{item['reason']}") @@ -575,13 +609,22 @@ def render(username: str = ""): history_action = _history_action_kind( item["status"], item["tool"], current_role ) - if history_action == "rollback": + reversed_record = ( + get_reversal_record(item["id"]) if history_action == "rollback" else None + ) + if history_action == "rollback" and reversed_record: + # 沖銷是補償交易,重按會再扣一次庫存/再取消一次訂單:已沖銷就不給按 + st.caption(f"🔁 已沖銷於 {reversed_record['timestamp']}") + elif history_action == "rollback": # 沖銷(補償交易):走 Gateway 執行、寫入 action log 供稽核。 # 不再把單號重置回 pending —— 沖銷本身已核准人一次確認, # 不需要再進一次審批單讓同一位管理員自己審自己。 if st.button("🔄 沖銷", key=f"retry_{item['id']}", use_container_width=True): from backend.tool_gateway import gateway ok, msg = False, "" + if get_reversal_record(item["id"]): + st.warning("此單已沖銷過,不重複執行。") + st.rerun() if item["tool"] == "update_inventory": args = item["raw_args"] pid = args.get("product_id") diff --git a/frontend/page_supply_chain_risk.py b/frontend/page_supply_chain_risk.py index e506373..a4be4dc 100644 --- a/frontend/page_supply_chain_risk.py +++ b/frontend/page_supply_chain_risk.py @@ -32,13 +32,13 @@ def render( st.markdown("
🌱 供應鏈與風險監控
", unsafe_allow_html=True) if "analysis" not in sections and "what_if" not in sections: - render_risk_overview() + render_risk_overview(actor=principal.username) return overview_tab, analysis_tab = st.tabs(["📊 L1 風險總覽", "🧭 L2 情報與決策"]) with overview_tab: - render_risk_overview() + render_risk_overview(actor=principal.username) with analysis_tab: # Step 1: Intelligence Hub diff --git a/scripts/check_llm_env.py b/scripts/check_llm_env.py new file mode 100644 index 0000000..185b179 --- /dev/null +++ b/scripts/check_llm_env.py @@ -0,0 +1,137 @@ +""" +scripts/check_llm_env.py +檢查 .env 的 LLM 端點設定(LLM_MODEL / OPENAI_API_BASE / OPENAI_API_KEY …), +並可選擇真的打一次模型(走 backend.llm_client.complete_text,跟分析頁同一條路)。 + +用法: + python scripts/check_llm_env.py # 只檢查設定 + 查端點模型清單 + python scripts/check_llm_env.py --call # 額外送一句 "ping" 確認金鑰可用 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +from dotenv import load_dotenv + + +ROOT_DIR = Path(__file__).resolve().parents[1] +DEFAULT_ENV_PATH = ROOT_DIR / ".env" + + +def mask_value(value: str) -> str: + if not value: + return "" + if len(value) <= 8: + return "*" * len(value) + return f"{value[:4]}...{value[-4:]}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Check LLM endpoint settings in .env.") + parser.add_argument("--env", default=str(DEFAULT_ENV_PATH), help="Path to the .env file.") + parser.add_argument("--call", action="store_true", help="Send one real completion to verify the key.") + return parser.parse_args() + + +def _provider_and_name(model: str) -> tuple[str, str]: + provider, _, name = model.partition("/") + return (provider, name) if name else ("", model) + + +def _list_models(api_base: str) -> list[str] | None: + """GET {api_base}/models(OpenAI 相容端點多半不需金鑰)。失敗回 None。""" + try: + req = urllib.request.Request(f"{api_base.rstrip('/')}/models", + headers={"User-Agent": "erp-inventory/check_llm_env"}) + with urllib.request.urlopen(req, timeout=15) as resp: + payload = json.load(resp) + return [m["id"] for m in payload.get("data", [])] + except (urllib.error.URLError, ValueError, KeyError, TimeoutError) as exc: + print(f"- model list: unavailable ({type(exc).__name__})") + return None + + +def main() -> int: + args = parse_args() + env_path = Path(args.env) + if env_path.exists(): + load_dotenv(env_path) + print(f"Loaded env file: {env_path}") + else: + print(f"Env file not found: {env_path}") + + model = os.environ.get("LLM_MODEL", "").strip() + analysis_model = os.environ.get("LLM_ANALYSIS_MODEL", "").strip() + fallbacks = [m.strip() for m in os.environ.get("LLM_FALLBACK_MODELS", "").split(",") if m.strip()] + api_base = os.environ.get("OPENAI_API_BASE", "").strip() + api_key = os.environ.get("OPENAI_API_KEY", "").strip() + gemini_key = os.environ.get("GEMINI_API_KEY", "").strip() + + problems: list[str] = [] + print("\nModel settings:") + print(f"- LLM_MODEL: {model or 'MISSING'}") + print(f"- LLM_ANALYSIS_MODEL: {analysis_model or '(same as LLM_MODEL)'}") + print(f"- LLM_FALLBACK_MODELS: {', '.join(fallbacks) if fallbacks else '(none; primary failure will surface as an error)'}") + print(f"- LLM_EXTRA_HEADERS: {os.environ.get('LLM_EXTRA_HEADERS', '').strip() or '(none)'}") + print(f"- LLM_TIMEOUT: {os.environ.get('LLM_TIMEOUT', '').strip() or '(default 120s)'}") + if not model: + problems.append("LLM_MODEL is empty") + + provider, model_name = _provider_and_name(model) + print("\nProvider keys:") + if provider == "openai": + print(f"- OPENAI_API_BASE: {api_base or '(default api.openai.com)'}") + print(f"- OPENAI_API_KEY: {'OK (' + mask_value(api_key) + ')' if api_key else 'MISSING'}") + if not api_key: + problems.append("OPENAI_API_KEY is empty") + if api_base: + ids = _list_models(api_base) + if ids is not None: + print(f"- model list: {len(ids)} models at {api_base}") + if model_name in ids: + print(f"- {model_name}: found on endpoint") + else: + problems.append(f"{model_name!r} is not in the endpoint model list") + print(f"- {model_name}: NOT FOUND on endpoint") + elif provider == "gemini": + print(f"- GEMINI_API_KEY: {'OK (' + mask_value(gemini_key) + ')' if gemini_key else 'MISSING'}") + if not gemini_key: + problems.append("GEMINI_API_KEY is empty") + elif model: + print(f"- provider {provider!r}: key is read by litellm from its own env var; not checked here") + + for fb in fallbacks: + fb_provider, _ = _provider_and_name(fb) + if fb_provider == "gemini" and not gemini_key: + problems.append(f"fallback {fb} needs GEMINI_API_KEY") + if fb_provider == "openai" and not api_key: + problems.append(f"fallback {fb} needs OPENAI_API_KEY") + + if args.call and not problems: + print("\nLive call:") + sys.path.insert(0, str(ROOT_DIR)) + from backend.llm_client import complete_text + try: + reply = complete_text("Reply with the single word: pong", temperature=0, tag="check_llm_env") + print(f"- {model}: OK -> {reply[:80]!r}") + except Exception as exc: # 診斷工具,錯誤原樣印出 + problems.append(f"live call failed: {type(exc).__name__}: {str(exc)[:300]}") + print(f"- {model}: FAILED") + + if problems: + print("\nLLM readiness: NOT READY") + for p in problems: + print(f"- {p}") + return 1 + print("\nLLM readiness: READY" + ("" if args.call else " (settings only; add --call to verify the key)")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_l1_alert_states.py b/tests/test_l1_alert_states.py new file mode 100644 index 0000000..50c6a6d --- /dev/null +++ b/tests/test_l1_alert_states.py @@ -0,0 +1,187 @@ +""" +tests/test_l1_alert_states.py +L1 風險總覽完善: + - 告警「已讀/處理中/已通知L2」狀態落地(RISK_ALERT_ACK,fail-closed) + - 告警 feed 併入狀態與 L3 提案計數 + - L1 通知 L2:待確認情報在 L2 頁列出,登錄成事件後自動消失 + - 系統內未結採購單可直接對映事件(不必上傳 CSV) +""" + +from __future__ import annotations + +import ast +from datetime import datetime +from pathlib import Path +import sqlite3 + +import pytest + +from backend import database +from backend import l1_monitoring as l1 +from backend import supply_chain_risk as risk +from backend.access_control import RISK_ALERT_ACK, capabilities_for_role + + +ROOT = Path(__file__).resolve().parents[1] +NOW = datetime(2026, 9, 14, 9, 0, 0) + + +@pytest.fixture +def l1_db(tmp_path, monkeypatch): + db_path = tmp_path / "l1-states.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.setattr(risk, "DB_FILE", str(db_path)) + monkeypatch.delenv("ERP_ENABLE_DEMO_SEED", raising=False) + database.init_db() + with sqlite3.connect(db_path) as conn: + conn.execute("DELETE FROM suppliers") + conn.execute("DELETE FROM purchase_orders") + conn.execute("DELETE FROM supply_chain_events") + conn.execute("DELETE FROM supply_chain_news") + conn.execute( + "INSERT INTO suppliers (supplier_id, name, country, region, latitude, longitude, is_official) " + "VALUES ('S-TW', '台北供應商', '台灣', '亞洲', 25.0, 121.5, 1)" + ) + conn.execute( + "INSERT INTO suppliers (supplier_id, name, country, region, latitude, longitude, is_official) " + "VALUES ('S-DE', '柏林供應商', '德國', '歐洲', 52.5, 13.4, 1)" + ) + conn.executemany( + "INSERT INTO purchase_orders (po_id, supplier_id, status, total_amount) VALUES (?,?,?,?)", + [("PO-TW", "S-TW", "已下單", 100.0), ("PO-DE", "S-DE", "運送中", 200.0), ("PO-DONE", "S-TW", "已完成", 5.0)], + ) + conn.executemany( + "INSERT INTO purchase_order_items (po_id, product_id, qty, unit_price) VALUES (?,?,?,?)", + [("PO-TW", "P1", 1, 100.0), ("PO-DE", "P2", 2, 100.0), ("PO-DONE", "P1", 1, 5.0)], + ) + conn.execute( + """INSERT INTO supply_chain_news (id, country, region, title, summary, url, source, published_at, + relevance_tag, fetched_at, category, is_relevant, estimated_delay) + VALUES (7, '台灣', '北區', 'Typhoon closes port', 's', 'https://n/7', 't', '2026-09-13 06:00', + 'supply_chain', '2026-09-13 07:00', '氣候', 1, 21)""" + ) + conn.commit() + return db_path + + +def test_alert_ack_capability_is_l1_only(): + assert RISK_ALERT_ACK in capabilities_for_role("risk_viewer") + assert RISK_ALERT_ACK in capabilities_for_role("supply_planner") + assert RISK_ALERT_ACK in capabilities_for_role("procurement_approver") + assert RISK_ALERT_ACK not in capabilities_for_role("hr") + + +def test_confirmed_alert_status_persists_and_shows_in_feed(l1_db): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + feed = l1.get_latest_event_alerts(actor="viewer", now=NOW) + assert feed["confirmed"][0]["ack_status"] == "未讀" + assert feed["confirmed"][0]["proposals"] == {"pending": 0, "approved": 0, "rejected": 0, "unsubmitted": 0} + + record = l1.set_alert_status("confirmed", event_id, "處理中", actor="viewer", note="已通知採購", now=NOW) + assert record["alert_key"] == f"confirmed:{event_id}" + + feed = l1.get_latest_event_alerts(actor="viewer", now=NOW) + item = feed["confirmed"][0] + assert item["ack_status"] == "處理中" and item["ack_note"] == "已通知採購" and item["ack_by"] == "viewer" + + # 同一鍵再標記 → 覆寫,不新增 + l1.set_alert_status("confirmed", event_id, "已讀", actor="viewer", now=NOW) + assert l1.get_alert_states("confirmed", [event_id])[event_id]["status"] == "已讀" + with sqlite3.connect(l1_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM risk_alert_states").fetchone()[0] == 1 + + +@pytest.mark.parametrize("actor", [None, "", "hr1", "nobody"]) +def test_set_alert_status_fails_closed(l1_db, actor): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + with pytest.raises(PermissionError): + l1.set_alert_status("confirmed", event_id, "已讀", actor=actor) + with sqlite3.connect(l1_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM risk_alert_states").fetchone()[0] == 0 + + +def test_status_values_are_validated_per_kind(l1_db): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + with pytest.raises(ValueError): + l1.set_alert_status("confirmed", event_id, "已通知L2", actor="viewer") # 已確認事件不能「通知 L2」 + with pytest.raises(ValueError): + l1.set_alert_status("candidate", 7, "處理中", actor="viewer") # 候選只有 未讀/已讀/已通知L2 + with pytest.raises(ValueError): + l1.set_alert_status("unknown", 7, "已讀", actor="viewer") + + +def test_l1_notify_l2_round_trip(l1_db): + feed = l1.get_latest_event_alerts(actor="viewer", now=NOW) + assert [c["news_id"] for c in feed["candidates"]] == [7] + assert feed["candidates"][0]["ack_status"] == "未讀" + assert l1.list_l1_notifications_for_l2(actor="planner") == [] + + l1.set_alert_status("candidate", 7, "已通知L2", actor="viewer", note="請優先確認", now=NOW) + notices = l1.list_l1_notifications_for_l2(actor="planner") + assert len(notices) == 1 + assert notices[0]["news_id"] == 7 and notices[0]["title"] == "Typhoon closes port" + assert notices[0]["notified_by"] == "viewer" and notices[0]["note"] == "請優先確認" + assert notices[0]["event_type"] == "氣候" and notices[0]["impact_days"] == 21 + assert l1.get_latest_event_alerts(actor="viewer", now=NOW)["candidates"][0]["ack_status"] == "已通知L2" + + # L2 登錄成事件 → 通知自動結案、候選消失 + risk.add_risk_event("氣候", "北區", "台灣", 21, "颱風", news_id=7, actor="planner") + assert l1.list_l1_notifications_for_l2(actor="planner") == [] + assert l1.get_latest_event_alerts(actor="viewer", now=NOW)["candidates"] == [] + + +@pytest.mark.parametrize("actor", [None, "viewer", "approver", "nobody"]) +def test_l2_notification_list_requires_analysis_read(l1_db, actor): + with pytest.raises(PermissionError): + l1.list_l1_notifications_for_l2(actor=actor) + + +def test_open_purchase_rows_map_to_events_without_csv(l1_db): + risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + rows = l1.load_open_purchase_rows(actor="viewer") + assert [r["po_id"] for r in rows] == ["PO-DE", "PO-TW"] # 已完成不算 + assert rows[1] == { + "external_id": "PO-TW", "po_id": "PO-TW", "supplier_id": "S-TW", "product_id": "P1", + "qty": 1, "status": "已下單", "order_date": "", "total_amount": 100.0, + } + with sqlite3.connect(l1_db) as conn: + conn.row_factory = sqlite3.Row + suppliers = {r["supplier_id"]: dict(r) for r in conn.execute( + "SELECT supplier_id, country, region, risk_level FROM suppliers")} + events = risk.get_risk_events_list(limit=30).to_dict("records") + mapped = {m["po_id"]: m for m in l1.map_purchase_rows_to_events(rows, supplier_context=suppliers, events=events)} + assert mapped["PO-TW"]["match_status"] == "需關注" and mapped["PO-TW"]["impact_days"] == 14 + assert mapped["PO-DE"]["match_status"] == "正常" + + +@pytest.mark.parametrize("actor", [None, "", "hr1", "nobody"]) +def test_open_purchase_rows_fail_closed(l1_db, actor): + with pytest.raises(PermissionError): + l1.load_open_purchase_rows(actor=actor) + + +def test_confirmed_alert_shows_l3_proposal_counts(l1_db, monkeypatch): + from backend import purchase_proposals as pp + + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + monkeypatch.setattr(pp, "proposal_status_summary_by_event", + lambda ids, conn=None: {event_id: {"pending": 1, "approved": 2, "rejected": 0, "unsubmitted": 0}}) + feed = l1.get_latest_event_alerts(actor="viewer", now=NOW) + assert feed["confirmed"][0]["proposals"]["approved"] == 2 + + +def _calls(tree, name): + return [n for n in ast.walk(tree) if isinstance(n, ast.Call) + and ((isinstance(n.func, ast.Name) and n.func.id == name) + or (isinstance(n.func, ast.Attribute) and n.func.attr == name))] + + +def test_frontend_forwards_actor_for_l1_writes_and_l2_notices(): + overview = ast.parse((ROOT / "frontend/components/risk_overview.py").read_text(encoding="utf-8")) + for name in ("set_alert_status", "load_open_purchase_rows", "get_latest_event_alerts"): + calls = _calls(overview, name) + assert calls, name + assert all(any(k.arg == "actor" for k in c.keywords) for c in calls), name + dashboard = ast.parse((ROOT / "frontend/components/risk_dashboard.py").read_text(encoding="utf-8")) + calls = _calls(dashboard, "list_l1_notifications_for_l2") + assert calls and all(any(k.arg == "actor" for k in c.keywords) for c in calls) diff --git a/tests/test_l1_event_alerts.py b/tests/test_l1_event_alerts.py new file mode 100644 index 0000000..957e7f4 --- /dev/null +++ b/tests/test_l1_event_alerts.py @@ -0,0 +1,224 @@ +"""L1 最新事件告警 feed:唯讀、fail-closed、資料直接來自 DB(非 session state)。""" + +from __future__ import annotations + +import ast +from datetime import datetime +from pathlib import Path +import sqlite3 + +import pytest + +from backend import database +from backend import l1_monitoring +from backend import supply_chain_risk as risk + + +NOW = datetime(2026, 9, 12, 9, 0, 0) +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def alert_db(tmp_path, monkeypatch): + db_path = tmp_path / "l1-alerts.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.setattr(risk, "DB_FILE", str(db_path)) + database.init_db() + + with sqlite3.connect(db_path) as conn: + conn.executemany( + """ + INSERT INTO supply_chain_news + (id, country, region, title, summary, url, source, published_at, + relevance_tag, fetched_at, category, is_relevant, estimated_delay) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + [ + # 已登錄為事件 → 不應再出現在候選 + (1, "日本", "關東", "Registered port strike", "s", "https://n/1", + "test", "2026-09-10 08:00", "supply_chain", "2026-09-10 09:00", + "罷工", 1, 10), + # 候選:高嚴重度、時間最新 + (2, "台灣", "北區", "Typhoon closes port", "s", "https://n/2", + "test", "2026-09-11 06:00", "supply_chain", "2026-09-11 07:00", + "氣候", 1, 21), + # 候選:published_at 無法解析 → 退回 fetched_at + (3, "越南", "", "Customs slowdown", "s", "https://n/3", + "test", "Thu, 10 Sep 2026 00:00:00 GMT", "supply_chain", + "2026-09-10 12:00", "政策", 1, 5), + # 與 3 重複(同標題同網址)→ 去重 + (4, "越南", "", "Customs slowdown", "s", "https://n/3", + "test", "2026-09-10 12:30", "supply_chain", "2026-09-10 12:30", + "政策", 1, 5), + # 延遲 0 天 → 排除 + (5, "美國", "", "General economy news", "s", "https://n/5", + "test", "2026-09-11 01:00", "supply_chain", "2026-09-11 01:00", + "其他", 1, 0), + # 標記不相關 → 排除 + (6, "德國", "", "Irrelevant", "s", "https://n/6", + "test", "2026-09-11 01:00", "supply_chain", "2026-09-11 01:00", + "其他", 0, 9), + # 超出 30 天視窗 → 排除 + (7, "墨西哥", "", "Old strike", "s", "https://n/7", + "test", "2026-07-01 01:00", "supply_chain", "2026-07-01 01:00", + "罷工", 1, 14), + ], + ) + conn.executemany( + """ + INSERT INTO supply_chain_events + (id, event_type, region, country, impact_days, description, + created_at, news_id) + VALUES (?,?,?,?,?,?,?,?) + """, + [ + # id 最小但 created_at 最新(覆寫更新的情境) + (1, "罷工", "關東", "日本", 10, "由新聞登錄", "2026-09-11 10:00", 1), + (2, "地震", "關西", "日本", 3, "人工登錄", "2026-09-05 10:00", None), + # 超出視窗 + (3, "戰爭", "", "伊朗", 45, "舊事件", "2026-06-01 10:00", None), + ], + ) + conn.commit() + return db_path + + +@pytest.mark.parametrize("actor", [None, "", "hr1", "nobody"]) +def test_alert_feed_is_denied_before_any_read(alert_db, monkeypatch, actor): + reads = [] + + def forbidden_read(*args, **kwargs): + reads.append(args) + raise AssertionError("alert data was read before authorization") + + monkeypatch.setattr(l1_monitoring, "_load_confirmed_alerts", forbidden_read) + monkeypatch.setattr(l1_monitoring, "_load_candidate_alerts", forbidden_read) + + with pytest.raises(PermissionError): + l1_monitoring.get_latest_event_alerts(actor=actor, now=NOW) + assert reads == [] + + +@pytest.mark.parametrize("actor", ["viewer", "planner", "approver", "admin"]) +def test_roles_with_overview_read_can_load_feed(alert_db, actor): + feed = l1_monitoring.get_latest_event_alerts(actor=actor, now=NOW) + assert feed["confirmed_count"] == 2 + assert feed["candidate_count"] == 2 + + +def test_confirmed_alerts_are_windowed_ordered_by_time_and_linked_to_news(alert_db): + feed = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + + assert feed["since"] == "2026-08-13" + assert [item["id"] for item in feed["confirmed"]] == [1, 2] + + newest = feed["confirmed"][0] + assert newest["severity"] == "中" + assert newest["source"] == l1_monitoring.ALERT_SOURCE_NEWS + assert newest["news_title"] == "Registered port strike" + assert newest["news_url"] == "https://n/1" + + manual = feed["confirmed"][1] + assert manual["source"] == l1_monitoring.ALERT_SOURCE_MANUAL + assert manual["news_title"] == "" + assert manual["severity"] == "低" + + +def test_candidates_exclude_registered_zero_delay_irrelevant_old_and_duplicates(alert_db): + feed = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + + assert feed["candidate_count"] == 2 + typhoon, customs = feed["candidates"] + assert typhoon["news_id"] == 2 + assert typhoon["severity"] == "高" + assert typhoon["status"] == l1_monitoring.CANDIDATE_STATUS + assert typhoon["url"] == "https://n/2" + # 3 與 4 是同一則新聞的重複列,只能出現一次 + assert customs["news_id"] in {3, 4} + assert customs["title"] == "Customs slowdown" + assert customs["impact_days"] == 5 + assert feed["highest_severity"] == "高" + + +def test_feed_reflects_new_news_and_new_registration_without_session_state(alert_db): + before = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + assert before["candidate_count"] == 2 + + # 排程/L2 寫入一則新新聞 → 下一次讀取立即成為候選 + with sqlite3.connect(alert_db) as conn: + conn.execute( + """ + INSERT INTO supply_chain_news + (id, country, region, title, summary, url, source, published_at, + relevance_tag, fetched_at, category, is_relevant, estimated_delay) + VALUES (8, '南韓', '', 'New rail strike', 's', 'https://n/8', 'test', + '2026-09-12 08:00', 'supply_chain', '2026-09-12 08:30', + '罷工', 1, 12) + """ + ) + conn.commit() + after_news = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + assert after_news["candidate_count"] == 3 + assert after_news["candidates"][0]["news_id"] == 8 + + # L2 登錄該新聞 → 候選消失、已確認增加,並帶著來源新聞 + risk.add_risk_event("罷工", "", "南韓", 12, "登錄", news_id=8, actor="planner") + after_register = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + assert after_register["candidate_count"] == 2 + assert after_register["confirmed"][0]["news_id"] == 8 + assert after_register["confirmed"][0]["news_title"] == "New rail strike" + + +def test_window_and_limit_are_respected(alert_db): + # 2026-09-12 往前 5 天 = 2026-09-07;事件 2(09-05)落在視窗外 + recent = l1_monitoring.get_latest_event_alerts(actor="viewer", since_days=5, now=NOW) + assert [item["id"] for item in recent["confirmed"]] == [1] + # 視窗邊界含當日:往前 7 天 = 2026-09-05,事件 2 剛好納入 + week = l1_monitoring.get_latest_event_alerts(actor="viewer", since_days=7, now=NOW) + assert [item["id"] for item in week["confirmed"]] == [1, 2] + + quarter = l1_monitoring.get_latest_event_alerts(actor="viewer", since_days=120, now=NOW) + assert [item["id"] for item in quarter["confirmed"]] == [1, 2, 3] + assert 7 in {item["news_id"] for item in quarter["candidates"]} + + capped = l1_monitoring.get_latest_event_alerts(actor="viewer", limit=1, now=NOW) + assert capped["confirmed_count"] == 1 + assert capped["candidate_count"] == 1 + + +def test_severity_classification_thresholds(): + assert l1_monitoring.classify_alert_severity(14) == "高" + assert l1_monitoring.classify_alert_severity(7) == "中" + assert l1_monitoring.classify_alert_severity(1) == "低" + assert l1_monitoring.classify_alert_severity(0) == "無" + assert l1_monitoring.classify_alert_severity(None) == "無" + assert l1_monitoring.classify_alert_severity("bad") == "無" + + +def test_risk_events_list_orders_by_created_at_not_id(alert_db): + frame = risk.get_risk_events_list(limit=10) + assert frame["id"].tolist() == [1, 2, 3] + + +def test_overview_component_forwards_live_actor_to_alert_feed(): + path = ROOT / "frontend/components/risk_overview.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + + render_signature = None + feed_calls = [] + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "render_risk_overview": + render_signature = {a.arg for a in node.args.args + node.args.kwonlyargs} + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "get_latest_event_alerts" + ): + feed_calls.append(node) + assert any(kw.arg == "actor" for kw in node.keywords) + + assert render_signature is not None and "actor" in render_signature + assert feed_calls + + page = (ROOT / "frontend/page_supply_chain_risk.py").read_text(encoding="utf-8") + assert "render_risk_overview(actor=principal.username)" in page diff --git a/tests/test_l2_risk_workspace.py b/tests/test_l2_risk_workspace.py new file mode 100644 index 0000000..80a7269 --- /dev/null +++ b/tests/test_l2_risk_workspace.py @@ -0,0 +1,451 @@ +""" +tests/test_l2_risk_workspace.py +L2 供應鏈風險頁完善: + 1. 熱圖預設值依事件嚴重度/筆數/時效差異化(不再全部 60%) + 2. 曝險金額改回傳完整資訊(未結採購單 + 供應商數),demo 可 opt-in 種採購單 + 3. 卡片判定納入新聞登錄事件(events_for_location / is_news_event) + 4. 同區域不同類型事件不再互相覆蓋;update_risk_event 就地修改 + 5. AI 摘要落地 risk_ai_summaries,L1 唯讀可讀、L2 重開頁面可載回 + 6. 證據閘門:AI 提到但資料裡沒有的地區略過、天數/類型超出證據則調整 +""" + +from __future__ import annotations + +import ast +from datetime import datetime +from pathlib import Path +import sqlite3 + +import pytest + +from backend import database +from backend import l1_monitoring +from backend import supply_chain_risk as risk + + +ROOT = Path(__file__).resolve().parents[1] +NOW = datetime(2026, 9, 13, 12, 0, 0) + + +@pytest.fixture +def l2_db(tmp_path, monkeypatch): + db_path = tmp_path / "l2-workspace.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.setattr(risk, "DB_FILE", str(db_path)) + monkeypatch.delenv("ERP_ENABLE_DEMO_SEED", raising=False) + database.init_db() + with sqlite3.connect(db_path) as conn: + conn.execute("DELETE FROM suppliers") + conn.execute("DELETE FROM purchase_orders") + conn.execute("DELETE FROM supply_chain_events") + conn.executemany( + "INSERT INTO suppliers (supplier_id, name, country, region, latitude, longitude, is_official) " + "VALUES (?,?,?,?,?,?,?)", + [ + ("S-TW1", "台北供應商", "台灣", "亞洲", 25.0, 121.5, 1), + ("S-TW2", "新竹供應商", "台灣", "亞洲", 24.8, 121.0, 0), + ("S-DE1", "柏林供應商", "德國", "歐洲", 52.5, 13.4, 1), + ("S-US1", "加州供應商", "美國", "北美洲", 37.7, -122.4, 1), + ], + ) + conn.commit() + return db_path + + +def _events(db_path): + with sqlite3.connect(db_path) as conn: + return conn.execute( + "SELECT event_type, region, country, impact_days, news_id FROM supply_chain_events ORDER BY id" + ).fetchall() + + +def _heatmap_by_name(): + return {row["display_name"]: row for row in risk.get_risk_heatmap_data()} + + +# ── 1. 熱圖預設值差異化 ────────────────────────────────────────────── + + +def test_score_region_events_weights_by_severity_count_and_age(): + def ev(days, created="2026-09-12", country="台灣", region="亞洲"): + return {"country": country, "region": region, "impact_days": days, "created_at": created} + + assert risk.score_region_events("台灣", "亞洲", [], now=NOW)["points"] == 0 + assert risk.score_region_events("台灣", "亞洲", [ev(7)], now=NOW)["points"] == 30 + assert risk.score_region_events("台灣", "亞洲", [ev(30)], now=NOW)["points"] == 50 + # 兩筆事件:最嚴重者 + 每多一筆 +5 + assert risk.score_region_events("台灣", "亞洲", [ev(7), ev(14)], now=NOW)["points"] == 45 + # 逾 30 天的事件加權減半 + stale = risk.score_region_events("台灣", "亞洲", [ev(30, created="2026-07-01")], now=NOW) + assert stale["points"] == 25 and "已逾" in stale["reason"] + # 其他地區的事件不算 + assert risk.score_region_events("德國", "歐洲", [ev(30)], now=NOW)["count"] == 0 + + +def test_heatmap_defaults_no_longer_flat_60(l2_db): + risk.add_risk_event("罷工", "亞洲", "台灣", 7, "港口罷工", actor="planner") + risk.add_risk_event("氣候", "亞洲", "台灣", 14, "颱風", actor="planner") + risk.add_risk_event("戰爭", "歐洲", "德國", 30, "衝突", actor="planner") + + rows = _heatmap_by_name() + assert rows["台灣 亞洲"]["risk_pct"] == 65 # 20 + 40(14 天)+ 5(第二筆) + assert rows["德國 歐洲"]["risk_pct"] == 70 # 20 + 50(30 天) + assert rows["美國 北美洲"]["risk_pct"] == 20 # 沒事件 + assert rows["台灣 亞洲"]["event_count"] == 2 + assert "2 則事件" in rows["台灣 亞洲"]["risk_reason"] + assert rows["美國 北美洲"]["risk_reason"] == "近期無登錄事件" + + +def test_heatmap_override_keeps_reason_of_override(l2_db): + risk.upsert_risk_heatmap("台灣|亞洲", "台灣 亞洲", 25.0, 121.5, 88, "AI", actor="planner") + row = _heatmap_by_name()["台灣 亞洲"] + assert row["risk_pct"] == 88 and row["risk_reason"].startswith("AI/人工設定") + + +# ── 2. 曝險資訊 ─────────────────────────────────────────────────────── + + +def test_region_exposure_explains_zero_amount(l2_db): + exposure = risk.get_region_exposure("台灣|亞洲") + assert exposure == { + "supplier_count": 2, "official_supplier_count": 1, + "open_po_count": 0, "open_po_amount": 0.0, + } + with sqlite3.connect(l2_db) as conn: + conn.executemany( + "INSERT INTO purchase_orders (po_id, supplier_id, status, total_amount) VALUES (?,?,?,?)", + [("PO-1", "S-TW1", "已下單", 1500.0), ("PO-2", "S-TW2", "已完成", 999.0), ("PO-3", "S-DE1", None, 40.0)], + ) + conn.commit() + exposure = risk.get_region_exposure("台灣|亞洲") + assert exposure["open_po_count"] == 1 and exposure["open_po_amount"] == 1500.0 # 已完成不算 + + +def test_demo_purchase_orders_seed_is_opt_in_and_idempotent(l2_db, monkeypatch): + with sqlite3.connect(l2_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] == 0 + monkeypatch.setenv("ERP_ENABLE_DEMO_SEED", "1") + database.init_db() + with sqlite3.connect(l2_db) as conn: + count = conn.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] + official = conn.execute("SELECT COUNT(*) FROM suppliers WHERE is_official=1").fetchone()[0] + assert count == official > 0 + database.init_db() # 第二次不重複種 + with sqlite3.connect(l2_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] == count + assert risk.get_region_exposure("台灣|亞洲")["open_po_amount"] > 0 + + +# ── 3. 卡片判定納入新聞登錄事件 ─────────────────────────────────────── + + +def test_events_for_location_separates_news_and_formal(l2_db): + risk.add_risk_event("其他", "", "台灣", 7, "新聞 A", news_id=11, actor="planner") + risk.add_risk_event("氣候", "", "台灣", 21, "新聞 B", news_id=12, actor="planner") + risk.add_risk_event("罷工", "亞洲", "台灣", 10, "人工", actor="planner") + + matched = risk.events_for_location("台灣", "亞洲") + assert [e["impact_days"] for e in matched] == [21, 10, 7] # 依天數排序 + assert [risk.is_news_event(e) for e in matched] == [True, False, True] + assert risk.events_for_location("美國", "北美洲") == [] + + +# ── 4. 事件不再互相覆蓋 ─────────────────────────────────────────────── + + +def test_add_risk_event_keeps_different_types_separate(l2_db): + first = risk.add_risk_event("罷工", "亞洲", "台灣", 7, "罷工", actor="planner") + second = risk.add_risk_event("氣候", "亞洲", "台灣", 14, "颱風", actor="planner") + assert first != second + assert len(_events(l2_db)) == 2 + # 同類型再登錄 → 更新同一筆(天數/說明),不新增 + again = risk.add_risk_event("罷工", "亞洲", "台灣", 9, "罷工延長", actor="planner") + assert again == first + assert [(t, d) for t, _, _, d, _ in _events(l2_db)] == [("罷工", 9), ("氣候", 14)] + + +def test_update_risk_event_changes_only_given_fields(l2_db): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 7, "原始", actor="planner") + assert risk.update_risk_event(event_id, impact_days=21, actor="planner") is True + assert _events(l2_db)[0][:4] == ("罷工", "亞洲", "台灣", 21) + assert risk.update_risk_event(event_id, event_type="戰爭", description="升級", actor="planner") is True + assert _events(l2_db)[0][0] == "戰爭" + assert risk.update_risk_event(999999, impact_days=1, actor="planner") is False + + +@pytest.mark.parametrize("actor", [None, "", "viewer", "nobody"]) +def test_update_risk_event_fails_closed(l2_db, actor): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 7, "原始", actor="planner") + with pytest.raises(PermissionError): + risk.update_risk_event(event_id, impact_days=99, actor=actor) + assert _events(l2_db)[0][3] == 7 + + +# ── 5. 摘要落地 ─────────────────────────────────────────────────────── + + +def _fake_llm(monkeypatch, payload: str): + import backend.llm_client as lc + monkeypatch.setattr(lc, "complete_text", lambda *a, **kw: payload) + + +NEWS = [ + {"country": "台灣", "region": "亞洲", "category": "氣候", "estimated_delay": 5, + "title": "Typhoon", "summary": "port closed", "published_at": "2026-09-12"}, +] +PAYLOAD = ( + '{"摘要": "### 現況\\n台灣颱風影響港口。美國戰爭風險上升。", ' + '"更新": [{"地區": "台灣 亞洲", "風險": 70}, {"地區": "美國 北美洲", "風險": 75}], ' + '"事件": [{"類型": "氣候", "地區": "亞洲", "國家": "台灣", "延遲天數": 30, "描述": "颱風"}, ' + '{"類型": "戰爭", "地區": "北美洲", "國家": "美國", "延遲天數": 30, "描述": "戰爭"}]}' +) + + +def test_analyze_persists_when_actor_given_and_l1_can_read(l2_db, monkeypatch): + _fake_llm(monkeypatch, PAYLOAD) + assert risk.get_latest_ai_risk_summary() is None + + result = risk.analyze_heatmap_risk(NEWS, reference_date="2026-09-13", actor="planner") + assert result["error"] is False and result["summary_id"] is not None + assert result["news_count"] == 1 + + latest = risk.get_latest_ai_risk_summary() + assert latest["summary_id"] == result["summary_id"] + assert "台灣颱風" in latest["summary"] + assert latest["updates"] == result["updates"] + assert latest["events"] == result["events"] + assert latest["audit"] == result["audit"] + assert latest["actor"] == "planner" + + # L1 唯讀入口:viewer 可讀,同一筆 + assert l1_monitoring.get_latest_risk_summary(actor="viewer")["summary_id"] == result["summary_id"] + + +@pytest.mark.parametrize("actor", [None, "", "hr1", "nobody"]) +def test_l1_latest_summary_fails_closed(l2_db, actor): + with pytest.raises(PermissionError): + l1_monitoring.get_latest_risk_summary(actor=actor) + + +def test_analyze_without_actor_does_not_persist_and_viewer_cannot_persist(l2_db, monkeypatch): + _fake_llm(monkeypatch, PAYLOAD) + result = risk.analyze_heatmap_risk(NEWS, reference_date="2026-09-13") + assert result["summary_id"] is None and risk.get_latest_ai_risk_summary() is None + with pytest.raises(PermissionError): + risk.save_ai_risk_summary(result, actor="viewer") + assert risk.get_latest_ai_risk_summary() is None + + +def test_legacy_tuple_interface_still_works(l2_db, monkeypatch): + _fake_llm(monkeypatch, PAYLOAD) + summary, updates, events = risk.get_heatmap_ai_summary(news_context="x", news_items=NEWS) + assert "台灣颱風" in summary and isinstance(updates, list) and isinstance(events, list) + + +def test_scheduler_refresh_persists_summary(l2_db, monkeypatch): + """排程/L2「更新即時新聞」路徑:摘要落地、建議事件不再被丟掉。""" + from backend import supply_chain_news as news + + _fake_llm(monkeypatch, PAYLOAD) + monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **kw: [ + {"country": "台灣", "region": "亞洲", "title": "Typhoon", "summary": "port closed", + "url": "https://example.test/n", "source": "t", "published_at": "2026-09-12 00:00", + "relevance_tag": "supply_chain"}, + ]) + monkeypatch.setattr(risk, "batch_infer_affected_region_from_news", lambda **kw: [ + {"is_relevant": True, "estimated_delay": 5, "event_type": "氣候", + "country": "台灣", "region": "亞洲", "chinese_summary": "颱風"}, + ]) + news.refresh_news_for_countries(["台灣"], actor="planner") + latest = risk.get_latest_ai_risk_summary() + assert latest is not None and latest["actor"] == "planner" + assert latest["events"] and latest["events"][0]["country"] == "台灣" + + +# ── 6. 證據閘門 ─────────────────────────────────────────────────────── + + +def test_gate_by_evidence_drops_unsupported_and_caps_days(l2_db, monkeypatch): + _fake_llm(monkeypatch, PAYLOAD) + # 已登錄事件:台灣 7 天罷工;新聞:台灣 5 天氣候。美國完全沒有依據。 + risk.add_risk_event("罷工", "亞洲", "台灣", 7, "罷工", actor="planner") + result = risk.analyze_heatmap_risk(NEWS, reference_date="2026-09-13") + + assert [u["display_name"] for u in result["updates"]] == ["台灣 亞洲"] + assert len(result["events"]) == 1 + tw = result["events"][0] + assert tw["country"] == "台灣" and tw["event_type"] == "氣候" + assert tw["impact_days"] == 14 # 證據最長 7 天 × 2 + + audit = {(a["kind"], a["name"], a["action"]) for a in result["audit"]} + assert ("更新", "美國 北美洲", "略過") in audit + assert ("事件", "美國 北美洲", "略過") in audit + assert ("事件", "台灣 亞洲", "調整") in audit + assert "台灣" in result["evidence_locations"] and "美國" not in result["evidence_locations"] + + +def test_gate_by_evidence_unit_rules(): + evidence = risk.build_risk_evidence( + [{"country": "越南", "region": "", "category": "政策", "estimated_delay": 3}], + [{"country": "台灣", "region": "北區", "event_type": "罷工", "impact_days": 10}], + ) + updates = [{"display_name": "台灣 北區", "risk_pct": 60}, {"display_name": "巴西", "risk_pct": 50}] + events = [ + {"event_type": "戰爭", "country": "越南", "region": "", "impact_days": 6, "description": ""}, + {"event_type": "罷工", "country": "台灣", "region": "北區", "impact_days": 12, "description": ""}, + ] + kept_u, kept_e, audit = risk.gate_by_evidence(updates, events, evidence) + assert [u["display_name"] for u in kept_u] == ["台灣 北區"] + # 越南:類型「戰爭」沒依據 → 其他;6 天 ≤ max(7, 3×2) 不動 + assert kept_e[0]["event_type"] == "其他" and kept_e[0]["impact_days"] == 6 + # 台灣:12 ≤ 10×2 不動,類型有依據 + assert kept_e[1]["event_type"] == "罷工" and kept_e[1]["impact_days"] == 12 + assert any(a["name"] == "巴西" and a["action"] == "略過" for a in audit) + assert any(a["name"] == "越南" and a["action"] == "調整" for a in audit) + + +def test_gate_flags_but_keeps_type_when_evidence_is_unclassified(): + """證據只有「其他」類新聞時不改寫類型(分類品質未知),改成提醒人工確認。""" + evidence = risk.build_risk_evidence( + [{"country": "美國", "region": "北美洲", "category": "其他", "estimated_delay": 7}], [], + ) + events = [{"event_type": "戰爭", "country": "美國", "region": "美國 北美洲", "impact_days": 30, "description": ""}] + _, kept, audit = risk.gate_by_evidence([], events, evidence) + assert kept[0]["event_type"] == "戰爭" and kept[0]["impact_days"] == 14 # 7×2 上限 + names = {(a["name"], a["action"]) for a in audit} + assert ("美國 北美洲", "提醒") in names and ("美國 北美洲", "調整") in names # 名稱不重複國家 + + +def test_gate_by_evidence_passthrough_without_evidence(): + updates = [{"display_name": "火星", "risk_pct": 99}] + events = [{"event_type": "戰爭", "country": "火星", "region": "", "impact_days": 90}] + assert risk.gate_by_evidence(updates, events, risk.build_risk_evidence([], [])) == (updates, events, []) + + +def test_prompt_tells_model_to_stay_within_evidence(): + from backend import prompts + assert "不要自行推測" in prompts.HEATMAP_AI_SUMMARY_PROMPT_V2 + + +# ── 前端契約(AST) ─────────────────────────────────────────────────── + + +def _calls(tree, name): + return [n for n in ast.walk(tree) if isinstance(n, ast.Call) + and ((isinstance(n.func, ast.Name) and n.func.id == name) + or (isinstance(n.func, ast.Attribute) and n.func.attr == name))] + + +def test_supply_map_uses_structured_summary_and_forwards_actor(): + tree = ast.parse((ROOT / "frontend/components/supply_map.py").read_text(encoding="utf-8")) + analyze = _calls(tree, "analyze_heatmap_risk") + assert analyze, "L2 應改用 analyze_heatmap_risk 取得 audit 與落地" + assert all(any(k.arg == "actor" for k in c.keywords) for c in analyze) + update = _calls(tree, "update_risk_event") + assert update and all(any(k.arg == "actor" for k in c.keywords) for c in update) + assert _calls(tree, "get_latest_ai_risk_summary"), "重開頁面要載回最近一次摘要" + assert not _calls(tree, "get_total_impact_amount"), "卡片改用 get_region_exposure" + for c in _calls(tree, "add_risk_event"): + assert any(k.arg == "actor" for k in c.keywords) + + +def test_risk_overview_shows_persisted_summary_read_only(): + src = (ROOT / "frontend/components/risk_overview.py").read_text(encoding="utf-8") + tree = ast.parse(src) + calls = _calls(tree, "get_latest_risk_summary") + assert calls and all(any(k.arg == "actor" for k in c.keywords) for c in calls) + assert "analyze_heatmap_risk" not in src # L1 不觸發模型 + + +def test_country_event_does_not_light_up_whole_region(l2_db): + risk.add_risk_event("罷工", "亞洲", "台灣", 7, "台灣罷工", actor="planner") + assert risk.events_for_location("台灣", "亞洲") + assert risk.events_for_location("越南", "亞洲") == [] # 同大區域、不同國家 + # 純大區域事件(沒有國家)才會擴散到該區所有據點 + risk.add_risk_event("戰爭", "亞洲", "", 30, "區域衝突", actor="planner") + assert len(risk.events_for_location("越南", "亞洲")) == 1 + assert len(risk.events_for_location("台灣", "亞洲")) == 2 + + +# ── 7. 受影響採購單標記:情報 → 事件 → 標記 → 步驟 5 ────────────────── + + +def _seed_open_po(db_path, po_id="PO-TW-1", supplier="S-TW1", amount=1500.0, product="P-L2"): + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT OR IGNORE INTO inventory (product_id, name, stock, daily_sales, reorder_point, baseline_reorder_point) " + "VALUES (?, 'L2 物料', 40, 4, 10, 10)", (product,), + ) + conn.execute( + "INSERT INTO purchase_orders (po_id, supplier_id, status, total_amount) VALUES (?,?,?,?)", + (po_id, supplier, "已下單", amount), + ) + conn.execute( + "INSERT INTO purchase_order_items (po_id, product_id, qty, unit_price) VALUES (?,?,?,?)", + (po_id, product, 10, amount / 10), + ) + conn.commit() + + +def test_impacted_pos_carry_amount_and_raw_fields(l2_db): + _seed_open_po(l2_db) + rows = risk.get_impacted_pos(region_key="亞洲", country="台灣") + assert [r["po_id"] for r in rows] == ["PO-TW-1"] + row = rows[0] + assert row["total_amount"] == 1500.0 and row["supplier_id"] == "S-TW1" + assert row["estimated_delay_days"] is None and row["alternative_suggestion_raw"] == "" + assert "庫存約剩 10 天" in row["key_materials"] + assert risk.get_impacted_pos(region_key="歐洲", country="德國") == [] + + +def test_planner_marks_po_and_step5_lists_it(l2_db): + """整條鏈:登錄事件 → 找到受災採購單 → planner 標記 → 步驟 5 查得到。""" + from backend.purchase_proposals import list_impacted_purchase_options + + _seed_open_po(l2_db) + risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + assert list_impacted_purchase_options(actor="planner") == [] # 標記前步驟 5 是空的 + + risk.update_po_impact("PO-TW-1", estimated_delay_days=14, + alternative_suggestion="改由越南倉出貨", actor="planner") + + options = list_impacted_purchase_options(actor="planner") + assert [o["po_id"] for o in options] == ["PO-TW-1"] + assert options[0]["estimated_delay_days"] == 14 + assert options[0]["alternative_suggestion"] == "改由越南倉出貨" + # 標記後 get_impacted_pos 也帶出既有值,讓 UI 顯示「目前 +14 天」 + marked = risk.get_impacted_pos(region_key="亞洲", country="台灣")[0] + assert marked["estimated_delay"] == "+14 天" and marked["estimated_delay_days"] == 14 + + +@pytest.mark.parametrize("actor", [None, "", "viewer", "approver", "nobody"]) +def test_update_po_impact_fails_closed_for_non_workspace_roles(l2_db, actor): + _seed_open_po(l2_db) + with pytest.raises(PermissionError): + risk.update_po_impact("PO-TW-1", estimated_delay_days=9, actor=actor) + with sqlite3.connect(l2_db) as conn: + assert conn.execute("SELECT estimated_delay_days FROM purchase_orders WHERE po_id='PO-TW-1'").fetchone()[0] is None + + +def test_step3_marks_impacted_pos_with_actor(): + tree = ast.parse((ROOT / "frontend/components/risk_dashboard.py").read_text(encoding="utf-8")) + marks = _calls(tree, "update_po_impact") + assert marks and all(any(k.arg == "actor" for k in c.keywords) for c in marks) + assert _calls(tree, "get_impacted_pos") and _calls(tree, "get_ai_alternative_suggestions") + + +def test_summary_prompt_never_leaks_nan_regions(l2_db, monkeypatch): + """事件 region 為 NULL 時,餵給模型的事件清單不能出現字面 nan。""" + import backend.llm_client as lc + seen = {} + + def fake(prompt, **kw): + seen["prompt"] = prompt if isinstance(prompt, str) else str(prompt) + return '{"摘要": "ok", "更新": [], "事件": []}' + + monkeypatch.setattr(lc, "complete_text", fake) + risk.add_risk_event("其他", "", "台灣", 7, "無地區", actor="planner") + risk.analyze_heatmap_risk([], reference_date="2026-09-13") + assert "nan" not in seen["prompt"].lower().replace("financial", "") + assert "區域:台灣" in seen["prompt"] diff --git a/tests/test_l3_proposal_closure.py b/tests/test_l3_proposal_closure.py new file mode 100644 index 0000000..b010095 --- /dev/null +++ b/tests/test_l3_proposal_closure.py @@ -0,0 +1,159 @@ +""" +tests/test_l3_proposal_closure.py +L3 受治理行動閉環: + - 步驟 5 清單附帶每條明細的提案狀態(pending / approved / rejected) + - 提案綁定風險事件,審批頁可讀事件依據與採購單註記(PROPOSAL_EVIDENCE_READ) + - L1 可取得各事件的提案狀態計數(不含提案內容) + - 沖銷紀錄可查、同一審批單只算一次 +""" + +from __future__ import annotations + +import ast +from pathlib import Path +import sqlite3 + +import pytest + +from backend import agent_logger +from backend import database +from backend import purchase_proposals as pp +from backend import supply_chain_risk as risk + + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def flow_db(tmp_path, monkeypatch): + db_path = tmp_path / "l3-flow.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.setattr(risk, "DB_FILE", str(db_path)) + monkeypatch.setenv("ERP_ENABLE_DEMO_SEED", "1") # 正式供應商各一張採購單 + database.init_db() + return db_path + + +def _first_impacted(actor="planner"): + opts = pp.list_impacted_purchase_options(actor=actor) + assert opts, "demo seed 應該讓至少一條明細被標記" + return opts[0] + + +def _propose(sel, proposal_id, event_id=None, actor="planner"): + alts = pp.list_alternative_suppliers( + affected_po_id=sel["po_id"], product_id=sel["product_id"], + source_po_item_id=sel["source_po_item_id"], actor=actor, + ) + assert alts + proposal = pp.prepare_alternative_purchase_proposal( + proposal_id=proposal_id, affected_po_id=sel["po_id"], product_id=sel["product_id"], + source_po_item_id=sel["source_po_item_id"], alternative_supplier_id=alts[0]["supplier_id"], + alternative_supplier_product_id=alts[0]["supplier_product_id"], reason="改由備援供貨", + estimated_delay_days=14, source_event_id=event_id, actor=actor, + ) + result = pp.submit_purchase_proposal(proposal, actor=actor) + assert result.status == "pending" + return proposal + + +def _mark_first_po(flow_db): + with sqlite3.connect(flow_db) as conn: + po_id, supplier = conn.execute( + "SELECT p.po_id, p.supplier_id FROM purchase_orders p ORDER BY p.po_id LIMIT 1" + ).fetchone() + country, region = conn.execute( + "SELECT country, region FROM suppliers WHERE supplier_id=?", (supplier,) + ).fetchone() + risk.update_po_impact(po_id, estimated_delay_days=14, alternative_suggestion="改由備援", actor="planner") + return po_id, country, region + + +def test_step5_reports_proposal_status_through_the_whole_flow(flow_db): + po_id, country, region = _mark_first_po(flow_db) + event_id = risk.add_risk_event("戰爭", region, country, 30, "港口攻擊", actor="planner") + + sel = _first_impacted() + assert sel["po_id"] == po_id and sel["proposal"] is None + + proposal = _propose(sel, "closure-001", event_id) + sel = _first_impacted() + assert sel["proposal"]["status"] == "pending" and sel["proposal"]["label"] == "待 L3 核准" + assert sel["proposal"]["source_event_id"] == event_id + + pp.decide_purchase_proposal(pp.ApprovalDecision(proposal_id="closure-001", outcome="approve"), actor="approver") + sel = _first_impacted() + assert sel["proposal"]["status"] == "approved" + assert sel["proposal"]["approver"] == "approver" and sel["proposal"]["decided_at"] + assert sel["proposal"]["proposed_po_id"] == proposal.proposed_po_id + with sqlite3.connect(flow_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM purchase_orders WHERE po_id=?", (proposal.proposed_po_id,)).fetchone()[0] == 1 + + # L1 只拿計數 + summary = pp.proposal_status_summary_by_event([event_id, 999999]) + assert summary == {event_id: {"pending": 0, "approved": 1, "rejected": 0, "unsubmitted": 0}} + + +def test_rejected_proposal_keeps_reason_and_latest_wins(flow_db): + po_id, country, region = _mark_first_po(flow_db) + sel = _first_impacted() + _propose(sel, "closure-r1") + pp.decide_purchase_proposal( + pp.ApprovalDecision(proposal_id="closure-r1", outcome="reject", reason="價格過高"), actor="approver" + ) + sel = _first_impacted() + assert sel["proposal"]["status"] == "rejected" and sel["proposal"]["reason"] == "價格過高" + # 重新提案 → 最新一筆為準 + _propose(sel, "closure-r2") + assert _first_impacted()["proposal"]["proposal_id"] == "closure-r2" + + +def test_proposal_context_exposes_event_and_po_annotation(flow_db): + po_id, country, region = _mark_first_po(flow_db) + event_id = risk.add_risk_event("罷工", region, country, 21, "碼頭罷工", actor="planner") + proposal = _propose(_first_impacted(), "closure-ctx", event_id) + + context = pp.get_purchase_proposal_context(proposal, actor="approver") + assert context["event"]["id"] == event_id and context["event"]["event_type"] == "罷工" + assert context["affected_po"]["po_id"] == po_id + assert context["affected_po"]["estimated_delay_days"] == 14 + assert context["affected_po"]["alternative_suggestion"] == "改由備援" + + unbound = _propose(_first_impacted(), "closure-ctx-2") + assert pp.get_purchase_proposal_context(unbound, actor="approver")["event"] is None + + +@pytest.mark.parametrize("actor", [None, "", "viewer", "planner", "nobody"]) +def test_proposal_context_fails_closed(flow_db, actor): + _mark_first_po(flow_db) + proposal = _propose(_first_impacted(), "closure-deny") + with pytest.raises(PermissionError): + pp.get_purchase_proposal_context(proposal, actor=actor) + + +def test_reversal_record_is_found_only_after_success(flow_db): + assert agent_logger.get_reversal_record("PENDING-X") is None + agent_logger.write_action_log("retry_approval", {"approval_id": "PENDING-X"}, "admin", "沖銷失敗", False) + assert agent_logger.get_reversal_record("PENDING-X") is None + agent_logger.write_action_log("retry_approval", {"approval_id": "PENDING-X"}, "admin", "已沖銷", True) + record = agent_logger.get_reversal_record("PENDING-X") + assert record and record["result"] == "已沖銷" and record["caller"] == "admin" + assert agent_logger.get_reversal_record("PENDING-Y") is None # 不會誤配其他單 + + +def _calls(tree, name): + return [n for n in ast.walk(tree) if isinstance(n, ast.Call) + and ((isinstance(n.func, ast.Name) and n.func.id == name) + or (isinstance(n.func, ast.Attribute) and n.func.attr == name))] + + +def test_workbench_binds_source_event_and_dashboard_guards_reversal(): + wb = ast.parse((ROOT / "frontend/components/purchase_proposal_workbench.py").read_text(encoding="utf-8")) + prepares = _calls(wb, "prepare_alternative_purchase_proposal") + assert prepares and all(any(k.arg == "source_event_id" for k in c.keywords) for c in prepares) + + dash_src = (ROOT / "frontend/page_agent_dashboard.py").read_text(encoding="utf-8") + dash = ast.parse(dash_src) + assert _calls(dash, "get_reversal_record"), "沖銷前必須查是否已沖銷" + contexts = _calls(dash, "get_purchase_proposal_context") + assert contexts and all(any(k.arg == "actor" for k in c.keywords) for c in contexts) diff --git a/tests/test_llm_extra_headers.py b/tests/test_llm_extra_headers.py new file mode 100644 index 0000000..c5cbc35 --- /dev/null +++ b/tests/test_llm_extra_headers.py @@ -0,0 +1,78 @@ +""" +tests/test_llm_extra_headers.py +LLM_EXTRA_HEADERS / LLM_TIMEOUT:讓 .env 指定每次 litellm 呼叫都要附帶的 HTTP header +(OpenCode Go 要求 x-opencode-session,缺了直接回 MissingSessionID)。 +""" + +from types import SimpleNamespace + +from backend import agent_orchestrator as orch + + +def _resp(text="ok"): + msg = SimpleNamespace(content=text, tool_calls=None) + return SimpleNamespace(choices=[SimpleNamespace(message=msg)]) + + +def _capture(monkeypatch): + calls = [] + + def fake_completion(**kw): + calls.append(kw) + return _resp() + + monkeypatch.setattr(orch.litellm, "completion", fake_completion) + monkeypatch.setattr(orch, "_FALLBACK_MODELS", []) + return calls + + +def test_extra_headers_forwarded_to_litellm(monkeypatch): + monkeypatch.setattr(orch, "_EXTRA_HEADERS", {"x-opencode-session": "erp"}) + calls = _capture(monkeypatch) + + orch._llm([{"role": "user", "content": "hi"}]) + + assert calls[0]["extra_headers"] == {"x-opencode-session": "erp"} + + +def test_no_extra_headers_when_unset(monkeypatch): + monkeypatch.setattr(orch, "_EXTRA_HEADERS", {}) + calls = _capture(monkeypatch) + + orch._llm([{"role": "user", "content": "hi"}]) + + assert "extra_headers" not in calls[0] + + +def test_load_extra_headers_parses_json(monkeypatch): + monkeypatch.setenv("LLM_EXTRA_HEADERS", '{"x-opencode-session": "erp", "x-n": 1}') + assert orch._load_extra_headers() == {"x-opencode-session": "erp", "x-n": "1"} + + +def test_load_extra_headers_ignores_bad_values(monkeypatch, capsys): + """打錯字或不是物件 → 當作未設定並印警告,不讓整個模組 import 失敗。""" + for bad in ("not json", '["a", "b"]', " "): + monkeypatch.setenv("LLM_EXTRA_HEADERS", bad) + assert orch._load_extra_headers() == {} + assert "LLM_EXTRA_HEADERS" in capsys.readouterr().out + + +def test_timeout_forwarded_to_litellm(monkeypatch): + """上游卡住時要能放棄換 fallback,所以每次呼叫都帶 timeout。""" + monkeypatch.setattr(orch, "_EXTRA_HEADERS", {}) + monkeypatch.setattr(orch, "_LLM_TIMEOUT", 42.0) + calls = _capture(monkeypatch) + + orch._llm([{"role": "user", "content": "hi"}]) + + assert calls[0]["timeout"] == 42.0 + + +def test_load_timeout_defaults_and_rejects_bad_values(monkeypatch): + monkeypatch.delenv("LLM_TIMEOUT", raising=False) + assert orch._load_timeout() == 120.0 + monkeypatch.setenv("LLM_TIMEOUT", "45") + assert orch._load_timeout() == 45.0 + for bad in ("abc", "0", "-5"): + monkeypatch.setenv("LLM_TIMEOUT", bad) + assert orch._load_timeout() == 120.0 diff --git a/tests/test_supply_chain_authorization.py b/tests/test_supply_chain_authorization.py index 9cda089..f5009b8 100644 --- a/tests/test_supply_chain_authorization.py +++ b/tests/test_supply_chain_authorization.py @@ -138,10 +138,11 @@ def _mutation(name: str, actor: str | None): "delete_factor", "clear_factors", "load_presets", + # 採購單延遲/替代建議是給 L3 看的評估註記,不動採購單本體 → L2 workspace + "update_po_impact", ) _ERP_POLICY_MUTATIONS = ( - "update_po_impact", "increase_stock", "restore_stock", "update_rop", diff --git a/tests/test_tier_authorization.py b/tests/test_tier_authorization.py index 97bd378..c878c0d 100644 --- a/tests/test_tier_authorization.py +++ b/tests/test_tier_authorization.py @@ -16,6 +16,7 @@ ERP_EXCHANGE_RECONCILE, PROPOSAL_EVIDENCE_READ, RISK_ANALYSIS_READ, + RISK_ALERT_ACK, RISK_OVERVIEW_READ, RISK_WHAT_IF_RUN, capabilities_for_role, @@ -116,7 +117,8 @@ def test_demo_roles_have_context_visibility_without_inheriting_actions(): planner = capabilities_for_role("supply_planner") approver = capabilities_for_role("procurement_approver") - assert viewer == {RISK_OVERVIEW_READ} + # L1 告警確認是監控狀態、不是 ERP 寫入,viewer 仍無任何 L2/L3 能力 + assert viewer == {RISK_OVERVIEW_READ, RISK_ALERT_ACK} assert {RISK_OVERVIEW_READ, RISK_ANALYSIS_READ, RISK_WHAT_IF_RUN} <= planner assert ERP_EXCHANGE_PROPOSE in planner diff --git a/tests/test_tier_navigation.py b/tests/test_tier_navigation.py index ea196a8..c045b80 100644 --- a/tests/test_tier_navigation.py +++ b/tests/test_tier_navigation.py @@ -178,7 +178,9 @@ def test_tier_pages_derive_sections_from_live_principal(): assert "principal = load_principal(username)" in risk_source assert "sections = risk_sections(principal)" in risk_source - assert risk_source.count("actor=principal.username") == 5 + # L2 五個渲染器 + L1 總覽的兩個呼叫點(單頁/分頁)都必須轉發 live actor + assert risk_source.count("actor=principal.username") == 7 + assert risk_source.count("render_risk_overview(actor=principal.username)") == 2 assert "principal = load_principal(username)" in exchange_source assert "sections = exchange_sections(principal)" in exchange_source assert "actor=current_actor" in exchange_source