diff --git a/.env.example b/.env.example index de37a5d..f01f5d1 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 # ── 新聞來源(供應鏈風險頁)── @@ -37,8 +42,13 @@ ERP_DEMO_MODE=false # then provision user_organizations and organization_entitlements before startup. # ERP_ORGANIZATION_ID=your-organization-id -# Optional service identity for the 24-hour supply-chain news refresh. +# Supply-chain scheduler: background execution is opt-in. # The account must have risk.workspace.write; for the local demo use planner. +ERP_SCHEDULER_ENABLED=0 +ERP_SCHEDULER_INTERVAL_SECONDS=86400 +ERP_SCHEDULER_INITIAL_DELAY_SECONDS=10 +ERP_SCHEDULER_MAX_ATTEMPTS=3 +ERP_SCHEDULER_RETRY_SECONDS=30 ERP_SCHEDULER_ACTOR= # Optional: seed Agent Dashboard with synthetic demo records. Keep disabled for real data. diff --git a/.gitignore b/.gitignore index 643aace..7b69966 100644 --- a/.gitignore +++ b/.gitignore @@ -232,3 +232,8 @@ line bot/ngrok.exe # Local AI-assistant and planning state must never enter the public repository. .claude/ .planning/ + +# Isolated batch-one runtime state +.isolated/ +*.db.news.lock +*.db.scheduler.lock diff --git a/app.py b/app.py index 391ba2d..b7cc34f 100644 --- a/app.py +++ b/app.py @@ -5,12 +5,14 @@ 所有商業邏輯均位於 backend/,所有 UI 頁面均位於 frontend/ """ +import os import streamlit as st # ── 載入 .env(模型設定 LLM_MODEL / 各供應商金鑰,issue #25)──────────── try: from dotenv import load_dotenv - load_dotenv() + if os.getenv("ERP_ISOLATED_TEST") != "1": + load_dotenv() except Exception: pass @@ -30,6 +32,9 @@ # ── 頁面設定 ──────────────────────────────────────────────────────── st.set_page_config(page_title="進銷存安全系統", page_icon="🛡️", layout="wide") +if os.getenv("ERP_ISOLATED_TEST") == "1": + news_mode = "真實新聞快照" if os.getenv("ERP_NEWS_CAPTURE") else "固定新聞" + st.warning(f"隔離測試環境|{news_mode}與模擬 AI|不發送通知|背景排程關閉") # ── 全域 CSS ──────────────────────────────────────────────────────── st.markdown(""" 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..8e2c688 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,31 @@ 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: + receipt = conn.execute("SELECT created_at,result,actor FROM approval_reversals WHERE approval_id=?", (approval_id,)).fetchone() + if receipt: + return {"timestamp": receipt[0], "result": receipt[1], "caller": receipt[2]} + 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..4d731e9 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,12 @@ 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} + if os.getenv("ERP_ISOLATED_TEST") == "1": + from types import SimpleNamespace + from .isolated_runtime import fixture_completion + msg = SimpleNamespace(content=fixture_completion(messages, usage_tag), tool_calls=None) + return SimpleNamespace(choices=[SimpleNamespace(message=msg)]) + kw = {"messages": messages, "temperature": temperature, "timeout": _LLM_TIMEOUT} if tools: kw["tools"] = tools kw["tool_choice"] = "auto" @@ -211,6 +252,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/approval_reversal.py b/backend/approval_reversal.py new file mode 100644 index 0000000..195867e --- /dev/null +++ b/backend/approval_reversal.py @@ -0,0 +1,59 @@ +"""Exactly-once compensation of a recorded approval, inside one SQLite transaction.""" +import json +import math +import re +from datetime import datetime +from . import database +from .access_control import GLOBAL_APPROVAL_DECIDE, require_capability + + +def migrate(conn): + conn.execute("""CREATE TABLE IF NOT EXISTS approval_reversals ( + approval_id TEXT PRIMARY KEY, tool_name TEXT NOT NULL, + actor TEXT NOT NULL, created_at TEXT NOT NULL, result TEXT NOT NULL)""") + + +def reverse_approval(approval_id, *, actor): + from .agent_logger import get_reversal_record, write_action_log + # BEGIN IMMEDIATE serializes readers/compensators across processes. The + # receipt, stock/order changes, stock move and audit share this commit. + with database.transaction(immediate=True) as conn: + principal = require_capability(actor, GLOBAL_APPROVAL_DECIDE, conn=conn) + if principal.role != "admin": + raise PermissionError("僅管理員可沖銷") + existing = get_reversal_record(approval_id, conn=conn) + if existing: + return dict(status="already_reversed", message=existing["result"]) + row = conn.execute("""SELECT p.tool_name,p.parameters,p.status,r.result + FROM pending_approvals p LEFT JOIN effect_receipts r ON r.approval_id=p.approval_id + WHERE p.approval_id=?""", (approval_id,)).fetchone() + if not row or row[2] != "approved" or row[0] not in {"update_inventory", "create_order"}: + raise ValueError("審批未成功或不支援沖銷") + if row[3] is None: + raise ValueError("舊審批缺少執行收據,需人工對帳後處理") + args = json.loads(row[1]) + product_id = args.get("product_id") + value = args.get("quantity_change") if row[0] == "update_inventory" else args.get("quantity") + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value == 0: + raise ValueError("原始審批數量無效") + delta = -value if row[0] == "update_inventory" else value + if row[0] == "create_order": + ids = set(re.findall(r"ORD-\d{8}-\d{6}", row[3])) + if len(ids) != 1 or value <= 0: + raise ValueError("執行收據無法唯一識別訂單,需人工對帳") + order_id = ids.pop() + order = conn.execute("SELECT product_id,quantity,status FROM orders WHERE order_id=?", (order_id,)).fetchone() + if not order or order[0] != product_id or order[1] != value or order[2] != "處理中": + raise ValueError("訂單狀態或內容已異動,需人工對帳") + conn.execute("UPDATE orders SET status='已取消' WHERE order_id=?", (order_id,)) + stock = conn.execute("SELECT stock,warehouse_id FROM inventory WHERE product_id=?", (product_id,)).fetchone() + if not stock or stock[0] + delta < 0: + raise ValueError("品項不存在或沖銷後庫存不足") + conn.execute("UPDATE inventory SET stock=stock+? WHERE product_id=?", (delta,product_id)) + now = datetime.now().isoformat() + conn.execute("INSERT INTO stock_moves(product_id,warehouse_id,qty,move_type,ref_no,move_date,note) VALUES(?,?,?,?,?,?,?)", + (product_id, stock[1] or "WH01", abs(delta), "入庫" if delta>0 else "出庫", approval_id, now, "核准紀錄沖銷")) + message = f"已沖銷 {approval_id};{product_id} 庫存異動 {delta:+g}。" + conn.execute("INSERT INTO approval_reversals VALUES(?,?,?,?,?)", (approval_id,row[0],actor,now,message)) + write_action_log("retry_approval", {"approval_id":approval_id}, actor, message, True, conn=conn) + return dict(status="ok", message=message) diff --git a/backend/database.py b/backend/database.py index 4af17ec..8738a5d 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,34 @@ def init_db(): ai_summary TEXT, updated_at TEXT )''') + from .news_store import migrate as migrate_news + migrate_news(conn) + from .risk_intelligence import migrate as migrate_intelligence + migrate_intelligence(conn) + from .approval_reversal import migrate as migrate_reversals + migrate_reversals(conn) + + 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 +806,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/inventory.py b/backend/inventory.py index d20f538..3cba657 100644 --- a/backend/inventory.py +++ b/backend/inventory.py @@ -133,14 +133,12 @@ def update_inventory(product_id: str, quantity_change: int) -> str: return f"找不到產品編號 {product_id}。" -def rollback_inventory(product_id: str, quantity_change: int) -> str: - """ - 沖銷先前的庫存異動(補償交易)。 - 將 update_inventory 的異動量反向執行,需 admin 權限。 - """ - if not check_permission(["admin"]): - return "權限不足:只有『管理員』可以執行庫存沖銷。" - return update_inventory(product_id=product_id, quantity_change=-quantity_change) +def rollback_inventory(product_id: str, quantity_change: int, *, approval_id=None, actor=None): + """Compensation must resolve its original parameters from a durable approval.""" + if not approval_id: + raise ValueError("沖銷需要原始 approval_id 與管理員身份") + from .approval_reversal import reverse_approval + return reverse_approval(approval_id, actor=actor)["message"] def get_inventory_total_value(use_cost: bool = True) -> str: diff --git a/backend/isolated_runtime.py b/backend/isolated_runtime.py new file mode 100644 index 0000000..efaf63c --- /dev/null +++ b/backend/isolated_runtime.py @@ -0,0 +1,89 @@ +"""Deterministic local fixtures and a network guard for the isolated launcher.""" +import ipaddress +import os +import json +import re +import socket +from pathlib import Path + + +def news_capture(): + """Optional real-source snapshot selected by the local review launcher.""" + path = os.getenv("ERP_NEWS_CAPTURE", "") + return json.loads(Path(path).read_text(encoding="utf-8")) if path else None + + +def block_external_network(): + if getattr(socket, "_erp_isolated", False): + return + original_connect = socket.socket.connect + original_connect_ex = socket.socket.connect_ex + original_getaddrinfo = socket.getaddrinfo + + def allowed(host): + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + def check(address): + if isinstance(address, tuple) and not allowed(address[0]): + raise OSError("Isolated ERP: external network and notifications are disabled") + + def connect(sock, address): + check(address) + return original_connect(sock, address) + + def connect_ex(sock, address): + check(address) + return original_connect_ex(sock, address) + + def getaddrinfo(host, *args, **kwargs): + if host is not None and not allowed(host): + raise OSError("Isolated ERP: external DNS is disabled") + return original_getaddrinfo(host, *args, **kwargs) + + socket.socket.connect = connect + socket.socket.connect_ex = connect_ex + socket.getaddrinfo = getaddrinfo + socket._erp_isolated = True + + +def fixture_news(country): + capture = news_capture() + if capture: + from .region_matching import normalize + return [dict(a) for a in capture["articles"] if normalize(a.get("country")) == normalize(country)] + rows = [ + ("zero", "港口恢復營運 [ZERO]", "確認目前無延遲。"), + ("delay", "港口罷工 [DELAY]", "固定測試事件:延遲五天。"), + ("unknown", "交期尚未確認 [UNKNOWN]", "目前沒有可靠的延遲天數。"), + ("invalid", "模型輸出格式錯誤案例 [INVALID]", "保留此原文供人工檢查。"), + ] + if os.getenv("ERP_ISOLATED_SCENARIO", "mixed") == "success": + rows = [row for row in rows if row[0] != "invalid"] + return [dict(country=country, region=None, title=f"{country} {title}", summary=summary, + url=f"https://fixture.invalid/{country}/{key}", source="固定測試資料", + published_at="2026-09-13 08:00", relevance_tag="supply_chain") + for key, title, summary in rows] + + +def fixture_completion(prompt, tag): + if tag == "analysis:news_batch": + results = [] + for idx, body in re.findall(r"【新聞編號 (\d+)】\n(.*?)(?=【新聞編號|$)", str(prompt), re.S): + country = next((c for c in ("台灣", "日本", "美國", "南韓", "中國", "越南", "墨西哥", "德國", "新加坡") if body.startswith(c)), "台灣") + delay = 0 if "[ZERO]" in body else None if "[UNKNOWN]" in body else "invalid" if "[INVALID]" in body else 5 + results.append({"news_id": int(idx), "相關性": "YES", "國家": country, "地區": "不明", + "事件類型": "交通", "預計延遲": delay, "繁體中文簡要": "固定模擬分析;非即時新聞。"}) + return json.dumps({"results": results}, ensure_ascii=False) + if tag == "analysis:heatmap": + return json.dumps({"摘要": "固定測試摘要:台灣北區確認為 0%/0 天,日本為 65%/5 天。", + "更新": [{"地區": "台灣 北區", "風險": 0}, {"地區": "日本", "風險": 65}], + "事件": [{"類型": "交通", "地區": "北區", "國家": "台灣", "延遲天數": 0, "描述": "固定零值測試"}, + {"類型": "罷工", "地區": "日本", "國家": "日本", "延遲天數": 5, "描述": "固定延遲測試"}]}, ensure_ascii=False) + if tag == "analysis:po_alternative": + return '{"results": []}' + return "隔離測試環境:這是固定模擬回應,未呼叫外部模型或發送通知。" diff --git a/backend/job_lock.py b/backend/job_lock.py new file mode 100644 index 0000000..f835886 --- /dev/null +++ b/backend/job_lock.py @@ -0,0 +1,36 @@ +"""Process-wide advisory file locks, released by the OS even on process death.""" +import os +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def exclusive_job_lock(db_path, name): + path = Path(str(db_path) + f".{name}.lock") + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a+b") as handle: + handle.seek(0, 2) + if handle.tell() == 0: + handle.write(b"0") + handle.flush() + handle.seek(0) + acquired = False + try: + if os.name == "nt": + import msvcrt + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + except (OSError, BlockingIOError): + pass + try: + yield acquired + finally: + if acquired: + handle.seek(0) + if os.name == "nt": + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) diff --git a/backend/l1_monitoring.py b/backend/l1_monitoring.py index 98af2a1..58edbcf 100644 --- a/backend/l1_monitoring.py +++ b/backend/l1_monitoring.py @@ -2,6 +2,22 @@ from __future__ import annotations +from datetime import datetime, timedelta +import sqlite3 + +from backend import database +from backend.risk_contract import valid_event_sql +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: @@ -12,34 +28,15 @@ def _text(value) -> str: return normalized -def _location_matches(left, right) -> bool: - left_text = "".join(_text(left).casefold().split()) - right_text = "".join(_text(right).casefold().split()) - if not left_text or not right_text: - return False - if left_text == right_text: - return True - return min(len(left_text), len(right_text)) >= 2 and ( - left_text in right_text or right_text in left_text - ) - +def _location_matches(left, right): + from .region_matching import matches_location, split_location + c,r = split_location(left) + return matches_location(c,r,right) -def _event_matches_supplier(event: dict, supplier: dict) -> bool: - event_country = _text(event.get("country")) - event_region = _text(event.get("region")) - supplier_country = _text(supplier.get("country")) - supplier_region = _text(supplier.get("region")) - country_matches = _location_matches(event_country, supplier_country) - region_matches = _location_matches(event_region, supplier_region) - - if event_region and supplier_region: - if event_country and supplier_country: - return country_matches and region_matches - return region_matches - if event_country and supplier_country: - return country_matches - return region_matches +def _event_matches_supplier(event, supplier): + from .region_matching import matches_location + return matches_location(supplier.get("country"), supplier.get("region"), event.get("region"), event.get("country")) def _impact_days(event: dict) -> int: @@ -143,3 +140,355 @@ 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( + f""" + 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 {valid_event_sql('e.')} + AND 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.analysis_region, n.analysis_country, n.estimated_delay, + n.title, n.analysis_summary, n.url, n.source, n.published_at, n.fetched_at + FROM supply_chain_news n + WHERE n.analysis_status='succeeded' AND n.is_relevant=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.analysis_country, n.analysis_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 n.analysis_status='succeeded' AND n.is_relevant=1 AND n.estimated_delay > 0 + 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/llm_client.py b/backend/llm_client.py index 31261ad..d061d5f 100644 --- a/backend/llm_client.py +++ b/backend/llm_client.py @@ -19,6 +19,8 @@ def llm_available() -> bool: """是否已設定任何可用的模型供應商(.env 驅動)。""" + if os.getenv("ERP_ISOLATED_TEST") == "1": + return True return bool(os.getenv("LLM_MODEL") or os.getenv("OPENAI_API_KEY") or os.getenv("GEMINI_API_KEY")) @@ -30,6 +32,9 @@ def complete_text(prompt, system: str | None = None, temperature: float = 0.2, 單次文字補全。prompt 可為字串或 messages list。 回傳純文字(失敗拋例外,由呼叫端決定 fallback 行為)。 """ + if os.getenv("ERP_ISOLATED_TEST") == "1": + from .isolated_runtime import fixture_completion + return fixture_completion(prompt, tag) from backend.agent_orchestrator import _llm, _content from backend.prompts import PROMPT_DEFENSE_BASELINE diff --git a/backend/news_store.py b/backend/news_store.py new file mode 100644 index 0000000..594f103 --- /dev/null +++ b/backend/news_store.py @@ -0,0 +1,98 @@ +"""Raw news retention, durable analysis state and pre-analysis deduplication.""" +import hashlib +import re +import sqlite3 +import unicodedata +from datetime import datetime, timezone +from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def identity_keys(item): + url = str(item.get("url") or "").strip() + if url: + parts = urlsplit(url) + query = sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) + if not k.lower().startswith("utm_") and k.lower() not in {"fbclid", "gclid"}) + url = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path.rstrip("/"), urlencode(query), "")) + title = re.sub(r"\s+", " ", unicodedata.normalize("NFKC", str(item.get("title") or ""))).strip().casefold() + # Different syndication URLs with the same headline/source/publication day are one story. + content = "|".join((title, str(item.get("source") or "").strip().casefold(), str(item.get("published_at") or "")[:10])) if title else "" + digest = lambda value: hashlib.sha256(value.encode("utf-8")).hexdigest() if value else None + return digest(url), digest(content) + + +def migrate(conn): + columns = {r[1] for r in conn.execute("PRAGMA table_info(supply_chain_news)")} + additions = {"analysis_status": "TEXT NOT NULL DEFAULT 'legacy_unverified'", + "analysis_error": "TEXT", "analysis_summary": "TEXT", "analysis_country": "TEXT", + "analysis_region": "TEXT", "analyzed_at": "TEXT", "url_key": "TEXT", "content_key": "TEXT"} + for name, declaration in additions.items(): + if name not in columns: + conn.execute(f"ALTER TABLE supply_chain_news ADD COLUMN {name} {declaration}") + if "estimated_delay" not in {r[1] for r in conn.execute("PRAGMA table_info(risk_heatmap)")}: + conn.execute("ALTER TABLE risk_heatmap ADD COLUMN estimated_delay INTEGER") + # Keep historical duplicates and IDs (events may reference them); index only the first owner. + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS news_url_unique ON supply_chain_news(url_key) WHERE url_key IS NOT NULL") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS news_content_unique ON supply_chain_news(content_key) WHERE content_key IS NOT NULL") + if "url_key" not in columns: + rows = conn.execute("SELECT id,title,url,source,published_at FROM supply_chain_news ORDER BY id").fetchall() + for row in rows: + uk, ck = identity_keys(dict(zip(("id", "title", "url", "source", "published_at"), row))) + for field, value in (("url_key", uk), ("content_key", ck)): + if value and not conn.execute(f"SELECT 1 FROM supply_chain_news WHERE {field}=?", (value,)).fetchone(): + conn.execute(f"UPDATE supply_chain_news SET {field}=? WHERE id=?", (value, row[0])) + conn.execute("""CREATE TABLE IF NOT EXISTS scheduled_jobs ( + job_key TEXT PRIMARY KEY, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, + started_at TEXT, finished_at TEXT, error TEXT, result_json TEXT)""") + + +def find_existing(conn, item): + uk, ck = identity_keys(item) + return conn.execute("SELECT id,analysis_status FROM supply_chain_news WHERE url_key=? OR content_key=? ORDER BY id LIMIT 1", (uk, ck)).fetchone() + + +def store_raw(conn, item): + existing = find_existing(conn, item) + if existing: + return existing[0], False + uk, ck = identity_keys(item) + if not (uk or ck): + raise ValueError("News needs a title or URL") + values = [item.get(k) for k in ("country", "region", "title", "summary", "url", "source", "published_at", "relevance_tag")] + try: + cur = conn.execute("""INSERT INTO supply_chain_news + (country,region,title,summary,url,source,published_at,relevance_tag,fetched_at, + analysis_status,is_relevant,estimated_delay,url_key,content_key) + VALUES (?,?,?,?,?,?,?,?,?,'pending',NULL,NULL,?,?)""", (*values, now(), uk, ck)) + return cur.lastrowid, True + except sqlite3.IntegrityError: + existing = find_existing(conn, item) + if existing: + return existing[0], False + raise + + +def store_analysis(conn, news_id, result): + from .risk_validation import number, text, EVENT_TYPES + success = result.get("analysis_status") == "succeeded" + delay = number(result.get("estimated_delay"), maximum=365, integer=True, nullable=True) if success else None + if success: + if type(result.get("is_relevant")) is not bool: + raise ValueError("Invalid analysis relevance") + for key in ("country", "region", "chinese_summary"): + text(result.get(key)) + if result.get("event_type") not in EVENT_TYPES: + raise ValueError("Invalid event type") + if not result["is_relevant"] and delay not in (None, 0): + raise ValueError("Irrelevant news cannot have delay") + conn.execute("""UPDATE supply_chain_news SET analysis_status=?,analysis_error=?, + analysis_summary=?,analysis_country=?,analysis_region=?,category=?,is_relevant=?, + estimated_delay=?,analyzed_at=? WHERE id=? AND analysis_status!='succeeded'""", + ("succeeded" if success else "failed", None if success else result.get("analysis_error", "invalid_output"), + result.get("chinese_summary") if success else None, result.get("country") if success else None, + result.get("region") if success else None, result.get("event_type") if success else None, + int(result["is_relevant"]) if success else None, delay, now(), news_id)) diff --git a/backend/orders.py b/backend/orders.py index 9f39c3f..d05389f 100644 --- a/backend/orders.py +++ b/backend/orders.py @@ -72,34 +72,12 @@ def create_order(product_id: str, quantity: int) -> str: return f"建立訂單時發生資料庫錯誤:{e}" -def cancel_order(product_id: str, quantity: int, customer_id: str = "") -> str: - """ - 取消最近一筆符合條件的訂單,並回補庫存。 - 需 admin 權限。 - 若提供 customer_id 則只比對該客戶的訂單,避免誤刪他客戶同品項同數量訂單。 - """ - if not check_permission(["admin"]): - return "權限不足:只有『管理員』可以取消訂單。" - - if customer_id: - rows = run_query( - "SELECT order_id FROM orders WHERE (customer_id = ? OR customer_id IS NULL) AND product_id = ? AND quantity = ? ORDER BY order_date DESC LIMIT 1", - (customer_id, product_id, quantity), - ) - else: - rows = run_query( - "SELECT order_id FROM orders WHERE product_id = ? AND quantity = ? ORDER BY order_date DESC LIMIT 1", - (product_id, quantity), - ) - - if not rows: - return f"找不到產品 {product_id} 數量 {quantity} 的待取消訂單。" - - order_id = rows[0][0] - run_query("DELETE FROM orders WHERE order_id = ?", (order_id,), fetch=False) - from backend.inventory import update_inventory - update_inventory(product_id=product_id, quantity_change=quantity) - return f"✅ 已取消訂單 {order_id},並將產品 {product_id} 庫存回補 {quantity} 件。" +def cancel_order(product_id: str, quantity: int, customer_id: str = "", *, approval_id=None, actor=None): + """Compensation must resolve its original parameters from a durable approval.""" + if not approval_id: + raise ValueError("沖銷需要原始 approval_id 與管理員身份") + from .approval_reversal import reverse_approval + return reverse_approval(approval_id, actor=actor)["message"] def get_receivables() -> str: diff --git a/backend/prompts.py b/backend/prompts.py index 0afa71a..d503904 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 物件(頂層必須是物件、不要其他文字): {{ @@ -44,7 +45,7 @@ {{"地區": "<合法區域名稱>", "風險": <0-100 整數>}} ], "事件": [ - {{"類型": "<戰爭|氣候|罷工|政策|交通|其他>", "地區": "<合法區域名稱>", "國家": "<國家名>", "延遲天數": <整數>, "描述": "<一句話>"}} + {{"類型": "<戰爭|氣候|罷工|政策|交通|其他>", "地區": "<合法區域名稱>", "國家": "<國家名>", "延遲天數": <0-365 整數或 null,未知用 null,確認無延遲用 0>, "描述": "<一句話>"}} ] }}""" @@ -64,7 +65,7 @@ 【輸出格式】只輸出以下 JSON 物件(頂層必須是物件、不要其他文字),results 共 {impact_count} 筆: {{ "results": [ - {{"po_id": "<採購單號>", "延遲天數": <整數>, "建議": "<含具體國家/地區名的替代建議>"}} + {{"po_id": "<採購單號>", "延遲天數": <0-365 整數或 null,未知用 null,確認無延遲用 0>, "建議": "<含具體國家/地區名的替代建議>"}} ] }}""" @@ -110,7 +111,7 @@ "地區": "地區/城市名(務必繁體中文)", "事件類型": "戰爭, 氣候, 罷工, 政策, 交通, 其他", "繁體中文簡要": "這則新聞的 150 字內繁體中文簡要分析", - "預計延遲": <數字,不相關則填 0> + "預計延遲": <0-365 整數或 null;0 為確認無延遲,null 為未知,不可用慣例猜測缺漏資料> }} ] }} diff --git a/backend/purchase_proposals.py b/backend/purchase_proposals.py index e040a71..b50f3e6 100644 --- a/backend/purchase_proposals.py +++ b/backend/purchase_proposals.py @@ -7,6 +7,8 @@ from __future__ import annotations +from .risk_contract import valid_event_sql + from dataclasses import asdict, dataclass from datetime import date, datetime import hashlib @@ -358,7 +360,7 @@ def prepare_alternative_purchase_proposal( raise ValueError("供應商主檔單價格式錯誤。") if source_event_id is not None: if conn.execute( - "SELECT 1 FROM supply_chain_events WHERE id = ?", (source_event_id,) + f"SELECT 1 FROM supply_chain_events WHERE id = ? AND {valid_event_sql()}", (source_event_id,) ).fetchone() is None: raise ValueError("找不到來源風險事件。") source_version = f"sha256:{_canonical_digest(source)}" @@ -467,7 +469,7 @@ def _validate_current_proposal( ): raise PermissionError("預估延誤天數超出允許範圍。") if proposal.source_event_id is not None and conn.execute( - "SELECT 1 FROM supply_chain_events WHERE id = ?", + f"SELECT 1 FROM supply_chain_events WHERE id = ? AND {valid_event_sql()}", (proposal.source_event_id,), ).fetchone() is None: raise PermissionError("找不到來源風險事件。") @@ -811,6 +813,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 +823,122 @@ 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( + f""" + 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.analysis_status, n.analysis_summary + FROM supply_chain_events e + LEFT JOIN supply_chain_news n ON n.id = e.news_id + WHERE e.id = ? AND {valid_event_sql('e.')} + """, + (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/region_matching.py b/backend/region_matching.py new file mode 100644 index 0000000..d230c49 --- /dev/null +++ b/backend/region_matching.py @@ -0,0 +1,86 @@ +"""One exact geographic contract for Python and SQLite consumers. + +Comma-separated selectors are OR; country + subregion is AND. Spaces inside +country names are preserved. Empty selectors never match. No SQL wildcards. +""" +import re + +REGION_COUNTRY_MAP = { + "亞洲": ["台灣", "日本", "中國", "南韓", "北韓", "越南", "泰國", "新加坡", "馬來西亞", "印尼", "菲律賓", "印度", "香港", "澳門", "緬甸", "柬埔寨", "寮國"], + "東亞": ["台灣", "日本", "中國", "南韓", "北韓", "香港", "澳門"], + "東南亞": ["越南", "泰國", "新加坡", "馬來西亞", "印尼", "菲律賓", "緬甸", "柬埔寨", "寮國"], + "歐洲": ["德國", "法國", "英國", "義大利", "西班牙", "荷蘭", "波蘭", "比利時", "奧地利", "瑞士"], + "北美": ["美國", "加拿大", "墨西哥"], + "中東": ["伊朗", "沙烏地阿拉伯", "阿拉伯聯合大公國", "以色列", "卡達", "伊拉克", "科威特", "約旦", "黎巴嫩", "敘利亞", "土耳其"], + "非洲": ["埃及", "南非", "摩洛哥", "奈及利亞"], +} +ALIASES = { + "臺灣": "台灣", "taiwan": "台灣", "tw": "台灣", + "japan": "日本", "jp": "日本", "united states": "美國", "usa": "美國", "us": "美國", + "韓國": "南韓", "south korea": "南韓", "korea": "南韓", "kr": "南韓", + "阿聯酋": "阿拉伯聯合大公國", "阿聯": "阿拉伯聯合大公國", "uae": "阿拉伯聯合大公國", + "china": "中國", "vietnam": "越南", "germany": "德國", "united kingdom": "英國", + "singapore": "新加坡", "canada": "加拿大", "mexico": "墨西哥", +} + + +def normalize(value): + value = re.sub(r"\s+", " ", str(value or "")).strip().casefold() + return ALIASES.get(value, value) + + +def _parts(value): + return [v.strip() for v in re.split(r"[,,、;;]", str(value or "")) if v.strip()] + + +def split_location(value): + value = str(value or "").strip() + if "|" in value: + c, r = value.split("|", 1) + return normalize(c), normalize(r) + known = set(ALIASES) | set(ALIASES.values()) | set(REGION_COUNTRY_MAP) + known.update(c for countries in REGION_COUNTRY_MAP.values() for c in countries) + for c in sorted(known, key=len, reverse=True): + if value.casefold().startswith(c.casefold() + " "): + return normalize(c), normalize(value[len(c):]) + return "", normalize(value) + + +def _country_match(selector, country): + return selector == country or country in REGION_COUNTRY_MAP.get(selector, []) + + +def matches_location(country, region, selector_region=None, selector_country=None): + country, region = normalize(country), normalize(region) + countries = [normalize(c) for c in _parts(selector_country)] + regions = _parts(selector_region) + if not countries and not regions: + return False + if countries and not any(_country_match(c, country) for c in countries): + return False + if not regions: + return True + for value in regions: + exact = normalize(value) + if exact in {country, region, f"{country} {region}", f"{country}|{region}"}: + return True + c, r = split_location(value) + if c: + if _country_match(c, country) and (r == c or r == region): + return True + elif _country_match(r, country) or r == region: + return True + return False + + +def expanded_region_where(region, country, prefix=""): + if prefix not in ("", "s.", "p.", "c."): + raise ValueError("Unsupported SQL alias") + return [f"erp_region_matches({prefix}country, {prefix}region, ?, ?) = 1"], [region, country] + + +def connect_db(path): + import sqlite3 + conn = sqlite3.connect(path) + conn.create_function("erp_region_matches", 4, matches_location, deterministic=True) + return conn diff --git a/backend/risk_contract.py b/backend/risk_contract.py new file mode 100644 index 0000000..1acb01b --- /dev/null +++ b/backend/risk_contract.py @@ -0,0 +1,49 @@ +"""Shared news eligibility and source validation for L1, L2 and L3.""" +from .risk_validation import number, text, EVENT_TYPES +from .region_matching import matches_location + + +def valid_news_sql(alias=""): + if alias not in ("", "n."): + raise ValueError("Unsupported news alias") + return f"{alias}analysis_status='succeeded' AND {alias}is_relevant=1" + + +def valid_event_sql(alias=""): + if alias not in ("", "e."): + raise ValueError("Unsupported event alias") + return (f"(typeof({alias}impact_days) IN ('integer','real') AND {alias}impact_days BETWEEN 0 AND 365 " + f"AND CAST({alias}impact_days AS INTEGER)={alias}impact_days AND " + f"({alias}news_id IS NULL OR {alias}news_id IN (SELECT id FROM supply_chain_news WHERE {valid_news_sql()} AND estimated_delay IS NOT NULL)))") + + +def analyzed_news(row): + """Project analysis without ever falling back to raw geography or prose.""" + if row.get("analysis_status") != "succeeded" or row.get("is_relevant") != 1: + return None + try: + days = number(row.get("estimated_delay"), maximum=365, integer=True, nullable=True) + country = text(row.get("analysis_country")) + region = text(row.get("analysis_region")) + summary = text(row.get("analysis_summary")) + if row.get("category") not in EVENT_TYPES: + return None + except (ValueError, TypeError): + return None + return dict(row, country=country, region=region, summary=summary, estimated_delay=days) + + +def validate_event(conn, event_type, region, country, impact_days, description, news_id): + event_type, region, country, description = map(text, (event_type, region, country, description)) + if event_type not in EVENT_TYPES or not (region or country): + raise ValueError("事件類型或地區無效") + days = number(impact_days, maximum=365, integer=True) + if news_id is not None: + news_id = number(news_id, maximum=2**53-1, integer=True) + row = conn.execute("SELECT analysis_status,is_relevant,estimated_delay,analysis_country,analysis_region FROM supply_chain_news WHERE id=?", (news_id,)).fetchone() + if not row or row[0] != "succeeded" or row[1] != 1 or row[2] is None: + raise ValueError("新聞分析尚未成功或延遲未知,無法登錄風險") + number(row[2], maximum=365, integer=True) + if not matches_location(country, region, row[4], row[3]): + raise ValueError("事件地區不符合來源新聞的分析地區") + return event_type, region, country, days, description, news_id diff --git a/backend/risk_intelligence.py b/backend/risk_intelligence.py new file mode 100644 index 0000000..b9ea1a4 --- /dev/null +++ b/backend/risk_intelligence.py @@ -0,0 +1,151 @@ +"""Evidence, provenance and durable summary storage using the batch-one contract.""" +import json +import sqlite3 +import math +from datetime import datetime +from . import database +from .access_control import RISK_WORKSPACE_WRITE, require_capability +from .risk_contract import analyzed_news +from .risk_validation import number +from .region_matching import matches_location, split_location, normalize + + +def migrate(conn): + conn.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)""") + columns = {r[1] for r in conn.execute("PRAGMA table_info(risk_ai_summaries)")} + for name, definition in { + "analysis_status": "TEXT NOT NULL DEFAULT 'legacy_unverified'", + "analysis_error": "TEXT", "sources_json": "TEXT NOT NULL DEFAULT '[]'", + "raw_summary": "TEXT", + }.items(): + if name not in columns: + conn.execute(f"ALTER TABLE risk_ai_summaries ADD COLUMN {name} {definition}") + + +def save_ai_risk_summary(result, *, actor=None, conn=None): + require_capability(actor, RISK_WORKSPACE_WRITE, conn=conn) + status = result.get("analysis_status") + if status not in {"succeeded", "failed"} or (status == "succeeded" and result.get("error")): + raise ValueError("Summary needs an explicit consistent analysis status") + owned = conn is None + conn = conn or sqlite3.connect(database.DB_FILE) + try: + migrate(conn) + cur = conn.execute("""INSERT INTO risk_ai_summaries + (created_at,actor,reference_date,summary,updates_json,events_json,audit_json, + news_count,event_count,analysis_status,analysis_error,sources_json,raw_summary) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( + result.get("generated_at") or datetime.now().isoformat(), 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), + result.get("news_count", 0), result.get("event_count", 0), status, result.get("analysis_error"), + json.dumps(result.get("sources") or [], ensure_ascii=False, allow_nan=False), result.get("raw_summary"), + )) + if owned: + conn.commit() + return cur.lastrowid + finally: + if owned: + conn.close() + + +def get_latest_ai_risk_summary(conn=None): + owned = conn is None + conn = conn or sqlite3.connect(database.DB_FILE) + try: + cur = conn.execute("SELECT * FROM risk_ai_summaries WHERE analysis_status='succeeded' ORDER BY id DESC LIMIT 1") + row = cur.fetchone() + if row is None: + return None + r = dict(zip([c[0] for c in cur.description], row)) + return dict(summary_id=r["id"], generated_at=r["created_at"], actor=r["actor"], + reference_date=r["reference_date"], summary=r["summary"], + updates=json.loads(r["updates_json"]), events=json.loads(r["events_json"]), + audit=json.loads(r["audit_json"]), sources=json.loads(r["sources_json"]), + analysis_status=r["analysis_status"], analysis_error=r["analysis_error"], + news_count=r["news_count"], event_count=r["event_count"], error=False) + finally: + if owned: + conn.close() + + +def build_risk_evidence(news_items, events): + locations = {} + sources = [] + def add(row, kind): + country, region = normalize(row.get("country")), normalize(row.get("region")) + if not (country or region): + return + days = number(row.get("estimated_delay") if kind == "news" else row.get("impact_days"), maximum=365, integer=True, nullable=True) + key = f"{country}|{region}" + entry = locations.setdefault(key, {"country": country, "region": region, "types": set(), "max_days": None}) + entry["types"].add(row.get("category") if kind == "news" else row.get("event_type")) + if days is not None: + entry["max_days"] = max(days, entry["max_days"] if entry["max_days"] is not None else 0) + scalar = lambda v: None if isinstance(v, float) and math.isnan(v) else v + sources.append(dict(kind=kind, id=scalar(row.get("id")), news_id=scalar(row.get("news_id")), + analysis_status="succeeded" if kind == "news" else "validated_event", + country=country, region=region, delay=days, + title=row.get("title"), url=row.get("url"), + analyzed_at=row.get("analyzed_at"), event_type=row.get("category") if kind == "news" else row.get("event_type"))) + for raw in news_items or []: + row = analyzed_news(raw) + if row is not None: + add(row, "news") + rows = events.to_dict("records") if hasattr(events, "to_dict") else events or [] + for row in rows: + # Event loaders already enforce source status. Unknown/invalid days are + # never turned into a zero measurement by evidence construction. + try: + add(row, "event") + except (TypeError, ValueError): + continue + return dict(locations=locations, sources=sources) + + +def _evidence_for(name, evidence): + country, region = split_location(name) + hits = [v for v in evidence.get("locations", {}).values() + if matches_location(v["country"], v["region"], name) + or (country and matches_location(country, region, v["region"], v["country"]))] + if not hits: + return None + known = [v["max_days"] for v in hits if v["max_days"] is not None] + return {"max_days": max(known) if known else None, "types": set().union(*(v["types"] for v in hits))} + + +def gate_by_evidence(updates, events, evidence): + kept_updates, kept_events, audit = [], [], [] + def note(kind, name, reason, action="略過"): + audit.append(dict(kind=kind, name=name, reason=reason, action=action)) + for u in updates or []: + name = u.get("display_name", "") + if _evidence_for(name, evidence) is None: + note("更新", name, "沒有此據點的有效分析證據") + else: + kept_updates.append(dict(u)) + for e in events or []: + name = f"{e.get('country') or ''}|{e.get('region') or ''}" + ev = _evidence_for(name, evidence) + if ev is None or ev["max_days"] is None: + note("事件", name, "沒有此據點的已知延遲證據") + continue + item = dict(e) + days = number(item.get("impact_days"), maximum=365, integer=True, nullable=True) + if days is None: + note("事件", name, "建議延遲未知,不能建立事件") + continue + cap = min(365, ev["max_days"] * 2) + if days > cap: + item["impact_days"] = cap + note("事件", name, f"延遲超過證據上限,調整為 {cap} 天", "調整") + types = ev["types"] - {None, "", "其他"} + if item.get("event_type") != "其他" and item.get("event_type") not in types: + item["event_type"] = "其他" + note("事件", name, "事件類型沒有證據,改為其他", "調整") + kept_events.append(item) + return kept_updates, kept_events, audit diff --git a/backend/risk_validation.py b/backend/risk_validation.py new file mode 100644 index 0000000..4fac1b6 --- /dev/null +++ b/backend/risk_validation.py @@ -0,0 +1,77 @@ +"""Fail-closed validators: zero is a measurement, None is unknown.""" +import json +import math +import numbers +import re + +EVENT_TYPES = {"戰爭", "氣候", "罷工", "政策", "交通", "其他", "地震", "天候", "政治", "疫情"} + + +def number(value, *, maximum, integer=False, nullable=False): + if value is None and nullable: + return None + if isinstance(value, bool) or not isinstance(value, numbers.Real): + raise ValueError("Expected a JSON number") + value = float(value) + if not math.isfinite(value) or not 0 <= value <= maximum: + raise ValueError("Number out of range") + if integer and not value.is_integer(): + raise ValueError("Expected an integer") + return int(value) if integer else value + + +def text(value): + if not isinstance(value, str): + raise ValueError("Expected text") + return value.strip() + + +def json_payload(raw): + raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip()) + def unique_object(pairs): + result = {} + for k, v in pairs: + if k in result: + raise ValueError("Duplicate JSON key") + result[k] = v + return result + return json.loads(raw, object_pairs_hook=unique_object, + parse_constant=lambda x: (_ for _ in ()).throw(ValueError("Non-finite JSON"))) + + +def failed_analysis(code="invalid_output"): + return dict(analysis_status="failed", analysis_error=code, is_relevant=None, + country="", region="", event_type=None, estimated_delay=None, chinese_summary=None) + + +def parse_news_batch(raw, count): + payload = json_payload(raw) + rows = payload.get("results") if isinstance(payload, dict) else payload + if not isinstance(rows, list): + raise ValueError("Expected results array") + results = [failed_analysis("missing_result") for _ in range(count)] + seen = set() + for row in rows: + if not isinstance(row, dict): + raise ValueError("Expected result object") + idx = row.get("news_id") + if type(idx) is not int or not 0 <= idx < count or idx in seen: + raise ValueError("Invalid or duplicate news_id") + seen.add(idx) + try: + if row["相關性"] not in ("YES", "NO"): + raise ValueError("Invalid relevance") + etype = text(row["事件類型"]) + if etype not in EVENT_TYPES: + raise ValueError("Invalid event type") + country, region = text(row["國家"]), text(row["地區"]) + delay = number(row["預計延遲"], maximum=365, integer=True, nullable=True) + if row["相關性"] == "NO" and delay not in (None, 0): + raise ValueError("Irrelevant news cannot have delay") + results[idx] = dict(analysis_status="succeeded", analysis_error=None, + is_relevant=row["相關性"] == "YES", country="" if country == "不明" else country, + region="" if region == "不明" else region, event_type=etype, + estimated_delay=delay, chinese_summary=text(row["繁體中文簡要"])) + except (KeyError, ValueError, TypeError): + results[idx] = failed_analysis() + return results diff --git a/backend/scheduler.py b/backend/scheduler.py index bb61a7d..1efefe3 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -4,6 +4,11 @@ """ import os +import json +import sqlite3 +import logging +from dataclasses import dataclass +from datetime import datetime, timezone import threading import time from backend.access_control import RISK_WORKSPACE_WRITE, require_capability @@ -35,29 +40,135 @@ def refresh_supply_chain_news_once(*, actor: str) -> dict: actor=actor, ) +@dataclass(frozen=True) +class SchedulerConfig: + actor: str + enabled: bool = False + interval_seconds: int = 86400 + initial_delay_seconds: int = 10 + max_attempts: int = 3 + retry_seconds: int = 30 + + def __post_init__(self): + if self.interval_seconds < 1 or self.initial_delay_seconds < 0 or self.max_attempts < 1 or self.retry_seconds < 0: + raise ValueError("Invalid scheduler timing or retry configuration") + + @classmethod + def from_env(cls): + return cls(actor=os.getenv("ERP_SCHEDULER_ACTOR", "").strip(), + enabled=os.getenv("ERP_SCHEDULER_ENABLED", "0") == "1", + interval_seconds=int(os.getenv("ERP_SCHEDULER_INTERVAL_SECONDS", "86400")), + initial_delay_seconds=int(os.getenv("ERP_SCHEDULER_INITIAL_DELAY_SECONDS", "10")), + max_attempts=int(os.getenv("ERP_SCHEDULER_MAX_ATTEMPTS", "3")), + retry_seconds=int(os.getenv("ERP_SCHEDULER_RETRY_SECONDS", "30"))) + + +def run_scheduled_refresh(config=None, *, job_key=None, wait=None): + """Explicit one-shot entry, with cross-process locking and durable idempotency. + + Same key + success => skip. Failures retry up to max_attempts per invocation; + an explicit rerun of a failed key is allowed. OS lock permits crash recovery. + """ + from .database import DB_FILE + from .job_lock import exclusive_job_lock + config = config or SchedulerConfig.from_env() + require_capability(config.actor, RISK_WORKSPACE_WRITE) + job_key = job_key or f"news:{int(time.time()) // config.interval_seconds}" + wait = wait or time.sleep + with exclusive_job_lock(DB_FILE, "scheduler") as acquired: + if not acquired: + return {"status": "busy", "job_key": job_key} + with sqlite3.connect(DB_FILE) as conn: + row = conn.execute("SELECT status FROM scheduled_jobs WHERE job_key=?", (job_key,)).fetchone() + if row and row[0] == "succeeded": + return {"status": "skipped", "job_key": job_key} + conn.execute("INSERT OR IGNORE INTO scheduled_jobs(job_key,status) VALUES (?,'pending')", (job_key,)) + for attempt in range(config.max_attempts): + with sqlite3.connect(DB_FILE) as conn: + conn.execute("UPDATE scheduled_jobs SET status='running',attempts=attempts+1,started_at=?,finished_at=NULL,error=NULL WHERE job_key=?", (_utcnow(), job_key)) + result = None + try: + result = refresh_supply_chain_news_once(actor=config.actor) + if result.get("status") in {"busy", "partial_failure", "pending_analysis"}: + raise RuntimeError(result["status"]) + except Exception as exc: + error = type(exc).__name__ # Do not persist credentials/provider payloads. + with sqlite3.connect(DB_FILE) as conn: + conn.execute("UPDATE scheduled_jobs SET status='failed',finished_at=?,error=?,result_json=? WHERE job_key=?", (_utcnow(), error, json.dumps(result, ensure_ascii=False), job_key)) + if isinstance(exc, PermissionError) or attempt + 1 >= config.max_attempts: + return {"status": "failed", "job_key": job_key, "error": error} + if wait(config.retry_seconds): + return {"status": "cancelled", "job_key": job_key} + else: + with sqlite3.connect(DB_FILE) as conn: + conn.execute("UPDATE scheduled_jobs SET status='succeeded',finished_at=?,error=NULL,result_json=? WHERE job_key=?", (_utcnow(), json.dumps(result, ensure_ascii=False), job_key)) + return {"status": "succeeded", "job_key": job_key, "result": result} + + +def _utcnow(): + return datetime.now(timezone.utc).isoformat() + + +_start_lock = threading.Lock() +_stop_event = threading.Event() +_job_thread = None + + def start_background_jobs(): - global _scheduler_started - if _scheduler_started: - return True - actor = os.getenv("ERP_SCHEDULER_ACTOR", "").strip() - if not actor: - print("Background scheduler disabled: ERP_SCHEDULER_ACTOR is not configured.") + global _scheduler_started, _job_thread + config = SchedulerConfig.from_env() + if not config.enabled or not config.actor or os.getenv("ERP_ISOLATED_TEST") == "1": return False - _scheduler_started = True + require_capability(config.actor, RISK_WORKSPACE_WRITE) + with _start_lock: + if _scheduler_started: + return True + _stop_event.clear() - def run_jobs(): - while True: + def run_jobs(): + global _scheduler_started try: - # 每天定時抓取一次新聞 (每 24 小時) - time.sleep(10) # 系統啟動後延遲 10 秒再抓 - refresh_supply_chain_news_once(actor=actor) - except Exception as e: - print(f"Background scheduler error: {e}") - - # 休息 24 小時 (可以視需求調整頻率) - time.sleep(24 * 60 * 60) - - # 設定為 Daemon Thread,讓主程式結束時能隨之關閉 - job_thread = threading.Thread(target=run_jobs, daemon=True) - job_thread.start() - return True + if _stop_event.wait(config.initial_delay_seconds): + return + while not _stop_event.is_set(): + try: + run_scheduled_refresh(config, wait=_stop_event.wait) + except Exception: + logging.exception("Scheduled news refresh failed") + if _stop_event.wait(config.interval_seconds): + return + finally: + _scheduler_started = False + + _job_thread = threading.Thread(target=run_jobs, name="erp-news-scheduler", daemon=True) + _scheduler_started = True + _job_thread.start() + return True + + +def stop_background_jobs(): + _stop_event.set() + if _job_thread: + _job_thread.join(timeout=5) + + +def main(): + import argparse + from .database import init_db + parser = argparse.ArgumentParser(description="Explicit supply-chain news refresh") + parser.add_argument("--once", action="store_true", required=True) + parser.add_argument("--job-key", help="Stable idempotency key; defaults to UTC interval bucket") + args = parser.parse_args() + if not os.getenv("ERP_DB_PATH"): + parser.error("Explicit ERP_DB_PATH is required") + if os.getenv("ERP_ISOLATED_TEST") == "1": + from .isolated_runtime import block_external_network + block_external_network() + init_db() + result = run_scheduled_refresh(job_key=args.job_key) + print(json.dumps(result, ensure_ascii=False)) + return 1 if result["status"] in {"failed", "busy", "cancelled"} else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/supply_chain_news.py b/backend/supply_chain_news.py index 9634c59..f30d8ef 100644 --- a/backend/supply_chain_news.py +++ b/backend/supply_chain_news.py @@ -45,6 +45,8 @@ def _get_db(): def _get_gnews_api_key() -> Optional[str]: """從環境變數或 Streamlit secrets 取得 GNews API Key(選填)。""" + if os.getenv("ERP_ISOLATED_TEST") == "1": + return None key = os.environ.get("GNEWS_API_KEY", "").strip() if key: return key @@ -65,23 +67,24 @@ def _fetch_via_gnews_api(country_name: str, api_key: str, max_results: int = 10, import requests except ImportError: return [] - name_en, code = COUNTRY_MAP.get(country_name, (country_name, None)) - # 關鍵字:擴充相關範疇確保不漏抓 - query = f"{name_en} (supply chain OR logistics OR shipping OR export OR tariff OR strike OR port OR pandemic OR war OR shortage OR conflict OR disruption OR natural disaster)" + _name_en, code = COUNTRY_MAP.get(country_name, (country_name, None)) + # 地區由 GNews 的 country 參數篩選;不要把國名放進 q,否則搜尋會 + # 要求文章正文同時包含國名與供應鏈詞,容易在短時間窗內得到 0 筆。 + query = "supply chain OR logistics OR shipping OR export OR tariff OR strike OR port OR shortage OR disruption" url = "https://gnews.io/api/v4/search" - + # 產出 GNews API 格式的時間 (YYYY-MM-DDTHH:mm:SSZ) from_date = (datetime.now() - timedelta(days=within_days)).strftime("%Y-%m-%dT00:00:00Z") - + params = { "q": query, "max": max_results, "apikey": api_key, "lang": "en", "from": from_date, - } - if code: - params["country"] = code + } + if code: + params["country"] = code.lower() try: r = requests.get(url, params=params, timeout=15) r.raise_for_status() @@ -104,8 +107,8 @@ def _fetch_via_gnews_api(country_name: str, api_key: str, max_results: int = 10, "relevance_tag": "supply_chain", }) return out - except Exception: - return [] + except Exception as exc: + raise RuntimeError("News provider request failed") from exc def _fetch_via_rss(country_name: str, max_results: int = 15, within_days: int = 7) -> List[dict]: @@ -135,7 +138,7 @@ def _fetch_via_rss(country_name: str, max_results: int = 15, within_days: int = if summary: summary = re.sub(r"<[^>]+>", "", summary)[:500] pub_date_raw = item.find("pubDate").text if item.find("pubDate") is not None else "" - + # 標準化日期格式 (RFC 2822 -> ISO) pub_date_iso = "" try: @@ -156,8 +159,8 @@ def _fetch_via_rss(country_name: str, max_results: int = 15, within_days: int = "relevance_tag": "supply_chain", }) return out - except Exception: - return [] + except Exception as exc: + raise RuntimeError("News provider request failed") from exc def fetch_country_news(country_name: str, api_key: Optional[str] = None, max_results: int = 10, within_days: int = 7) -> List[dict]: @@ -165,182 +168,153 @@ def fetch_country_news(country_name: str, api_key: Optional[str] = None, max_res 取得指定國家可能影響銷售或出貨的即時新聞。 若有 GNews API Key 則優先使用 API,否則使用 Google News RSS。 """ + if os.getenv("ERP_ISOLATED_TEST") == "1": + from .isolated_runtime import fixture_news + return fixture_news(country_name)[:max_results] if api_key: - items = _fetch_via_gnews_api(country_name, api_key, max_results, within_days) - if items: - return items + try: + items = _fetch_via_gnews_api(country_name, api_key, max_results, within_days) + if items: + return items + except RuntimeError: + pass return _fetch_via_rss(country_name, max_results, within_days) def save_news_to_db(items: List[dict]) -> int: - """將新聞寫入 supply_chain_news 表。""" - if not items: - return 0 - db = _get_db() - conn = sqlite3.connect(db) - now = datetime.now().strftime("%Y-%m-%d %H:%M") - n = 0 - for it in items: - # 只存入相關的新聞 (Filter irrelevant already done in refresh_news or here) - if not it.get("is_relevant", True): - continue - try: - conn.execute( - """INSERT INTO supply_chain_news (country, region, title, summary, url, source, published_at, relevance_tag, fetched_at, category, is_relevant, estimated_delay) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - it.get("country") or "", - it.get("region"), - (it.get("title") or "")[:500], - (it.get("summary") or "")[:1000], - (it.get("url") or "")[:500], - (it.get("source") or "")[:100], - it.get("published_at"), - it.get("relevance_tag"), - now, - it.get("category"), - 1 if it.get("is_relevant", True) else 0, - it.get("estimated_delay") or 0, - ), - ) - n += 1 - except Exception: - continue - conn.commit() - conn.close() - return n + """Retain raw items once; analysis fields are stored separately.""" + from .news_store import store_raw, store_analysis + added = 0 + with sqlite3.connect(_get_db()) as conn: + for item in items: + news_id, created = store_raw(conn, item) + added += created + if "analysis_status" in item: + store_analysis(conn, news_id, item) + return added -def get_news_from_db( - country: Optional[str] = None, - limit: int = 50, - order_by_latest: bool = True, - within_days: Optional[int] = None, -) -> List[dict]: - """從資料庫讀取已快取的新聞。order_by_latest=True 依發布/取得時間取最近最新;within_days=30 僅取近 N 天內。""" - db = _get_db() - conn = sqlite3.connect(db) - conn.row_factory = sqlite3.Row - order = "ORDER BY COALESCE(published_at, fetched_at) DESC, id DESC LIMIT ?" - date_filter = "" - params_where = [] +def get_news_from_db(country=None, limit=50, order_by_latest=True, within_days=None, + *, analyzed_only=False) -> List[dict]: + """Raw content plus explicit analysis fields. Only successful relevant rows feed AI.""" + clauses, params = [], [] + if country: + clauses.append("country=?") + params.append(country) if within_days is not None and within_days > 0: - date_filter = " AND date(COALESCE(published_at, fetched_at)) >= date('now', ?) " - params_where.append(f"-{int(within_days)} days") - if country: - params = [country] + params_where + [limit] - rows = conn.execute( - f"""SELECT id, country, region, title, summary, url, source, published_at, relevance_tag, fetched_at, category, estimated_delay - FROM supply_chain_news WHERE country = ?{date_filter}{order}""", - params, - ).fetchall() - else: - params = params_where + [limit] - rows = conn.execute( - f"""SELECT id, country, region, title, summary, url, source, published_at, relevance_tag, fetched_at, category, estimated_delay - FROM supply_chain_news WHERE 1=1{date_filter}{order}""", - params, - ).fetchall() - conn.close() - return [dict(r) for r in rows] + clauses.append("date(COALESCE(NULLIF(published_at,''),fetched_at)) >= date('now',?)") + params.append(f"-{int(within_days)} days") + if analyzed_only: + clauses.append("analysis_status='succeeded' AND is_relevant=1") + order = "DESC" if order_by_latest else "ASC" + with sqlite3.connect(_get_db()) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute(f"SELECT * FROM supply_chain_news WHERE {' AND '.join(clauses) or '1=1'} ORDER BY COALESCE(NULLIF(published_at,''),fetched_at) {order}, id {order} LIMIT ?", (*params, limit)).fetchall() + return [dict(row) for row 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 歸類以極大化提升效能。 - """ +def refresh_news_for_countries(countries, gemini_api_key=None, gnews_api_key=None, + max_per_country=15, within_days=7, gemini_model="gemini-2.5-flash", *, actor=None): + from .job_lock import exclusive_job_lock require_capability(actor, RISK_WORKSPACE_WRITE) - import concurrent.futures + with exclusive_job_lock(_get_db(), "news") as acquired: + if not acquired: + return {"status": "busy", "saved_count": 0, "updated": 0} + return _refresh(countries, gnews_api_key, max_per_country, within_days, actor) + + +def _refresh(countries, gnews_api_key, max_per_country, within_days, actor): + from .news_store import store_raw, store_analysis from .supply_chain_risk import batch_infer_affected_region_from_news from .llm_client import llm_available - - # issue #27:AI 歸類/熱圖摘要改由 .env 模型設定驅動(gemini_api_key 參數棄用) + from .risk_validation import failed_analysis + from .region_matching import normalize + countries = list(dict.fromkeys(normalize(c) for c in countries if str(c or "").strip())) ai_enabled = llm_available() g_key = gnews_api_key or _get_gnews_api_key() - used_gnews = bool(g_key) - by_country = {} - total_saved = 0 - total_fetched = 0 - - # 1. 平行抓取各國原始新聞 (I/O Bound) - def fetch_job(c): - return c, fetch_country_news(c, api_key=g_key, max_results=max_per_country, within_days=within_days) - - all_raw_items = [] - with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(countries), 10)) as executor: - futures = [executor.submit(fetch_job, c) for c in countries] - for future in concurrent.futures.as_completed(futures): - country, items = future.result() - if items: - total_fetched += len(items) - all_raw_items.append((country, items)) - - # 2. 批量進行 AI 分析 - if ai_enabled: - for country, items in all_raw_items: - texts = [f"{it.get('title', '')}\n{it.get('summary', '')}" for it in items] - inferred_list = batch_infer_affected_region_from_news(news_texts=texts) - - relevant_items = [] - for it, inferred in zip(items, inferred_list): - # 如果不相關,或者 AI 推估延遲為 0 天,則視為無影響而不抓取 - if not inferred.get("is_relevant", True) or int(inferred.get("estimated_delay") or 0) <= 0: - continue - - it["is_relevant"] = True - it["category"] = inferred.get("event_type", "其他") - it["estimated_delay"] = int(inferred.get("estimated_delay") or 0) - - if inferred.get("country"): - it["country"] = inferred["country"] - if inferred.get("region"): - it["region"] = inferred["region"] - if inferred.get("chinese_summary"): - it["summary"] = inferred["chinese_summary"] - relevant_items.append(it) - - n = save_news_to_db(relevant_items) - by_country[country] = n - total_saved += n - else: - # 無 API Key 時僅存入 - for country, items in all_raw_items: - n = save_news_to_db(items) - by_country[country] = n - total_saved += n - - # 進行熱圖自動更新 (AI Heatmap Update) + result = dict(status="succeeded", fetched_count=0, saved_count=0, updated=0, + duplicate_count=0, analyzed_count=0, failed_count=0, pending_count=0, + filtered_count=0, fetch_failed_count=0, by_country={}, used_api=bool(g_key)) + processed = set() + import concurrent.futures + fetched = {} + if countries: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(10, len(countries))) as pool: + jobs = {pool.submit(fetch_country_news, c, api_key=g_key, max_results=max_per_country, within_days=within_days): c for c in countries} + for future in concurrent.futures.as_completed(jobs): + c = jobs[future] + try: + fetched[c] = future.result() + except Exception: + result["fetch_failed_count"] += 1 + result["by_country"][c] = 0 + for country in countries: + items = fetched.get(country, []) + result["fetched_count"] += len(items) + pending = [] + result["by_country"][country] = 0 + require_capability(actor, RISK_WORKSPACE_WRITE) + with sqlite3.connect(_get_db()) as conn: + for item in items: + news_id, created = store_raw(conn, item) + result["saved_count"] += int(created) + result["by_country"][country] += int(created) + result["duplicate_count"] += int(not created) + status = conn.execute("SELECT analysis_status FROM supply_chain_news WHERE id=?", (news_id,)).fetchone()[0] + if news_id not in processed and status != "succeeded": + raw = conn.execute("SELECT title,summary FROM supply_chain_news WHERE id=?", (news_id,)).fetchone() + pending.append((news_id, f"{raw[0] or ''}\n{raw[1] or ''}")) + processed.add(news_id) + if not ai_enabled: + result["pending_count"] += len(pending) + continue + if pending: + inferred = batch_infer_affected_region_from_news(news_texts=[p[1] for p in pending]) + require_capability(actor, RISK_WORKSPACE_WRITE) + with sqlite3.connect(_get_db()) as conn: + for i, (news_id, _) in enumerate(pending): + analysis = inferred[i] if i < len(inferred) else failed_analysis("missing_result") + store_analysis(conn, news_id, analysis) + if analysis.get("analysis_status") == "succeeded": + result["analyzed_count"] += 1 + result["filtered_count"] += int(analysis["is_relevant"] is False) + else: + result["failed_count"] += 1 + # Include retained failures/pending rows even if the next provider fetch omits them. + with sqlite3.connect(_get_db()) as conn: + exclusions = ",".join("?" for _ in processed) or "NULL" + clause = f"AND id NOT IN ({exclusions})" if processed else "" + backlog = conn.execute(f"SELECT id,title,summary FROM supply_chain_news WHERE analysis_status IN ('pending','failed') {clause} ORDER BY COALESCE(analyzed_at,fetched_at),id LIMIT 100", sorted(processed)).fetchall() + if ai_enabled and backlog: + require_capability(actor, RISK_WORKSPACE_WRITE) + inferred = batch_infer_affected_region_from_news(news_texts=[f"{r[1] or ''}\n{r[2] or ''}" for r in backlog]) + require_capability(actor, RISK_WORKSPACE_WRITE) + with sqlite3.connect(_get_db()) as conn: + for i, row in enumerate(backlog): + analysis = inferred[i] if i < len(inferred) else failed_analysis("missing_result") + store_analysis(conn, row[0], analysis) + result["analyzed_count" if analysis.get("analysis_status") == "succeeded" else "failed_count"] += 1 + with sqlite3.connect(_get_db()) as conn: + result["remaining_analysis_count"] = conn.execute("SELECT COUNT(*) FROM supply_chain_news WHERE analysis_status IN ('pending','failed')").fetchone()[0] + result["updated"] = result["saved_count"] + if result["failed_count"] or result["fetch_failed_count"]: + result["status"] = "partial_failure" + elif result["remaining_analysis_count"]: + result["status"] = "pending_analysis" if ai_enabled: - 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) - if updates: - apply_heatmap_updates(updates, summary_text, actor=actor) - except PermissionError: - raise - except Exception: - pass - - return { - "updated": total_saved, - "fetched_count": total_fetched, - "saved_count": total_saved, - "filtered_count": total_fetched - total_saved, - "by_country": by_country, - "used_api": used_gnews - } + from .supply_chain_risk import get_heatmap_ai_analysis, apply_heatmap_updates, build_heatmap_review_rows, get_risk_heatmap_data + eligible = get_news_from_db(limit=25, within_days=30, analyzed_only=True) + result["heatmap_status"] = "no_valid_news" + if eligible: + context = "\n".join(f"{n['title']} {n.get('analysis_summary') or ''} [delay={n.get('estimated_delay')}]" for n in eligible) + heatmap = get_heatmap_ai_analysis(news_context=context, news_items=eligible, actor=actor, + persist=False, reference_date=datetime.now().strftime("%Y-%m-%d")) + summary, updates, events = heatmap["summary"], heatmap["updates"], heatmap["events"] + if heatmap["analysis_status"] != "succeeded": + result["heatmap_status"] = "failed" + result["status"] = "partial_failure" + else: + review = build_heatmap_review_rows(updates, events, get_risk_heatmap_data()) + apply_heatmap_updates([dict(display_name=r["地區"],risk_pct=r["預估風險 (%)"],estimated_delay=r["預估延遲 (天)"]) for r in review], summary, actor=actor, summary_result=heatmap) + result["heatmap_status"] = "succeeded" + return result diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index bcbf9a3..26ffd91 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -41,75 +41,12 @@ "新加坡": (1.3521, 103.8198), } -# 區域與國家映射表:當事件標記為「中東」時,自動影響該區域內的所有國家。 -_REGION_COUNTRY_MAP = { - "中東": ["伊朗", "沙烏地阿拉伯", "阿聯酋", "以色列", "卡達", "伊拉克", "科威特", "約旦", "黎巴嫩", "敘利亞"], - "東亞": ["台灣", "日本", "中國", "南韓", "北韓", "香港", "澳門"], - "東南亞": ["越南", "泰國", "新加坡", "菲律賓", "馬來西亞", "印尼", "緬甸"], - "北美": ["美國", "加拿大", "墨西哥"], - "非洲": ["埃及", "南非", "摩洛哥", "奈及利亞"] -} - -def _get_expanded_region_where(region, country_val, prefix=""): - """ - 擴展區域篩選邏輯:支援逗號分隔的多個國家/地區。 - 若輸入包含大區域名稱(如「中東」),則自動擴展為該區域下所有國家的 OR 條件。 - 【修正】當 country 與 region 同時指定且皆為單一值時,使用 AND 精確比對, - 避免台灣北區事件誤擴展至台灣中區/南區。 - """ - # 精確節點比對:若 country 與 region 皆提供且為單一值,直接回傳 AND 查詢 - c_single = (country_val or "").strip() if country_val and "," not in str(country_val) and "," not in str(country_val) else "" - r_single = (region or "").strip() if region and "," not in str(region) and "," not in str(region) else "" - # 若 region 以 country 為前綴(如 "台灣 北區"),去掉前綴只保留地區部分("北區") - if c_single and r_single and r_single.startswith(c_single): - r_single = r_single[len(c_single):].strip() - # 只有當 region 確實指向子地區(不為空、且不等於 country)才使用 AND 精確查詢 - if c_single and r_single and r_single != c_single and c_single not in _REGION_COUNTRY_MAP: - # 使用精確 AND 比對確保只選該特定節點 - return [f"({prefix}country LIKE ? AND {prefix}region LIKE ?)"], [f"%{c_single}%", f"%{r_single}%"] - - where_sub = [] - params_sub = [] - - # 解析輸入:支援「美國, 伊朗」或「北美, 中東」或「台灣 北區」 - input_names = [] - if region: - # 將全型逗號轉半型,且將空格也視為分隔符(若非大區域關鍵字) - raw_names = str(region).replace(",", ",").split(",") - for r in raw_names: - if r.strip(): - # 特殊處理:如果有空格且不是已定義的大區域,則拆分 - if " " in r.strip() and r.strip() not in _REGION_COUNTRY_MAP: - input_names.extend([p.strip() for p in r.strip().split() if p.strip()]) - else: - input_names.append(r.strip()) - - if country_val: - input_names.extend([n.strip() for n in str(country_val).replace(",", ",").split(",") if n.strip()]) - - if not input_names: - return [], [] - - # 展開大區域並收集所有目標關鍵字 - target_set = set() - for name in input_names: - target_set.add(name) - # 檢查是否為大區域 - if name in _REGION_COUNTRY_MAP: - for c in _REGION_COUNTRY_MAP[name]: - target_set.add(c) - - # 產生內容包含其中任一關鍵字的 OR 條件 (LIKE 查詢) - conditions = [] - for c in sorted(list(target_set)): - conditions.append(f"{prefix}country LIKE ?") - conditions.append(f"{prefix}region LIKE ?") - params_sub.extend([f"%{c}%", f"%{c}%"]) - - if conditions: - where_sub.append(f"({' OR '.join(conditions)})") - - return where_sub, params_sub +from .region_matching import ( + REGION_COUNTRY_MAP, matches_location, split_location, connect_db, normalize, + expanded_region_where as _get_expanded_region_where, +) +from .risk_validation import number, text, json_payload, failed_analysis, parse_news_batch, EVENT_TYPES +from .risk_intelligence import save_ai_risk_summary, get_latest_ai_risk_summary, build_risk_evidence, gate_by_evidence def _fill_coords_from_country(df, country_col="country", lat_col="latitude", lon_col="longitude"): @@ -123,7 +60,7 @@ def _fill_coords_from_country(df, country_col="country", lat_col="latitude", lon df[lon_col] = pd.NA for idx, row in df.iterrows(): if pd.isna(row.get(lat_col)) or pd.isna(row.get(lon_col)): - country = (row.get(country_col) or "").strip() + country = normalize(row.get(country_col)) if country and country in _COUNTRY_DEFAULT_COORDS: lat, lon = _COUNTRY_DEFAULT_COORDS[country] df.at[idx, lat_col], df.at[idx, lon_col] = lat, lon @@ -132,7 +69,7 @@ def _fill_coords_from_country(df, country_col="country", lat_col="latitude", lon def get_suppliers_for_map(): """取得正式供應商清單(含經緯度、國家、地區、風險等級),供地圖與清單使用。經緯度僅後端使用。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read("SELECT supplier_id, name, country, region, latitude, longitude, risk_level FROM suppliers WHERE is_official=1", conn) conn.close() return _fill_coords_from_country(df) @@ -140,7 +77,7 @@ def get_suppliers_for_map(): def get_customers_for_map(): """取得客戶清單(含經緯度、國家、地區、風險等級),供地圖與清單使用。經緯度僅後端使用。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) try: df = __pd_read("SELECT customer_id, name, country, region, latitude, longitude, risk_level FROM customers", conn) except Exception: @@ -149,11 +86,16 @@ def get_customers_for_map(): return _fill_coords_from_country(df) +from .risk_contract import valid_event_sql + +_VALID_EVENT_SOURCE = valid_event_sql() + + def get_recent_events_for_delay(limit=50): """取得近期供應鏈事件,供地圖判定出貨延遲狀況。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read( - "SELECT event_type, region, country, impact_days FROM supply_chain_events ORDER BY id DESC LIMIT ?", + f"SELECT event_type, region, country, impact_days, created_at, news_id FROM supply_chain_events WHERE {_VALID_EVENT_SOURCE} ORDER BY COALESCE(created_at, '') DESC, id DESC LIMIT ?", conn, params=(limit,), ) @@ -161,11 +103,91 @@ 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, country, region): + return matches_location(country, region, _clean_text(ev.get("region")), _clean_text(ev.get("country"))) + + +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。 用於初始熱圖:採購佔比愈高,集中度風險愈高,可對應風險低/中/高。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) total = __pd_read( "SELECT COALESCE(SUM(total_amount), 0) as tot FROM purchase_orders WHERE total_amount IS NOT NULL AND total_amount > 0", conn, @@ -218,15 +240,6 @@ def get_region_procurement_share(): # # 【廣域地區對應】當 AI 建議的更新地區為廣域名稱(如「亞洲」)時, # apply_heatmap_updates 需將其對應到該區所有國家之熱點一併更新。 -REGION_COUNTRY_MAP = { - "亞洲": ["台灣", "日本", "中國", "南韓", "北韓", "越南", "泰國", "新加坡", "馬來西亞", "印尼", "菲律賓", "印度", "香港", "澳門"], - "東亞": ["台灣", "日本", "中國", "南韓", "北韓", "香港", "澳門"], - "東南亞": ["越南", "泰國", "新加坡", "馬來西亞", "印尼", "菲律賓", "緬甸", "柬埔寨", "寮國"], - "歐洲": ["德國", "法國", "英國", "義大利", "西班牙", "荷蘭", "波蘭", "比利時", "奧地利", "瑞士"], - "北美": ["美國", "加拿大", "墨西哥"], - "中東": ["以色列", "沙烏地阿拉伯", "阿拉伯聯合大公國", "伊朗", "伊拉克", "土耳其", "約旦", "黎巴嫩"], -} - def get_risk_heatmap_data(): """ 取得熱圖資料:永遠以「供應商據點」為基礎產出完整熱點清單,再以 risk_heatmap 表覆寫風險%與摘要。 @@ -236,10 +249,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 +261,25 @@ 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 matches_location(country, region, k): + 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,13 +289,17 @@ 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, + "estimated_delay": None, }) # 2. 讀取 DB 中手動/AI 覆寫的風險%與摘要,依 region_key 覆蓋到預設清單 - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read( - "SELECT region_key, display_name, latitude, longitude, risk_pct, ai_summary, updated_at FROM risk_heatmap", + "SELECT region_key, display_name, latitude, longitude, risk_pct, ai_summary, updated_at, estimated_delay FROM risk_heatmap", conn, ) conn.close() @@ -295,6 +312,7 @@ def get_risk_heatmap_data(): "risk_pct": r.get("risk_pct"), "ai_summary": r.get("ai_summary"), "updated_at": r.get("updated_at"), + "estimated_delay": None if pd.isna(r.get("estimated_delay")) else r.get("estimated_delay"), "latitude": r.get("latitude"), "longitude": r.get("longitude"), } @@ -304,14 +322,20 @@ 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"), + "estimated_delay": o.get("estimated_delay"), }) else: out.append(row) @@ -323,8 +347,9 @@ def upsert_risk_heatmap( ): """新增或更新一筆熱圖熱點。""" require_capability(actor, RISK_WORKSPACE_WRITE) + risk_pct = number(risk_pct, maximum=100) now = datetime.now().strftime("%Y-%m-%d %H:%M") - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) conn.execute( """INSERT INTO risk_heatmap (region_key, display_name, latitude, longitude, risk_pct, ai_summary, updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(region_key) DO UPDATE SET @@ -339,7 +364,7 @@ def upsert_risk_heatmap( def reset_risk_heatmap_to_initial(*, actor=None): """清空 risk_heatmap 表,使熱圖還原為依供應商據點與風險事件計算的初始狀態。""" require_capability(actor, RISK_WORKSPACE_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) conn.execute("DELETE FROM risk_heatmap") conn.commit() conn.close() @@ -350,84 +375,139 @@ def reset_risk_heatmap_to_initial(*, actor=None): def _gate_heatmap_updates(raw_updates, valid_list, name_expansions) -> list[dict]: - """ - issue #47 P1-3:合法區域檢核由 code 執行(取代 prompt 的嚴詞要求)。 - - 名稱在合法清單 → 直接收 - - 名稱是可展開的總稱(如「台灣」「中東」)→ 展開為完整節點 - - 其餘 → 丟棄 - 無合法清單(DB 無正式供應商)時退回寬鬆模式:全收。 - """ - gate_on = bool(valid_list) and not (len(valid_list) == 1 and valid_list[0].startswith("(")) + """Only validated numeric suggestions matching an actual node are actionable.""" + if not isinstance(raw_updates, list): + return [] out = [] - for u in raw_updates or []: - u = u or {} - name = str(u.get("地區") or u.get("display_name") or "").strip() - pct = u.get("風險", u.get("risk_pct")) + for u in raw_updates: try: - pct = float(str(pct).replace("%", "").strip()) - except (TypeError, ValueError): + name = text(u.get("地區", u.get("display_name"))) + pct = number(u.get("風險", u.get("risk_pct")), maximum=100) + except (AttributeError, TypeError, ValueError): continue - if not name: - continue - if not gate_on or name in valid_list: - out.append({"display_name": name, "risk_pct": pct}) - elif name in name_expansions: - for expanded in name_expansions[name]: - out.append({"display_name": expanded, "risk_pct": pct}) - # 不在清單也不可展開 → 丟棄(code-side gate) + for node in valid_list: + c, r = split_location(node) + if node == name or node in name_expansions.get(name, []) or matches_location(c or node, r, name): + out.append({"display_name": node, "risk_pct": pct}) return out def _coerce_heatmap_events(raw_events) -> list[dict]: - """AI 回傳事件 → 內部契約(型別修正 + 預設值)。維持舊行為:事件不做地區硬閘。""" + """Reject malformed events; null delay remains unknown and 0 stays zero.""" + if not isinstance(raw_events, list): + return [] out = [] - for e in raw_events or []: - e = e or {} + for e in raw_events: try: - days = int(e.get("延遲天數", e.get("impact_days", 14)) or 14) - except (TypeError, ValueError): - days = 14 - out.append({ - "event_type": str(e.get("類型") or e.get("event_type") or "其他").strip() or "其他", - "region": str(e.get("地區") or e.get("region") or "").strip(), - "country": str(e.get("國家") or e.get("country") or "").strip(), - "impact_days": days, - "description": str(e.get("描述") or e.get("description") or "").strip(), - }) + etype = text(e.get("類型", e.get("event_type"))) + if etype not in EVENT_TYPES: + raise ValueError("Invalid event type") + region = text(e.get("地區", e.get("region", ""))) + country = text(e.get("國家", e.get("country", ""))) + if not (region or country): + raise ValueError("Missing geography") + if "延遲天數" not in e and "impact_days" not in e: + raise ValueError("Missing delay") + days = number(e.get("延遲天數", e.get("impact_days")), maximum=365, integer=True, nullable=True) + out.append(dict(event_type=etype, region=region, country=country, + impact_days=days, description=text(e.get("描述", e.get("description", ""))))) + except (AttributeError, TypeError, ValueError): + continue 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 熱圖摘要,並整合現有的正式事件,確保「情報 -> 摘要 -> 應變」流程連貫。 +# ── 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 _summary_news_context(news_items): + from .risk_contract import analyzed_news + rows = [analyzed_news(n) for n in news_items or []] + return "\n".join(f"{n.get('title') or ''} {n['summary']} [{n['country']} {n['region']}; 預估延遲: {n['estimated_delay'] if n['estimated_delay'] is not None else '未知'}天]" for n in rows if n is not None) + + +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 + + from .risk_contract import analyzed_news + news_items = [n for n in (news_items or []) if analyzed_news(n) is not None] + 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() ]) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) try: # 僅選取正式供應商 (is_official=1) 的據點,確保建議清單精確對齊 valid_regions_df = pd.read_sql_query("SELECT DISTINCT country, region FROM suppliers WHERE is_official=1 AND country IS NOT NULL", conn) valid_regions = [] + valid_locations = [] for _, r in valid_regions_df.iterrows(): - c = str(r['country']).strip() - rg = str(r['region']).strip() + c = _clean_text(r['country']) + rg = _clean_text(r['region']) + valid_locations.append((c, rg)) if rg and rg != c: valid_regions.append(f"{c} {rg}") else: valid_regions.append(c) valid_regions_text = "、".join(set(valid_regions)) or "(目前無正式供應商據點資料,請跳過風險建議清單)" except Exception: + valid_locations = [] valid_regions_text = "(系統讀取區域資料失敗,請跳過風險建議清單)" 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, @@ -436,112 +516,117 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ ) # 合法區域清單與展開表(code-side gate 用;如「台灣」→「台灣 北區/中區/南區」) valid_list = [v.strip() for v in (valid_regions_text or "").split("、") if v.strip()] - name_expansions: dict = {} - for v in valid_list: - parts = v.split(" ") - c = parts[0] - name_expansions.setdefault(c, []) - if v not in name_expansions[c]: - name_expansions[c].append(v) - if len(parts) > 1: - r = parts[1] - name_expansions.setdefault(r, []) - if v not in name_expansions[r]: - name_expansions[r].append(v) - + name_expansions = {} + for country, region in valid_locations: + name = f"{country} {region}" if region and region != country else country + name_expansions.setdefault(country, []).append(name) + + 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, "analysis_status": "succeeded", "analysis_error": None, + "sources": evidence["sources"], "actor": actor, + } 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 摘要失敗:模型未回傳內容。", [], [] - payload = json.loads(re.sub(r"```json\s*|```\s*", "", raw)) - - summary = str(payload.get("摘要") or "").strip() or "(AI 未提供摘要內容)" + result.update(analysis_status="failed", analysis_error="empty_response", summary="AI 摘要失敗:模型未回傳內容。", error=True) + return result + payload = json_payload(raw) + if not isinstance(payload, dict) or not isinstance(payload.get("摘要"), str) or not isinstance(payload.get("更新"), list) or not isinstance(payload.get("事件"), list): + raise ValueError("Invalid heatmap response schema") + + for update in payload["更新"]: + if not isinstance(update, dict) or not text(update.get("地區")): + raise ValueError("Invalid heatmap update") + number(update.get("風險"), maximum=100) + if len(_coerce_heatmap_events(payload["事件"])) != len(payload["事件"]): + raise ValueError("Invalid heatmap event") + summary = text(payload["摘要"]) + if not summary: + raise ValueError("Missing summary") updates = _gate_heatmap_updates(payload.get("更新"), valid_list, name_expansions) - suggested_events = _coerce_heatmap_events(payload.get("事件")) - return summary, updates, suggested_events - except Exception as e: - import traceback - traceback.print_exc() - return f"AI 摘要解析失敗:{e}", [], [] + suggested_events = [e for e in _coerce_heatmap_events(payload.get("事件")) + if any(matches_location(c, r, e["region"], e["country"]) for c,r in valid_locations)] + updates, suggested_events, audit = gate_by_evidence(updates, suggested_events, evidence) + result["raw_summary"] = summary + if audit: + summary = "證據檢核後的風險摘要:\n" + "\n".join([f"- {u['display_name']}: {u['risk_pct']}%" for u in updates] + [f"- {e['country']} {e['region']}: {e['event_type']},{e['impact_days']} 天" for e in suggested_events]) + if not updates and not suggested_events: + summary += "沒有可套用的有效建議。" + 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: + result.update(summary="AI 摘要解析失敗:請稍後重試。", error=True, analysis_status="failed", analysis_error="invalid_output_or_provider_error", updates=[], events=[]) + return result + if persist and actor: + result["summary_id"] = save_ai_risk_summary(result, actor=actor) + return result -def apply_heatmap_updates(updates, ai_summary=None, *, actor=None): - """ - 將 AI 回傳的 UPDATE 清單套用到熱圖。 - - 若 update 的 display_name 為廣域地區(如「亞洲」),則將該地區內所有熱點都更新為對應 risk_pct。 - - 否則依「display_name 包含於熱點 display_name」匹配單一熱點後更新。 - """ - require_capability(actor, RISK_WORKSPACE_WRITE) - if not updates: - return 0 - heatmap_rows = get_risk_heatmap_data() - if not heatmap_rows: - return - summary_snippet = (ai_summary or "")[:500] - for u in updates: - name = (u.get("display_name") or "").strip() - risk_pct = u.get("risk_pct") - # 建立別名映射以提升匹配率 - synonyms = {"韓國": "南韓", "南韓": "韓國", "美國": "美洲", "德國": "德國"} - - matched_count = 0 + +def resolve_heatmap_updates(updates, heatmap_rows): + """Resolve once for both UI preview and persistence; last matching update wins.""" + resolved = {} for u in updates: - name = (u.get("display_name") or "").strip() - risk_pct = u.get("risk_pct") - if not name or risk_pct is None: - continue - - target_names = [name] - if name in synonyms: - target_names.append(synonyms[name]) - - # 1. 廣域地區匹配 - is_region_match = False - for t_name in target_names: - if t_name in REGION_COUNTRY_MAP: - countries = REGION_COUNTRY_MAP[t_name] - for r in heatmap_rows: - country = (r.get("region_key") or "").split("|")[0].strip() - if country in countries: - upsert_risk_heatmap( - r["region_key"], r["display_name"], r["latitude"], r["longitude"], - float(risk_pct), summary_snippet, actor=actor, - ) - matched_count += 1 - is_region_match = True + pct = number(u.get("risk_pct"), maximum=100) + name = text(u.get("display_name")) + for row in heatmap_rows: + c, r = split_location(row["region_key"]) + if matches_location(c, r, name): + value = dict(row, risk_pct=pct) + if "estimated_delay" in u: + value["estimated_delay"] = number(u["estimated_delay"], maximum=365, integer=True, nullable=True) + resolved[row["region_key"]] = value + return list(resolved.values()) + + +def build_heatmap_review_rows(updates, events, heatmap_rows): + rows = [] + for row in resolve_heatmap_updates(updates, heatmap_rows): + c, r = split_location(row["region_key"]) + days = row.get("estimated_delay") + for event in events: + if matches_location(c, r, event.get("region"), event.get("country")): + days = event.get("impact_days") break - - if is_region_match: - continue - - # 2. 國家/地區精準或模糊匹配 - for r in heatmap_rows: - d_name = r.get("display_name") or "" - r_key = r.get("region_key") or "" - country_part = r_key.split("|")[0] if "|" in r_key else d_name - - matched = False - for t_name in target_names: - # 匹配邏輯:名稱包含、國家部包含、或熱點名稱包含 - if t_name in d_name or t_name in country_part or d_name in t_name: - matched = True - break - - if matched: - upsert_risk_heatmap( - r["region_key"], r["display_name"], r["latitude"], r["longitude"], - float(risk_pct), summary_snippet, actor=actor, - ) - matched_count += 1 - return matched_count + rows.append({"套用": True, "地區": row["display_name"], + "預估風險 (%)": row["risk_pct"], "預估延遲 (天)": days}) + return rows +def apply_heatmap_updates(updates, ai_summary=None, *, actor=None, summary_result=None): + """Atomically persist the exact reviewed risk AND delay per node.""" + require_capability(actor, RISK_WORKSPACE_WRITE) + rows = resolve_heatmap_updates(updates or [], get_risk_heatmap_data()) + now = datetime.now().strftime("%Y-%m-%d %H:%M") + with connect_db(DB_FILE) as conn: + for row in rows: + conn.execute("""INSERT INTO risk_heatmap + (region_key,display_name,latitude,longitude,risk_pct,ai_summary,updated_at,estimated_delay) + VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(region_key) DO UPDATE SET + risk_pct=excluded.risk_pct,ai_summary=excluded.ai_summary, + updated_at=excluded.updated_at,estimated_delay=excluded.estimated_delay""", + (row["region_key"],row["display_name"],row["latitude"],row["longitude"], + row["risk_pct"],(ai_summary or "")[:500],now,row.get("estimated_delay"))) + if summary_result is not None: + save_ai_risk_summary(summary_result, actor=actor, conn=conn) + return len(rows) + def translate_to_chinese_traditional(api_key: str = "", text: str = "", model_name: str = "") -> str: """將文字翻譯為繁體中文;失敗回傳原文。(issue #27:api_key/model_name 參數棄用,.env 驅動)""" @@ -584,9 +669,43 @@ def generate_communication_draft(api_key: str = "", context: str = "", target_ty +def get_region_exposure(region_key) -> dict: + """單一據點的曝險資訊:未結採購單金額/張數 + 該區供應商數。 + + 「曝險金額」只算 status 不在 (已完成, 已取消) 的採購單;沒有採購單時金額為 0, + 前端應改顯示供應商家數而不是誤導的 $0。 + """ + conn = connect_db(DB_FILE) + try: + country, region = split_location(region_key) + where_sub, params_sub = _get_expanded_region_where(region, country, 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) + conn = connect_db(DB_FILE) where = ["(p.status IS NULL OR p.status NOT IN ('已完成','已取消'))"] params = [] where_sub, params_sub = _get_expanded_region_where(region_key, None, prefix="s.") @@ -607,7 +726,7 @@ def get_total_impact_amount(region_key): def infer_affected_region_from_news(api_key: str, news_text: str, model: str | None = None) -> dict: """單篇新聞分析(保留原介面)。""" res = batch_infer_affected_region_from_news(api_key, [news_text], model=model) - return res[0] if res else {"is_relevant": True, "country": "", "region": "", "event_type": "其他", "estimated_delay": 0, "chinese_summary": ""} + return res[0] if res else failed_analysis("missing_result") def batch_infer_affected_region_from_news(api_key: str = "", news_texts: List[str] = None, model: str | None = None) -> List[dict]: @@ -641,59 +760,30 @@ def batch_infer_affected_region_from_news(api_key: str = "", news_texts: List[st # issue #27:統一 LLM 入口(json_mode + 低溫;供應商 fallback 在底層) from backend.llm_client import complete_text try: - raw_text = (complete_text(prompt, temperature=0.1, json_mode=True, - tag="analysis:news_batch") or "").strip() - except Exception as e: - print("批量新聞分析失敗,回傳預設 7 天延遲。錯誤:", e) - return [{"is_relevant": True, "country": "", "region": "", "event_type": "其他", "estimated_delay": 7, "chinese_summary": f"AI 分析失敗: {e}"}] * len(news_texts) - # 去除 markdown 程式碼區塊符號 - clean_json = re.sub(r"```json\s*", "", raw_text) - clean_json = re.sub(r"```\s*", "", clean_json) - - payload = json.loads(clean_json) - # issue #47 P0-2:頂層改為物件 {"results": [...]}(json_object 模式規格要求); - # 相容舊版頂層 array(模型偶爾仍會直接回 array) - data = payload.get("results", []) if isinstance(payload, dict) else payload - # 映射回原始順序 - results = [{"is_relevant": True, "country": "", "region": "", "event_type": "其他", "estimated_delay": 0, "chinese_summary": ""}] * len(news_texts) - for item in data: - idx = item.get("news_id") - if idx is not None and 0 <= idx < len(results): - results[idx] = { - "is_relevant": item.get("相關性") == "YES", - "country": item.get("國家") if item.get("國家") != "不明" else "", - "region": item.get("地區") if item.get("地區") != "不明" else "", - "event_type": item.get("事件類型") or "其他", - "chinese_summary": item.get("繁體中文簡要") or "", - "estimated_delay": item.get("預計延遲") or 0 - } - return results - except Exception as e: - import traceback - traceback.print_exc() - return [{"is_relevant": True, "country": "", "region": "", "event_type": "其他", "estimated_delay": 7, "chinese_summary": f"系統錯誤: {e}"}] * len(news_texts) + raw_text = complete_text(prompt, temperature=0.1, json_mode=True, tag="analysis:news_batch") or "" + except Exception: + return [failed_analysis("provider_error") for _ in news_texts] + return parse_news_batch(raw_text, len(news_texts)) + except Exception: + return [failed_analysis("invalid_output") for _ in news_texts] # ── 受災採購清單 (Impacted PO List) ──────────────────────────────────── def get_impacted_pos(region_key=None, country=None, supplier_id=None): """依熱點(地區/國家)或供應商 ID 篩選未結案採購單,回傳:採購單號、供應商、關鍵物料、預計延遲、替代建議。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where, params = ["(p.status IS NULL OR p.status NOT IN ('已完成','已取消'))"], [] if supplier_id: where.append("p.supplier_id = ?") params.append(supplier_id) - if region_key: - where_sub, params_sub = _get_expanded_region_where(region_key, None, prefix="s.") - where.extend(where_sub) - params.extend(params_sub) - if country: - where_sub, params_sub = _get_expanded_region_where(None, country, prefix="s.") + if region_key or country: + where_sub, params_sub = _get_expanded_region_where(region_key, country, prefix="s.") where.extend(where_sub) 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) @@ -702,7 +792,7 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): if pos is None or pos.empty: return [] out = [] - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) for _, row in pos.iterrows(): items = __pd_read( "SELECT product_id FROM purchase_order_items WHERE po_id = ?", conn, params=(row["po_id"],) @@ -732,27 +822,32 @@ 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 -def update_po_impact( - po_id, estimated_delay_days=None, alternative_suggestion=None, *, actor=None -): - """更新採購單的預計延遲天數與替代建議。""" - require_capability(actor, ERP_POLICY_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)) - if alternative_suggestion is not None: - conn.execute("UPDATE purchase_orders SET alternative_suggestion = ? WHERE po_id = ?", (alternative_suggestion, po_id)) - conn.commit() - conn.close() +def update_po_impact(po_id, estimated_delay_days=None, alternative_suggestion=None, *, actor=None): + """Planner may change assessment notes, never the underlying transaction.""" + require_capability(actor, RISK_WORKSPACE_WRITE) + days = number(estimated_delay_days, maximum=365, integer=True) if estimated_delay_days is not None else None + suggestion = text(alternative_suggestion) if alternative_suggestion is not None else None + with connect_db(DB_FILE) as conn: + if days is not None: + conn.execute("UPDATE purchase_orders SET estimated_delay_days=? WHERE po_id=?", (days,po_id)) + if suggestion is not None: + conn.execute("UPDATE purchase_orders SET alternative_suggestion=? WHERE po_id=?", (suggestion,po_id)) def get_ai_alternative_suggestions(api_key="", impacted_list=None, hotspot_name="", model: str | None = None): @@ -771,7 +866,6 @@ def get_ai_alternative_suggestions(api_key="", impacted_list=None, hotspot_name= suppliers = get_suppliers_for_map() if suppliers is not None and not suppliers.empty: # 當前熱點可能為「墨西哥 中北部」或「台灣 北區」,用關鍵字排除 - hotspot_parts = [p.strip() for p in (hotspot_name or "").replace(" ", " ").split() if p.strip()] seen = set() parts = [] for _, s in suppliers.iterrows(): @@ -780,7 +874,7 @@ def get_ai_alternative_suggestions(api_key="", impacted_list=None, hotspot_name= if not country: continue # 若該據點屬於當前熱點(國家或地區名重合)則跳過 - if any(p in country or p in region for p in hotspot_parts): + if matches_location(country, region, hotspot_name): continue key = f"{country} {region}".strip() if key not in seen: @@ -805,20 +899,25 @@ def get_ai_alternative_suggestions(api_key="", impacted_list=None, hotspot_name= import json from backend.llm_client import complete_text raw = (complete_text(prompt, json_mode=True, tag="analysis:po_suggest") or "").strip() - payload = json.loads(re.sub(r"```json\s*|```\s*", "", raw)) - items = payload.get("results", []) if isinstance(payload, dict) else payload - - result = [] - for it in items or []: - it = it or {} - po_id = str(it.get("po_id") or "").strip() + payload = json_payload(raw) + items = payload.get("results") if isinstance(payload, dict) else payload + if not isinstance(items, list): + raise ValueError("Invalid PO response") + result, seen = [], set() + for it in items: + if not isinstance(it, dict): + raise ValueError("Invalid PO item") + po_id = text(it.get("po_id")) if po_id not in po_ids: continue + if po_id in seen: + raise ValueError("Duplicate PO result") + seen.add(po_id) try: - delay_days = int(it.get("延遲天數", 7) or 7) - except (TypeError, ValueError): - delay_days = 7 - suggestion = str(it.get("建議") or "").strip() + delay_days = number(it["延遲天數"], maximum=365, integer=True, nullable=True) + suggestion = text(it["建議"]) + except (KeyError, TypeError, ValueError): + continue if suggestion: result.append({"po_id": po_id, "estimated_delay_days": delay_days, "alternative_suggestion": suggestion}) @@ -838,7 +937,7 @@ def what_if_simulation( ): """依使用者情境問題,結合 ERP 供應商、未結案採購單、庫存安全天數,由 AI 回覆影響與建議。model 為 Gemini 模型 ID。""" require_capability(actor, RISK_WHAT_IF_RUN) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) suppliers = __pd_read("SELECT supplier_id, name, country, region FROM suppliers", conn) pos = __pd_read( """SELECT p.po_id, p.supplier_id, s.name, s.country, s.region, p.estimated_delay_days, p.alternative_suggestion @@ -874,9 +973,9 @@ def what_if_simulation( def get_risk_events_list(limit=20): """取得風險事件列表(id, event_type, region, country, impact_days, description, created_at)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(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 ?", + f"SELECT id, event_type, region, country, impact_days, description, created_at, news_id FROM supply_chain_events WHERE {_VALID_EVENT_SOURCE} ORDER BY COALESCE(created_at,'') DESC,id DESC LIMIT ?", conn, params=(limit,), ) @@ -889,14 +988,14 @@ def get_active_risk_events(limit=30): def get_supply_chain_summary_kpis(): """計算供應鏈風險總覽 KPI:30天內事件數、去重後的受影響供應商數與銷售訂單數。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) # 1. 30 天內事件數 since = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d %H:%M") - event_count = conn.execute("SELECT COUNT(*) FROM supply_chain_events WHERE created_at >= ?", (since,)).fetchone()[0] + event_count = conn.execute(f"SELECT COUNT(*) FROM supply_chain_events WHERE {_VALID_EVENT_SOURCE} AND created_at >= ?", (since,)).fetchone()[0] # 2. 受波及供應商與訂單 (去重) # 取得最近 50 件事件作為代表性 KPI - active_events = conn.execute("SELECT region, country, impact_days FROM supply_chain_events ORDER BY id DESC LIMIT 50").fetchall() + active_events = conn.execute(f"SELECT region, country, impact_days FROM supply_chain_events WHERE {_VALID_EVENT_SOURCE} ORDER BY id DESC LIMIT 50").fetchall() conn.close() affected_suppliers = set() @@ -919,13 +1018,13 @@ def get_supply_chain_summary_kpis(): def get_historical_event_precedents(): """從資料庫統計各類事件的平均延遲天數,作為 AI 推估的依據。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) try: # 統計各類事件的平均值與次數 res = conn.execute( - """SELECT event_type, AVG(impact_days) as avg_days, COUNT(*) as cnt + f"""SELECT event_type, AVG(impact_days) as avg_days, COUNT(*) as cnt FROM supply_chain_events - WHERE impact_days > 0 + WHERE {_VALID_EVENT_SOURCE} AND impact_days > 0 GROUP BY event_type ORDER BY cnt DESC""" ).fetchall() @@ -936,42 +1035,35 @@ def get_historical_event_precedents(): conn.close() -def add_risk_event( - event_type, region, country, impact_days, description, news_id=None, *, actor=None -): - """新增或更新風險事件(如果該區域已存在事件則覆蓋)。""" +def add_risk_event(event_type, region, country, impact_days, description, news_id=None, *, actor=None): + from .risk_contract import validate_event 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) - ) - existing = c.fetchone() - - if existing: - event_id = existing[0] - c.execute( - """UPDATE supply_chain_events - SET event_type=?, impact_days=?, description=?, created_at=? - WHERE id=?""", - (event_type, impact_days, description or None, datetime.now().strftime("%Y-%m-%d %H:%M"), event_id) - ) - conn.commit() - conn.close() - return event_id - else: - c.execute( - "INSERT INTO supply_chain_events (event_type, region, country, impact_days, description, created_at, news_id) VALUES (?,?,?,?,?,?,?)", - (event_type, region or None, country or None, impact_days, description or None, datetime.now().strftime("%Y-%m-%d %H:%M"), news_id) - ) - new_id = c.lastrowid - conn.commit() - conn.close() - return new_id + with connect_db(DB_FILE) as conn: + conn.execute("BEGIN IMMEDIATE") + event_type, region, country, impact_days, description, news_id = validate_event(conn, event_type, region, country, impact_days, description, news_id) + row = conn.execute("SELECT id FROM supply_chain_events WHERE COALESCE(country,'')=? AND COALESCE(region,'')=? AND event_type=? AND news_id IS ?", (country, region, event_type, news_id)).fetchone() + if row: + conn.execute("UPDATE supply_chain_events SET impact_days=?, description=?, created_at=? WHERE id=?", (impact_days, description, datetime.now().isoformat(), row[0])) + return row[0] + cur = conn.execute("INSERT INTO supply_chain_events(event_type,region,country,impact_days,description,created_at,news_id) VALUES(?,?,?,?,?,?,?)", (event_type,region,country,impact_days,description,datetime.now().isoformat(),news_id)) + return cur.lastrowid + + +def update_risk_event(event_id, *, event_type=None, impact_days=None, description=None, actor=None): + from .risk_contract import validate_event + require_capability(actor, RISK_WORKSPACE_WRITE) + event_id = number(event_id, maximum=2**53-1, integer=True) + with connect_db(DB_FILE) as conn: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute("SELECT event_type,region,country,impact_days,description,news_id FROM supply_chain_events WHERE id=?", (event_id,)).fetchone() + if not row: + return False + values = validate_event(conn, row[0] if event_type is None else event_type, row[1] or '', row[2] or '', row[3] if impact_days is None else impact_days, (row[4] or '') if description is None else description, row[5]) + collision = conn.execute("SELECT id FROM supply_chain_events WHERE COALESCE(country,'')=? AND COALESCE(region,'')=? AND event_type=? AND news_id IS ? AND id<>?", (values[2],values[1],values[0],values[5],event_id)).fetchone() + if collision: + raise ValueError("另一事件已使用相同類型與來源;請更新該事件") + conn.execute("UPDATE supply_chain_events SET event_type=?,impact_days=?,description=?,created_at=? WHERE id=?", (values[0],values[3],values[4],datetime.now().isoformat(),event_id)) + return True def delete_risk_event(event_id, *, actor=None): @@ -982,7 +1074,7 @@ def delete_risk_event(event_id, *, actor=None): def get_affected_suppliers_by_event(region: str, country: str = None): """依地區與國家篩選受影響的正式供應商(僅限 is_official=1)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where, params = _get_expanded_region_where(region, country) if not where: conn.close() @@ -1002,7 +1094,7 @@ def get_affected_sales_orders_by_event(region: str, country: str, impact_days: i Trace: Suppliers (Region) -> Purchase Orders (Pending) -> Products -> BOM (Finished Good) -> Sales Orders (Pending). Returns list of dicts with order details. """ - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where, params = _get_expanded_region_where(region, country, prefix="s.") if not where: @@ -1078,16 +1170,12 @@ def get_stockout_alerts_for_event(region: str, country: str, impact_days: int): 計算因風險事件導致的採購延遲,是否會造成庫存斷鏈(量 < 0)或跌破安全水位(量 < reorder_point)。 回傳列表:包含商品名稱、現有庫存、預估延期消耗量、預估剩餘庫存、警報等級。 """ - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where = [] params = [] - combined_loc = f"{region} {country}".strip() - if combined_loc: - where_sub, params_sub = _get_expanded_region_where(combined_loc, None, prefix="s.") - where.extend(where_sub) - params.extend(params_sub) - + where, params = _get_expanded_region_where(region, country, prefix="s.") + if not where: conn.close() return [] @@ -1168,7 +1256,7 @@ def increase_safety_stock_for_event( 基準水位會被保存在 baseline_reorder_point 中以供日後還原。 """ require_capability(actor, ERP_POLICY_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where, params = _get_expanded_region_where(region, country, prefix="s.") if not where: conn.close() @@ -1220,7 +1308,7 @@ def increase_safety_stock_for_event( def restore_all_rop_to_baseline(*, actor=None): """將所有產品的安全水位還原至基準值 (baseline_reorder_point)。""" require_capability(actor, ERP_POLICY_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) # 僅針對有設定 baseline 的進行還原 conn.execute("UPDATE inventory SET reorder_point = baseline_reorder_point WHERE baseline_reorder_point IS NOT NULL") conn.commit() @@ -1229,7 +1317,7 @@ def restore_all_rop_to_baseline(*, actor=None): def update_reorder_point(product_id: str, new_reorder_point: int, *, actor=None): """手動更新指定物料的安全庫存水位。""" require_capability(actor, ERP_POLICY_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) conn.execute( "UPDATE inventory SET reorder_point = ? WHERE product_id = ?", (int(new_reorder_point), product_id) @@ -1239,7 +1327,7 @@ def update_reorder_point(product_id: str, new_reorder_point: int, *, actor=None) def get_event_risk_scores(): """取得事件類型對應的風險分數(event_type -> score)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read( "SELECT risk_type, risk_key, risk_score, weight FROM esg_risk_factors WHERE risk_type = 'event_type'", conn) conn.close() @@ -1250,7 +1338,7 @@ def get_event_risk_scores(): def get_region_risk_scores(): """取得地區對應的風險分數(region key -> score)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read("SELECT risk_type, risk_key, risk_score, weight FROM esg_risk_factors WHERE risk_type = 'region'", conn) conn.close() if df is None or df.empty: @@ -1262,7 +1350,7 @@ def get_region_risk_scores(): def get_risk_factors(): """取得所有風險係數(id, 類型, 代碼, 風險分數, 權重, 備註, 更新時間)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read( "SELECT id, risk_type as 類型, risk_key as 代碼, risk_score as 風險分數, weight as 權重, note as 備註, updated_at as 更新時間 FROM esg_risk_factors ORDER BY risk_type, risk_key", conn, @@ -1273,7 +1361,7 @@ def get_risk_factors(): def get_risk_factors_raw(): """取得原始欄位名的風險係數(供加權計算、預覽用)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read("SELECT risk_type, risk_key, risk_score, weight FROM esg_risk_factors", conn) conn.close() return df @@ -1301,7 +1389,7 @@ def delete_risk_factor(factor_id, *, actor=None): def clear_all_risk_factors(*, actor=None): """清空全部風險係數(供重新實作或重置使用)。""" require_capability(actor, RISK_WORKSPACE_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) conn.execute("DELETE FROM esg_risk_factors") conn.commit() conn.close() @@ -1320,7 +1408,7 @@ def get_geographic_risk_display(): seen.add(name) score = 0 for rk, rs in region_scores.items(): - if rk in name or name in rk: + if matches_location(name, name, rk): score = max(score, min(100, rs)) break if score == 0 and name in default_fallback: @@ -1377,7 +1465,7 @@ def get_risk_ai_suggestions(api_key: str = "", news_context: str = "", region_su def load_preset_risk_factors(*, actor=None): """載入預設風險係數範本(地區、事件類型、供應商類別)。""" require_capability(actor, RISK_WORKSPACE_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) now = datetime.now().strftime("%Y-%m-%d %H:%M") presets = [ ("region", "東亞", 60, 1.0, "預設範本"), @@ -1420,7 +1508,7 @@ def get_procurement_by_region_with_risk(): supplier_count = int(v.get("supplier_count") or 0) risk_score = 0 for rk, rs in region_scores.items(): - if rk in display_name or rk in key: + if matches_location(*split_location(key), rk): risk_score = max(risk_score, min(100, rs)) out.append({ "display_name": display_name, @@ -1433,7 +1521,7 @@ def get_procurement_by_region_with_risk(): def get_aggregated_risk_preview(): """綜合風險預覽:據點 × 地區係數 × 供應商類別係數,回傳 list of dict。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) factors = __pd_read("SELECT risk_type, risk_key, risk_score, weight FROM esg_risk_factors", conn) sup = __pd_read( "SELECT supplier_id as id, name, country, region, risk_level FROM suppliers WHERE (country IS NOT NULL AND country != '') OR (region IS NOT NULL AND region != '')", @@ -1465,7 +1553,7 @@ def get_aggregated_risk_preview(): for _, p in partners.iterrows(): region_score = None for k, v in region_map.items(): - if k in str(p.get("region") or "") or k in str(p.get("country") or ""): + if matches_location(p.get("country"), p.get("region"), k): region_score = v break cat_score = cat_map.get(str(p.get("risk_level") or "").strip()) @@ -1502,3 +1590,50 @@ 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 + + +def get_heatmap_ai_summary(api_key="", news_context="", reference_date=None, model=None, *, news_items=None, actor=None): + """Compatibility tuple for existing callers; structured status is available below.""" + result = get_heatmap_ai_analysis(api_key, news_context, reference_date, model, news_items=news_items, actor=actor) + return result["summary"], result["updates"], result["events"] + + + +def get_heatmap_ai_analysis(api_key="", news_context="", reference_date=None, model=None, *, news_items=None, actor=None, persist=True): + return analyze_heatmap_risk(news_items, news_context=news_context, reference_date=reference_date, actor=actor, persist=persist) diff --git a/backend/tool_gateway.py b/backend/tool_gateway.py index 312ea0f..989686d 100644 --- a/backend/tool_gateway.py +++ b/backend/tool_gateway.py @@ -611,6 +611,8 @@ def call( def _execute(self, tool_name: str, args: dict, role: str) -> GatewayResult: """實際執行工具函式""" + if tool_name in {"rollback_inventory", "cancel_order"}: + return GatewayResult(status="denied", message="沖銷須使用 reverse_approval,綁定原始審批及執行收據。") try: import inspect func = tools_mapping[tool_name] diff --git a/docs/batch1-isolated-review.md b/docs/batch1-isolated-review.md new file mode 100644 index 0000000..bbfd48e --- /dev/null +++ b/docs/batch1-isolated-review.md @@ -0,0 +1,87 @@ +# 第一批改善 1~6:隔離實作與檢查 + +基準:`fcc2737`;分支:`codex/batch1-isolated`。 + +2026-09-14 更新:功能驗收提交 `b258d44` 的 Windows 完整測試為 **388 passed(27.41 秒)**;後續 GNews 真實擷取 6 篇、模擬 AI 流程驗收 14 項通過。以下 387 項與本機網站健康檢查是最初修補的歷史紀錄,並非網站目前仍啟動。這批成果以 Draft PR 供協作審查,暫不合併或部署。技術步驟見 [技術流程與 PR 操作說明](batch1-technical-workflow.md)。 + +## 環境與範圍 + +- Worktree:`C:\新EPR系統\ERP-batch1-isolated`。 +- 主要工作區 `C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new` 的程式、`.env`、資料庫及 6 個未追蹤架構圖片均未修改。 +- 檢查資料庫:`C:\新EPR系統\ERP-batch1-isolated\.isolated\erp-batch1.db`。 +- 啟動器在任何後端匯入之前明確設定 `ERP_DB_PATH`,不繼承外部資料庫路徑;成功案例使用另一個 `erp-batch1-success.db`。 +- 重用現有 Python 3.12.3 虛擬環境的已安裝依賴,未修改依賴版本。亦可在 worktree 建立自己的 `.venv`,安裝 `requirements.txt` 與 `requirements-dev.txt`,啟動器會優先使用它。 +- 新聞與 LLM 使用固定模擬資料;關閉背景排程、不載入原工作區 `.env`,移除程序內供應商金鑰及代理設定,阻擋 Python 程序的對外 DNS/TCP 連線;只允許本機連線。未啟動 LINE Bot。 +- 未執行第二、三批功能;初次驗收未推送,後續以 Draft PR 協作審查,暫不合併或部署。 + +## 修改對照 + +| 項目 | 修改與行為 | 主要位置 | +|---|---|---| +| 1. AI 失敗不成為風險 | 移除例外時「相關、延遲 7 天」的預設;原始標題、本文、國家、URL 保留,分析摘要與地區存入獨立欄位;失敗及未驗證新聞不進入熱圖分析或事件登錄 | `risk_validation.py`、`news_store.py`、`supply_chain_news.py` | +| 2. 排程入口 | 明確啟用、間隔與重試次數可設定;SQLite 保存工作識別碼、狀態、嘗試次數;跨程序 OS 檔案鎖避免同時執行;成功識別碼跳過,失敗識別碼可重跑;程序終止後鎖由 OS 釋放 | `scheduler.py`、`job_lock.py`、`.env.example` | +| 3. 新聞去重 | 分析前以 URL 正規化雜湊、標題+來源+發布日雜湊尋找既有新聞;URL 去除追蹤參數與片段;資料庫唯一索引再防止重複寫入;成功新聞不重做逐篇分析,失敗/待分析新聞可重試 | `news_store.py`、`supply_chain_news.py` | +| 4. 嚴格 AI 驗證 | 檢查 JSON 結構、重複鍵、新聞編號、重複/遺漏結果、相關性、事件類型、文字、整數天數與數值範圍;拒絕布林、數字字串、NaN、Infinity、負數及越界值;零為有效值,null 為未知,例外為失敗 | `risk_validation.py`、`supply_chain_risk.py`、`prompts.py` | +| 5. 地區規則 | Python 與 SQLite 共用同一函式;國家+子地區為交集,多選為聯集,廣域名稱展開固定國家表;支援臺灣/台灣、韓國/南韓、阿聯酋及常見英文別名;不使用子字串或 SQL 萬用字元 | `region_matching.py`、`supply_chain_risk.py`、`supply_map.py` | +| 6. 畫面與保存一致 | 熱圖新增持久化延遲欄位;審核表與寫入使用同一地區解析;單一交易保存所有選取節點的百分比與天數,失敗整批回滾;0%/0 天可保存,空白天數為未知;重新開頁讀取資料庫值 | `supply_chain_risk.py`、`supply_map.py`、`risk_dashboard.py` | + +新聞刷新保留既有的自動更新熱圖行為,但僅使用驗證成功且相關的新聞。熱圖分析本身失敗時不覆寫既有值,排程收到可重試的失敗狀態。熱圖建議的保存不等於登錄正式事件,畫面成功訊息已說明此區別。手動登錄未有 AI 天數的事件須在畫面確認天數,不再靜默補入 7 天。 + +分析狀態:`pending` 待分析、`succeeded` 成功(可包含未知天數)、`failed` 失敗、`legacy_unverified` 舊資料待確認。錯誤欄位保存錯誤代碼,不將供應商例外內容寫入原始新聞。 + +資料表升級可重複執行。既有新聞與事件 ID 保留,不刪除歷史重複新聞;只有第一筆取得唯一識別鍵。舊新聞標記為未驗證,其來源事件不供本次風險計算使用。過去已被覆寫的原始摘要無法自動還原,既有熱圖覆寫值也未進行來源推測或清除;正式資料盤點不在這次隔離執行內。 + +## 啟動檢查 + +PowerShell: + +```powershell +Set-Location 'C:\新EPR系統\ERP-batch1-isolated' +.\scripts\start-isolated.ps1 +``` + +開啟 。測試帳號/密碼為 `planner`/`planner`;管理員為 `admin`/`admin`,僅存在隔離 Demo 資料庫。 + +啟動器預設展示台灣北區、台灣南區、日本東京 3 個正式測試據點。進入供應鏈風險頁面: + +1. 「更新即時新聞」會產生確認零延遲、5 天延遲、未知天數、格式錯誤四類固定資料。 +2. 重按刷新:原始新聞筆數不增加;格式錯誤會重試並保持失敗狀態。 +3. 檢查失敗新聞原文仍在,事件登錄停用;未知與 0 天的顯示不同。 +4. 「產生/更新即時風險摘要」會得到台灣北區 0%/0 天、日本 65%/5 天建議。編輯並套用,重新開頁檢查保存結果。 +5. 台灣北區的事件與採購/缺貨分析不應包含台灣南區或日本北區。 + +預設不啟動任何背景排程。以下是**手動一次性**排程測試,固定識別碼重跑可檢查防重複執行: + +```powershell +$py = 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' +& $py scripts/run_isolated.py --scenario success --scheduler-once review-success +& $py scripts/run_isolated.py --scenario success --scheduler-once review-success +# 第一次成功;第二次 skipped。資料庫為 .isolated/erp-batch1-success.db。 + +& $py scripts/run_isolated.py --scheduler-once review-failure +# 固定格式錯誤案例:重試後 failed,退出碼 1;失敗資料仍可檢查。 +``` + +通用入口為 `python -m backend.scheduler --once --job-key <識別碼>`,要求明確 `ERP_DB_PATH` 與有 `risk.workspace.write` 權限的 `ERP_SCHEDULER_ACTOR`。背景入口 `start_background_jobs()` 額外要求 `ERP_SCHEDULER_ENABLED=1`;本次未掛入 app 啟動,也未啟動它。設定預設為 86400 秒間隔、10 秒起始延遲、3 次嘗試、30 秒重試等待。`ERP_ISOLATED_TEST=1` 會強制停用背景入口。 + +鎖限定使用同一個本機 SQLite 路徑的程序。這次未實作跨機器或網路檔案系統上的分散式排程。 + +若此次協作啟動的 8511 程序仍在運行,可直接開啟網址,無須再啟動一份。其 PID 與輸出記錄在 `.isolated/server.pid`、`.isolated/server.stdout.log`、`.isolated/server.stderr.log`;重新啟動時可用 `-Port 8512` 指定另一個本機埠。 + +## 測試與審查 + +最終本機結果:**387 項通過,0 項失敗(27.33 秒)**,包含 60 個新增測試案例。JUnit 報告在 `.isolated/test-results.xml`。`git diff --check` 通過;本機 `http://127.0.0.1:8511/_stcore/health` 回傳 `ok`。 + +```powershell +Set-Location 'C:\新EPR系統\ERP-batch1-isolated' +$py = 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' +& $py -m pytest -q --disable-warnings +git diff fcc2737 --stat +git diff fcc2737 -- backend frontend tests scripts docs/batch1-isolated-review.md +``` + +pytest 在載入後端前將 `ERP_DB_PATH` 指向獨立暫存資料庫,且阻擋對外連線。測試涵蓋:原始內容保留、失敗不生風險、零/未知、嚴格 AI 驗證、去重、失敗回補、舊資料遷移、Python/SQL 地區一致性、跨程序鎖及程序中止後復原、重試、識別碼冪等、權限撤銷、寫入失敗回滾,以及 Streamlit 真實按鈕流程與新 session 讀取。 + +本機測試平台為 Windows/Python 3.12.3。POSIX 檔案鎖分支未在本機執行。 + +初次驗收未包含真實新聞服務;後續已分別完成 RSS 與 GNews 擷取驗收,AI 仍為模擬回應。尚未驗證:付費模型品質、真實 LINE 通知、正式資料庫遷移、大量資料效能及長時間背景運行。瀏覽器地圖底圖的外部素材可用性也不屬於本次後端連線驗證。 diff --git a/docs/batch1-live-news-acceptance.md b/docs/batch1-live-news-acceptance.md new file mode 100644 index 0000000..0a5e92d --- /dev/null +++ b/docs/batch1-live-news-acceptance.md @@ -0,0 +1,68 @@ +# 第一階段追加驗收:6 筆真實 RSS 資料 + +> 本文記錄最初的 RSS 驗收。後續在 `b258d44` 已完成 GNews 真實擷取 6 篇與 14 項模擬 AI 流程檢查;功能分支完整 pytest 為 388 項通過。GNews 本機資料夾為 `.isolated/live-news-20260913T124300104848Z/`,資料庫、快照與詳細執行報告不包含於 Git。後續操作方式見 [技術流程與 PR 操作說明](batch1-technical-workflow.md)。下文提到的「尚未驗證 GNews」僅適用於最初 RSS 驗收當時。 + +抓取時間:2026-09-13 19:14(台灣時間)。原修補提交:`5c9aa36`。 + +**結果:14 項流程檢查通過;使用同一份擷取資料離線重播,14 項也全部通過。** + +本次外部來源是 Google News RSS。使用者指出的設定是 `GEMINI_API_KEY`,它不是 GNews 新聞金鑰;本次沒有使用 GNews 或 Gemini API。模型回應均為驗收模擬,沒有付費模型呼叫、背景排程或真實通知。 + +## 取得的 6 筆資料 + +以下是 RSS 條目的中文主題摘要;來源標示取自 RSS 標題,時間是 RSS 標示時間,未獨立核對各出版者頁面。 + +| # | 主題/來源連結 | RSS 標示來源 | RSS 標示時間 | +|---|---|---|---| +| 1 | [伊朗衝突追蹤](https://news.google.com/rss/articles/CBMingFBVV95cUxQMWduRExjZXhMUjFuYjV4Zzc0LS11OHVKUEhXSlpUNUdFMm9MVGlJeFd5eGh1ZWpEWWhFNEdJX0tOVTlJbnZ5NmpBQ3dYeGNpSTBOSWtISUduak5WSHhfQUhQcHBKbVpLdG1IVi1ySWVGd05NM18yektrem1TM3ZvaV9xVjR2eC04cE9RZ3FZajNCZWFHNzBaS0hhSS02dw?oc=5) | Council on Foreign Relations | 2026-09-09 00:00 | +| 2 | [美國 2022 年降低通膨法政策資料](https://news.google.com/rss/articles/CBMiZkFVX3lxTE1WZ1JFc24yV0d0Z09fdUF1ZTluaWJVVmZwY3I3SnFjb01GT3dUbk5zemY1OFAzeUU5aFVNNHQ3MlZ0MVlPS0hSVGV5eFZJWGRTdmFpNS1ueUVsbjQzUVoxWjRycmo0UQ?oc=5) | 美國能源部 | 2026-09-10 16:21 | +| 3 | [九一一事件 25 年後的恐怖主義情勢分析](https://news.google.com/rss/articles/CBMijwFBVV95cUxPaEFiSFBMOTZIeFNqLUJVRzFYcHdaV3ZoTHZ4NldxTnY2NlJwa3BuMDNqV21PUHUyWjRLV1Q1UXFNbGZYYTBiRnNaSEF5WC1BNHBXYUtTTG9ocWFrTUVYcHZtUGc1NklSNWs5T3lMeF9iMTdWbzdZaEtoOFNmSXVDX2JIMnNVd3VQc1JYOGRyMA?oc=5) | Atlantic Council | 2026-09-10 10:00 | +| 4 | [美國宣稱摧毀五艘伊朗油輪的報導](https://news.google.com/rss/articles/CBMirgFBVV95cUxNeTFBdWRVb3JiRXFnODNwakw4d3IxdlNzZUFnSXhKN1BVVTc4VXlZMl9sYzJFUGtGWnRyVU9wSi1Ua1FoVUhtYVNaaDdhMG9MOE1nd1F3RHlVNHJWSlYxbTY4RVdTQTFpSEtJYnV3TmpxR2FsS0x4RGVOdjZMak1HLWN0YzVmamxyYkZncy1jOEpybGloT1NxVzlPZG5jUlFwY082VUtndEFVUnVHUkHSAcIBQVVfeXFMUFVIY0NEVmtnbUZPTWpkQXVTZER5UXZDa2VuaFQwWmlfOGFrcmI2TXJOSmdaVW4tcVdBUXpyRTVnX2l0dE5Rb2VhdUo0Wno4UUV3dXVKWkY4eF9WdVRrejk3OW1YaWpIM3NLUUxab2h3azBWd3dqblZpZDRzel9USUFiak1UbElOY3NyNWtZbEprZ0N5ZnpfQmJIVTc2R3FMOTVEWS1VWWUxanpqS0ptZzZOWHZTTHNER05mX216SjRHcHc?oc=5) | EL PAÍS | 2026-09-09 09:54 | +| 5 | [荷姆茲海峽重新通航進展與伊朗戰事的報導](https://news.google.com/rss/articles/CBMixwFBVV95cUxPVFowaUI1M1lFY0RPVk9JMjY0XzdTdkVoV0JxZzZjcEZlZHM2eWpCZXE2N3JMajhqWUl5RlpCOGlsYjlqeHlvczN5ZHAtUVVVVnE4djF2LVFqUkxYbnNSVmRtQ3N2Z1pDbFZYM0s0N213RkU2Zjh4elFjZVpiUFEwc0FXNFRyd1RYTXlFV1ZLc01WbXpVSkxHMmk2UFp6aWM1Tk4tMThWX1I1YUlyU0FzSGJvU2FyMGMyYUhrRE01MVVuanRZdTJr0gHMAUFVX3lxTE5DMm15WVlJdkwwYXVLcy1KdS1mdlVGMmRST2tNSldPOFZWNGRaQmNHREFJWWVIWkVfdnFoSXJTVDU4WEZuWVFGenV5SzRxNXRrYTQ3cFVTTEtBdlphaC1KdzdORG5UUndqOU5Cb21IY2s0Wm11eTIwaGg3YlpUN09RQlpSMFhVRnZfR2dEWGZoNDVLc18tblg3R3pBRjAycWRtYURiTnlZOERPWWRVTUx5ZmlYSFNtOHU1NkVSODJIdzVRZGRBMi1aZmFscg?oc=5) | PBS | 2026-09-11 16:37 | +| 6 | [美國第十三修正案與廢除奴役倡議](https://news.google.com/rss/articles/CBMiYkFVX3lxTE1PSUFrNTR1QWtsQUp5MVNpR0FScFNTdlUyS2hYQjNZV0pQV3kzWTJpNjZrRmZIUXhYbG41UWtsN21abm1COTFfbGZXMlkwTjljeUVMdGs0MzloRXpWblJrWExn?oc=5) | Freedom United | 2026-09-09 19:01 | + +這 6 筆都來自實際外部回應,並非固定測試新聞。但第 2、6 筆從標題看屬政策/倡議資料,其他也包含背景追蹤與分析。因此本次應視為「6 筆真實 RSS 來源資料」的流程驗收,不能宣稱已完成 6 篇全文新聞的 AI 風險判讀。 + +## 追加驗證結果 + +| 驗證 | 結果 | +|---|---| +| 首次保存 | 新增 6 筆,狀態 pending,延遲未知 | +| 模擬模型服務失敗 | 6 筆標記 failed,不補入 7 天,不成為有效風險輸入 | +| 12 筆重複輸入 | 資料庫仍為 6 筆,逐篇分析僅執行 6 筆 | +| 嚴格輸出驗證 | 0、未知、5 天、無關資料分開處理;字串天數與負數遭拒絕 | +| 登錄事件 | 未知或失敗的新聞不能登錄為已知風險事件 | +| 重試 | 只重試 2 筆失敗資料;已成功資料不重做逐篇分析 | +| 再次刷新 | 新增 0 筆、逐篇分析 0 筆 | +| 原始資料保存 | RSS 標題、摘要、URL、來源、時間、搜尋國別在所有步驟後均保持原值 | +| 一次性排程測試 | 首次失敗後重試成功;相同工作識別碼再次執行為 skipped | +| 同時執行防護 | 持有新聞鎖時,另一刷新回傳 busy | +| 地區一致性 | 用合成供應商/採購/庫存驗證台灣北區只匹配北區 | +| 零值保存 | 0% 與 0 天在重新連接 SQLite 後仍保留 | +| 隔離限制 | 背景排程停用;擷取完成後阻擋所有後續對外連線 | + +上述天數、地區與百分比是刻意注入的驗收案例,不是對這些新聞的真實風險結論。原始 RSS 搜尋國別「美國」亦不能直接等同事件受影響地區。 + +## 本次發現的限制 + +- 6 筆 RSS 摘要基本上是標題與來源名稱的重複,不能替代新聞全文。 +- 目前 RSS 搜尋仍可能包含政策、背景或倡議頁面;本次沒有擴大實作來源品質篩選。 +- 原始文字中的 HTML entity(例如 ` `)保留在擷取資料內。 +- 尚未驗證 GNews API、Gemini 模型判斷品質、文章全文擷取或真實事件延遲推估。 + +本次沒有發現第一階段資料處理斷言失敗;也沒有改動既有業務程式。新增的只有一次性驗收腳本與此報告。第一階段原有 387 項測試結果維持在既有報告,本次沒有把 14 項腳本斷言混算為 pytest 測試數。 + +## 驗收資料與重播 + +- 資料庫:`C:\新EPR系統\ERP-batch1-isolated\.isolated\live-news-20260913T111447765066Z\acceptance.db` +- 原始擷取:`C:\新EPR系統\ERP-batch1-isolated\.isolated\live-news-20260913T111447765066Z\news-capture.json` +- 各階段計數與狀態:`C:\新EPR系統\ERP-batch1-isolated\.isolated\live-news-20260913T111447765066Z\acceptance-results.json` +- 離線重播結果:`.isolated/live-news-20260913T111702955359Z/acceptance-results.json`。 + +```powershell +Set-Location 'C:\新EPR系統\ERP-batch1-isolated' +$py = 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' +& $py scripts/accept_live_news.py --snapshot '.isolated/live-news-20260913T111447765066Z/news-capture.json' +``` + +此命令不再抓取外部新聞,會建立新的獨立驗收資料庫。原工作區與原有檢查資料庫均未修改,`.env` 未修改,未合併、推送或部署。 diff --git a/docs/batch1-technical-workflow.md b/docs/batch1-technical-workflow.md new file mode 100644 index 0000000..93751f2 --- /dev/null +++ b/docs/batch1-technical-workflow.md @@ -0,0 +1,94 @@ +# 第一階段技術流程與 Draft PR 操作說明 + +本文件說明本批修補的執行流程、隔離方式、驗證證據,以及上傳 Draft PR 各步驟的用途。白話範圍說明見 [修改六項供應鏈區塊風險](修改六項供應鏈區塊風險.md)。 + +## 一、程式如何處理新聞與風險 + +| 步驟 | 技術做法 | 用途 | +| --- | --- | --- | +| 1. 驗證操作者 | 刷新入口要求 `risk.workspace.write`,透過 `actor` 載入有效權限;排程使用 `ERP_SCHEDULER_ACTOR` | 避免沒有權限的呼叫開始讀取與寫入風險工作區。 | +| 2. 取得執行鎖 | 新聞流程取得資料庫路徑對應的 OS 檔案鎖;排程另有自己的鎖 | 避免同一資料庫的不同程序同時刷新;程序結束或崩潰後由 OS 釋放鎖。 | +| 3. 取得新聞 | 一般模式使用 GNews,有需要時使用 RSS 備援;隔離模式讀固定資料或快照 | 將外部來源擷取和可重播的驗收分開。GNews 的來源國別不直接等於事件影響國別。 | +| 4. 找出重複新聞 | 正規化 URL、移除追蹤參數,另以標題、來源、發布日建立識別雜湊,搭配資料庫唯一索引 | 先找既有資料再分析,減少重複寫入與逐篇 AI 分析。 | +| 5. 保存原始內容 | `news_store.store_raw()` 保存原始標題、摘要、URL 等欄位;新資料為 `pending` | AI 失敗或重試時仍能追溯來源,不以 AI 內容覆蓋原文。 | +| 6. 驗證 AI 輸出 | 檢查 JSON、新聞編號、缺漏或重複結果、類型、數值範圍;熱圖更新另驗證合法據點 | 明確區分零、未知、失敗,不以預設 7 天補齊錯誤結果。格式驗證不等於證據充分或 AI 判斷正確。 | +| 7. 保存分析狀態 | 分開保存 `analysis_status`、錯誤代碼、分析摘要與分析地區 | 原始內容與分析結果可分別檢視;失敗資料不冒充成功結果。 | +| 8. 更新風險 | 熱圖 AI 輸入限成功且相關新聞;新聞來源事件另檢查有效來源與已知天數;查詢共用地區匹配函式 | 避免失敗新聞與錯誤地區影響熱圖、供應商、採購與缺貨判斷。 | +| 9. 套用與保存 | 審核與寫入共用據點解析;一次交易保存所選熱圖節點的風險、摘要、延遲 | 0% 與 0 天能保存;中途失敗整批回滾;新 session 可讀回資料庫值。 | +| 10. 排程記錄與重試 | `scheduled_jobs` 保存工作識別碼、狀態、次數與結果;成功識別碼跳過,失敗可重試,權限失敗停止重試 | 讓更新可追蹤,且重跑不代表重複執行已成功的工作。 | + +新聞分析狀態為 `pending`、`succeeded`、`failed`、`legacy_unverified`。這些狀態描述分析結果,不代表人工已確認或已核准,也不等於事件已讀/處理中狀態。 + +## 二、隔離環境如何保護主工作區 + +1. **Git worktree 與分支分開。** 第一階段位於 `ERP-batch1-isolated`,分支為 `codex/batch1-isolated`。worktree 共享 Git 物件與 refs,但有獨立檔案目錄與索引;fetch 更新 refs 不等於把程式合併到 main。 +2. **Python 執行環境與資料庫分開管理。** 本機重用既有虛擬環境的 Python/套件,未藉此共用正式資料庫。虛擬環境主要隔離套件,真正的資料隔離由明確的 `ERP_DB_PATH` 決定。 +3. **啟動時明確指定測試資料庫。** `run_isolated.py` 在載入後端前設定 `.isolated/` 下的資料庫路徑,不繼承外部正式資料庫設定。 +4. **網站檢查使用模擬。** 啟動器設定 `ERP_ISOLATED_TEST=1`、移除程序內供應商金鑰與代理設定、封鎖外部 DNS/TCP、停用背景排程。允許本機連線供 Streamlit 檢視;這不是整台電腦或瀏覽器的網路防火牆。 +5. **真實新聞擷取使用獨立入口。** `accept_live_news.py` 明確讀取所指定檔案的 `GNEWS_API_KEY`,一次取得六篇,再封鎖後續外部網路,用模擬 AI 驗收。每次建立新的資料夾與 `acceptance.db`。 +6. **快照檢視另外保存操作結果。** 指定驗收資料夾啟動網站時,先以 `acceptance.db` 建立 `preview.db`,畫面操作不覆寫原驗收資料庫或 `news-capture.json`。 + +## 三、如何在本機重現 + +以下指令供組員選擇執行;建立 Draft PR 本身不會啟動這些網站或真實新聞擷取。 + +### 準備依賴與執行離線測試 + +在自己的專案工作目錄執行: + +```powershell +python -m venv .venv +& .\.venv\Scripts\python.exe -m pip install -r requirements.txt -r requirements-dev.txt +& .\.venv\Scripts\python.exe -m pytest tests/ -q +``` + +用途:建立自己的套件環境、安裝相同依賴、驗證程式。`tests/conftest.py` 在後端載入前將 `ERP_DB_PATH` 指向暫存資料庫,並封鎖外部網路、停用背景排程。 + +### 檢視固定測試案例 + +```powershell +& .\.venv\Scripts\python.exe scripts/run_isolated.py --port 8511 +``` + +用途:啟動本機隔離 Streamlit。開啟 `http://127.0.0.1:8511`,以測試帳號 `planner`/`planner` 檢查第一階段流程。帳號由隔離 Demo 資料建立,不能當成正式部署的帳號設定。 + +### 一次性 GNews 驗收(會使用新聞 API 配額) + +```powershell +& .\.venv\Scripts\python.exe scripts/accept_live_news.py --source gnews --env-file 'C:\你的設定位置\gnews.env' --country 美國 +``` + +設定檔只需 `GNEWS_API_KEY=你的金鑰`;不要提交金鑰。腳本輸出 `.isolated/live-news-時間戳/` 路徑,包含原始新聞快照、驗收資料庫與結果報告。抓取不足六篇或 API 失敗時,不代表驗收成功;應先檢查查詢、配額與回應。 + +### 檢視已抓取的快照 + +```powershell +& .\.venv\Scripts\python.exe scripts/run_isolated.py --port 8511 --acceptance-dir '.isolated/live-news-實際時間戳' +``` + +用途:在供應鏈區塊檢視新聞來源與驗收資料。按「重播本批真實新聞」讀既有快照,AI 仍為模擬,不重新抓取 GNews。 + +## 四、上傳 Draft PR 的步驟與用途 + +| 順序 | 操作 | 用途與影響 | +| --- | --- | --- | +| 1 | 查看 `git status`、`git remote -v`、目前分支與提交 | 確認將上傳的是第一階段分支,保留使用者目前文件名稱與其他工作區資料。 | +| 2 | `git fetch --no-tags origin main`,比對 `origin/main` 與第一階段 | 取得最新基準,確認 PR 差異範圍;不 checkout、merge 或 pull 到主工作區。 | +| 3 | 檢查差異、忽略規則與待推送提交內容 | 排除 `.env`、金鑰、`.isolated/`、資料庫與驗收快照。只看 `.gitignore` 不夠,已追蹤檔案和新增提交內容也需檢查。 | +| 4 | 核對本機驗證紀錄、GitHub Actions 設定 | PR 說明區分真實新聞與模擬 AI;目前工作流程在 PR 上執行 Ubuntu/Python 3.11 測試,沒有部署步驟。 | +| 5 | 將本次說明文件明確 `git add`,再 `git commit` | 把程式、範圍、驗收與限制一起交給審查者;本機 commit 尚未上傳遠端。 | +| 6 | `git push --set-upstream origin codex/batch1-isolated` | 將此分支上傳,設定追蹤分支;不更新 main,不使用 force push。 | +| 7 | 建立 GitHub PR,`base=main`、`head=codex/batch1-isolated`、`draft=true` | 提供可討論的程式差異並標示仍待整合。此環境未安裝 gh,使用 Git Credential Manager 既有登入,透過 GitHub REST API 建立;憑證只在程序記憶體使用,不輸出或寫入 PR。 | +| 8 | 讀回 PR 狀態、SHA 與 CI 結果 | 確認是 Draft、來源與目標正確、遠端內容等於本機提交。Draft 仍可能執行 CI;CI 通過不代表已部署或完成業務驗收。 | +| 9 | 等待組員 PR,做獨立副本整合測試 | 對照 L1~L3 的實際修改,保留失敗隔離、去重、地區匹配與零值保存,避免直接整檔覆蓋。 | +| 10 | 日後完成整合審查,再另行決定轉正式審查與合併 | 這次只建立 Draft PR。合併與部署需要後續決定;不設定自動合併。 | + +## 五、已驗證的範圍與後續整合 + +- 功能驗收提交 `b258d44`:Windows/Python 3.12.3,完整測試 **388 passed in 27.41s**。這是既有本機執行紀錄,不是本文件編輯時重新跑出的數字。 +- GNews:六篇真實來源資料,模擬 AI 驗收 **14 項通過**。原始快照、資料庫與細節報告留在本機 `.isolated/`,不放入 PR。 +- 先前分別與 PR #12、#13、#14、#15 的固定版本整合測試:406、392、393、395 項通過。這不是四個 PR 一起合併的結果,也不涵蓋尚未取得的組員 L1~L3 成果。 +- #12 與 #15 在 LINE 身分處理互有衝突;#13 與 #15 在 `backend/auth.py` 衝突,整合時需保留缺少有效身分即拒絕的授權邊界。 +- 尚未完成真實 LLM 品質、正式資料遷移、真實通知、正式負載與長時間排程驗收。新聞格式合法不等於證據充分,分析成功不等於人工確認,新聞去重也不等於事件去重。 + +PR 需維持 Draft,待組員提供 PR 後確認共同基準與重疊功能,再規劃第二階段剩餘工作。 diff --git a/docs/pr16-pr17-integration-report.md b/docs/pr16-pr17-integration-report.md new file mode 100644 index 0000000..a407ecd --- /dev/null +++ b/docs/pr16-pr17-integration-report.md @@ -0,0 +1,171 @@ +# PR #16 + PR #17 整合交付報告 + +日期:2026-09-14 +整合分支:`codex/integrate-pr16-pr17` +本機程式提交:`2932e1a0f524967ba91c346ce5fea0848f231ad1`、`253c01d322d5b9a58c87f65bef37011329b9f7f3` +PR #16 基準:`7538a410b7d14e6f88e6f9ac98d789109e014576` +PR #17 基準:`0bc642f9468a50567eb4f765534236cee155e8af` + +## 做了什麼 + +這次以 #16 的新聞資料、分析狀態、去重、排程、地區規則及熱圖保存為底,再把 #17 的 L1、L2、L3 功能依資料流整合到獨立 worktree。主工作區及原始分支保留;本整合分支準備提交獨立 Draft PR,供組員審查、組長決定合併,未部署或啟動正式服務。 + +本整合 PR 涵蓋 #16、#17,建議集中審查此分支,暫緩分別合併原 PR;待整合 PR 合併後,由維護者將原 PR 標記為已被涵蓋並關閉。審查期間若 main 或原 PR 更新,需重新核對新增差異及測試。 + +### #16 的規則保留 + +- 原始新聞保留在原始欄位;AI 結果寫入 `analysis_status`、`analysis_error`、`analysis_country`、`analysis_region`、`analysis_summary` 等欄位。 +- `succeeded`、`failed`、`pending`、`legacy_unverified` 分開處理;失敗不會自動變成 7 天風險。 +- URL/content hash 及資料庫唯一索引負責去重;成功分析不重做,失敗與待處理資料可再次分析。 +- 排程維持明確觸發、重試及跨程序工作鎖;背景排程預設關閉。 +- `backend/region_matching.py` 成為 Python 與 SQLite 的地區匹配入口,國家與分區同時存在時使用交集。 +- 熱圖的風險、延遲及摘要使用同一筆套用交易;0% 與 0 天保留為有效值,未知值保留為 `None`。 +- 隔離執行器固定使用 fixture、關閉外部網路、移除 API 金鑰及正式通知設定。 + +### #17 的功能整合 + +- L1 直接讀資料庫顯示已確認事件與 AI 待確認告警,保存已讀、處理中及通知 L2 狀態。 +- L1 可將系統內未結採購單對映到風險事件,無須上傳 CSV。 +- L2 熱圖分數加入事件嚴重度、事件數量、時效及可讀依據;曝險金額改讀未結採購單並顯示供應商數。 +- L2 摘要、更新建議、事件建議及來源資料落地到 `risk_ai_summaries`,換頁或重整後可重新載入。 +- L2 可標記受影響採購單、建立替代供應商提案;L3 可查看事件與新聞證據、核准或駁回,結果回到 L1 的提案計數。 +- 事件 identity 保留事件類型,因此同一地點的罷工與地震不會互相覆蓋。 +- LLM 額外 headers、timeout 及 demo seed 設定保留,但隔離測試預設關閉 demo seed。 + +## 衝突取捨與相容性修補 + +### 分析與來源 + +新增 `backend/risk_contract.py` 統一新聞有效性與事件欄位驗證。L1、L2、L3 不再用原始新聞的國家、地區或摘要取代分析欄位。成功且相關的新聞才可作為摘要證據;延遲未知仍保留為未知,不能建立新聞事件,L1 待確認風險告警另要求正延遲。 + +### 證據與摘要 + +新增 `backend/risk_intelligence.py`。證據只收成功且相關的新聞,地點以國家/分區組合保存,來源 ID、分析狀態、摘要與時間一起保存。沒有有效證據時,AI 更新與事件會被略過;未知延遲不能被當作 0 或 7 天。失敗摘要不會取代資料庫內最新成功摘要。 + +### 地區 + +事件、L1 告警、L2 證據、熱圖節點、供應商曝險及採購對映均改用共用 resolver。這修正了 #17 原本只比國家、雙向 substring 以及未處理「臺灣/台灣」別名造成的跨分區誤命中。 + +### 0、未知與 UI 保存 + +所有事件建立/更新 API 使用嚴格數字驗證。地圖新聞捷徑、批次登錄、熱圖編輯器與手動登錄都保留 0;未知值不會被補成 7。AI 摘要失敗時畫面保留上一個有效摘要並顯示失敗狀態。 + +### 事件與權限 + +採用 #17 的事件類型 identity,再接回 #16 的來源、型別、範圍及地區驗證。planner 的 `RISK_WORKSPACE_WRITE` 只能修改採購單的風險註記欄位,不會核准採購或改供應商/金額/交易狀態。L3 的提案核准仍須 approver 權限。 + +### 沖銷 + +新增 `backend/approval_reversal.py`。沖銷現在以 `BEGIN IMMEDIATE`、`approval_reversals` 唯一鍵、原始執行收據及同一交易保護訂單/庫存/異動紀錄/稽核寫入。同時請求只會有一筆有效沖銷;舊審批若沒有唯一執行收據會拒絕自動處理,交由人工對帳。Gateway 也不再接受未綁定審批的直接 `rollback_inventory`/`cancel_order`。提案審批由 approver 執行,沖銷另要求 admin 身分。 + +## 測試 + +### 已通過 + +- 整合完成時完整 pytest:**504 passed in 36.69s**;送審前於程式版本 `933443c` 重新執行:**504 passed in 47.32s**。後續僅更新本報告。 +- 本機環境為 Windows/Python 3.12.3;GitHub Actions 使用 Ubuntu/Python 3.11,整合 PR 的 CI 結果須另行確認。 +- `pip check`:`No broken requirements found`。 +- `git diff --check`:通過。 +- 兩個 PR 的相關 L1/L2/L3、授權、排程、資料管線及 UI 測試均在同一份整合工作樹執行。 +- 新增 `tests/test_pr16_pr17_integration.py`,涵蓋: + - L1 → L2 通知 → 事件登錄 → L3 提案/證據 → L1 提案狀態回讀。 + - 成功、失敗、legacy、0、未知、非法天數及重複事件。 + - 共用地區匹配、別名、分區隔離及曝險金額。 + - 摘要 provenance、失敗摘要不覆蓋成功摘要、熱圖交易回滾。 + - 空資料庫/舊資料庫升級與重複初始化。 + - 兩個子程序同時沖銷只產生一次庫存異動。 + - Streamlit L1/L2/L3 主要元件重整後仍讀到資料。 +- 隔離單次排程: + + ```text + fetched_count=6, saved_count=6, analyzed_count=6, + failed_count=0, pending_count=0, heatmap_status=succeeded + ``` + +- 同一 `job_key=integration-review-v1` 再執行回傳 `status=skipped`。 +- 啟動隔離 Streamlit 後 `http://127.0.0.1:8513/_stcore/health` 回傳 HTTP 200,之後已停止。 + +### 尚未驗證 + +- 沒有使用真實 GNews、Gemini 或其他付費模型;此次網路被封鎖,6 則是既有固定 fixture。 +- 沒有發送 LINE、Email 或其他真實通知;L1 通知是資料庫內狀態轉移。 +- 沒有在正式資料庫、正式排程器或生產多組織環境執行。 +- 沒有做瀏覽器人工逐頁操作錄影;Streamlit `AppTest` 已涵蓋主要元件流程。 +- 舊資料的自動分析回補策略尚未決定;目前維持 `legacy_unverified`,不自動升格為有效分析。 + +## 隔離環境 + +整合 worktree: + +```text +C:\新EPR系統\ERP-pr16-pr17-isolated +``` + +分支:`codex/integrate-pr16-pr17` +測試資料庫(已被 `.gitignore` 排除): + +```text +C:\新EPR系統\ERP-pr16-pr17-isolated\.isolated\erp-batch1-success.db +``` + +啟動 Streamlit 預覽(固定新聞、模擬 LLM、禁止外網、背景排程關閉): + +```powershell +Set-Location 'C:\新EPR系統\ERP-pr16-pr17-isolated' +& 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' scripts/run_isolated.py --scenario success --integration-demo --port 8513 +``` + +單次排程驗收(必須明確指定工作鍵): + +```powershell +& 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' scripts/run_isolated.py --scenario success --integration-demo --scheduler-once integration-review-v1 +``` + +停止預覽: + +```powershell +Get-NetTCPConnection -State Listen -LocalPort 8513 -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique | + Stop-Process -Force +``` + +`run_isolated.py` 會明確設定 `ERP_DB_PATH`、`ERP_SCHEDULER_ENABLED=0`、`ERP_ISOLATED_TEST=1`、`ERP_ENABLE_DEMO_SEED=0`,並在啟動時移除 GNews/LLM/通知金鑰。不要把 `.isolated` 內資料庫、XML、快照或任何 `.env` 加入 Git。 + +## 可審查差異與提交 + +第一個整合提交: + +```text +2932e1a0f524967ba91c346ce5fea0848f231ad1 +Integrate PR17 tiers with PR16 analysis, geography and persistence contracts +``` + +主要新增模組: + +- `backend/risk_contract.py`:新聞/事件共用資料契約。 +- `backend/risk_intelligence.py`:摘要 provenance、有效證據及安全閘門。 +- `backend/approval_reversal.py`:審批綁定的 exactly-once 沖銷。 +- `tests/test_pr16_pr17_integration.py`:整合回歸測試。 + +可用下列命令檢查差異: + +```powershell +Set-Location 'C:\新EPR系統\ERP-pr16-pr17-isolated' +git show --stat --oneline 2932e1a +git diff 7538a410..HEAD -- backend frontend tests scripts +git diff fcc2737..HEAD --stat +git log --oneline fcc2737..HEAD +git status --short +``` + +程式變更集中於整合提交 `2932e1a` 與回歸修補 `253c01d`。其餘後續提交是報告與送審說明整理;完整提交清單以上述 `git log` 為準。整合提交保留 #16 與 #17 作為兩個 parent,原作者提交歷史保留。 + +## 剩餘問題與建議方向 + +合併前需確認整合 PR 的 CI、組員的 L1 → L2 → L3 畫面驗收,以及正式資料庫隔離副本的升級與重複初始化。目前只驗證空庫及合成舊版測試資料庫,沒有使用正式資料。審查者也應確認接受 legacy 資料暫不列為有效分析、缺少執行收據的舊審批改採人工對帳等行為。 + +事件是否需要額外「事件批次/episode」欄位及 legacy 自動回補可另案規劃;本次保留事件類型 identity,legacy 維持未驗證。正式部署前需核對現有組織權限初始化設定;本次沒有自動改動正式組織或授權資料。 + +可留到下一階段的工作包括真實新聞供應商輪替、付費模型觀測與成本控管、外部通知傳送、更多瀏覽器端 UX、以及報表/效能優化。本次沒有開始這些功能。 + +此分支供 Draft PR 審查,是否轉為可合併及實際合併由組員與組長依驗收結果決定。主工作區與兩個原始 PR 分支保留;本次不啟用自動合併。 diff --git "a/docs/\344\277\256\346\224\271\345\205\255\351\240\205\344\276\233\346\207\211\351\217\210\345\215\200\345\241\212\351\242\250\351\232\252.md" "b/docs/\344\277\256\346\224\271\345\205\255\351\240\205\344\276\233\346\207\211\351\217\210\345\215\200\345\241\212\351\242\250\351\232\252.md" new file mode 100644 index 0000000..05585af --- /dev/null +++ "b/docs/\344\277\256\346\224\271\345\205\255\351\240\205\344\276\233\346\207\211\351\217\210\345\215\200\345\241\212\351\242\250\351\232\252.md" @@ -0,0 +1,102 @@ +# 供應鏈風險功能:第一階段修改說明與整合協作 + +整理日期:2026-09-14 + +這份文件說明我原本第一階段要修正的範圍,方便大家對照目前的成果,避免整合時互相覆蓋修正或重複開發。 + +## 第一階段主要修了什麼? + +這一批主要處理新聞、AI 分析與風險資料的正確性,共六項。 + +### 1. AI 分析失敗,不能被當成真的有風險 + +原本 AI 發生錯誤時,可能被當成「相關新聞、延遲 7 天」。現在會明確標示分析失敗,保留新聞原始內容,並將 AI 分析結果分開保存。 + +分析失敗的新聞不能直接成為有效風險,也不能直接拿去登錄正式事件,避免影響熱圖與後續判斷。 + +### 2. 更新工作可以重試,但不能重複執行 + +加入可設定的排程入口。更新失敗時可以重試;同一個已完成的工作不重跑。同時有人按更新或排程正在執行時,也要避免重複處理同一批資料。 + +這次只測試排程入口與執行規則,沒有啟動正式背景排程。 + +### 3. 重複新聞先排除,再交給 AI + +相同新聞不重複新增,也不反覆交給 AI 分析。之前尚未分析或分析失敗的新聞,仍然可以重試。 + +這裡處理的是「新聞去重」。至於不同新聞是否描述同一件事件,或同一篇新聞是否包含多個事件,需要另外整合事件管理規則。 + +### 4. 分清楚「0 天」「不知道幾天」「分析失敗」 + +這三種情況不能混用: + +- **0 天**:分析明確判斷沒有延遲,是有效數值。 +- **未知**:有新聞或分析結果,但無法確認延遲天數。 +- **分析失敗**:AI 沒有提供可使用的結果,需要重試或人工處理。 + +同時檢查 AI 回傳的格式、數值範圍,以及熱圖建議是否對應系統合法據點,避免把缺漏或不合法內容隨便補成風險數字。 + +這些檢查不代表已證明 AI 的判斷有新聞證據支持。組員做的「證據過濾」可以進一步補強這部分。 + +### 5. 各畫面使用同一套地區判斷 + +熱圖、供應商、採購單與缺貨分析,對「哪些地區受到影響」必須一致。 + +例如選台灣北區時,不能把台灣南區或其他國家的同名地區一起算進去;常見的國家別名也要一致處理。 + +### 6. 畫面套用的內容,要真的存進資料庫 + +修正畫面顯示與保存結果不一致的問題,尤其是 **0% 風險、0 天延遲**,不能因為數值是零就被忽略。 + +重新開頁後應讀到保存的百分比與天數;一次套用多個據點時,若保存中途失敗,也不能只存一半。 + +這批已保存套用到熱圖的摘要,但不代表已完成「完整 AI 摘要跨頁共享、L1 可查看」的全部流程,這部分仍可整合組員的成果。 + +## 額外完成的 GNews 驗收 + +測試真實新聞時,也修正了 GNews 的搜尋條件與國家代碼,已成功取得六篇真實新聞。 + +- 真實新聞由 GNews 抓取。 +- 後續 AI 分析使用固定模擬回應,驗證失敗、未知、零值、去重與重試等流程。 +- 隔離網站使用這批新聞快照重播;按鈕不會重新連線抓取 GNews。 +- 正式模式的新聞更新與隔離驗收是不同執行方式,正式上線前仍需另做整合驗證。 + +## 目前完成與驗證狀態 + +- 程式在獨立 Git worktree 與分支實作,使用獨立測試資料庫。 +- 第一階段最新完整測試:**388 項通過**。 +- 六篇 GNews 真實新聞搭配模擬 AI 的追加驗收:**14 項檢查通過**。 +- 這批成果以 Draft PR 供組員比對;暫不合併回 main 或部署。 +- 尚未開始原定第二、三批功能。 + +尚未驗證的部分包括真實 LLM 分析品質、正式資料庫遷移、真實通知、長時間正式排程與正式負載,因此上述測試結果不等於已完成正式上線驗收。 + +程式識別資訊: + +- 本機分支:`codex/batch1-isolated` +- 本機功能驗收提交:`b258d44`(含第一階段修改與後續 GNews 修正;後續文件更新另行提交) +- 開發基準:`fcc2737` + +## 與組員成果如何整合? + +你完成的 L1~L3 流程與這批修改有部分重疊,主要需要一起確認: + +| 組員成果 | 整合時需要保留的規則 | +| --- | --- | +| L1 已確認/AI 待確認告警 | AI 分析成功不等於人工確認;分析狀態與人工確認狀態要分開,失敗資料不能被當成有效風險。 | +| 熱圖、曝險金額、採購單對映 | 共用地區判斷,保留零值、未知值與一致的保存結果。 | +| AI 地區與天數的證據過濾 | 同時保留格式與數值驗證,再加入證據檢查。 | +| AI 摘要存 DB,L1、L2 都看得到 | 統一摘要的保存位置、來源與分析狀態,避免不同畫面讀到不同版本。 | +| 事件不互相覆蓋、一鍵建立應變計畫 | 不同事件不能互相覆蓋,同一事件重按也不能重複建立;這部分需要一起確認事件識別方式。 | +| 標記受影響採購單、提案與審批證據 | 沿用一致的新聞、事件、地區與延遲資料,並保留各角色的權限限制。 | +| 已讀/處理中、通知 L2、審批結果回寫、沖銷防重按 | 主要是新增流程,可接在第一階段基礎上,再確認狀態保存、通知與防重複操作。 | + +目前僅依功能清單判斷重疊處,尚未取得這份成果的程式分支,因此還不能確認實際 Git 衝突或宣稱整合後測試通過。 + +## 請先開 PR,後續依實際差異調整 + +麻煩先把目前成果上傳並開成 PR,**Draft PR 也可以**。PR 請附上已完成項目、尚未完成或已知問題,以及測試結果;若有資料表或套件變更,也請一起註明。 + +拿到 PR 後,我會先比對雙方修改,再依實際成果調整我們這邊的程式,在獨立測試環境驗證整合結果。整合前先不要用整個檔案互相覆蓋,也不用為了避開重疊先刪掉已完成的功能。 + +等這批成果整合清楚後,再確認第二階段還有哪些工作需要做,避免兩邊重複實作。 diff --git a/frontend/components/news_acceptance.py b/frontend/components/news_acceptance.py new file mode 100644 index 0000000..7831ce8 --- /dev/null +++ b/frontend/components/news_acceptance.py @@ -0,0 +1,65 @@ +"""Show captured real news and acceptance evidence inside the risk workspace.""" +import json +import os +from pathlib import Path + +import pandas as pd +import streamlit as st + +from backend.isolated_runtime import news_capture +from backend.supply_chain_news import get_news_from_db + + +def render_news_acceptance(): + if os.getenv("ERP_ISOLATED_TEST") != "1": + return + capture = news_capture() + if not capture: + return + source = "GNews API" if capture["source"] == "gnews" else "Google News RSS" + report_path = os.getenv("ERP_NEWS_ACCEPTANCE", "") + report = json.loads(Path(report_path).read_text(encoding="utf-8")) if report_path else {} + st.subheader("📰 真實新聞追加驗收") + st.info(f"新聞來源:{source}|這是已抓取的真實新聞快照。AI 分析為模擬驗收,非實際風險判斷。") + st.caption(f"抓取時間:{capture['captured_at']}|此頁讀取獨立檢查資料庫;重播不再連線抓新聞。") + latest_attempt = Path(__file__).resolve().parents[2] / ".isolated" / "gnews-latest-attempt.json" + if latest_attempt.is_file(): + attempt = json.loads(latest_attempt.read_text(encoding="utf-8")) + if attempt.get("status") == "capture_failed": + st.warning(f"最近一次 GNews 擷取失敗(HTTP {attempt.get('http_status', '未知')}):{attempt.get('message', '請核對 API 設定')}。目前下表來源是 {source},不是該次 GNews 的成功結果。") + + rows = get_news_from_db(limit=1000) + captured_urls = {a["url"] for a in capture["articles"]} + rows = [r for r in rows if r["url"] in captured_urls] + c1, c2, c3 = st.columns(3) + c1.metric("本批真實來源資料", len(capture["articles"])) + c2.metric("目前資料庫筆數", len(rows)) + c3.metric("匯入時流程驗收", f"{len(report.get('checks', []))} 項通過") + states = {"succeeded": "成功", "failed": "失敗", "pending": "待分析", "legacy_unverified": "舊資料待確認"} + table = [] + for row in rows: + table.append({"新聞標題": row["title"], "出版來源": row.get("source") or source, + "發布時間": row.get("published_at"), + "分析狀態(模擬)": states.get(row.get("analysis_status"), "未知"), + "延遲(模擬)": "未知" if row.get("estimated_delay") is None else f"{row['estimated_delay']} 天", + "原文連結": row["url"]}) + if table: + st.dataframe(pd.DataFrame(table), hide_index=True, width="stretch", + column_config={"原文連結": st.column_config.LinkColumn("原文", display_text="開啟來源")}) + with st.expander("逐則查看來源摘要"): + for row in rows: + st.markdown(f"**{row['title']}**") + st.write(row.get("summary") or "來源沒有提供摘要。") + st.caption(f"來源:{row.get('source') or source}|發布:{row.get('published_at') or '未知'}") + with st.expander("查看追加驗收過程"): + phases = report.get("phases", {}) + labels = {"pending": "初次保存", "outage": "模擬模型中斷", "strict_validation": "輸出驗證與去重", + "retry": "只重試失敗項目", "deduped_replay": "再次重播"} + records = [] + for key, label in labels.items(): + phase = phases.get(key, {}) + records.append({"步驟": label, "新增": phase.get("saved_count", 0), + "重複": phase.get("duplicate_count", 0), "分析成功": phase.get("analyzed_count", 0), + "分析失敗": phase.get("failed_count", 0)}) + st.dataframe(pd.DataFrame(records), hide_index=True, width="stretch") + st.caption("此處是抓取後的驗收紀錄;天數與地區是測試案例,不是新聞的實際影響。") 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..98ba514 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -1,4 +1,7 @@ +from backend.region_matching import matches_location, split_location +import os 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 @@ -33,7 +36,7 @@ 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_list = get_news_from_db(limit=10, order_by_latest=True, within_days=30, analyzed_only=True) news_context = "" if news_list: news_context = "\n".join([ @@ -47,6 +50,31 @@ def _auto_refresh_heatmap_ai(api_key, gemini_model): 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 +89,10 @@ def render_intelligence_gathering( """ st.subheader("🔍 即時全球情報與事件登錄") st.caption("透過 GNews/RSS 抓取全球供應鏈相關新聞,並利用 AI 自動偵測受影響國家、地區與事件類型(戰爭、氣候、罷工等)。") + _render_l1_handoff_notices(actor=actor) + + from backend.isolated_runtime import news_capture + capture = news_capture() if os.getenv("ERP_ISOLATED_TEST") == "1" else None # 更新即時新聞:依供應商國家從 GNews/RSS 抓取並寫入 DB _suppliers = get_suppliers_for_map() @@ -71,6 +103,9 @@ def render_intelligence_gathering( if not _countries: _countries = ["台灣", "日本", "美國", "南韓", "中國", "越南", "墨西哥"] + if capture: + _countries = list(dict.fromkeys(a["country"] for a in capture["articles"])) + col_time, col_cate, col_btn, col_help = st.columns([1, 1, 1, 2]) with col_time: time_options = {"7 天": 7, "30 天": 30, "90 天": 90} @@ -103,10 +138,12 @@ def render_intelligence_gathering( st.caption("系統會過濾不相關新聞,並參考過往紀錄推估延遲。") with col_btn: st.markdown("
", unsafe_allow_html=True) - if st.button("📡 更新即時新聞", key="refresh_news_btn", help="依各供應商國家抓取最近新聞,並由 AI 自動分析類別與延遲天數。"): + refresh_label = "🔁 重播本批真實新聞" if capture else "📡 更新即時新聞" + refresh_help = "使用已抓取的新聞快照,不重新連線;成功資料不重做逐篇分析。" if capture else "依各供應商國家抓取最近新聞,並由 AI 自動分析類別與延遲天數。" + if st.button(refresh_label, key="refresh_news_btn", help=refresh_help): with st.status("正在獲獲取供應鏈情報並由 AI 進行分析評分...") as status: - status.write("📡 正在平行抓取各國原始新聞與預過濾...") - status.write("🧠 正在啟動 Gemini 進行深度風險評估 (約 30-40 秒)...") + status.write("正在重播已抓取的真實新聞..." if capture else "📡 正在平行抓取各國原始新聞與預過濾...") + status.write("AI 使用模擬回應;不呼叫付費模型。" if os.getenv("ERP_ISOLATED_TEST") == "1" else "🧠 正在啟動模型進行風險評估...") res = refresh_news_for_countries( _countries, gemini_api_key=api_key or None, @@ -120,17 +157,17 @@ def render_intelligence_gathering( filtered = res.get("filtered_count", 0) saved = res.get("saved_count", 0) - status.update(label=f"✅ 全球情報更新完成!(已掃描 {fetched} 則,AI 過濾掉 {filtered} 則無關情報)", state="complete") - st.toast(f"📍 AI 已自動過濾 {filtered} 則不相關新聞,保留 {saved} 則關鍵情報。", icon="🤖") + status.update(label=f"情報處理完成:掃描 {fetched}、新增 {saved}、重複 {res.get('duplicate_count', 0)}、分析失敗 {res.get('failed_count', 0)}、待分析 {res.get('pending_count', 0)}", + state="error" if res.get("failed_count") else "complete") + st.toast(f"已保存 {saved} 則原始新聞;分析判定無關 {filtered} 則。", icon="📍") st.rerun() # 讀取現有新聞 - news_list_raw = get_news_from_db(limit=60, order_by_latest=True, within_days=within_days) + news_list_raw = get_news_from_db(limit=60, order_by_latest=True, within_days=None if capture else within_days) # 執行類別過濾與去重 filtered_news = [] seen = set() - ai_filtered_count = 0 for n in news_list_raw: cat = n.get("category") or "其他" if "全部" not in selected_cates and selected_cates and cat not in selected_cates: @@ -140,21 +177,12 @@ def render_intelligence_gathering( if key in seen or (not key[0] and not key[1]): continue - # 3. 延遲過濾 (只抓有實質影響的新聞,排除預估 0 天者) - if (n.get("estimated_delay") or 0) <= 0: - ai_filtered_count += 1 - seen.add(key) - continue - + # Include all states for inspection. Only successful, known analysis can register an event. seen.add(key) filtered_news.append(n) if not filtered_news: - st.info("✅ 目前尚無具有「實質延遲風險 (大於 0 天)」的情報。") - if ai_filtered_count > 0: - st.caption(f"🤖 AI 在背景已為您處理並過濾了 **{ai_filtered_count}** 筆無顯著影響(預估 0 天延遲)的一般新聞或重複新聞。") - else: - st.caption("請點擊上方按鈕更新或調整時間/類別篩選條件。") + st.info("目前沒有符合篩選條件的新聞,請更新新聞或調整篩選。") return st.markdown("---") @@ -176,7 +204,7 @@ def render_intelligence_gathering( news_options = [] for n in unregistered_news: cat = n.get('category') or '其他' - delay = n.get('estimated_delay') or 0 + delay = n.get('estimated_delay') if n.get('estimated_delay') is not None else '未知' title = n.get('title') or '(無標題)' news_options.append(f"【{cat} | 預估 {delay}天】{title}") @@ -189,17 +217,18 @@ def render_intelligence_gathering( raw_intro = "\n\n".join(p for p in [(chosen.get("title") or "").strip(), (chosen.get("summary") or "").strip()] if p).strip() or "(無簡介)" # --- 🚀 一鍵批量登錄功能 --- + registrable = [n for n in unregistered_news if n.get("analysis_status") == "succeeded" and n.get("is_relevant") == 1 and n.get("estimated_delay") is not None] col_bulk, _ = st.columns([1, 2]) with col_bulk: - if st.button("🚀 一鍵登錄全部情報", use_container_width=True, type="primary"): + if st.button(f"🚀 登錄已驗證情報 ({len(registrable)} 則)", use_container_width=True, type="primary", disabled=not registrable): with st.status("正在登錄情報...") as status: bulk_count = 0 - for n in unregistered_news: + for n in registrable: add_risk_event( event_type=n.get("category") or "其他", - region=n.get("region") or "", - country=n.get("country") or "", - impact_days=n.get("estimated_delay") or 7, + region=n.get("analysis_region") or "", + country=n.get("analysis_country") or "", + impact_days=n["estimated_delay"], description=f"【一鍵批量登錄】{n.get('title')}", news_id=n.get('id'), actor=actor, @@ -211,7 +240,10 @@ def render_intelligence_gathering( # 不再切分兩欄,直接全寬顯示簡介與單筆一鍵登錄按鈕 st.markdown("**📝 簡介分析**") - intro_text = chosen.get("summary") or "(無簡介)" + intro_text = chosen.get("analysis_summary") or "(尚無有效分析)" + st.caption(f"分析狀態:{chosen.get('analysis_status', 'legacy_unverified')};延遲:{chosen.get('estimated_delay') if chosen.get('estimated_delay') is not None else '未知'}") + with st.expander("原始新聞內容"): + st.write(chosen.get("summary") or "(無簡介)") st.info(intro_text) # 選配:點擊後才進行深度翻譯 @@ -229,12 +261,13 @@ def render_intelligence_gathering( with col_link: if chosen.get("url"): st.link_button("🔗 查看原文", chosen.get("url"), use_container_width=True) with col_reg: - def_country = chosen.get("country") or "" - def_region = chosen.get("region") or "" + def_country = chosen.get("analysis_country") or "" + def_region = chosen.get("analysis_region") or "" def_etype = chosen.get("category") or "其他" - def_delay = chosen.get("estimated_delay") or 0 + def_delay = chosen.get("estimated_delay") + can_register = chosen.get("analysis_status") == "succeeded" and chosen.get("is_relevant") == 1 and def_delay is not None - if st.button(f"🚀 一鍵登錄:{def_etype}風險 (預估延遲 {def_delay} 天)", type="primary", use_container_width=True): + if st.button(f"🚀 一鍵登錄:{def_etype}風險 (預估延遲 {def_delay} 天)", type="primary", use_container_width=True, disabled=not can_register): add_risk_event( def_etype, def_region, @@ -268,6 +301,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 = "", @@ -295,32 +431,13 @@ def render_response_execution( st.info("目前尚無正式應變事件。請至「步驟 2: 全域風險監控」點擊地圖區域之「加入應變計畫」以啟動分析。") return - # 【核心優化】過濾選單,僅顯示熱圖中具備中高風險 (>20%) 或 AI 有積極建議的地區 - heatmap_rows = get_risk_heatmap_data() - high_risk_names = [hr['display_name'] for hr in (heatmap_rows or []) if (hr.get('risk_pct') or 0) > 20] - - # 建立 country -> display_name 的查詢字典 - country_to_display = {} - for hr in (heatmap_rows or []): - c = (hr.get('display_name') or '').split(' ')[0] - if c and c not in country_to_display: - country_to_display[c] = hr['display_name'] - event_options = ["--- 請選擇要分析的事件 ---"] event_ids = [None] - - seen_display = set() for _, row in events.iterrows(): - country = (row.get('country') or '').strip() - display = country_to_display.get(country) or country or '未知' - - # 僅顯示高風險區域,或若該區域已經有進入應變狀態,則保留顯示 - if display in high_risk_names or display in seen_display: - if display not in seen_display: - event_options.append(f"【{row['event_type']}】{display}") - event_ids.append(row['id']) - seen_display.add(display) - + display = f"{row.get('country') or ''} {row.get('region') or ''}".strip() or "未知" + event_options.append(f"【{row['event_type']}】{display} (#{row['id']})") + event_ids.append(row["id"]) + # ── 聯動邏輯:檢查是否有外部 (如地圖/情報) 指令要選中特定事件 ── if "resp_active_event_sel" not in st.session_state: st.session_state["resp_active_event_sel"] = 0 @@ -381,6 +498,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..2895dcf 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,265 @@ 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.get("sources"): + with st.expander("摘要來源與分析狀態"): + st.json(latest["sources"]) + 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 +374,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 +408,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 +421,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..c66a45d 100644 --- a/frontend/components/supply_map.py +++ b/frontend/components/supply_map.py @@ -1,10 +1,13 @@ +from backend.region_matching import matches_location, split_location +from backend.supply_chain_risk import build_heatmap_review_rows import streamlit as st import pandas as pd import plotly.express as px 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 +34,27 @@ 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(產生與載入共用)。""" + if result.get("analysis_status") != "succeeded": + st.session_state["heatmap_analysis_error"] = result.get("summary") or "AI 分析失敗;保留上次有效摘要。" + return + st.session_state.pop("heatmap_analysis_error", None) + 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")), + "analysis_status": result.get("analysis_status"), "sources": result.get("sources", []), + } + + def render_risk_heatmap(key: str = "risk_heatmap", heatmap_rows=None): """僅渲染風險熱圖 (Plotly Chart)。""" if heatmap_rows is None: @@ -70,196 +94,198 @@ 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): + return split_location(display_name) + + +def _find_suggestion(display_name, suggested_events): + c,r = split_location(display_name) + return next((s for s in suggested_events or [] if s.get("impact_days") is not None and matches_location(c,r,s.get("region"),s.get("country"))), 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) + if reg.get("estimated_delay") is not None and pd.notna(reg["estimated_delay"]): + match_suggest = dict(country=ev_country, region=ev_region, + impact_days=int(reg["estimated_delay"]), event_type=(match_suggest or {}).get("event_type", "其他"), + description=reg.get("ai_summary") or "已保存的風險評估") + 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["impact_days"], + 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["impact_days"], + 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["impact_days"]) + 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: + confirmed_days = st.number_input("手動確認延遲天數", min_value=0, max_value=365, value=0, key=f"manual_days_{key}_{i}") + 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, confirmed_days, 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: + confirmed_days = st.number_input("確認延遲天數", min_value=0, max_value=365, value=0, key=f"{key}_other_days") + 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["impact_days"] + 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 = confirmed_days, "其他", 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, @@ -271,6 +297,8 @@ def render_supply_chain_map( """供應鏈地圖:第一層即時風險熱圖 + AI 摘要,第二層受災採購清單,第三層 What-If 模擬。""" st.subheader("🌍 原物料風險管理地圖") st.caption("熱圖顯示與管理、AI 深度摘要。") + if st.session_state.get("heatmap_analysis_error"): + st.error(st.session_state["heatmap_analysis_error"]) heatmap_rows = get_risk_heatmap_data() @@ -279,90 +307,76 @@ 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, analyzed_only=True) 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"]) - + if meta.get("sources"): + with st.expander("分析依據與來源"): + st.json(meta["sources"]) + 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: - name = (u.get("display_name") or "").strip() - 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 - - if risk_val is not None: - table_rows.append({ - "套用": True, - "地區": node_name, - "預估風險 (%)": float(risk_val), - "預估延遲 (天)": int(suggested_days) - }) + st.caption("套用會保存各據點的風險與延遲天數;空白代表未知,0 代表確認為零。正式事件需另行登錄。") + table_rows = build_heatmap_review_rows( + h_updates_raw, st.session_state.get("suggested_events", []), heatmap_rows_for_update + ) + if table_rows: df_upd = pd.DataFrame(table_rows) edited_risk_df = st.data_editor( @@ -370,37 +384,28 @@ 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("影響 %", min_value=0, max_value=100, step=1, required=True), "預估延遲 (天)": 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 - st.session_state["suggested_events"] = current_suggested + final_updates = [{"display_name": r["地區"], "risk_pct": r["預估風險 (%)"], + "estimated_delay": None if pd.isna(r["預估延遲 (天)"]) else int(r["預估延遲 (天)"])} + for _, r in sel_risks.iterrows()] cnt = apply_heatmap_updates( final_updates, st.session_state["heatmap_ai_summary"], actor=actor, ) - st.session_state["heatmap_apply_success"] = f"✅ 已成功同步 {cnt} 個地區的風險等級與天數設定!" + st.session_state.pop("suggested_events", None) + st.session_state["heatmap_apply_success"] = f"✅ 已成功同步 {cnt} 個地區的風險與延遲天數至資料庫(尚未登錄正式事件)!" if "heatmap_updates" in st.session_state: del st.session_state["heatmap_updates"] st.rerun() @@ -411,9 +416,6 @@ def render_supply_chain_map( st.rerun() else: st.info("AI 本次分析未偵測到與您供應商節點直接相關的變動建議。") - - # 移除原有的「審核 AI 偵測到之新事件」區塊(依需求隱藏) - pass else: st.caption("提示:點擊「即時全球情報」區塊的「更新即時新聞」後,系統會自動同步更新此熱圖與 AI 摘要。") @@ -434,7 +436,7 @@ def render_supply_chain_map( column_config={ "region_key": None, "display_name": st.column_config.TextColumn("熱點名稱", disabled=True), - "risk_pct": st.column_config.NumberColumn("影響 %", min_value=0, max_value=100, step=1) + "risk_pct": st.column_config.NumberColumn("影響 %", min_value=0, max_value=100, step=1, required=True) }, hide_index=True, use_container_width=True, @@ -457,6 +459,7 @@ def render_supply_chain_map( actor=actor, ) st.success("地圖已更新。") + st.rerun() def render_what_if_analysis( api_key: str, diff --git a/frontend/page_agent_dashboard.py b/frontend/page_agent_dashboard.py index b9d1e21..02af0e2 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,42 @@ 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']})") + if event.get("analysis_summary"): + st.markdown(f"**來源新聞 AI 摘要**({event['analysis_status']}):{event['analysis_summary']}") + 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 +303,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 +400,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,52 +611,25 @@ 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 item["tool"] == "update_inventory": - args = item["raw_args"] - pid = args.get("product_id") - qty_change = args.get("quantity_change") - if pid and qty_change is not None: - qty_change = float(qty_change) - res = gateway.execute_approved( - "rollback_inventory", - {"product_id": pid, "quantity_change": qty_change}, - "admin", - ) - ok, msg = res.is_ok(), ( - f"🔄 已沖銷!產品 {pid} 庫存扣回 {qty_change} 件。" if res.is_ok() - else f"沖銷庫存失敗:{res.message}" - ) - elif item["tool"] == "create_order": - args = item["raw_args"] - pid = args.get("product_id") - qty = args.get("quantity") - cust_id = args.get("customer_id", "") - if pid and qty is not None: - cancel_args = {"product_id": pid, "quantity": int(qty)} - if cust_id: - cancel_args["customer_id"] = cust_id - res = gateway.execute_approved("cancel_order", cancel_args, "admin") - ok, msg = res.is_ok(), ( - f"🔄 已取消銷售訂單,並將產品 {pid} 庫存回補 {qty} 件!" if res.is_ok() - else f"沖銷訂單失敗:{res.message}" - ) - - write_action_log( - "retry_approval", {"approval_id": item["id"]}, "admin", - msg or "沖銷未執行(缺少必要參數)", ok, - ) - if ok: - st.toast(msg) + from backend.approval_reversal import reverse_approval + try: + outcome = reverse_approval(item["id"], actor=principal.username) + except (ValueError, PermissionError) as exc: + st.error(str(exc)) else: - st.error(msg or "沖銷未執行:缺少必要參數。") - st.rerun() + st.toast(outcome["message"]) + st.rerun() elif history_action == "admin_required": st.caption("🔒 僅管理員可沖銷") elif history_action == "not_rollbackable": diff --git a/frontend/page_supply_chain_risk.py b/frontend/page_supply_chain_risk.py index e506373..7526093 100644 --- a/frontend/page_supply_chain_risk.py +++ b/frontend/page_supply_chain_risk.py @@ -30,15 +30,17 @@ def render( return st.markdown("
🌱 供應鏈與風險監控
", unsafe_allow_html=True) + from frontend.components.news_acceptance import render_news_acceptance + render_news_acceptance() 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/accept_live_news.py b/scripts/accept_live_news.py new file mode 100644 index 0000000..4161544 --- /dev/null +++ b/scripts/accept_live_news.py @@ -0,0 +1,192 @@ +"""Capture six real articles once; replay batch-one acceptance offline with mocked AI. + +Never loads provider credentials other than GNEWS_API_KEY, never starts a server +or background scheduler, and never uses the main workspace database. +""" +import argparse +from copy import deepcopy +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import sqlite3 +import sys +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def write_json(path, data): + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", choices=("gnews", "rss"), default="gnews") + parser.add_argument("--env-file", type=Path) + parser.add_argument("--country", default="美國") + parser.add_argument("--snapshot", type=Path, help="Replay an earlier six-article capture without network") + args = parser.parse_args() + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + output = ROOT / ".isolated" / f"live-news-{stamp}" + output.mkdir(parents=True, exist_ok=False) + db_path = output / "acceptance.db" + key = os.getenv("GNEWS_API_KEY", "").strip() + if args.env_file: + from dotenv import dotenv_values + key = (dotenv_values(args.env_file).get("GNEWS_API_KEY") or "").strip() + # All other inherited credentials and paid model configuration are ignored. + for name in ("GNEWS_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", + "LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LLM_MODEL", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): + os.environ.pop(name, None) + os.environ.update(ERP_DB_PATH=str(db_path), ERP_DEMO_MODE="1", ERP_ISOLATED_TEST="1", + ERP_SCHEDULER_ENABLED="0", ERP_SCHEDULER_ACTOR="planner", + LITELLM_LOCAL_MODEL_COST_MAP="True", OTEL_SDK_DISABLED="true") + from backend import database, supply_chain_news as news, supply_chain_risk as risk, scheduler + from backend.isolated_runtime import block_external_network + from backend.news_store import identity_keys + from backend.job_lock import exclusive_job_lock + + # The only external operation is this explicit news capture. No automatic RSS + # fallback here: the report must identify which provider actually succeeded. + try: + if args.snapshot: + capture = json.loads(args.snapshot.read_text(encoding="utf-8")) + articles = capture["articles"] + else: + if args.source == "gnews": + if not key or key.startswith("replace_"): + raise ValueError("GNEWS_API_KEY is not configured in the selected file") + articles = news._fetch_via_gnews_api(args.country, key, max_results=6, within_days=7) + else: + articles = news._fetch_via_rss(args.country, max_results=6, within_days=7) + capture = dict(source=args.source, search_country=args.country, + captured_at=datetime.now(timezone.utc).isoformat(), articles=articles) + except Exception as exc: + # requests exceptions may contain the API key in their URL; never print them. + cause = exc.__cause__ + response = getattr(cause, "response", None) + error = dict(status="capture_failed", source=args.source, error_type=type(exc).__name__, + http_status=getattr(response, "status_code", None)) + write_json(output / "capture-error.json", error) + print(json.dumps(error)) + print(f"Output: {output}") + return 1 + finally: + key = "" + block_external_network() + + assert len(articles) == 6, f"Expected six articles; provider returned {len(articles)}" + assert len({identity_keys(a)[0] for a in articles}) == 6, "Provider returned duplicate URLs" + assert all(a.get("title") and a.get("url") and a.get("published_at") for a in articles) + write_json(output / "news-capture.json", capture) + database.init_db() + with sqlite3.connect(db_path) as conn: + conn.execute("UPDATE suppliers SET is_official=0") + for sid, country, region in (("LIVE-N", "台灣", "北區"), ("LIVE-S", "台灣", "南區"), ("LIVE-J", "日本", "北區")): + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,latitude,longitude,is_official) VALUES (?,?,?,?,25,121,1)", (sid, sid, country, region)) + conn.execute("INSERT INTO inventory(product_id,name,stock,reorder_point,daily_sales) VALUES (?,?,10,5,3)", (sid, sid)) + conn.execute("INSERT INTO purchase_orders(po_id,supplier_id,status,total_amount) VALUES (?,?,'pending',100)", (sid, sid)) + conn.execute("INSERT INTO purchase_order_items(po_id,product_id,qty,unit_price) VALUES (?,?,1,100)", (sid, sid)) + + checks = [] + phases = {} + def checked(name, condition): + assert condition, name + checks.append(name) + + def rows(): + with sqlite3.connect(db_path) as conn: + conn.row_factory = sqlite3.Row + return [dict(r) for r in conn.execute("SELECT * FROM supply_chain_news ORDER BY id")] + + simulated = {article["title"]: i for i, article in enumerate(articles)} + mode = "failure" + analyzed_titles = [] + def mock_complete(prompt, **kwargs): + if kwargs.get("tag") == "analysis:heatmap": + return json.dumps({"摘要": "驗收模擬摘要,不代表真實新聞風險。", "更新": [], "事件": []}, ensure_ascii=False) + if mode == "failure": + raise RuntimeError("Simulated provider outage") + result = [] + for news_id, body in re.findall(r"【新聞編號 (\d+)】\n(.*?)(?=【新聞編號|$)", prompt, re.S): + title = body.splitlines()[0] + index = simulated[title] + analyzed_titles.append(title) + delay = [0, None, 5, 0, "7", -1][index] if mode == "mixed" else 0 + result.append({"news_id": int(news_id), "相關性": "NO" if index == 3 else "YES", + "國家": "台灣", "地區": "北區", "事件類型": "交通", "預計延遲": delay, + "繁體中文簡要": f"[驗收模擬,非新聞風險判斷] 測試案例 {index + 1}"}) + return json.dumps({"results": result}, ensure_ascii=False) + + country = capture.get("search_country", args.country) + with patch.object(news, "fetch_country_news", return_value=deepcopy(articles)), \ + patch("backend.llm_client.complete_text", side_effect=mock_complete): + with patch("backend.llm_client.llm_available", return_value=False): + phases["pending"] = news.refresh_news_for_countries([country], actor="planner") + checked("Six real articles saved as pending with unknown delay", len(rows()) == 6 and all(r["analysis_status"] == "pending" and r["estimated_delay"] is None for r in rows())) + phases["outage"] = news.refresh_news_for_countries([country], actor="planner") + checked("Provider failure cannot become a seven-day risk", all(r["analysis_status"] == "failed" and r["is_relevant"] is None and r["estimated_delay"] is None for r in rows())) + checked("Failed news excluded from risk inputs", not news.get_news_from_db(analyzed_only=True) and risk.get_active_risk_events().empty) + mode = "mixed" + with patch.object(news, "fetch_country_news", return_value=deepcopy(articles + articles)): + phases["strict_validation"] = news.refresh_news_for_countries([country], actor="planner") + mixed_rows = rows() + phases["strict_validation_states"] = [dict(id=r["id"], analysis_status=r["analysis_status"], is_relevant=r["is_relevant"], estimated_delay=r["estimated_delay"], analysis_error=r["analysis_error"]) for r in mixed_rows] + checked("Twelve replayed entries deduplicated before six analyses", phases["strict_validation"]["duplicate_count"] == 12 and len(analyzed_titles) == 6 and len(mixed_rows) == 6) + checked("Zero, unknown, delay, irrelevant, malformed remain distinct", [r["estimated_delay"] for r in mixed_rows] == [0, None, 5, 0, None, None] and [r["analysis_status"] for r in mixed_rows] == ["succeeded"] * 4 + ["failed"] * 2) + for source in (mixed_rows[1], mixed_rows[4], mixed_rows[5]): + try: + risk.add_risk_event("交通", "北區", "台灣", 7, "Must reject", source["id"], actor="planner") + except ValueError: + continue + raise AssertionError("Unknown or failed news was registered as risk") + checked("Unknown and failed news cannot register an event", True) + mode = "recovery" + analyzed_titles.clear() + phases["retry"] = news.refresh_news_for_countries([country], actor="planner") + checked("Only the two failed analyses retry", len(analyzed_titles) == 2 and all(r["analysis_status"] == "succeeded" for r in rows())) + analyzed_titles.clear() + phases["deduped_replay"] = news.refresh_news_for_countries([country], actor="planner") + checked("Successful news is not reanalyzed or inserted twice", not analyzed_titles and phases["deduped_replay"]["saved_count"] == 0 and len(rows()) == 6) + for raw, stored in zip(articles, rows()): + assert all(raw.get(k) == stored.get(k) for k in ("title", "summary", "url", "source", "published_at", "country", "region")) + checked("Original news content preserved through all failures and retries", True) + + cfg = scheduler.SchedulerConfig(actor="planner", max_attempts=2, retry_seconds=0) + scheduled_calls = [] + def scheduled_refresh(**kwargs): + scheduled_calls.append(1) + if len(scheduled_calls) == 1: + raise RuntimeError("Simulated retry") + return news.refresh_news_for_countries([country], actor=kwargs["actor"]) + with patch.object(scheduler, "refresh_supply_chain_news_once", side_effect=scheduled_refresh): + phases["scheduler"] = scheduler.run_scheduled_refresh(cfg, job_key="live-acceptance") + phases["scheduler_replay"] = scheduler.run_scheduled_refresh(cfg, job_key="live-acceptance") + checked("One-shot scheduler retries and skips the completed key", len(scheduled_calls) == 2 and phases["scheduler"]["status"] == "succeeded" and phases["scheduler_replay"]["status"] == "skipped") + with exclusive_job_lock(db_path, "news") as locked: + checked("Overlapping refresh is blocked", locked and news.refresh_news_for_countries([country], actor="planner")["status"] == "busy") + + checked("Supplier, PO and stockout scope agree on Taiwan north only", + [r["supplier_id"] for r in risk.get_affected_suppliers_by_event("北區", "台灣")] == ["LIVE-N"] + and [r["po_id"] for r in risk.get_impacted_pos("北區", "台灣")] == ["LIVE-N"] + and [r["product_id"] for r in risk.get_stockout_alerts_for_event("北區", "台灣", 5)] == ["LIVE-N"]) + risk.apply_heatmap_updates([dict(display_name="台灣 北區", risk_pct=0, estimated_delay=0)], "[驗收模擬] 零值保存", actor="planner") + with sqlite3.connect(db_path) as conn: + checked("Zero percent and zero days survive database reconnect", conn.execute("SELECT risk_pct,estimated_delay FROM risk_heatmap WHERE region_key='台灣|北區'").fetchone() == (0, 0)) + checked("Background scheduling remains disabled", scheduler.start_background_jobs() is False) + report = dict(status="passed", checked_at=datetime.now(timezone.utc).isoformat(), source=capture["source"], + capture_sha256=hashlib.sha256((output / "news-capture.json").read_bytes()).hexdigest(), + article_count=6, analysis_mode="mocked; not real news risk assessment", database=str(db_path), + checks=checks, phases=phases, articles=[{k:a.get(k) for k in ("title", "url", "source", "published_at")} for a in articles]) + write_json(output / "acceptance-results.json", report) + print(json.dumps(dict(status="passed", article_count=6, checks_passed=len(checks), source=capture["source"], output=str(output)), ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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/scripts/run_isolated.py b/scripts/run_isolated.py new file mode 100644 index 0000000..cb1195b --- /dev/null +++ b/scripts/run_isolated.py @@ -0,0 +1,95 @@ +"""python scripts/run_isolated.py [--seed-only | --scheduler-once KEY] [--port 8511]""" +import argparse +import os +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--seed-only", action="store_true") + parser.add_argument("--integration-demo", action="store_true", help="Seed deterministic local PO and supplier offers for L1/L2/L3 review") + parser.add_argument("--scheduler-once", metavar="KEY") + parser.add_argument("--port", type=int, default=8511) + parser.add_argument("--scenario", choices=("mixed", "success"), default="mixed") + parser.add_argument("--acceptance-dir", type=Path, + help="Show a real-news acceptance snapshot in its own review database") + args = parser.parse_args() + os.chdir(ROOT) + # Always pick a local test DB; never inherit a user's production database setting. + db_name = "erp-batch1.db" if args.scenario == "mixed" else "erp-batch1-success.db" + db_path = ROOT / ".isolated" / db_name + db_path.parent.mkdir(exist_ok=True) + os.environ.pop("ERP_NEWS_CAPTURE", None) + os.environ.pop("ERP_NEWS_ACCEPTANCE", None) + if args.acceptance_dir: + import sqlite3 + acceptance_dir = args.acceptance_dir.resolve() + if not acceptance_dir.is_relative_to((ROOT / ".isolated").resolve()): + parser.error("Acceptance directory must be inside this worktree's .isolated folder") + capture_path = acceptance_dir / "news-capture.json" + report_path = acceptance_dir / "acceptance-results.json" + original_db = acceptance_dir / "acceptance.db" + if not all(p.is_file() for p in (capture_path, report_path, original_db)): + parser.error("A complete news capture, acceptance report and database are required") + db_path = acceptance_dir / "preview.db" + if not db_path.exists(): + with sqlite3.connect(str(original_db)) as source, sqlite3.connect(str(db_path)) as target: + source.backup(target) + os.environ.update(ERP_NEWS_CAPTURE=str(capture_path), ERP_NEWS_ACCEPTANCE=str(report_path)) + for key in ("OPENAI_API_KEY", "GEMINI_API_KEY", "GNEWS_API_KEY", "LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): + os.environ.pop(key, None) + os.environ["ERP_ISOLATED_SCENARIO"] = args.scenario + os.environ.update(ERP_DB_PATH=str(db_path), ERP_DEMO_MODE="1", ERP_ENABLE_DEMO_SEED="0", ERP_ISOLATED_TEST="1", + ERP_SCHEDULER_ENABLED="0", ERP_SCHEDULER_ACTOR="planner", + STREAMLIT_BROWSER_GATHER_USAGE_STATS="false", LITELLM_LOCAL_MODEL_COST_MAP="True", + OTEL_SDK_DISABLED="true") + from backend.isolated_runtime import block_external_network + block_external_network() + from backend.database import init_db + init_db() + # Stable official nodes for review, independent of the legacy random demo seed. + import sqlite3 + with sqlite3.connect(db_path) as conn: + if args.acceptance_dir: + print("Real news snapshot loaded; analysis remains mocked; acceptance evidence is preserved.") + else: + seed_preview_suppliers(conn) + if args.integration_demo: + seed_integration_demo(conn) + print(f"ISOLATED ERP_DB_PATH={db_path}") + print("News replay/LLM fixtures; external network blocked; background scheduler disabled.") + if args.scheduler_once: + from backend.scheduler import SchedulerConfig, run_scheduled_refresh + result = run_scheduled_refresh(SchedulerConfig(actor="planner", max_attempts=2, retry_seconds=0), job_key=args.scheduler_once) + print(result) + return 1 if result["status"] in {"failed", "busy", "cancelled"} else 0 + elif not args.seed_only: + from streamlit.web import cli + sys.argv = ["streamlit", "run", str(ROOT / "app.py"), "--server.address=127.0.0.1", + f"--server.port={args.port}", "--server.headless=true", "--browser.gatherUsageStats=false"] + cli.main() + + +def seed_preview_suppliers(conn): + conn.execute("UPDATE suppliers SET is_official=0 WHERE supplier_id NOT LIKE 'BATCH1-%'") + for sid, country, region, lat, lon in (("BATCH1-TWN","台灣","北區",25.03,121.56),("BATCH1-TWS","台灣","南區",22.63,120.30),("BATCH1-JP","日本","東京",35.68,139.69)): + conn.execute("INSERT OR IGNORE INTO suppliers(supplier_id,name,country,region,latitude,longitude,is_official) VALUES (?,?,?,?,?,?,1)", (sid,f"測試供應商 {country} {region}",country,region,lat,lon)) + conn.execute("UPDATE suppliers SET is_official=1 WHERE supplier_id=?", (sid,)) + + +def seed_integration_demo(conn): + """Only invoked by the network-blocked launcher in this worktree's DB.""" + conn.execute("INSERT OR IGNORE INTO inventory(product_id,name,stock,price,cost,reorder_point) VALUES('INTEGRATION-ITEM','整合驗收物料',100,120,100,20)") + for sid, price in (("BATCH1-JP",100),("BATCH1-TWN",110),("BATCH1-TWS",115)): + if not conn.execute("SELECT 1 FROM supplier_products WHERE supplier_id=? AND product_id='INTEGRATION-ITEM'",(sid,)).fetchone(): + conn.execute("INSERT INTO supplier_products(supplier_id,product_id,price,carbon_factor) VALUES(?,'INTEGRATION-ITEM',?,1)",(sid,price)) + conn.execute("INSERT OR IGNORE INTO purchase_orders(po_id,supplier_id,order_date,status,total_amount,note) VALUES('INTEGRATION-PO-JP','BATCH1-JP',date('now'),'已下單',1000,'固定隔離驗收資料')") + if not conn.execute("SELECT 1 FROM purchase_order_items WHERE po_id='INTEGRATION-PO-JP'").fetchone(): + conn.execute("INSERT INTO purchase_order_items(po_id,product_id,qty,unit_price) VALUES('INTEGRATION-PO-JP','INTEGRATION-ITEM',10,100)") + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start-isolated.ps1 b/scripts/start-isolated.ps1 new file mode 100644 index 0000000..120a8c9 --- /dev/null +++ b/scripts/start-isolated.ps1 @@ -0,0 +1,11 @@ +param([int]$Port = 8511) +$ErrorActionPreference = 'Stop' +$isolatedRoot = Split-Path -Parent $PSScriptRoot +$isolatedPython = Join-Path $isolatedRoot '.venv\Scripts\python.exe' +if (-not (Test-Path -LiteralPath $isolatedPython)) { + $isolatedPython = Join-Path (Split-Path -Parent $isolatedRoot) 'AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' +} +if (-not (Test-Path -LiteralPath $isolatedPython)) { + throw 'Python environment missing. Create .venv and install requirements.txt plus requirements-dev.txt.' +} +& $isolatedPython (Join-Path $PSScriptRoot 'run_isolated.py') --port $Port diff --git a/tests/conftest.py b/tests/conftest.py index 649c295..ce15eea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,3 +20,10 @@ os.environ["ERP_DB_PATH"] = os.path.join(_TMP_DIR, "test_erp.db") # 測試套件明確啟用合成資料;正式執行的安全預設維持關閉。 os.environ["ERP_DEMO_MODE"] = "1" +os.environ["ERP_ENABLE_DEMO_SEED"] = "0" + +# Tests must not access paid providers or external notification endpoints. +os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" +os.environ["ERP_SCHEDULER_ENABLED"] = "0" +from backend.isolated_runtime import block_external_network +block_external_network() diff --git a/tests/test_batch1_risk_pipeline.py b/tests/test_batch1_risk_pipeline.py new file mode 100644 index 0000000..4880e60 --- /dev/null +++ b/tests/test_batch1_risk_pipeline.py @@ -0,0 +1,262 @@ +import json +import sqlite3 +from datetime import datetime + +import pandas as pd +import pytest + +from backend import database, supply_chain_news as news, supply_chain_risk as risk +from backend.news_store import migrate, store_raw, store_analysis, identity_keys +from backend.region_matching import matches_location, connect_db, expanded_region_where +from backend.risk_validation import parse_news_batch, number + + +@pytest.fixture +def risk_db(tmp_path, monkeypatch): + path = str(tmp_path / "batch1.db") + monkeypatch.setattr(database, "DB_FILE", path) + monkeypatch.setattr(risk, "DB_FILE", path) + database.init_db() + with sqlite3.connect(path) as conn: + for table in ("suppliers", "purchase_orders", "purchase_order_items", "inventory", "supply_chain_events", "risk_heatmap", "esg_risk_factors"): + conn.execute(f"DELETE FROM {table}") + for i, (country, region) in enumerate((("台灣", "北區"), ("台灣", "南區"), ("日本", "北區"), ("阿聯酋", "杜拜"))): + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,is_official,latitude,longitude) VALUES (?,?,?,?,1,25,121)", (f"S{i}", f"S{i}", country, region)) + conn.execute("INSERT INTO inventory(product_id,name,stock,reorder_point,daily_sales) VALUES (?,?,10,5,3)", (f"P{i}", f"P{i}")) + conn.execute("INSERT INTO purchase_orders(po_id,supplier_id,status,total_amount) VALUES (?,?, 'pending',100)", (f"PO{i}", f"S{i}")) + conn.execute("INSERT INTO purchase_order_items(po_id,product_id,qty,unit_price) VALUES (?,?,1,100)", (f"PO{i}", f"P{i}")) + return path + + +def item(**kwargs): + return dict(dict(country="台灣", region="北區", title="Original headline", summary="Original news body", + url="https://example.test/news", source="fixture", published_at=datetime.now().strftime("%Y-%m-%d %H:%M")), **kwargs) + + +def response(idx=0, **kwargs): + return dict(dict(news_id=idx, 相關性="YES", 國家="台灣", 地區="北區", 事件類型="交通", 預計延遲=0, 繁體中文簡要="分析摘要"), **kwargs) + + +def mock_llm(monkeypatch, rows): + def complete(prompt, **kwargs): + if kwargs.get("tag") == "analysis:heatmap": + return '{"摘要":"有效摘要","更新":[],"事件":[]}' + return json.dumps({"results": rows}, ensure_ascii=False) + monkeypatch.setattr("backend.llm_client.complete_text", complete) + monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) + + +@pytest.mark.parametrize("bad", [True, False, "0", "55%", -1, 366, 0.5, float("nan"), float("inf"), [], {}]) +def test_delay_rejects_invalid_values(bad): + with pytest.raises(ValueError): + number(bad, maximum=365, integer=True) + + +@pytest.mark.parametrize("value", [0, None, 365]) +def test_zero_unknown_and_known_remain_distinct(value): + rows = parse_news_batch(json.dumps({"results": [response(預計延遲=value)]}), 1) + assert rows[0]["analysis_status"] == "succeeded" + assert rows[0]["estimated_delay"] == value + + +@pytest.mark.parametrize("rows", [[response(news_id=True)], [response(news_id="0")], [response(news_id=2)], [response(), response()], [None]]) +def test_batch_rejects_ambiguous_identifiers(rows): + with pytest.raises(ValueError): + parse_news_batch(json.dumps({"results": rows}), 1) + + +def test_partial_output_marks_missing_item_failed(): + results = parse_news_batch(json.dumps({"results": [response()]}), 2) + assert results[0]["estimated_delay"] == 0 + assert results[1]["analysis_status"] == "failed" + assert results[1]["estimated_delay"] is None + assert results[1]["is_relevant"] is None + + +@pytest.mark.parametrize("changes", [{"相關性": "maybe"}, {"事件類型": "typo"}, {"國家": 42}, {"預計延遲": "7"}, {"繁體中文簡要": None}]) +def test_bad_fields_fail_without_inventing_risk(changes): + result = parse_news_batch(json.dumps({"results": [response(**changes)]}), 1)[0] + assert result["analysis_status"] == "failed" + assert result["estimated_delay"] is None and result["is_relevant"] is None + + +def test_provider_failure_retains_raw_content_and_cannot_create_risk(risk_db, monkeypatch): + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: [item()]) + monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) + def fail(*a, **k): + raise RuntimeError("paid-provider-secret must not become news") + monkeypatch.setattr("backend.llm_client.complete_text", fail) + result = news.refresh_news_for_countries(["台灣"], actor="planner") + assert result["failed_count"] == 1 and result["status"] == "partial_failure" + row = news.get_news_from_db()[0] + assert row["summary"] == "Original news body" and row["analysis_summary"] is None + assert row["estimated_delay"] is None and row["is_relevant"] is None + assert row["analysis_error"] == "provider_error" + assert news.get_news_from_db(analyzed_only=True) == [] + assert risk.get_active_risk_events().empty + with pytest.raises(ValueError): + risk.add_risk_event("交通", "北區", "台灣", 7, "bad", row["id"], actor="planner") + with sqlite3.connect(risk_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM risk_heatmap").fetchone()[0] == 0 + + +def test_dedupe_before_analysis_across_countries_and_refreshes(risk_db, monkeypatch): + mock_llm(monkeypatch, [response()]) + observed = [] + original = risk.batch_infer_affected_region_from_news + def infer(**kwargs): + observed.extend(kwargs["news_texts"]) + return original(**kwargs) + monkeypatch.setattr(risk, "batch_infer_affected_region_from_news", infer) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: [item(), item(url="https://example.test/news?utm_source=test#top"), item(url="https://other.test/syndicated")]) + result = news.refresh_news_for_countries(["台灣", "Taiwan", "日本"], actor="planner") + assert result["saved_count"] == 1 and result["duplicate_count"] == 5 + assert len(observed) == 1 + assert news.refresh_news_for_countries(["日本"], actor="planner")["saved_count"] == 0 + assert len(observed) == 1 + row = news.get_news_from_db()[0] + assert row["summary"] == "Original news body" and row["analysis_summary"] == "分析摘要" + assert row["estimated_delay"] == 0 + + +def test_failed_retained_news_retries_even_if_fetch_no_longer_returns_it(risk_db, monkeypatch): + mock_llm(monkeypatch, [response(預計延遲="invalid")]) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: [item()]) + assert news.refresh_news_for_countries(["台灣"], actor="planner")["failed_count"] == 1 + mock_llm(monkeypatch, [response(預計延遲=5)]) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: []) + result = news.refresh_news_for_countries(["台灣"], actor="planner") + assert result["saved_count"] == 0 and result["analyzed_count"] == 1 + assert news.get_news_from_db()[0]["estimated_delay"] == 5 + + +def test_no_model_retains_pending_and_empty_countries_is_safe(risk_db, monkeypatch): + monkeypatch.setattr("backend.llm_client.llm_available", lambda: False) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: [item()]) + assert news.refresh_news_for_countries([], actor="planner")["fetched_count"] == 0 + assert news.refresh_news_for_countries(["台灣"], actor="planner")["pending_count"] == 1 + assert news.get_news_from_db()[0]["analysis_status"] == "pending" + assert not news.get_news_from_db(analyzed_only=True) + + +def test_fetch_error_is_retryable_not_empty_success(risk_db, monkeypatch): + monkeypatch.setattr("backend.llm_client.llm_available", lambda: False) + def fail(*a, **k): + raise RuntimeError("offline") + monkeypatch.setattr(news, "fetch_country_news", fail) + result = news.refresh_news_for_countries(["台灣"], actor="planner") + assert result["status"] == "partial_failure" and result["fetch_failed_count"] == 1 + + +def test_migration_preserves_duplicates_and_ids(tmp_path): + with sqlite3.connect(tmp_path / "old.db") as conn: + conn.execute("CREATE TABLE supply_chain_news(id INTEGER PRIMARY KEY,title TEXT,url TEXT,source TEXT,published_at TEXT,summary TEXT)") + conn.execute("CREATE TABLE risk_heatmap(region_key TEXT PRIMARY KEY)") + for i in (10, 20): + conn.execute("INSERT INTO supply_chain_news VALUES (?,'same','https://test/','test','2026-09-13','original')", (i,)) + migrate(conn) + migrate(conn) + assert conn.execute("SELECT id,summary,analysis_status FROM supply_chain_news ORDER BY id").fetchall() == [(10,"original","legacy_unverified"),(20,"original","legacy_unverified")] + assert conn.execute("SELECT COUNT(url_key) FROM supply_chain_news").fetchone()[0] == 1 + + +@pytest.mark.parametrize("region,country,expected", [("北區", "台灣", ["S0"]), ("台灣 北區", None, ["S0"]), ("台灣|北區", None, ["S0"]), ("台灣", None, ["S0","S1"]), ("東亞", None, ["S0","S1","S2"]), ("中東", None, ["S3"]), ("阿拉伯聯合大公國", None, ["S3"]), ("台灣,日本", None, ["S0","S1","S2"]), ("%", None, []), ("灣", None, []), (None,None,[])]) +def test_all_risk_consumers_match_identical_geography(risk_db, region, country, expected): + suppliers = risk.get_affected_suppliers_by_event(region, country) + pos = risk.get_impacted_pos(region, country) + stock = risk.get_stockout_alerts_for_event(region, country, 5) + assert sorted(s["supplier_id"] for s in suppliers) == expected + if region or country: + assert sorted(p["po_id"] for p in pos) == [s.replace("S","PO") for s in expected] + assert sorted(p["product_id"] for p in stock) == [s.replace("S","P") for s in expected] + with connect_db(risk_db) as conn: + where, params = expanded_region_where(region, country) + sql_ids = [r[0] for r in conn.execute(f"SELECT supplier_id FROM suppliers WHERE {where[0]} ORDER BY supplier_id", params)] + python_ids = [r[0] for r in conn.execute("SELECT supplier_id,country,region FROM suppliers ORDER BY supplier_id") if matches_location(r[1],r[2],region,country)] + assert sql_ids == python_ids == expected + + +def test_spaces_aliases_and_country_region_intersection(): + assert matches_location("United States", "West", "United States West") + assert not matches_location("United States", "East", "United States West") + assert matches_location("韓國", "首爾", "南韓") + assert not matches_location("加拿大", "北美", "美國") + assert not matches_location("日本", "北區", "北區", "台灣") + + +def test_zero_risk_and_delay_persist_atomically_and_reload(risk_db): + before = risk.get_risk_heatmap_data() + review = risk.build_heatmap_review_rows([{"display_name":"台灣 北區","risk_pct":0}], + [{"country":"台灣","region":"北區","impact_days":0}], before) + assert review == [{"套用":True,"地區":"台灣 北區","預估風險 (%)":0.0,"預估延遲 (天)":0}] + assert risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=0,estimated_delay=0)], "zero", actor="planner") == 1 + reloaded = {r["region_key"]: r for r in risk.get_risk_heatmap_data()} + assert reloaded["台灣|北區"]["risk_pct"] == 0 and reloaded["台灣|北區"]["estimated_delay"] == 0 + assert reloaded["台灣|南區"]["risk_pct"] > 0 + with pytest.raises(ValueError): + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=55),dict(display_name="日本",risk_pct=float("nan"))], actor="planner") + assert risk.get_risk_heatmap_data()[0]["risk_pct"] == 0 + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=10,estimated_delay=None)], actor="planner") + assert risk.get_risk_heatmap_data()[0]["estimated_delay"] is None + + +def test_zero_news_can_register_and_unknown_cannot(risk_db, monkeypatch): + mock_llm(monkeypatch, [response()]) + monkeypatch.setattr(news,"fetch_country_news",lambda *a,**k:[item()]) + news.refresh_news_for_countries(["台灣"],actor="planner") + row = news.get_news_from_db()[0] + event_id = risk.add_risk_event("交通","北區","台灣",0,"zero",row["id"],actor="planner") + with sqlite3.connect(risk_db) as conn: + assert conn.execute("SELECT impact_days FROM supply_chain_events WHERE id=?",(event_id,)).fetchone()[0] == 0 + conn.execute("UPDATE supply_chain_news SET estimated_delay=NULL WHERE id=?",(row["id"],)) + with pytest.raises(ValueError): + risk.add_risk_event("交通","北區","台灣",0,"unknown",row["id"],actor="planner") + + +def test_heatmap_transaction_rolls_back_on_storage_failure(risk_db): + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=0,estimated_delay=0)],actor="planner") + with sqlite3.connect(risk_db) as conn: + conn.execute("CREATE TRIGGER reject_japan BEFORE INSERT ON risk_heatmap WHEN NEW.region_key='日本|北區' BEGIN SELECT RAISE(ABORT,'test failure'); END") + with pytest.raises(sqlite3.IntegrityError): + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=25,estimated_delay=3),dict(display_name="日本",risk_pct=80,estimated_delay=8)],actor="planner") + with sqlite3.connect(risk_db) as conn: + assert conn.execute("SELECT risk_pct,estimated_delay FROM risk_heatmap").fetchall() == [(0,0)] + + +def test_manual_refresh_obeys_shared_pipeline_lock(risk_db,monkeypatch): + from backend.job_lock import exclusive_job_lock + monkeypatch.setattr(news,"fetch_country_news",lambda *a,**k:pytest.fail("Duplicate fetch")) + with exclusive_job_lock(risk_db,"news") as acquired: + assert acquired + assert news.refresh_news_for_countries(["台灣"],actor="planner")["status"] == "busy" + + +def test_legacy_unverified_source_events_are_not_risk_inputs(risk_db): + with sqlite3.connect(risk_db) as conn: + nid = conn.execute("INSERT INTO supply_chain_news(title,summary,analysis_status) VALUES ('old','previous content','legacy_unverified')").lastrowid + conn.execute("INSERT INTO supply_chain_events(event_type,country,region,impact_days,news_id) VALUES ('交通','台灣','北區',7,?)",(nid,)) + assert risk.get_recent_events_for_delay().empty + assert risk.get_active_risk_events().empty + assert not risk.get_historical_event_precedents() + with sqlite3.connect(risk_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM supply_chain_events").fetchone()[0] == 1 + + +def test_heatmap_invalid_numeric_output_is_explicit_failure(risk_db,monkeypatch): + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**k:'{"摘要":"bad","更新":[{"地區":"台灣","風險":101}],"事件":[]}') + risk.add_risk_event("交通", "聖保羅", "巴西", 0, "zero evidence", actor="planner") + result = risk.get_heatmap_ai_analysis(news_context="fixture") + assert result["analysis_status"] == "failed" + assert result["updates"] == [] and result["events"] == [] + + +def test_exact_nodes_outside_country_dictionary_can_be_reviewed_and_saved(risk_db,monkeypatch): + with sqlite3.connect(risk_db) as conn: + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,is_official,latitude,longitude) VALUES ('BR','BR','巴西','聖保羅',1,-23,-46)") + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**k:'{"摘要":"test","更新":[{"地區":"巴西","風險":0}],"事件":[{"類型":"交通","國家":"巴西","地區":"聖保羅","延遲天數":0,"描述":"test"}]}') + risk.add_risk_event("交通", "聖保羅", "巴西", 0, "zero evidence", actor="planner") + result = risk.get_heatmap_ai_analysis(news_context="fixture") + assert result["updates"] == [{"display_name":"巴西 聖保羅","risk_pct":0}] + assert len(result["events"]) == 1 + assert risk.apply_heatmap_updates(result["updates"],actor="planner") == 1 + assert matches_location("Czech Republic","Prague","Czech Republic Prague") diff --git a/tests/test_batch1_scheduler.py b/tests/test_batch1_scheduler.py new file mode 100644 index 0000000..cce7d7f --- /dev/null +++ b/tests/test_batch1_scheduler.py @@ -0,0 +1,97 @@ +import os +import sqlite3 +import subprocess +import sys + +import pytest +from backend import database, scheduler +from backend.job_lock import exclusive_job_lock + + +@pytest.fixture +def job_db(tmp_path, monkeypatch): + path = str(tmp_path / "scheduler.db") + monkeypatch.setattr(database, "DB_FILE", path) + database.init_db() + return path + + +def test_retry_then_success_and_skip_same_key(job_db, monkeypatch): + calls, waits = [], [] + def refresh(**kwargs): + calls.append(kwargs) + if len(calls) == 1: + raise RuntimeError("first attempt") + return {"saved_count":1,"status":"succeeded"} + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",refresh) + cfg = scheduler.SchedulerConfig(actor="planner",max_attempts=3,retry_seconds=2) + assert scheduler.run_scheduled_refresh(cfg,job_key="fixed",wait=waits.append)["status"] == "succeeded" + assert scheduler.run_scheduled_refresh(cfg,job_key="fixed")["status"] == "skipped" + assert len(calls) == 2 and waits == [2] + with sqlite3.connect(job_db) as conn: + assert conn.execute("SELECT status,attempts,error FROM scheduled_jobs").fetchone() == ("succeeded",2,None) + + +def test_partial_failure_can_retry_same_key_later(job_db, monkeypatch): + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",lambda **k:{"status":"partial_failure"}) + cfg = scheduler.SchedulerConfig(actor="planner",max_attempts=2,retry_seconds=0) + assert scheduler.run_scheduled_refresh(cfg,job_key="retry")["status"] == "failed" + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",lambda **k:{"status":"succeeded"}) + assert scheduler.run_scheduled_refresh(cfg,job_key="retry")["status"] == "succeeded" + with sqlite3.connect(job_db) as conn: + assert conn.execute("SELECT attempts FROM scheduled_jobs").fetchone()[0] == 3 + + +def test_permission_revocation_does_not_retry(job_db, monkeypatch): + def revoked(**kwargs): + raise PermissionError("revoked") + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",revoked) + assert scheduler.run_scheduled_refresh(scheduler.SchedulerConfig(actor="planner"),job_key="revoked")["status"] == "failed" + with sqlite3.connect(job_db) as conn: + assert conn.execute("SELECT attempts FROM scheduled_jobs").fetchone()[0] == 1 + + +def test_cross_process_lock_and_crash_release(job_db): + code = "from backend.job_lock import exclusive_job_lock; import sys;\nwith exclusive_job_lock(sys.argv[1], 'scheduler') as acquired: print(acquired)" + with exclusive_job_lock(job_db,"scheduler") as acquired: + assert acquired + result = subprocess.run([sys.executable,"-c",code,job_db],capture_output=True,text=True,timeout=30) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "False" + result = subprocess.run([sys.executable,"-c",code,job_db],capture_output=True,text=True,timeout=30) + assert result.returncode == 0 and result.stdout.strip() == "True" + crash = "from backend.job_lock import exclusive_job_lock; import sys,os;\nwith exclusive_job_lock(sys.argv[1], 'scheduler') as acquired: os._exit(17 if acquired else 18)" + assert subprocess.run([sys.executable,"-c",crash,job_db],timeout=30).returncode == 17 + with exclusive_job_lock(job_db,"scheduler") as acquired: + assert acquired + + +def test_abandoned_running_record_recovered(job_db,monkeypatch): + with sqlite3.connect(job_db) as conn: + conn.execute("INSERT INTO scheduled_jobs(job_key,status,attempts) VALUES ('crashed','running',1)") + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",lambda **k:{"status":"succeeded"}) + assert scheduler.run_scheduled_refresh(scheduler.SchedulerConfig(actor="planner"),job_key="crashed")["status"] == "succeeded" + + +def test_background_is_opt_in_and_isolation_overrides_enable(monkeypatch): + monkeypatch.setenv("ERP_SCHEDULER_ACTOR","planner") + monkeypatch.setenv("ERP_SCHEDULER_ENABLED","0") + assert scheduler.start_background_jobs() is False + monkeypatch.setenv("ERP_SCHEDULER_ENABLED","1") + monkeypatch.setenv("ERP_ISOLATED_TEST","1") + assert scheduler.start_background_jobs() is False + + +def test_configuration_rejects_invalid_interval(monkeypatch): + monkeypatch.setenv("ERP_SCHEDULER_INTERVAL_SECONDS","0") + with pytest.raises(ValueError): + scheduler.SchedulerConfig.from_env() + + +def test_scheduler_busy_does_not_start_another_job(job_db,monkeypatch): + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",lambda **k:pytest.fail("Duplicate job")) + with exclusive_job_lock(job_db,"scheduler") as acquired: + assert acquired + assert scheduler.run_scheduled_refresh(scheduler.SchedulerConfig(actor="planner"),job_key="busy")["status"] == "busy" + with sqlite3.connect(job_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM scheduled_jobs").fetchone()[0] == 0 diff --git a/tests/test_batch1_ui.py b/tests/test_batch1_ui.py new file mode 100644 index 0000000..b3a15b4 --- /dev/null +++ b/tests/test_batch1_ui.py @@ -0,0 +1,82 @@ +import sqlite3 + +from streamlit.testing.v1 import AppTest + +from backend import database, supply_chain_risk as risk + + +def prepare(tmp_path, monkeypatch): + path = str(tmp_path / "ui.db") + monkeypatch.setattr(database, "DB_FILE", path) + monkeypatch.setattr(risk, "DB_FILE", path) + database.init_db() + with sqlite3.connect(path) as conn: + conn.execute("DELETE FROM suppliers") + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,latitude,longitude,is_official) VALUES ('UI','UI','台灣','北區',25,121,1)") + monkeypatch.setattr("backend.llm_client.complete_text", lambda *a, **k: + '{"摘要":"測試摘要","更新":[{"地區":"台灣 北區","風險":0}],' + '"事件":[{"類型":"交通","國家":"台灣","地區":"北區","延遲天數":0,"描述":"零延遲"}]}') + return path + + +def test_streamlit_generate_apply_and_new_session_reload(tmp_path, monkeypatch): + path = prepare(tmp_path, monkeypatch) + script = "from frontend.components.supply_map import render_supply_chain_map\nrender_supply_chain_map('', '', actor='planner')" + at = AppTest.from_string(script, default_timeout=20).run() + assert not at.exception + risk.add_risk_event("交通", "北區", "台灣", 0, "zero evidence", actor="planner") + at.button(key="heatmap_ai_btn").click().run() + assert not at.exception + assert not at.button(key="apply_ai_risk_btn").disabled + at.button(key="apply_ai_risk_btn").click().run() + assert not at.exception + assert any("至資料庫" in s.value for s in at.success) + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT risk_pct,estimated_delay FROM risk_heatmap WHERE region_key='台灣|北區'").fetchone() == (0,0) + fresh = AppTest.from_string(script, default_timeout=20).run() + assert not fresh.exception + rows = risk.get_risk_heatmap_data() + assert rows[0]["risk_pct"] == 0 and rows[0]["estimated_delay"] == 0 + + +def test_streamlit_failed_news_shows_original_and_disables_registration(tmp_path, monkeypatch): + path = prepare(tmp_path, monkeypatch) + with sqlite3.connect(path) as conn: + conn.execute("""INSERT INTO supply_chain_news(title,summary,country,source,published_at, + analysis_status,analysis_error,is_relevant,estimated_delay) + VALUES ('Failed fixture','Original body retained','台灣','fixture',date('now'), + 'failed','provider_error',NULL,NULL)""") + at = AppTest.from_string("from frontend.components.risk_dashboard import render_intelligence_gathering\nrender_intelligence_gathering(actor='planner')", default_timeout=20).run() + assert not at.exception + buttons = [b for b in at.button if "登錄" in b.label] + assert len(buttons) == 2 and all(b.disabled for b in buttons) + assert any("未知" in c.value and "failed" in c.value for c in at.caption) + assert any("Original body retained" in m.value for m in at.markdown) + + +def test_real_news_snapshot_is_visible_and_replay_uses_captured_items(tmp_path, monkeypatch): + import json + from backend.isolated_runtime import fixture_news + path = prepare(tmp_path, monkeypatch) + articles = [dict(country="美國", title=f"Captured article {i}", summary=f"Source description {i}", + url=f"https://publisher.test/article-{i}", source="Publisher", published_at="2026-09-13 10:00") for i in range(6)] + capture_path = tmp_path / "capture.json" + capture_path.write_text(json.dumps(dict(source="gnews", captured_at="2026-09-13T10:01:00Z", articles=articles)), encoding="utf-8") + report_path = tmp_path / "results.json" + report_path.write_text(json.dumps(dict(checks=[str(i) for i in range(14)], phases={})), encoding="utf-8") + monkeypatch.setenv("ERP_ISOLATED_TEST", "1") + monkeypatch.setenv("ERP_NEWS_CAPTURE", str(capture_path)) + monkeypatch.setenv("ERP_NEWS_ACCEPTANCE", str(report_path)) + from backend.supply_chain_news import save_news_to_db + assert save_news_to_db(articles) == 6 + assert fixture_news("美國") == articles + assert fixture_news("日本") == [] + at = AppTest.from_string("from frontend.components.news_acceptance import render_news_acceptance\nrender_news_acceptance()", default_timeout=20).run() + assert not at.exception + assert any("GNews API" in item.value and "模擬" in item.value for item in at.info) + assert [m.value for m in at.metric] == ["6", "6", "14 項通過"] + assert len(at.dataframe[0].value) == 6 + assert at.dataframe[0].value["新聞標題"].str.startswith("Captured article").all() + at = AppTest.from_string("from frontend.components.risk_dashboard import render_intelligence_gathering\nrender_intelligence_gathering(actor='planner')", default_timeout=20).run() + assert not at.exception + assert at.button(key="refresh_news_btn").label == "🔁 重播本批真實新聞" diff --git a/tests/test_l1_alert_states.py b/tests/test_l1_alert_states.py new file mode 100644 index 0000000..cb5b385 --- /dev/null +++ b/tests/test_l1_alert_states.py @@ -0,0 +1,188 @@ +""" +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.execute("UPDATE supply_chain_news SET analysis_status='succeeded', analysis_country=country, analysis_region=region, analysis_summary=summary") + 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..38c2a5d --- /dev/null +++ b/tests/test_l1_event_alerts.py @@ -0,0 +1,226 @@ +"""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.execute("UPDATE supply_chain_news SET analysis_status='succeeded', analysis_country=country, analysis_region=region, analysis_summary=summary") + 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.execute("UPDATE supply_chain_news SET analysis_status='succeeded', analysis_country=country, analysis_region=region, analysis_summary=summary") + 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..b7301af --- /dev/null +++ b/tests/test_l2_risk_workspace.py @@ -0,0 +1,455 @@ +""" +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 + country, region = conn.execute("SELECT s.country,s.region FROM purchase_orders p JOIN suppliers s ON s.supplier_id=p.supplier_id ORDER BY p.po_id LIMIT 1").fetchone() + assert risk.get_region_exposure(f"{country}|{region}")["open_po_amount"] > 0 + + +# ── 3. 卡片判定納入新聞登錄事件 ─────────────────────────────────────── + + +def test_events_for_location_separates_news_and_formal(l2_db): + with sqlite3.connect(l2_db) as conn: + conn.executemany("INSERT INTO supply_chain_news(id,title,analysis_status,is_relevant,analysis_country,analysis_region,analysis_summary,estimated_delay,category) VALUES (?,?,'succeeded',1,'台灣','','fixture',?,?)", [(11,'A',7,'其他'),(12,'B',21,'氣候')]) + 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", "id": 1, "analysis_status": "succeeded", "is_relevant": 1, "analysis_country": "台灣", "analysis_region": "亞洲", "analysis_summary": "颱風"}, +] +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"] and "美國戰爭" not 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: [ + {"analysis_status": "succeeded", "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 any(k.startswith("美國|") for k in result["evidence_locations"]) + + +def test_gate_by_evidence_unit_rules(): + evidence = risk.build_risk_evidence( + [{"analysis_status":"succeeded", "is_relevant":1, "analysis_country":"越南", "analysis_region":"", "analysis_summary":"fixture", "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 天 ≤ 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_removes_unsupported_type_when_evidence_is_unclassified(): + """證據沒有戰爭分類時,不得保留模型自行推測的戰爭類型。""" + evidence = risk.build_risk_evidence( + [{"analysis_status":"succeeded", "is_relevant":1, "analysis_country":"美國", "analysis_region":"北美洲", "analysis_summary":"fixture", "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 # 名稱不重複國家 + + +def test_gate_by_evidence_rejects_without_evidence(): + updates = [{"display_name": "火星", "risk_pct": 99}] + events = [{"event_type": "戰爭", "country": "火星", "region": "", "impact_days": 90}] + kept_u, kept_e, audit = risk.gate_by_evidence(updates, events, risk.build_risk_evidence([], [])) + assert kept_u == kept_e == [] and len(audit) == 2 + + +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_pr16_pr17_integration.py b/tests/test_pr16_pr17_integration.py new file mode 100644 index 0000000..05f97b2 --- /dev/null +++ b/tests/test_pr16_pr17_integration.py @@ -0,0 +1,279 @@ +"""Regression acceptance for the contracts joining PR16's pipeline to PR17's tiers.""" +import json +import os +import sqlite3 +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +import pytest +from streamlit.testing.v1 import AppTest +from backend import database, supply_chain_risk as risk, l1_monitoring as l1 +from backend import news_store, risk_intelligence as intelligence +from backend.approval_reversal import reverse_approval +from test_l3_proposal_closure import flow_db, _mark_first_po, _first_impacted, _propose + + +@pytest.fixture +def integration_db(tmp_path, monkeypatch): + path = str(tmp_path / "integration.db") + monkeypatch.setenv("ERP_DB_PATH", path) + monkeypatch.setattr(database, "DB_FILE", path) + monkeypatch.setattr(risk, "DB_FILE", path) + database.init_db() + with sqlite3.connect(path) as conn: + conn.execute("DELETE FROM suppliers") + conn.executemany("INSERT INTO suppliers(supplier_id,name,country,region,is_official,latitude,longitude) VALUES(?,?,?,?,1,25,121)", + [("N","North","台灣","北區"),("S","South","台灣","南區")]) + return path + + +def news(path, *, status="succeeded", days=5, country="台灣", region="北區", title="fixture"): + with sqlite3.connect(path) as conn: + nid, _ = news_store.store_raw(conn, dict(title=title, country="美國", region="北美", summary="raw body", source="fixture", published_at=datetime.now().isoformat(), url=f"https://fixture.invalid/{title}")) + if status in {"succeeded", "failed"}: + news_store.store_analysis(conn, nid, dict(analysis_status=status, analysis_error="provider_error", + country=country, region=region, event_type="交通", chinese_summary="分析結果", is_relevant=True, estimated_delay=days)) + elif status == "legacy_unverified": + conn.execute("UPDATE supply_chain_news SET analysis_status=?,is_relevant=1,estimated_delay=7 WHERE id=?", (status,nid)) + conn.row_factory=sqlite3.Row + return dict(conn.execute("SELECT * FROM supply_chain_news WHERE id=?", (nid,)).fetchone()) + + +def test_l1_uses_analysis_and_rejects_legacy_sources(integration_db): + valid = news(integration_db) + for status in ("pending","failed","legacy_unverified"): + row=news(integration_db,status=status,title=status) + with sqlite3.connect(integration_db) as conn: + conn.execute("INSERT INTO supply_chain_events(event_type,country,region,impact_days,created_at,news_id) VALUES('其他','美國','北美',7,datetime('now'),?)", (row["id"],)) + feed=l1.get_latest_event_alerts(actor="viewer") + assert feed["confirmed"] == [] + assert [(r["news_id"],r["country"],r["region"],r["summary"]) for r in feed["candidates"]] == [(valid["id"],"台灣","北區","分析結果")] + l1.set_alert_status(l1.ALERT_KIND_CANDIDATE,valid["id"],l1.ALERT_STATUS_NOTIFIED_L2,actor="viewer",note="請 L2 確認") + assert l1.list_l1_notifications_for_l2(actor="planner")[0]["country"] == "台灣" + eid=risk.add_risk_event("交通","北區","台灣",5,"登錄",valid["id"],actor="planner") + assert l1.list_l1_notifications_for_l2(actor="planner") == [] + assert l1.get_latest_event_alerts(actor="viewer")["confirmed"][0]["id"] == eid + + +@pytest.mark.parametrize("days,expected", [(0,0),(None,None),(5,7)]) +def test_evidence_distinguishes_zero_unknown_and_known(integration_db,days,expected): + n=news(integration_db,days=days) + evidence=risk.build_risk_evidence([n],[]) + _,events,audit=risk.gate_by_evidence([], [dict(country="台灣",region="北區",event_type="交通",impact_days=7)],evidence) + if expected is None: + assert events == [] and audit + else: + assert events[0]["impact_days"] == expected + if days == 0: + assert audit[0]["action"] == "調整" + + +def test_empty_failed_evidence_never_authorizes_action(integration_db): + n=news(integration_db,status="failed") + evidence=risk.build_risk_evidence([n],[]) + u,e,a=risk.gate_by_evidence([dict(display_name="台灣 北區",risk_pct=90)], [dict(country="台灣",region="北區",event_type="交通",impact_days=7)], evidence) + assert not evidence["locations"] and u == e == [] and len(a)==2 + + +def test_geography_shared_by_cards_alerts_evidence_exposure(integration_db): + ev=dict(country="臺灣",region="北區",event_type="交通",impact_days=5) + assert risk.events_for_location("台灣","南區",[ev]) == [] + assert risk.events_for_location("台灣","北區",[ev]) == [ev] + assert not l1._event_matches_supplier(ev,dict(country="台灣",region="南區")) + assert l1._event_matches_supplier(ev,dict(country="台灣",region="北區")) + evidence=risk.build_risk_evidence([news(integration_db)],[]) + u,e,a=risk.gate_by_evidence([dict(display_name="台灣 南區",risk_pct=99)], [{**ev,"region":"南區"}],evidence) + assert u == e == [] + with sqlite3.connect(integration_db) as conn: + conn.executemany("INSERT INTO purchase_orders(po_id,supplier_id,total_amount,status) VALUES(?,?,?,'已下單')",[("PN","N",100),("PS","S",900)]) + assert risk.get_region_exposure("臺灣|北區")["open_po_amount"] == 100 + assert [r["supplier_id"] for r in risk.get_affected_suppliers_by_event("北區","台灣")] == ["N"] + + +@pytest.mark.parametrize("value", [True,-1,1.5,"7",None]) +def test_event_new_rejects_invalid_days(integration_db,value): + with pytest.raises(ValueError): + risk.add_risk_event("交通","北區","台灣",value,"x",actor="planner") + + +@pytest.mark.parametrize("value", [True,-1,1.5,"7"]) +def test_event_update_validates_and_preserves_row(integration_db,value): + eid=risk.add_risk_event("交通","北區","台灣",0,"x",actor="planner") + with pytest.raises(ValueError): + risk.update_risk_event(eid,impact_days=value,actor="planner") + assert risk.get_risk_events_list().iloc[0]["impact_days"] == 0 + + +def test_event_source_revalidated_on_update_and_types_do_not_overwrite(integration_db): + n=news(integration_db) + one=risk.add_risk_event("交通","北區","台灣",5,"one",n["id"],actor="planner") + two=risk.add_risk_event("政策","北區","台灣",5,"two",n["id"],actor="planner") + assert one != two + with pytest.raises(ValueError): + risk.add_risk_event("交通","南區","台灣",5,"wrong",n["id"],actor="planner") + with sqlite3.connect(integration_db) as conn: + conn.execute("UPDATE supply_chain_news SET analysis_status='failed' WHERE id=?",(n["id"],)) + with pytest.raises(ValueError): + risk.update_risk_event(one,description="try",actor="planner") + + +def test_summary_provenance_failure_and_atomic_apply(integration_db,monkeypatch): + n=news(integration_db,days=0) + payload={"摘要":"確認零延遲","更新":[{"地區":"台灣 北區","風險":0}],"事件":[{"類型":"交通","國家":"台灣","地區":"北區","延遲天數":0,"描述":"無延遲"}]} + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**kw:json.dumps(payload)) + result=risk.analyze_heatmap_risk([n],actor="planner") + assert result["analysis_status"] == "succeeded" + latest=intelligence.get_latest_ai_risk_summary() + assert latest["sources"][0]["id"] == n["id"] and latest["sources"][0]["country"] == "台灣" + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**kw:"not JSON") + failed=risk.analyze_heatmap_risk([n],actor="planner") + assert failed["analysis_status"] == "failed" and failed["error"] + intelligence.save_ai_risk_summary(failed,actor="planner") + assert intelligence.get_latest_ai_risk_summary()["summary_id"] == latest["summary_id"] + with sqlite3.connect(integration_db) as conn: + conn.execute("CREATE TRIGGER fail_summary BEFORE INSERT ON risk_ai_summaries BEGIN SELECT RAISE(ABORT,'test rollback'); END") + with pytest.raises(sqlite3.IntegrityError): + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=0,estimated_delay=0)],"x",actor="planner",summary_result=result) + with sqlite3.connect(integration_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM risk_heatmap").fetchone()[0] == 0 + + +def test_empty_and_legacy_database_migrations_preserve_ids(tmp_path,monkeypatch): + path=str(tmp_path/"legacy.db") + monkeypatch.setattr(database,"DB_FILE",path) + with sqlite3.connect(path) as conn: + conn.executescript(""" + CREATE TABLE supply_chain_news(id INTEGER PRIMARY KEY,country TEXT,region TEXT,title TEXT,summary TEXT,url TEXT,source TEXT,published_at TEXT,relevance_tag TEXT,fetched_at TEXT,category TEXT,is_relevant INTEGER,estimated_delay INTEGER); + CREATE TABLE risk_heatmap(region_key TEXT PRIMARY KEY,display_name TEXT,latitude REAL,longitude REAL,risk_pct REAL,ai_summary TEXT,updated_at TEXT); + INSERT INTO supply_chain_news VALUES(42,'美國','','old','raw','https://example.invalid/old','fixture','2026-09-13','','','其他',1,7); + """) + database.init_db();database.init_db() + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT id,summary,analysis_status FROM supply_chain_news").fetchone() == (42,"raw","legacy_unverified") + assert "estimated_delay" in {r[1] for r in conn.execute("PRAGMA table_info(risk_heatmap)")} + assert "sources_json" in {r[1] for r in conn.execute("PRAGMA table_info(risk_ai_summaries)")} + assert conn.execute("SELECT COUNT(*) FROM approval_reversals").fetchone()[0] == 0 + + +def seed_reversal(path,kind="update_inventory",receipt=True): + args={"product_id":"REV","quantity_change":5} if kind=="update_inventory" else {"product_id":"REV","quantity":5} + with sqlite3.connect(path) as conn: + conn.execute("INSERT INTO inventory(product_id,name,stock,warehouse_id) VALUES('REV','fixture',20,'WH01')") + conn.execute("INSERT INTO pending_approvals(approval_id,tool_name,parameters,status) VALUES('REV-A',?,?,'approved')",(kind,json.dumps(args))) + if receipt: + conn.execute("INSERT INTO effect_receipts(operation_id,approval_id,payload_digest,result,created_at) VALUES('REV-OP','REV-A','fixture','成功 單號: ORD-20260914-010101',datetime('now'))") + if kind=="create_order": + conn.execute("INSERT INTO orders(order_id,product_id,quantity,status) VALUES('ORD-20260914-010101','REV',5,'處理中')") + + +def test_reversal_cross_process_exactly_once(integration_db): + seed_reversal(integration_db) + code="from backend.isolated_runtime import block_external_network; block_external_network(); from backend.approval_reversal import reverse_approval; print(reverse_approval('REV-A',actor='admin')['status'])" + env=dict(os.environ,ERP_DB_PATH=integration_db,PYTHONIOENCODING="utf-8") + procs=[subprocess.Popen([sys.executable,"-c",code],cwd=Path(__file__).resolve().parents[1],env=env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,encoding="utf-8") for _ in range(2)] + outputs=[p.communicate(timeout=30) for p in procs] + assert all(p.returncode==0 for p in procs),outputs + assert sorted(o[0].strip() for o in outputs)==["already_reversed","ok"] + with sqlite3.connect(integration_db) as conn: + assert conn.execute("SELECT stock FROM inventory WHERE product_id='REV'").fetchone()[0] == 15 + assert conn.execute("SELECT COUNT(*) FROM approval_reversals").fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM stock_moves WHERE ref_no='REV-A'").fetchone()[0] == 1 + + +def test_reversal_rolls_back_when_audit_fails(integration_db): + seed_reversal(integration_db,kind="create_order") + with sqlite3.connect(integration_db) as conn: + conn.execute("CREATE TRIGGER fail_audit BEFORE INSERT ON agent_action_logs BEGIN SELECT RAISE(ABORT,'fail'); END") + with pytest.raises(sqlite3.IntegrityError): + reverse_approval("REV-A",actor="admin") + with sqlite3.connect(integration_db) as conn: + assert conn.execute("SELECT stock FROM inventory WHERE product_id='REV'").fetchone()[0] == 20 + assert conn.execute("SELECT status FROM orders WHERE order_id='ORD-20260914-010101'").fetchone()[0] == "處理中" + assert conn.execute("SELECT COUNT(*) FROM approval_reversals").fetchone()[0] == 0 + + +def test_reversal_denies_planner_and_receiptless_legacy(integration_db): + seed_reversal(integration_db,receipt=False) + with pytest.raises(PermissionError): + reverse_approval("REV-A",actor="planner") + with pytest.raises(ValueError,match="收據"): + reverse_approval("REV-A",actor="admin") + + +def test_zero_day_shortcut_and_fresh_summary_ui(integration_db): + n=news(integration_db,days=0) + risk.add_risk_event("交通","北區","台灣",0,"新聞",n["id"],actor="planner") + risk.upsert_risk_heatmap("台灣|北區","台灣 北區",25,121,60,"fixture",actor="planner") + script="from frontend.components.supply_map import render_risk_shortcuts\nrender_risk_shortcuts('integration',actor='planner')" + at=AppTest.from_string(script,default_timeout=20).run() + assert not at.exception + button=next(b for b in at.button if "建立應變計畫" in b.label) + assert "0 天" in button.label + button.click().run() + assert not at.exception + events=risk.get_risk_events_list() + assert events["impact_days"].tolist()==[0,0] + fresh=AppTest.from_string(script,default_timeout=20).run() + assert not fresh.exception and any("查看分析" in b.label for b in fresh.button) + + +def test_l1_to_l2_to_l3_and_fresh_views(flow_db,monkeypatch): + from backend import purchase_proposals as pp + po_id,country,region=_mark_first_po(flow_db) + n=news(flow_db,country=country,region=region,title="L1-L2-L3") + l1.set_alert_status(l1.ALERT_KIND_CANDIDATE,n["id"],l1.ALERT_STATUS_NOTIFIED_L2,actor="viewer",note="檢查供貨") + assert l1.list_l1_notifications_for_l2(actor="planner")[0]["news_id"] == n["id"] + eid=risk.add_risk_event("交通",region,country,5,"L2 確認",n["id"],actor="planner") + l1.set_alert_status(l1.ALERT_KIND_CONFIRMED,eid,"處理中",actor="viewer") + payload={"摘要":"本次有效分析摘要","更新":[],"事件":[]} + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**k:json.dumps(payload)) + summary=risk.analyze_heatmap_risk([n],actor="planner") + assert summary["analysis_status"]=="succeeded" + proposal=_propose(_first_impacted(),"integrated-flow",eid) + # Planner cannot bypass the reviewer even after being allowed to annotate POs. + with pytest.raises(PermissionError): + pp.decide_purchase_proposal(pp.ApprovalDecision(proposal_id=proposal.proposal_id,outcome="approve"),actor="planner") + ctx=pp.get_purchase_proposal_context(proposal,actor="approver") + assert ctx["event"]["analysis_summary"]=="分析結果" + l3script=("from backend.purchase_proposals import get_purchase_proposal_for_operation\n" + "from backend.access_control import load_principal\n" + "from frontend.page_agent_dashboard import _render_domain_proposal_evidence\n" + f"proposal=get_purchase_proposal_for_operation({pp.proposal_operation_id(proposal.proposal_id)!r},actor='approver')\n" + "_render_domain_proposal_evidence(proposal,load_principal('approver'))") + l3=AppTest.from_string(l3script,default_timeout=20).run() + assert not l3.exception + assert any("分析結果" in m.value for m in l3.markdown) + pp.decide_purchase_proposal(pp.ApprovalDecision(proposal_id=proposal.proposal_id,outcome="approve"),actor="approver") + feed=l1.get_latest_event_alerts(actor="viewer") + confirmed=next(e for e in feed["confirmed"] if e["id"]==eid) + assert confirmed["ack_status"]=="處理中" and confirmed["proposals"]["approved"]==1 + assert not l1.list_l1_notifications_for_l2(actor="planner") + assert intelligence.get_latest_ai_risk_summary()["summary_id"]==summary["summary_id"] + for script in ( + "from frontend.components.risk_overview import _render_latest_event_alerts,_render_latest_ai_summary\n_render_latest_event_alerts(actor='viewer')\n_render_latest_ai_summary(actor='viewer')", + "from frontend.components.purchase_proposal_workbench import render_purchase_proposal_workbench\nrender_purchase_proposal_workbench(actor='planner')", + ): + fresh=AppTest.from_string(script,default_timeout=20).run() + assert not fresh.exception + 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 + + +def test_empty_database_repeated_init_without_demo(tmp_path,monkeypatch): + path=str(tmp_path/"empty.db") + monkeypatch.setattr(database,"DB_FILE",path) + monkeypatch.setenv("ERP_DEMO_MODE","0") + database.init_db();database.init_db() + with sqlite3.connect(path) as conn: + for table in ("supply_chain_news","supply_chain_events","purchase_orders","risk_ai_summaries","risk_alert_states","approval_reversals"): + assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]==0 + + +@pytest.mark.parametrize("days", [None,-1,1.5,"unknown",366]) +def test_legacy_manual_unknown_or_invalid_days_are_not_confirmed_zero(integration_db,days): + with sqlite3.connect(integration_db) as conn: + conn.execute("INSERT INTO supply_chain_events(event_type,country,region,impact_days,created_at) VALUES('其他','台灣','北區',?,datetime('now'))",(days,)) + assert l1.get_latest_event_alerts(actor="viewer")["confirmed"]==[] + assert risk.get_active_risk_events().empty diff --git a/tests/test_prompt_p1p2.py b/tests/test_prompt_p1p2.py index f6c64ca..6f49efa 100644 --- a/tests/test_prompt_p1p2.py +++ b/tests/test_prompt_p1p2.py @@ -64,24 +64,32 @@ def test_gate_drops_unknown_region(): assert out == [] # code-side gate:不在清單也不可展開 → 丟棄 -def test_gate_soft_mode_when_no_suppliers(): +def test_gate_fails_closed_when_no_suppliers(): fallback = ["(目前無正式供應商據點資料,請跳過風險建議清單)"] out = _gate_heatmap_updates([{"地區": "任何地方", "風險": "55%"}], fallback, {}) - assert out == [{"display_name": "任何地方", "risk_pct": 55.0}] # 寬鬆模式全收 + assert out == [] # 無據點或非數字風險不得套用 def test_coerce_events_types_and_defaults(): out = _coerce_heatmap_events([ - {"類型": "罷工", "地區": "台灣 北區", "國家": "台灣", "延遲天數": "14", "描述": "港口罷工"}, + {"類型": "罷工", "地區": "台灣 北區", "國家": "台灣", "延遲天數": 14, "描述": "港口罷工"}, {"類型": None, "延遲天數": "not-a-number"}, ]) assert out[0] == {"event_type": "罷工", "region": "台灣 北區", "country": "台灣", "impact_days": 14, "description": "港口罷工"} - assert out[1]["event_type"] == "其他" and out[1]["impact_days"] == 14 + assert len(out) == 1 # 非法天數不再補成 14 天 -def test_heatmap_flow_end_to_end(monkeypatch): +def test_heatmap_flow_end_to_end(monkeypatch, tmp_path): """整條 get_heatmap_ai_summary:假 JSON 回應 → 三元組契約不變。""" + import sqlite3 + from backend import database, supply_chain_risk + path = str(tmp_path / "heatmap-contract.db") + monkeypatch.setattr(database, "DB_FILE", path) + monkeypatch.setattr(supply_chain_risk, "DB_FILE", path) + database.init_db() + with sqlite3.connect(path) as conn: + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,is_official) VALUES ('P1P2','Fixture','台灣','北區',1)") import backend.llm_client as lc monkeypatch.setattr(lc, "complete_text", lambda *a, **kw: '{"摘要": "### 摘要\\n台灣風險升高。", ' @@ -89,6 +97,7 @@ def test_heatmap_flow_end_to_end(monkeypatch): '"事件": [{"類型": "政策", "地區": "台灣", "國家": "台灣", ' '"延遲天數": 12, "描述": "出口管制"}]}') from backend.supply_chain_risk import get_heatmap_ai_summary + supply_chain_risk.add_risk_event("政策", "", "台灣", 12, "政策依據", actor="planner") summary, updates, events = get_heatmap_ai_summary(news_context="測試新聞") assert "台灣風險升高" in summary diff --git a/tests/test_supply_chain_authorization.py b/tests/test_supply_chain_authorization.py index 9cda089..51a5927 100644 --- a/tests/test_supply_chain_authorization.py +++ b/tests/test_supply_chain_authorization.py @@ -98,7 +98,7 @@ def _mutation(name: str, actor: str | None): factor_id = risk.get_risk_factors().iloc[0]["id"] operations = { "add_event": lambda: risk.add_risk_event( - "strike", "Kaohsiung", "Taiwan", 7, "new", actor=actor + "罷工", "Kaohsiung", "Taiwan", 7, "new", actor=actor ), "delete_event": lambda: risk.delete_risk_event(event_id, actor=actor), "upsert_heatmap": lambda: risk.upsert_risk_heatmap( @@ -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", @@ -321,7 +322,7 @@ def test_planner_news_refresh_applies_heatmap_update(supply_db, monkeypatch): "summary": "Delay expected", "url": "https://example.test/news", "source": "test", - "published_at": "2026-07-20 00:00", + "published_at": __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M"), "relevance_tag": "supply_chain", } ], @@ -331,9 +332,10 @@ def test_planner_news_refresh_applies_heatmap_update(supply_db, monkeypatch): "batch_infer_affected_region_from_news", lambda **kwargs: [ { + "analysis_status": "succeeded", "is_relevant": True, "estimated_delay": 5, - "event_type": "delay", + "event_type": "交通", "country": "Taiwan", "region": "Taichung", "chinese_summary": "Test summary", @@ -342,12 +344,8 @@ def test_planner_news_refresh_applies_heatmap_update(supply_db, monkeypatch): ) monkeypatch.setattr( risk, - "get_heatmap_ai_summary", - lambda **kwargs: ( - "Authorized update", - [{"display_name": "Taiwan Taichung", "risk_pct": 88}], - [], - ), + "get_heatmap_ai_analysis", + lambda **kwargs: dict(analysis_status="succeeded", summary="Authorized update", updates=[{"display_name": "Taiwan Taichung", "risk_pct": 88}], events=[]), ) result = news.refresh_news_for_countries(["Taiwan"], actor="planner") @@ -367,7 +365,10 @@ def test_news_refresh_does_not_swallow_midflight_authorization_failure( supply_db, monkeypatch ): monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) - monkeypatch.setattr(news, "fetch_country_news", lambda *args, **kwargs: []) + def revoked_during_fetch(*args, **kwargs): + monkeypatch.setattr(news, "require_capability", lambda *a, **k: (_ for _ in ()).throw(PermissionError("entitlement was revoked"))) + return [] + monkeypatch.setattr(news, "fetch_country_news", revoked_during_fetch) monkeypatch.setattr( risk, "get_heatmap_ai_summary", 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