diff --git a/.env.example b/.env.example index eeb38f6..e13d4f5 100644 --- a/.env.example +++ b/.env.example @@ -29,5 +29,15 @@ LINE_BRIEFING_ENABLED=false # Optional: override the default SQLite path data/erp.db # ERP_DB_PATH=C:\ERP專案\data\erp.db +# Local competition/demo only. Safe default is false; never enable on a public service. +ERP_DEMO_MODE=false + +# Optional service identity for the 24-hour supply-chain news refresh. +# The account must have risk.workspace.write; for the local demo use planner. +ERP_SCHEDULER_ACTOR= + # Optional: seed Agent Dashboard with synthetic demo records. Keep disabled for real data. # ERP_ENABLE_DEMO_SEED=false + +# Required only when running line bot/setup_rich_menu.py. +LINE_RICH_MENU_IMAGE_PATH= diff --git a/README.md b/README.md index e83a465..c884737 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ pip install -r requirements.txt # 2. 設定模型(.env) cp .env.example .env # LLM_MODEL=gemini/gemini-2.5-flash ← 填你的供應商/模型與對應金鑰 +# 本機比賽 Demo 才設定 ERP_DEMO_MODE=true(會建立並顯示已知測試帳密) # 3. 啟動 streamlit run app.py @@ -155,7 +156,9 @@ streamlit run app.py 登入後左側選單進入「AI 智能助理」即可用自然語言操作;「Agent Dashboard」檢視派工、稽核與待審批。 -測試帳號(示範資料):`admin/admin`(管理者)、`wh1/wh1`(倉管)、`sales1/sales1`(業務)、`hr1/hr1`(人資)。 +當且僅當 `.env` 明確設定 `ERP_DEMO_MODE=true` 時,系統才會建立並顯示測試帳號。此模式只供本機比賽展示,不得用於公開部署。 + +若某個既有資料庫曾以 Demo 模式初始化,之後把旗標改回 `false` 不會自動刪除帳號;公開或正式部署前必須改用乾淨資料庫,或由管理者移除/輪替所有測試帳密。現階段的 L1/L2/L3 權限模型是單一組織、本機展示邊界,尚未提供多租戶資料列隔離或外部 IAM/SSO,不能直接當成網路服務的正式身分系統。 ### LINE Bot(選用) @@ -168,6 +171,9 @@ python "line bot/bot_server.py" # FastAPI 於 :8000,webhook 需公開網址 | 環境變數 | 用途 | 預設 | |---------|------|------| +| `ERP_DEMO_MODE` | 建立合成資料與已知 Demo 帳密;僅限本機展示 | `false` | +| `ERP_SCHEDULER_ACTOR` | 背景新聞刷新使用的 ERP 服務身分;未設定時排程停用 | 未設定 | +| `LINE_RICH_MENU_IMAGE_PATH` | 執行 LINE Rich Menu 設定腳本時使用的本機 PNG 路徑 | 未設定 | | `LLM_MODEL` | 主模型(LiteLLM 格式 `provider/model`),AI 助理與分析頁共用 | `gemini/gemini-2.5-flash` | | `LLM_FALLBACK_MODELS` | 備援模型(逗號分隔,主模型失敗時依序切換) | `openai/kimi-k2.6,gemini/gemini-2.5-flash` | | `LLM_ANALYSIS_MODEL` | 分析副任務別名(選填;新聞歸類/翻譯可指到較便宜模型) | 未設=用主模型鏈 | @@ -177,6 +183,8 @@ python "line bot/bot_server.py" # FastAPI 於 :8000,webhook 需公開網址 | `ERP_DB_PATH` | 資料庫路徑(企業可指定既有 .db) | `data/erp.db` | | `LINE_CHANNEL_ACCESS_TOKEN` / `LINE_CHANNEL_SECRET` | LINE Bot 憑證(選用) | — | +若一般(非採購/ERP 交換)寫入在效果完成後、執行收據落庫前中斷,審批會安全停在 `executing`,系統不會自動重試。處理方式見 [Generic approval reconciliation runbook](docs/generic_approval_reconciliation.md)。 + ## 測試 ```bash diff --git a/app.py b/app.py index 8d11523..391ba2d 100644 --- a/app.py +++ b/app.py @@ -15,6 +15,15 @@ pass from backend import init_db, check_login +from backend.database import is_demo_mode_enabled +from backend.access_control import load_principal +from frontend.access_navigation import ( + ROLE_NAMES, + build_menu_structure, + clear_identity_session_state, + effective_product_levels, + normalize_navigation_state, +) # ── 初始化資料庫 ──────────────────────────────────────────────────── init_db() @@ -142,7 +151,17 @@ ) col1, col2, col3 = st.columns([1, 1, 1]) with col2: - st.info("💡 **測試帳號 / 密碼**:\n- 店長:`admin / admin`\n- 倉管:`wh1 / wh1`\n- 業務:`sales1 / sales1`\n- 人資:`hr1 / hr1`") + if is_demo_mode_enabled(): + st.info( + "💡 **分層 Demo 帳號 / 密碼**:\n" + "- L1 風險觀測:`viewer / viewer`\n" + "- L2 決策規劃:`planner / planner`\n" + "- L3 核准執行:`approver / approver`\n\n" + "**既有測試帳號**:`admin / admin`、`wh1 / wh1`、" + "`sales1 / sales1`、`hr1 / hr1`" + ) + else: + st.info("請使用已由系統管理者配置的帳號登入。") with st.form("login_form"): username = st.text_input("使用者帳號") password = st.text_input("密碼", type="password") @@ -161,15 +180,18 @@ # ── 登出 ──────────────────────────────────────────────────────────── def logout(): - st.session_state.logged_in = False - st.session_state.menu_selection = "📊 營運分析看板" - st.session_state.sub_menu = None - for key in list(st.session_state.keys()): - if key.startswith("erp_csv_"): - del st.session_state[key] - if "messages" in st.session_state: - st.session_state.messages = [] + clear_identity_session_state(st.session_state) + st.rerun() + + +# 每次 Streamlit rerun 都從資料庫重新解析身分、角色與有效 entitlement。 +principal = load_principal(st.session_state.get("username", "")) +if principal is None: + clear_identity_session_state(st.session_state) + st.error("登入身分已失效,請重新登入。") st.rerun() +st.session_state.role = principal.role +st.session_state.name = principal.name # ── CSS 選單優化 ─────────────────────────────────────────────────── st.markdown(""" @@ -199,10 +221,12 @@ def logout(): """, unsafe_allow_html=True) # ── 側邊欄導覽 (樹狀結構) ────────────────────────────────────────── -role_names = {"admin": "系統管理員", "warehouse": "倉管部", "hr": "人資部", "sales": "業務部"} +role_names = ROLE_NAMES -st.sidebar.title(f"🛡️ {st.session_state.name}") -st.sidebar.markdown(f"**身分**: `{role_names.get(st.session_state.role, '未知')}`") +st.sidebar.title(f"🛡️ {principal.name}") +st.sidebar.markdown(f"**身分**: `{role_names.get(principal.role, '未知')}`") +levels = effective_product_levels(principal) +st.sidebar.markdown(f"**有效產品層級**: `{' / '.join(levels) if levels else '無'}`") # ── 模型/金鑰設定(issue #27):全部由 .env 驅動,側邊欄不再輸入 API Key ── # LLM_MODEL / LLM_FALLBACK_MODELS / LLM_ANALYSIS_MODEL / GNEWS_API_KEY @@ -219,35 +243,14 @@ def logout(): st.sidebar.markdown("---") st.sidebar.markdown("## 📋 導航選單") -# 定義所有選單結構 -FULL_MENU = { - "📊 營運分析看板": [], - "🤖 AI 智能助理": ["對話介面", "LINE 客服記錄", "Agent Dashboard"], - "📦 進銷存": ["商品管理", "庫存數量", "入庫/出庫", "條碼掃描", "倉庫管理"], - "🛒 採購管理": ["採購單", "供應商管理", "進貨成本", "採購歷史", "ERP CSV 交換"], - "💰 銷售管理": ["報價單", "銷售單", "客戶消費視覺化", "客戶個人消費分析", "收款管理"], - "📒 財務會計": ["應收/應付", "總帳", "成本分析", "財報"], - "👥 人資": ["員工資料", "薪資", "出勤"], - "🌿 碳排放管理": ["碳排放總覽", "碳足跡追蹤", "減量目標", "年度碳目標分析", "ESG 報告","供應商風險與碳排"], - "🌱 供應鏈與風險": [] -} - -# 角色權限對照表 -ROLE_PERMISSIONS = { - "admin": list(FULL_MENU.keys()), - "warehouse": ["📊 營運分析看板", "🤖 AI 智能助理", "📦 進銷存", "🛒 採購管理", "🌱 供應鏈與風險"], - "sales": ["📊 營運分析看板", "🤖 AI 智能助理", "💰 銷售管理", "🌿 碳排放管理"], - "hr": ["📊 營運分析看板", "🤖 AI 智能助理", "👥 人資"] -} - -# 根據目前角色過濾出的選單 -allowed_menus = ROLE_PERMISSIONS.get(st.session_state.role, ["📊 營運分析看板"]) -MENU_STRUCTURE = {k: v for k, v in FULL_MENU.items() if k in allowed_menus} - -# 若目前選中的主選單不在權限內,強制跳回第一個 -if st.session_state.menu_selection not in MENU_STRUCTURE: - st.session_state.menu_selection = list(MENU_STRUCTURE.keys())[0] - st.session_state.sub_menu = MENU_STRUCTURE[st.session_state.menu_selection][0] if MENU_STRUCTURE[st.session_state.menu_selection] else None +# 導覽只使用本次 rerun 從資料庫取得的有效 principal。 +MENU_STRUCTURE = build_menu_structure(principal) +if not MENU_STRUCTURE: + st.error("此帳號目前沒有可用的產品權限,請聯絡管理員。") + st.stop() + +# 角色或 entitlement 變更後,立即清除不再有效的主/子選單狀態。 +normalize_navigation_state(st.session_state, MENU_STRUCTURE) for main_item, subs in MENU_STRUCTURE.items(): is_active = (st.session_state.menu_selection == main_item) @@ -309,7 +312,7 @@ def update_submenu(item_key): render_line_logs() elif sub_menu == "Agent Dashboard": from frontend.page_agent_dashboard import render as render_agent_dashboard - render_agent_dashboard() + render_agent_dashboard(username=principal.username) else: render_ai(api_key=api_key, role_names=role_names) @@ -317,7 +320,7 @@ def update_submenu(item_key): render_inventory(sub_menu=sub_menu) elif menu_selection == "🛒 採購管理": - render_procurement(sub_menu=sub_menu) + render_procurement(sub_menu=sub_menu, username=principal.username) elif menu_selection == "💰 銷售管理": render_sales(sub_menu=sub_menu , api_key=api_key) @@ -332,4 +335,10 @@ def update_submenu(item_key): render_carbon(sub_menu=sub_menu, api_key=api_key) elif menu_selection == "🌱 供應鏈與風險": - render_supply_chain_risk(sub_menu=sub_menu, api_key=api_key, gnews_api_key=gnews_api_key or "", gemini_model=gemini_model) + render_supply_chain_risk( + sub_menu=sub_menu, + api_key=api_key, + gnews_api_key=gnews_api_key or "", + gemini_model=gemini_model, + username=principal.username, + ) diff --git a/backend/access_control.py b/backend/access_control.py new file mode 100644 index 0000000..87b5b1d --- /dev/null +++ b/backend/access_control.py @@ -0,0 +1,191 @@ +"""Server-side authorization policy for the tiered ERP demo. + +Commercial entitlements and employee roles are separate dimensions. This +module owns the role-to-capability contract; database-backed entitlement +resolution is added at the same boundary rather than in Streamlit widgets. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import sqlite3 + +from backend import database + + +RISK_OVERVIEW_READ = "risk.overview.read" +RISK_ANALYSIS_READ = "risk.analysis.read" +RISK_WHAT_IF_RUN = "risk.what_if.run" +RISK_WORKSPACE_WRITE = "risk.workspace.write" +ERP_POLICY_WRITE = "erp.policy.write" +ERP_EXCHANGE_PROPOSE = "erp.exchange.propose" +PROPOSAL_EVIDENCE_READ = "proposal.evidence.read" +APPROVAL_QUEUE_READ = "approval.queue.read" +APPROVAL_DECIDE = "approval.decide" +GLOBAL_APPROVAL_DECIDE = "approval.global.decide" +ERP_EXCHANGE_EXPORT = "erp.exchange.export" +ERP_EXCHANGE_RECONCILE = "erp.exchange.reconcile" + +L1_MONITOR = "l1_monitor" +L2_DECISION = "l2_decision" +L3_GOVERNED_ACTION = "l3_governed_action" + +_CAPABILITY_ENTITLEMENT = { + RISK_OVERVIEW_READ: L1_MONITOR, + RISK_ANALYSIS_READ: L2_DECISION, + RISK_WHAT_IF_RUN: L2_DECISION, + RISK_WORKSPACE_WRITE: L2_DECISION, + ERP_POLICY_WRITE: L3_GOVERNED_ACTION, + ERP_EXCHANGE_PROPOSE: L2_DECISION, + PROPOSAL_EVIDENCE_READ: L3_GOVERNED_ACTION, + APPROVAL_QUEUE_READ: L3_GOVERNED_ACTION, + APPROVAL_DECIDE: L3_GOVERNED_ACTION, + GLOBAL_APPROVAL_DECIDE: L3_GOVERNED_ACTION, + ERP_EXCHANGE_EXPORT: L3_GOVERNED_ACTION, + ERP_EXCHANGE_RECONCILE: L3_GOVERNED_ACTION, +} + +_ALL_CAPABILITIES = frozenset(_CAPABILITY_ENTITLEMENT) + + +_ROLE_CAPABILITIES = { + "risk_viewer": frozenset({RISK_OVERVIEW_READ}), + "supply_planner": frozenset( + { + RISK_OVERVIEW_READ, + RISK_ANALYSIS_READ, + RISK_WHAT_IF_RUN, + RISK_WORKSPACE_WRITE, + ERP_EXCHANGE_PROPOSE, + } + ), + "procurement_approver": frozenset( + { + RISK_OVERVIEW_READ, + PROPOSAL_EVIDENCE_READ, + APPROVAL_QUEUE_READ, + APPROVAL_DECIDE, + ERP_EXCHANGE_EXPORT, + ERP_EXCHANGE_RECONCILE, + } + ), + # Preserve the existing demo accounts while routing the new accounts + # through the narrower role bundles above. + "warehouse": frozenset( + { + RISK_OVERVIEW_READ, + RISK_ANALYSIS_READ, + RISK_WHAT_IF_RUN, + RISK_WORKSPACE_WRITE, + ERP_POLICY_WRITE, + ERP_EXCHANGE_PROPOSE, + APPROVAL_QUEUE_READ, + ERP_EXCHANGE_EXPORT, + ERP_EXCHANGE_RECONCILE, + } + ), + "admin": _ALL_CAPABILITIES, +} + + +def capabilities_for_role(role: str) -> set[str]: + """Return an isolated capability set; unknown roles are denied by default.""" + return set(_ROLE_CAPABILITIES.get(str(role or "").strip(), frozenset())) + + +@dataclass(frozen=True) +class AccessContext: + username: str + role: str + name: str + organization_id: str + entitlements: frozenset[str] + capabilities: frozenset[str] + + def can(self, capability: str) -> bool: + return capability in self.capabilities + + +def load_principal( + username: str, *, conn: sqlite3.Connection | None = None +) -> AccessContext | None: + """Reload one principal from SQLite; missing identity or membership denies.""" + username = str(username or "").strip() + if not username: + return None + + def _load(active_conn: sqlite3.Connection) -> AccessContext | None: + row = active_conn.execute( + """ + SELECT u.username, u.role, u.name, membership.organization_id + FROM users u + JOIN user_organizations membership + ON membership.username = u.username + WHERE u.username = ? + """, + (username,), + ).fetchone() + if row is None: + return None + organization_id = row[3] + entitlement_rows = active_conn.execute( + """ + SELECT entitlement_key + FROM organization_entitlements + WHERE organization_id = ? AND enabled = 1 + """, + (organization_id,), + ).fetchall() + entitlements = frozenset(item[0] for item in entitlement_rows) + effective = frozenset( + capability + for capability in capabilities_for_role(row[1]) + if _CAPABILITY_ENTITLEMENT.get(capability) in entitlements + ) + return AccessContext( + username=row[0], + role=row[1], + name=row[2], + organization_id=organization_id, + entitlements=entitlements, + capabilities=effective, + ) + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) + + +def has_capability( + username: str, capability: str, *, conn: sqlite3.Connection | None = None +) -> bool: + principal = load_principal(username, conn=conn) + return bool(principal and principal.can(capability)) + + +def require_capability( + username: str, capability: str, *, conn: sqlite3.Connection | None = None +) -> AccessContext: + principal = load_principal(username, conn=conn) + if principal is None or not principal.can(capability): + raise PermissionError(f"使用者沒有必要權限:{capability}") + return principal + + +def require_any_capability( + username: str, + capabilities: set[str] | frozenset[str] | tuple[str, ...], + *, + conn: sqlite3.Connection | None = None, +) -> AccessContext: + """Require at least one capability while still resolving identity live.""" + requested = frozenset(capabilities) + if not requested: + raise ValueError("至少需要指定一項 capability") + principal = load_principal(username, conn=conn) + if principal is None or requested.isdisjoint(principal.capabilities): + raise PermissionError( + "使用者沒有任何必要權限:" + ", ".join(sorted(requested)) + ) + return principal diff --git a/backend/agent_logger.py b/backend/agent_logger.py index 1bf30df..9cb1cd3 100644 --- a/backend/agent_logger.py +++ b/backend/agent_logger.py @@ -54,9 +54,10 @@ def create_pending_approval( args: dict, role: str, *, + requester_username: str | None = None, operation_id: str | None = None, resource_version: str = "unspecified", - policy_version: str = "po-approval-v1", + policy_version: str = "po-approval-v2", ) -> str: """ 建立待審批項目至 pending_approvals 表中,含雜湊鏈 checksum,回傳 approval_id。 @@ -81,6 +82,7 @@ def create_pending_approval( args=args or {}, resource_version=resource_version, policy_version=policy_version, + requester_username=requester_username, ) with transaction(immediate=operation_id is not None) as conn: @@ -109,11 +111,12 @@ def create_pending_approval( query = """ INSERT INTO pending_approvals ( - approval_id, tool_name, parameters, requester, status, approver, + approval_id, tool_name, parameters, requester, + requester_username, status, approver, created_at, updated_at, reason, checksum, operation_id, payload_digest, resource_version, policy_version, version ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ tx_run( conn, @@ -123,6 +126,7 @@ def create_pending_approval( tool_name, parameters_str, role, + requester_username, "pending", None, created_at, @@ -179,7 +183,8 @@ def get_pending_approvals(status_filter: str = None) -> list[dict]: query = """ SELECT approval_id, tool_name, parameters, requester, status, approver, created_at, updated_at, reason, checksum, operation_id, - payload_digest, resource_version, policy_version, version + payload_digest, resource_version, policy_version, version, + requester_username FROM pending_approvals WHERE status = ? ORDER BY created_at DESC @@ -189,7 +194,8 @@ def get_pending_approvals(status_filter: str = None) -> list[dict]: query = """ SELECT approval_id, tool_name, parameters, requester, status, approver, created_at, updated_at, reason, checksum, operation_id, - payload_digest, resource_version, policy_version, version + payload_digest, resource_version, policy_version, version, + requester_username FROM pending_approvals ORDER BY created_at DESC """ @@ -218,6 +224,7 @@ def get_pending_approvals(status_filter: str = None) -> list[dict]: "resource_version": row[12], "policy_version": row[13], "version": row[14], + "requester_username": row[15], }) return approvals @@ -291,7 +298,7 @@ def _transition(active_conn) -> bool: if conn is not None: return _transition(conn) - with transaction() as owned_conn: + with transaction(immediate=True) as owned_conn: return _transition(owned_conn) @@ -326,7 +333,8 @@ def get_pending_approval_by_id(approval_id: str) -> dict | None: query = """ SELECT approval_id, tool_name, parameters, requester, status, approver, created_at, updated_at, reason, checksum, operation_id, - payload_digest, resource_version, policy_version, version + payload_digest, resource_version, policy_version, version, + requester_username FROM pending_approvals WHERE approval_id = ? """ @@ -355,6 +363,7 @@ def get_pending_approval_by_id(approval_id: str) -> dict | None: "resource_version": row[12], "policy_version": row[13], "version": row[14], + "requester_username": row[15], } @@ -379,6 +388,7 @@ def get_pending_list() -> list[dict]: "tool": tool_name, "args": r["parameters"], "role": r["requester"], + "requester_username": r["requester_username"], "risk": risk_level, "operation_id": r["operation_id"], }) diff --git a/backend/agent_orchestrator.py b/backend/agent_orchestrator.py index e820e05..c4f3a9d 100644 --- a/backend/agent_orchestrator.py +++ b/backend/agent_orchestrator.py @@ -404,6 +404,7 @@ def execute_tool_call( role: str, agent_id: str = "", *, + actor: str | None = None, operation_id: str | None = None, ) -> dict: """經 A 的 Tool Gateway 執行單一工具呼叫(治理鏈在此)。可離線測。 @@ -412,6 +413,7 @@ def execute_tool_call( tool_name, args or {}, role, + actor=actor, agent_name=agent_id, operation_id=operation_id, ) @@ -429,7 +431,7 @@ def execute_tool_call( # ════════════════════════════════════════════════════════════════════════ def run_agent(agent_id: str, task: str, role: str, model=None, api_key=None, api_base=None, max_turns: int = 6, - history: list | None = None) -> dict: + history: list | None = None, actor: str | None = None) -> dict: """ 讓指定專責 Agent 處理任務:LLM 推理 → 讀 tool_calls → 交 gateway 執行 → 回灌 → 再推理。 history:近期對話(sliding window,_trim_history 整理後夾在 system 與本輪任務之間)。 @@ -476,6 +478,7 @@ def run_agent(agent_id: str, task: str, role: str, args, role, agent_id=agent_id, + actor=actor, operation_id=f"agent:{tc.id}", ) tool_calls_made.append({"tool": tc.function.name, "args": args, @@ -514,7 +517,7 @@ def run_agent(agent_id: str, task: str, role: str, # ════════════════════════════════════════════════════════════════════════ def orchestrate(task: str, role: str = "admin", model=None, api_key=None, api_base=None, use_llm: bool = True, - history: list | None = None) -> dict: + history: list | None = None, actor: str | None = None) -> dict: """ 端到端入口:判斷派工 → 執行 → 彙整。 模型由 model(LiteLLM 格式字串)決定,預設 LLM_MODEL;api_key/api_base 可覆蓋。 @@ -541,7 +544,7 @@ def orchestrate(task: str, role: str = "admin", try: results = [run_agent(aid, task, role, model=model, api_key=api_key, api_base=api_base, - history=hist) + history=hist, actor=actor) for aid in chain] except Exception as e: name = (get_agent(routing["primary_agent"]) or {}).get("name_zh", routing["primary_agent"]) diff --git a/backend/database.py b/backend/database.py index fcaf3ef..6632e81 100644 --- a/backend/database.py +++ b/backend/database.py @@ -35,6 +35,16 @@ def _get_db_path(): DB_FILE = _get_db_path() +def is_demo_mode_enabled() -> bool: + """Return whether synthetic users/data may be seeded and disclosed.""" + return os.environ.get("ERP_DEMO_MODE", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + def _ensure_db_dir(): """確保資料庫所在目錄存在,方便企業指定任意路徑或匯入既有 .db""" d = os.path.dirname(DB_FILE) @@ -49,6 +59,20 @@ def init_db(): # 使用者與權限 c.execute('''CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password TEXT, role TEXT, name TEXT)''') + c.execute('''CREATE TABLE IF NOT EXISTS user_organizations ( + username TEXT PRIMARY KEY, + organization_id TEXT NOT NULL + )''') + c.execute('''CREATE TABLE IF NOT EXISTS organization_entitlements ( + organization_id TEXT NOT NULL, + entitlement_key TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + PRIMARY KEY (organization_id, entitlement_key) + )''') + c.execute('''CREATE TABLE IF NOT EXISTS app_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )''') # 進銷存:商品、倉庫 c.execute('''CREATE TABLE IF NOT EXISTS warehouses (warehouse_id TEXT PRIMARY KEY, name TEXT, address TEXT)''') c.execute('''CREATE TABLE IF NOT EXISTS inventory (product_id TEXT PRIMARY KEY, name TEXT, stock INTEGER, price INTEGER, cost REAL, reorder_point INTEGER, baseline_reorder_point INTEGER, daily_sales INTEGER, barcode TEXT, warehouse_id TEXT)''') @@ -143,6 +167,7 @@ def init_db(): tool_name TEXT, parameters TEXT, requester TEXT, + requester_username TEXT, status TEXT DEFAULT 'pending', approver TEXT, created_at TEXT, @@ -255,6 +280,7 @@ def init_db(): # A′:舊資料庫的審批列保留不動;新增欄位允許 NULL,只有新版操作受唯一鍵保護。 pending_approval_migrations = ( + ("requester_username", "TEXT"), ("operation_id", "TEXT"), ("payload_digest", "TEXT"), ("resource_version", "TEXT"), @@ -474,7 +500,7 @@ def init_db(): # Insert Mock Data if empty c.execute("SELECT COUNT(*) FROM users") - if c.fetchone()[0] == 0: + if is_demo_mode_enabled() and c.fetchone()[0] == 0: # N3:種子帳密以 salted hash 儲存(帳密仍為 admin/admin 等,僅儲存形式加密) from backend.passwords import hash_password c.execute("INSERT INTO users VALUES ('admin', ?, 'admin', '系統管理員')", (hash_password('admin'),)) @@ -530,8 +556,48 @@ def init_db(): c.execute("INSERT OR IGNORE INTO orders VALUES (?, ?, ?, ?, ?, ?, ?)", (f"ORD-HIST-{i}-01", "C001", "P001", 5 + i, "已出貨", past_date, 135000)) c.execute("INSERT OR IGNORE INTO orders VALUES (?, ?, ?, ?, ?, ?, ?)", (f"ORD-HIST-{i}-02", "C002", "P002", 20 - i, "已出貨", past_date, 12000)) + # Demo 授權只在明確啟用時執行一次。日常 init_db 不得把已撤銷的 + # membership/entitlement 自動補回,否則 live revocation 形同虛設。 + demo_seeded = c.execute( + "SELECT 1 FROM app_metadata WHERE key = 'tier_demo_seed_v1'" + ).fetchone() + if is_demo_mode_enabled() and demo_seeded is None: + from backend.passwords import hash_password + + demo_users = ( + ("viewer", "viewer", "risk_viewer", "風險觀測員"), + ("planner", "planner", "supply_planner", "供應鏈規劃員"), + ("approver", "approver", "procurement_approver", "採購核准主管"), + ) + for username, password, role, name in demo_users: + c.execute( + "INSERT OR IGNORE INTO users (username, password, role, name) " + "VALUES (?, ?, ?, ?)", + (username, hash_password(password), role, name), + ) + c.execute( + "INSERT OR IGNORE INTO user_organizations (username, organization_id) " + "SELECT username, 'demo-org' FROM users " + "WHERE username IN " + "('admin', 'hr1', 'wh1', 'sales1', 'viewer', 'planner', 'approver')" + ) + for entitlement_key in ( + "l1_monitor", + "l2_decision", + "l3_governed_action", + ): + c.execute( + "INSERT OR IGNORE INTO organization_entitlements " + "(organization_id, entitlement_key, enabled) " + "VALUES ('demo-org', ?, 1)", + (entitlement_key,), + ) + c.execute( + "INSERT INTO app_metadata (key, value) VALUES ('tier_demo_seed_v1', '1')" + ) + c.execute("SELECT COUNT(*) FROM suppliers") - if c.fetchone()[0] < 50: + if is_demo_mode_enabled() and c.fetchone()[0] < 50: import random regions = [ ("伊朗", "中東", 35.6892, 51.3890, ["Tehran Supply Co.", "Pars Logistics", "Persian Tech Components", "Iran Manufacturing"]), diff --git a/backend/erp_exchange.py b/backend/erp_exchange.py index 2cbdd77..7c1c3ff 100644 --- a/backend/erp_exchange.py +++ b/backend/erp_exchange.py @@ -21,6 +21,14 @@ from typing import Iterable from . import database +from .access_control import ( + ERP_EXCHANGE_EXPORT, + ERP_EXCHANGE_PROPOSE, + ERP_EXCHANGE_RECONCILE, + PROPOSAL_EVIDENCE_READ, + require_any_capability, + require_capability, +) MAX_IMPORT_BYTES = 1_000_000 @@ -64,7 +72,7 @@ "status", "note", ) -ERP_EXCHANGE_POLICY_VERSION = "external-po-sync-v1" +ERP_EXCHANGE_POLICY_VERSION = "external-po-sync-v2" _IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$") _SOURCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") @@ -213,7 +221,9 @@ def _load_known_ids(conn, table: str, id_column: str, ids: set[str]) -> dict: return {row[id_column]: row for row in rows} -def stage_purchase_order_rows(source_system: str, rows: Iterable[dict]) -> dict: +def stage_purchase_order_rows( + source_system: str, rows: Iterable[dict], *, actor: str +) -> dict: """Atomically stage a fully valid batch; invalid batches write nothing.""" source_system = normalize_source_system(source_system) normalized = [_normalize_row(row) for row in rows] @@ -232,6 +242,7 @@ def stage_purchase_order_rows(source_system: str, rows: Iterable[dict]) -> dict: summary = {"inserted": 0, "updated": 0, "unchanged": 0, "records": []} with database.transaction(immediate=True) as conn: conn.row_factory = sqlite3.Row + _require_exchange_actor(actor, conn, ERP_EXCHANGE_PROPOSE) suppliers = _load_known_ids( conn, "suppliers", "supplier_id", {row["supplier_id"] for row in normalized} ) @@ -494,7 +505,9 @@ def _with_sync_state(record: dict, *, conn=None) -> dict: return record -def list_exchange_records(source_system: str | None = None) -> list[dict]: +def list_exchange_records( + source_system: str | None = None, *, actor: str +) -> list[dict]: params: tuple = () where = "" if source_system is not None: @@ -503,6 +516,12 @@ def list_exchange_records(source_system: str | None = None) -> list[dict]: params = (source_system,) with sqlite3.connect(database.DB_FILE) as conn: conn.row_factory = sqlite3.Row + actor = _clean_text(actor, "actor", required=True, max_length=128) + require_any_capability( + actor, + {ERP_EXCHANGE_PROPOSE, PROPOSAL_EVIDENCE_READ}, + conn=conn, + ) rows = conn.execute( f""" SELECT e.*, s.country AS supplier_country, @@ -697,14 +716,11 @@ def sync_external_purchase_order( } -def _require_exchange_actor(actor: str, conn: sqlite3.Connection) -> str: +def _require_exchange_actor( + actor: str, conn: sqlite3.Connection, capability: str +) -> str: actor = _clean_text(actor, "actor", required=True, max_length=128) - role_row = conn.execute( - "SELECT role FROM users WHERE username = ?", (actor,) - ).fetchone() - role = role_row[0] if role_row else None - if role not in {"admin", "warehouse"}: - raise PermissionError("目前使用者沒有 ERP CSV 匯出或回執對帳權限") + require_capability(actor, capability, conn=conn) return actor @@ -888,6 +904,7 @@ def _validated_action_snapshot(row: sqlite3.Row) -> dict: args=parameters, resource_version=resource_version, policy_version=row["policy_version"], + requester_username=row["requester_username"], ) if not hmac.compare_digest(row["payload_digest"], expected_payload_digest): raise ValueError("核准內容摘要驗證失敗,拒絕匯出或對帳") @@ -913,11 +930,12 @@ def _approved_action_rows( ) with sqlite3.connect(database.DB_FILE) as conn: conn.row_factory = sqlite3.Row - _require_exchange_actor(actor, conn) + _require_exchange_actor(actor, conn, ERP_EXCHANGE_EXPORT) rows = conn.execute( """ SELECT p.operation_id, p.approval_id, p.payload_digest, p.parameters, p.resource_version, p.policy_version, + p.requester_username, local_receipt.result FROM pending_approvals p JOIN effect_receipts local_receipt @@ -1062,7 +1080,7 @@ def reconcile_receipt_csv(content: bytes, *, actor: str) -> dict: now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with database.transaction(immediate=True) as conn: conn.row_factory = sqlite3.Row - actor = _require_exchange_actor(actor, conn) + actor = _require_exchange_actor(actor, conn, ERP_EXCHANGE_RECONCILE) expected_key_id, secret = _receipt_verification_config() rows = _parse_receipt_rows(content) for row in rows: @@ -1076,6 +1094,7 @@ def reconcile_receipt_csv(content: bytes, *, actor: str) -> dict: """ SELECT p.operation_id, p.approval_id, p.payload_digest, p.parameters, p.resource_version, p.policy_version, + p.requester_username, p.status, local_receipt.result FROM pending_approvals p JOIN effect_receipts local_receipt @@ -1252,7 +1271,7 @@ def list_exchange_receipts( ) with sqlite3.connect(database.DB_FILE) as conn: conn.row_factory = sqlite3.Row - _require_exchange_actor(actor, conn) + _require_exchange_actor(actor, conn, ERP_EXCHANGE_RECONCILE) rows = conn.execute( """ SELECT r.*, diff --git a/backend/scheduler.py b/backend/scheduler.py index 19cafbf..bb61a7d 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -3,18 +3,46 @@ 背景任務排程器:定時自動抓取最新供應鏈新聞。 """ +import os import threading import time +from backend.access_control import RISK_WORKSPACE_WRITE, require_capability from backend.supply_chain_news import refresh_news_for_countries from backend.supply_chain_risk import get_suppliers_for_map # 記錄排程器是否已啟動,避免重複執行 _scheduler_started = False + +def refresh_supply_chain_news_once(*, actor: str) -> dict: + """Run one authorized refresh; the actor is checked live by the service.""" + actor = str(actor or "").strip() + if not actor: + raise PermissionError("背景新聞刷新需要 ERP_SCHEDULER_ACTOR") + require_capability(actor, RISK_WORKSPACE_WRITE) + + suppliers = get_suppliers_for_map() + countries = [] + if suppliers is not None and not suppliers.empty and "country" in suppliers.columns: + countries = suppliers["country"].dropna().unique().tolist() + countries = [str(country).strip() for country in countries if str(country).strip()] + if not countries: + countries = ["台灣", "日本", "美國", "南韓", "中國", "越南", "墨西哥"] + + return refresh_news_for_countries( + countries, + max_per_country=5, + actor=actor, + ) + def start_background_jobs(): global _scheduler_started if _scheduler_started: - return + return True + actor = os.getenv("ERP_SCHEDULER_ACTOR", "").strip() + if not actor: + print("Background scheduler disabled: ERP_SCHEDULER_ACTOR is not configured.") + return False _scheduler_started = True def run_jobs(): @@ -22,16 +50,7 @@ def run_jobs(): try: # 每天定時抓取一次新聞 (每 24 小時) time.sleep(10) # 系統啟動後延遲 10 秒再抓 - _suppliers = get_suppliers_for_map() - countries = [] - if _suppliers is not None and not _suppliers.empty and "country" in _suppliers.columns: - countries = _suppliers["country"].dropna().unique().tolist() - countries = [str(c).strip() for c in countries if str(c).strip()] - if not countries: - countries = ["台灣", "日本", "美國", "南韓", "中國", "越南", "墨西哥"] - - # 自動更新新聞 (不使用 API key,使用 RSS 備援) - refresh_news_for_countries(countries, api_key=None, max_per_country=5) + refresh_supply_chain_news_once(actor=actor) except Exception as e: print(f"Background scheduler error: {e}") @@ -41,3 +60,4 @@ def run_jobs(): # 設定為 Daemon Thread,讓主程式結束時能隨之關閉 job_thread = threading.Thread(target=run_jobs, daemon=True) job_thread.start() + return True diff --git a/backend/supply_chain_news.py b/backend/supply_chain_news.py index 68935a5..9634c59 100644 --- a/backend/supply_chain_news.py +++ b/backend/supply_chain_news.py @@ -10,7 +10,9 @@ from datetime import datetime, timedelta import email.utils from typing import List, Optional -from urllib.parse import quote_plus +from urllib.parse import quote_plus + +from .access_control import RISK_WORKSPACE_WRITE, require_capability # 國家名稱 → 英文搜尋用 / 雙碼(給 GNews API 用) COUNTRY_MAP = { @@ -243,10 +245,20 @@ def get_news_from_db( return [dict(r) for r in rows] -def refresh_news_for_countries(countries: List[str], gemini_api_key: Optional[str] = None, gnews_api_key: Optional[str] = None, max_per_country: int = 15, within_days: int = 7, gemini_model: str = "gemini-2.5-flash") -> dict: - """ - 為多個國家平行抓取新聞,並使用批量 AI 歸類以極大化提升效能。 - """ +def refresh_news_for_countries( + countries: List[str], + gemini_api_key: Optional[str] = None, + gnews_api_key: Optional[str] = None, + max_per_country: int = 15, + within_days: int = 7, + gemini_model: str = "gemini-2.5-flash", + *, + actor: str | None = None, +) -> dict: + """ + 為多個國家平行抓取新聞,並使用批量 AI 歸類以極大化提升效能。 + """ + require_capability(actor, RISK_WORKSPACE_WRITE) import concurrent.futures from .supply_chain_risk import batch_infer_affected_region_from_news from .llm_client import llm_available @@ -318,9 +330,11 @@ def fetch_job(c): 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) - except Exception: - pass + apply_heatmap_updates(updates, summary_text, actor=actor) + except PermissionError: + raise + except Exception: + pass return { "updated": total_saved, diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index 725a781..bcbf9a3 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -10,6 +10,12 @@ from datetime import datetime, timedelta from typing import List, Optional, Any from backend.database import DB_FILE, run_query +from backend.access_control import ( + ERP_POLICY_WRITE, + RISK_WHAT_IF_RUN, + RISK_WORKSPACE_WRITE, + require_capability, +) from backend.prompts import ( HEATMAP_AI_SUMMARY_PROMPT_V2, BATCH_INFER_WITH_PRECEDENTS_PROMPT, @@ -312,8 +318,11 @@ def get_risk_heatmap_data(): return out -def upsert_risk_heatmap(region_key, display_name, latitude, longitude, risk_pct, ai_summary=None): +def upsert_risk_heatmap( + region_key, display_name, latitude, longitude, risk_pct, ai_summary=None, *, actor=None +): """新增或更新一筆熱圖熱點。""" + require_capability(actor, RISK_WORKSPACE_WRITE) now = datetime.now().strftime("%Y-%m-%d %H:%M") conn = sqlite3.connect(DB_FILE) conn.execute( @@ -327,8 +336,9 @@ def upsert_risk_heatmap(region_key, display_name, latitude, longitude, risk_pct, conn.close() -def reset_risk_heatmap_to_initial(): +def reset_risk_heatmap_to_initial(*, actor=None): """清空 risk_heatmap 表,使熱圖還原為依供應商據點與風險事件計算的初始狀態。""" + require_capability(actor, RISK_WORKSPACE_WRITE) conn = sqlite3.connect(DB_FILE) conn.execute("DELETE FROM risk_heatmap") conn.commit() @@ -461,12 +471,13 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ return f"AI 摘要解析失敗:{e}", [], [] -def apply_heatmap_updates(updates, ai_summary=None): +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() @@ -500,7 +511,7 @@ def apply_heatmap_updates(updates, ai_summary=None): if country in countries: upsert_risk_heatmap( r["region_key"], r["display_name"], r["latitude"], r["longitude"], - float(risk_pct), summary_snippet, + float(risk_pct), summary_snippet, actor=actor, ) matched_count += 1 is_region_match = True @@ -525,7 +536,7 @@ def apply_heatmap_updates(updates, ai_summary=None): if matched: upsert_risk_heatmap( r["region_key"], r["display_name"], r["latitude"], r["longitude"], - float(risk_pct), summary_snippet, + float(risk_pct), summary_snippet, actor=actor, ) matched_count += 1 return matched_count @@ -730,8 +741,11 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): return out -def update_po_impact(po_id, estimated_delay_days=None, alternative_suggestion=None): +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)) @@ -815,8 +829,15 @@ def get_ai_alternative_suggestions(api_key="", impacted_list=None, hotspot_name= # ── 模擬情境分析 (What-If Simulation) ────────────────────────────────── -def what_if_simulation(api_key, user_question, model: str | None = "gemini-2.5-flash"): +def what_if_simulation( + api_key, + user_question, + model: str | None = "gemini-2.5-flash", + *, + actor=None, +): """依使用者情境問題,結合 ERP 供應商、未結案採購單、庫存安全天數,由 AI 回覆影響與建議。model 為 Gemini 模型 ID。""" + require_capability(actor, RISK_WHAT_IF_RUN) conn = sqlite3.connect(DB_FILE) suppliers = __pd_read("SELECT supplier_id, name, country, region FROM suppliers", conn) pos = __pd_read( @@ -915,8 +936,11 @@ def get_historical_event_precedents(): conn.close() -def add_risk_event(event_type, region, country, impact_days, description, news_id=None): +def add_risk_event( + event_type, region, country, impact_days, description, news_id=None, *, actor=None +): """新增或更新風險事件(如果該區域已存在事件則覆蓋)。""" + require_capability(actor, RISK_WORKSPACE_WRITE) conn = sqlite3.connect(DB_FILE) c = conn.cursor() @@ -950,8 +974,9 @@ def add_risk_event(event_type, region, country, impact_days, description, news_i return new_id -def delete_risk_event(event_id): +def delete_risk_event(event_id, *, actor=None): """刪除一筆風險事件。""" + require_capability(actor, RISK_WORKSPACE_WRITE) run_query("DELETE FROM supply_chain_events WHERE id = ?", (event_id,), fetch=False) @@ -1129,12 +1154,20 @@ def risk_weight(lvl): alerts.sort(key=lambda x: risk_weight(x['risk_level'])) return alerts -def increase_safety_stock_for_event(region: str, country: str, impact_days: int, multiplier: float = 1.0): +def increase_safety_stock_for_event( + region: str, + country: str, + impact_days: int, + multiplier: float = 1.0, + *, + actor=None, +): """ 針對受風險事件影響的地區,找出該區供應商提供的所有物料, 動態計算應調高的安全水位。公式:新水位 = 基準水位 + (日銷量 * 影響天數 * 倍率)。 基準水位會被保存在 baseline_reorder_point 中以供日後還原。 """ + require_capability(actor, ERP_POLICY_WRITE) conn = sqlite3.connect(DB_FILE) where, params = _get_expanded_region_where(region, country, prefix="s.") if not where: @@ -1184,16 +1217,18 @@ def increase_safety_stock_for_event(region: str, country: str, impact_days: int, conn.close() return updated_count -def restore_all_rop_to_baseline(): +def restore_all_rop_to_baseline(*, actor=None): """將所有產品的安全水位還原至基準值 (baseline_reorder_point)。""" + require_capability(actor, ERP_POLICY_WRITE) conn = sqlite3.connect(DB_FILE) # 僅針對有設定 baseline 的進行還原 conn.execute("UPDATE inventory SET reorder_point = baseline_reorder_point WHERE baseline_reorder_point IS NOT NULL") conn.commit() conn.close() return True -def update_reorder_point(product_id: str, new_reorder_point: int): +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.execute( "UPDATE inventory SET reorder_point = ? WHERE product_id = ?", @@ -1244,8 +1279,11 @@ def get_risk_factors_raw(): return df -def save_risk_factor(risk_type, risk_key, risk_score, weight, note=None): +def save_risk_factor( + risk_type, risk_key, risk_score, weight, note=None, *, actor=None +): """新增或更新一筆風險係數。""" + require_capability(actor, RISK_WORKSPACE_WRITE) now = datetime.now().strftime("%Y-%m-%d %H:%M") run_query( "INSERT OR REPLACE INTO esg_risk_factors (risk_type, risk_key, risk_score, weight, note, updated_at) VALUES (?,?,?,?,?,?)", @@ -1254,13 +1292,15 @@ def save_risk_factor(risk_type, risk_key, risk_score, weight, note=None): ) -def delete_risk_factor(factor_id): +def delete_risk_factor(factor_id, *, actor=None): """刪除一筆風險係數。""" + require_capability(actor, RISK_WORKSPACE_WRITE) run_query("DELETE FROM esg_risk_factors WHERE id = ?", (factor_id,), fetch=False) -def clear_all_risk_factors(): +def clear_all_risk_factors(*, actor=None): """清空全部風險係數(供重新實作或重置使用)。""" + require_capability(actor, RISK_WORKSPACE_WRITE) conn = sqlite3.connect(DB_FILE) conn.execute("DELETE FROM esg_risk_factors") conn.commit() @@ -1334,8 +1374,9 @@ def get_risk_ai_suggestions(api_key: str = "", news_context: str = "", region_su return text -def load_preset_risk_factors(): +def load_preset_risk_factors(*, actor=None): """載入預設風險係數範本(地區、事件類型、供應商類別)。""" + require_capability(actor, RISK_WORKSPACE_WRITE) conn = sqlite3.connect(DB_FILE) now = datetime.now().strftime("%Y-%m-%d %H:%M") presets = [ diff --git a/backend/tool_classification.py b/backend/tool_classification.py index f4cffbe..518d217 100644 --- a/backend/tool_classification.py +++ b/backend/tool_classification.py @@ -131,7 +131,7 @@ "sync_external_purchase_order": { "module": "procurement", "risk_level": "write", - "allowed_roles": ["admin", "warehouse"], + "allowed_roles": ["admin", "warehouse", "supply_planner"], "description": "將已驗證的外部 ERP 採購單版本送審並同步", }, diff --git a/backend/tool_gateway.py b/backend/tool_gateway.py index 8dc63bb..b5b4500 100644 --- a/backend/tool_gateway.py +++ b/backend/tool_gateway.py @@ -22,7 +22,7 @@ from backend.erp_exchange import ERP_EXCHANGE_POLICY_VERSION -PO_APPROVAL_POLICY_VERSION = "po-approval-v1" +PO_APPROVAL_POLICY_VERSION = "po-approval-v2" def canonical_payload_digest( @@ -31,6 +31,7 @@ def canonical_payload_digest( args: dict, resource_version: str, policy_version: str, + requester_username: str | None = None, ) -> str: """Return a stable digest of the exact action that a person will approve.""" target = (args or {}).get("po_id") @@ -45,6 +46,7 @@ def canonical_payload_digest( "parameters": args or {}, "resource_version": resource_version, "policy_version": policy_version, + "requester_username": str(requester_username or "").strip() or None, } canonical = json.dumps( payload, @@ -111,6 +113,7 @@ def _create_pending_approval( args: dict, role: str, *, + requester_username: str | None = None, operation_id: str | None = None, resource_version: str = "unspecified", policy_version: str = PO_APPROVAL_POLICY_VERSION, @@ -126,6 +129,7 @@ def _create_pending_approval( tool_name, args, role, + requester_username=requester_username, operation_id=operation_id, resource_version=resource_version, policy_version=policy_version, @@ -144,6 +148,7 @@ def _replay_protected_operation( expected_tool: str, expected_resource_version: str, expected_policy_version: str, + requester_username: str | None = None, ) -> "GatewayResult | None": """Return the durable state for an existing protected operation.""" from backend.database import run_query @@ -151,7 +156,7 @@ def _replay_protected_operation( rows = run_query( """ SELECT approval_id, tool_name, status, payload_digest, - resource_version, policy_version + resource_version, policy_version, requester_username FROM pending_approvals WHERE operation_id = ? """, @@ -167,6 +172,7 @@ def _replay_protected_operation( stored_digest, resource_version, policy_version, + stored_requester_username, ) = rows[0] if ( tool_name != expected_tool @@ -180,11 +186,25 @@ def _replay_protected_operation( approval_id=approval_id, ) + submitted_requester = str(requester_username or "").strip() + stored_requester = str(stored_requester_username or "").strip() + if ( + not submitted_requester + or not stored_requester + or not hmac.compare_digest(submitted_requester, stored_requester) + ): + return GatewayResult( + status="denied", + message="operation_id 已綁定其他提案人,拒絕重放。", + approval_id=approval_id, + ) + submitted_digest = canonical_payload_digest( tool_name=tool_name, args=args, resource_version=resource_version, policy_version=policy_version, + requester_username=stored_requester, ) if not hmac.compare_digest(stored_digest, submitted_digest): return GatewayResult( @@ -239,7 +259,7 @@ def _replay_protected_operation( def _replay_purchase_order_operation( - operation_id: str, args: dict + operation_id: str, args: dict, *, requester_username: str | None = None ) -> "GatewayResult | None": """Backward-compatible wrapper for protected PO creation replay.""" return _replay_protected_operation( @@ -248,6 +268,7 @@ def _replay_purchase_order_operation( expected_tool="create_purchase_order", expected_resource_version="absent", expected_policy_version=PO_APPROVAL_POLICY_VERSION, + requester_username=requester_username, ) @@ -257,6 +278,7 @@ def _replay_protected_operation_from_storage( *, expected_tool: str, expected_policy_version: str, + requester_username: str | None = None, ) -> "GatewayResult | None": """Replay before consulting mutable current resource state.""" from backend.database import run_query @@ -286,6 +308,7 @@ def _replay_protected_operation_from_storage( expected_tool=expected_tool, expected_resource_version=resource_version, expected_policy_version=expected_policy_version, + requester_username=requester_username, ) @@ -329,6 +352,7 @@ def call( role: str, agent_name: str = "", *, + actor: str | None = None, operation_id: str | None = None, ) -> GatewayResult: """ @@ -338,6 +362,7 @@ def call( tool_name : 工具名稱(對應 tools_mapping 的 key) args : 工具參數(dict) role : 呼叫者角色(admin / warehouse / sales / hr) + actor : 伺服器端登入帳號;所有 write/dangerous 操作必填 agent_name : 呼叫者 Agent ID(選填)。填入時額外檢查 Agent 白名單。 例如:inventory_agent、sales_agent、orchestrator @@ -354,6 +379,10 @@ def call( message="工具參數不得包含保留的內部欄位。", ) args = dict(args) + protected_po = tool_name in { + "create_purchase_order", + "sync_external_purchase_order", + } # Step 1:確認工具存在 if not registry.tool_exists(tool_name): @@ -391,9 +420,36 @@ def call( _write_log(tool_name, args, role, msg, success=False) return GatewayResult(status="denied", message=msg) + if protected_po: + from backend.access_control import ( + ERP_EXCHANGE_PROPOSE, + load_principal, + ) + + principal = load_principal(actor or "") + if ( + principal is None + or principal.role != role + or not principal.can(ERP_EXCHANGE_PROPOSE) + ): + msg = "受保護採購操作需要與登入身分一致的提案權限。" + _write_log(tool_name, args, role, msg, success=False) + return GatewayResult(status="denied", message=msg) + actor = principal.username + # Step 5:依風險等級決定行為 risk_level = registry.get_risk_level(tool_name) + if risk_level in {"write", "dangerous"} and not protected_po: + from backend.access_control import load_principal + + principal = load_principal(actor or "") + if principal is None or principal.role != role: + msg = "寫入操作需要與登入身分一致的可驗證提案人。" + _write_log(tool_name, args, role, msg, success=False) + return GatewayResult(status="denied", message=msg) + actor = principal.username + if risk_level in ("read_only", "suggestion"): # 直接執行 return self._execute(tool_name, args, role) @@ -403,10 +459,6 @@ def call( try: resource_version = "unspecified" policy_version = PO_APPROVAL_POLICY_VERSION - protected_po = tool_name in { - "create_purchase_order", - "sync_external_purchase_order", - } if protected_po: operation_id = str(operation_id or "").strip() if not operation_id: @@ -431,6 +483,7 @@ def call( args, expected_tool=tool_name, expected_policy_version=ERP_EXCHANGE_POLICY_VERSION, + requester_username=actor, ) if replay is not None: return replay @@ -457,6 +510,7 @@ def call( expected_tool=tool_name, expected_resource_version=resource_version, expected_policy_version=policy_version, + requester_username=actor, ) if replay is not None: return replay @@ -474,6 +528,7 @@ def call( tool_name, args, role, + requester_username=actor, operation_id=operation_id, resource_version=resource_version, policy_version=policy_version, @@ -485,6 +540,7 @@ def call( expected_tool=tool_name, expected_resource_version=resource_version, expected_policy_version=policy_version, + requester_username=actor, ) if replay is None: raise RuntimeError("審批單建立後無法讀回。") @@ -508,6 +564,7 @@ def call( tool_name, args, role, + requester_username=actor, operation_id=operation_id, ) except Exception as exc: @@ -566,6 +623,39 @@ def approve_action(self, approval_id: str, approver: str) -> GatewayResult: approver, expected_tool="sync_external_purchase_order", ) + + from backend.access_control import ( + GLOBAL_APPROVAL_DECIDE, + load_principal, + ) + + approver_principal = load_principal(approver) + if ( + approver_principal is None + or not approver_principal.can(GLOBAL_APPROVAL_DECIDE) + ): + return GatewayResult( + status="denied", + message="核准者沒有處理全域審批項目的權限。", + ) + approver_username = approver_principal.username + + requester_username = str(item.get("requester_username") or "").strip() + if not requester_username: + return GatewayResult( + status="denied", + message=( + "此舊版審批缺少可驗證的提案人,不能核准;" + "請拒絕後由已登入使用者重新送審。" + ), + approval_id=approval_id, + ) + if hmac.compare_digest(requester_username, approver_username): + return GatewayResult( + status="denied", + message="提案人不得核准自己的提案。", + approval_id=approval_id, + ) if item["status"] != "pending": return GatewayResult(status="error", message=f"該審批項目的狀態為 {item['status']},無法重複核准。") @@ -574,24 +664,103 @@ def approve_action(self, approval_id: str, approver: str) -> GatewayResult: args = item["parameters"] role = item["requester"] - # 真正執行操作 - result_gateway = self._execute(tool_name, args, role) - if result_gateway.status != "ok": - return result_gateway - - # Only mark approved after the underlying write succeeds. - transitioned = transition_approval_status( + # 先用 CAS 取得唯一執行權,避免雙擊或兩個工作階段重複執行。 + # 舊版工具尚未全面支援共用 DB connection;若效果完成後終態落庫失敗, + # 狀態會保留 executing 並要求人工對帳,不會自動重試。 + claimed = transition_approval_status( approval_id, expected_status="pending", expected_version=item["version"], - new_status="approved", - approver=approver, + new_status="executing", + approver=approver_username, ) - if not transitioned: + if not claimed: return GatewayResult( status="error", - message="審批狀態已被其他操作更新,無法重複核准。", + message="審批狀態已被其他操作更新,未取得執行權。", + approval_id=approval_id, ) + + result_gateway = self._execute(tool_name, args, role) + if result_gateway.status != "ok": + transition_approval_status( + approval_id, + expected_status="executing", + expected_version=item["version"] + 1, + new_status="failed", + approver=approver_username, + reason=result_gateway.message, + ) + result_gateway.approval_id = approval_id + return result_gateway + + from backend.database import transaction + + receipt_operation_id = str(item.get("operation_id") or "").strip() + if not receipt_operation_id: + receipt_operation_id = f"generic-approval:{approval_id}" + receipt_digest = str(item.get("payload_digest") or "").strip() + if not receipt_digest: + receipt_digest = canonical_payload_digest( + tool_name=tool_name, + args=args, + resource_version=str(item.get("resource_version") or "unspecified"), + policy_version=str(item.get("policy_version") or "generic-approval-v1"), + requester_username=requester_username, + ) + try: + result_json = json.dumps( + result_gateway.data, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + default=str, + ) + with transaction(immediate=True) as conn: + conn.execute( + """ + INSERT INTO effect_receipts ( + operation_id, approval_id, payload_digest, result, + created_at + ) VALUES (?, ?, ?, ?, ?) + """, + ( + receipt_operation_id, + approval_id, + receipt_digest, + result_json, + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + transitioned = transition_approval_status( + approval_id, + expected_status="executing", + expected_version=item["version"] + 1, + new_status="approved", + approver=approver_username, + conn=conn, + ) + if not transitioned: + raise RuntimeError("無法寫入審批終態。") + except Exception as exc: + msg = ( + "工具效果已執行,但執行收據或審批終態寫入失敗;" + f"請人工對帳,系統不會自動重試:{exc}" + ) + _write_log( + tool_name, + {"approval_id": approval_id}, + approver_username, + msg, + success=False, + ) + return GatewayResult( + status="error", + message=msg, + approval_id=approval_id, + ) + result_gateway.approval_id = approval_id return result_gateway def _approve_purchase_order( @@ -616,13 +785,14 @@ def _approve_purchase_order( ) failure_args = {"approval_id": approval_id} + approver_username = str(approver or "").strip() try: with transaction(immediate=True) as conn: conn.row_factory = sqlite3.Row item = conn.execute( """ SELECT approval_id, tool_name, parameters, requester, status, - approver, operation_id, payload_digest, + requester_username, approver, operation_id, payload_digest, resource_version, policy_version, version FROM pending_approvals WHERE approval_id = ? @@ -639,14 +809,33 @@ def _approve_purchase_order( status="error", message="審批單工具類型不相符。" ) - actor = conn.execute( - "SELECT role FROM users WHERE username = ?", - (approver,), - ).fetchone() - if actor is None or actor["role"] != "admin": + from backend.access_control import ( + APPROVAL_DECIDE, + load_principal, + ) + + approver_principal = load_principal(approver, conn=conn) + if ( + approver_principal is None + or not approver_principal.can(APPROVAL_DECIDE) + ): + return GatewayResult( + status="denied", + message="核准者目前不具審批權限,操作未執行。", + ) + approver_username = approver_principal.username + requester_username = str( + item["requester_username"] or "" + ).strip() + if not requester_username: return GatewayResult( status="denied", - message="核准者目前不具管理員權限,操作未執行。", + message="審批單缺少可驗證的提案人,操作未執行。", + ) + if hmac.compare_digest(requester_username, approver_username): + return GatewayResult( + status="denied", + message="提案人不得核准自己的操作。", ) operation_id = item["operation_id"] @@ -696,6 +885,7 @@ def _approve_purchase_order( args=args, resource_version=resource_version, policy_version=policy_version, + requester_username=requester_username, ) if not hmac.compare_digest(stored_digest, computed_digest): return GatewayResult( @@ -767,7 +957,7 @@ def _approve_purchase_order( expected_status="pending", expected_version=start_version, new_status="executing", - approver=approver, + approver=approver_username, conn=conn, approval_context=_PROTECTED_APPROVAL_CONTEXT, ): @@ -823,7 +1013,7 @@ def _approve_purchase_order( expected_status="executing", expected_version=start_version + 1, new_status="approved", - approver=approver, + approver=approver_username, conn=conn, approval_context=_PROTECTED_APPROVAL_CONTEXT, ): @@ -834,7 +1024,7 @@ def _approve_purchase_order( _write_log( expected_tool, failure_args, - approver, + approver_username, msg, success=False, ) @@ -860,12 +1050,40 @@ def reject_action(self, approval_id: str, reason: str, approver: str) -> Gateway }: return self._reject_purchase_order(approval_id, reason, approver) + from backend.access_control import ( + GLOBAL_APPROVAL_DECIDE, + load_principal, + ) + + approver_principal = load_principal(approver) + if ( + approver_principal is None + or not approver_principal.can(GLOBAL_APPROVAL_DECIDE) + ): + return GatewayResult( + status="denied", + message="拒絕者沒有處理全域審批項目的權限。", + ) + approver_username = approver_principal.username + if item["status"] != "pending": return GatewayResult(status="error", message=f"該審批項目的狀態為 {item['status']},無法重複拒絕。") tool_name = item["tool_name"] args = item["parameters"] role = item["requester"] + + requester_username = str(item.get("requester_username") or "").strip() + if requester_username and hmac.compare_digest( + requester_username, approver_username + ): + return GatewayResult( + status="denied", + message="提案人不得拒絕自己的提案。", + approval_id=approval_id, + ) + if not requester_username: + reason = f"[legacy originator unavailable] {reason}" # 將狀態更新為 rejected 並存入拒絕原因 if not transition_approval_status( @@ -873,7 +1091,7 @@ def reject_action(self, approval_id: str, reason: str, approver: str) -> Gateway expected_status="pending", expected_version=item["version"], new_status="rejected", - approver=approver, + approver=approver_username, reason=reason, ): return GatewayResult( @@ -881,7 +1099,7 @@ def reject_action(self, approval_id: str, reason: str, approver: str) -> Gateway ) # 記錄作廢日誌,包含原因 - msg = f"操作遭管理者「{approver}」拒絕,原因:{reason}。該工具執行已作廢。" + msg = f"操作遭管理者「{approver_username}」拒絕,原因:{reason}。該工具執行已作廢。" write_action_log(tool_name, args, role, msg, success=False) return GatewayResult(status="denied", message=msg) @@ -896,11 +1114,13 @@ def _reject_purchase_order( ) from backend.database import transaction + approver_username = str(approver or "").strip() try: with transaction(immediate=True) as conn: row = conn.execute( """ - SELECT tool_name, parameters, requester, status, version + SELECT tool_name, parameters, requester, status, version, + requester_username FROM pending_approvals WHERE approval_id = ? """, (approval_id,), @@ -917,32 +1137,52 @@ def _reject_purchase_order( return GatewayResult( status="error", message="審批單工具類型不相符。" ) - actor = conn.execute( - "SELECT role FROM users WHERE username = ?", (approver,) - ).fetchone() - if actor is None or actor[0] != "admin": + from backend.access_control import ( + APPROVAL_DECIDE, + load_principal, + ) + + approver_principal = load_principal(approver, conn=conn) + if ( + approver_principal is None + or not approver_principal.can(APPROVAL_DECIDE) + ): return GatewayResult( - status="denied", message="拒絕者目前不具管理員權限。" + status="denied", message="拒絕者目前不具審批權限。" + ) + approver_username = approver_principal.username + requester_username = str(row[5] or "").strip() + legacy_originator_missing = not requester_username + if requester_username and hmac.compare_digest( + requester_username, approver_username + ): + return GatewayResult( + status="denied", message="提案人不得拒絕自己的操作。" ) if row[3] != "pending": return GatewayResult( status="error", message=f"該審批項目的狀態為 {row[3]},無法拒絕。", ) + recorded_reason = ( + f"[legacy originator unavailable] {reason}" + if legacy_originator_missing + else reason + ) if not transition_approval_status( approval_id, expected_status="pending", expected_version=row[4], new_status="rejected", - approver=approver, - reason=reason, + approver=approver_username, + reason=recorded_reason, conn=conn, approval_context=_PROTECTED_APPROVAL_CONTEXT, ): raise RuntimeError("審批狀態競態,拒絕未生效。") args = json.loads(row[1]) msg = ( - f"操作遭管理者「{approver}」拒絕,原因:{reason}。" + f"操作遭管理者「{approver_username}」拒絕,原因:{recorded_reason}。" "該工具執行已作廢。" ) write_action_log( diff --git a/day1.md b/day1.md index 9cd827a..253044f 100644 --- a/day1.md +++ b/day1.md @@ -5,15 +5,15 @@ ## 變更內容 ### 1. 資料庫結構調整 (Database Schema) -修改了 [database.py](file:///Users/huangsiqi/.gemini/antigravity/worktrees/AI-Risk-Based-Inventory-ERP/plasma-venus-dips-18h25/backend/database.py),在 `init_db()` 中宣告並建立以下兩張資料表: +修改了 [database.py](backend/database.py),在 `init_db()` 中宣告並建立以下兩張資料表: - `agent_action_logs`:記錄工具名稱、參數、呼叫者、執行結果、成功與否、時間戳記。 - `pending_approvals`:記錄審批單 ID、工具名稱、參數、申請人、目前狀態(預設為 pending)、核准者、建立時間、更新時間。 ### 2. 資料庫遷移腳本 (Migration Script) -建立了 [migration_day1_logs.py](file:///Users/huangsiqi/.gemini/antigravity/worktrees/AI-Risk-Based-Inventory-ERP/plasma-venus-dips-18h25/scripts/migration_day1_logs.py)。執行此腳本會自動完成新資料表的建立與驗證,確保其他團隊成員也能建出一樣的表格。 +建立了 [migration_day1_logs.py](scripts/migration_day1_logs.py)。執行此腳本會自動完成新資料表的建立與驗證,確保其他團隊成員也能建出一樣的表格。 ### 3. 日誌與審批模組 (Log & Approval Module) -建立了 [agent_logger.py](file:///Users/huangsiqi/.gemini/antigravity/worktrees/AI-Risk-Based-Inventory-ERP/plasma-venus-dips-18h25/backend/agent_logger.py),實作下列核心函式: +建立了 [agent_logger.py](backend/agent_logger.py),實作下列核心函式: - `write_action_log`:將工具呼叫紀錄寫入資料庫。 - `create_pending_approval`:建立待審批項目並回傳審批單 ID。 - `get_action_logs`:提供給 Dashboard 讀取近期日誌的 API。 @@ -21,10 +21,10 @@ - `update_approval_status`:提供給 Dashboard 管理者更新審批單狀態(如核准/拒絕)的 API。 ### 4. 工具網關整合 (Tool Gateway Integration) -修改了 [tool_gateway.py](file:///Users/huangsiqi/.gemini/antigravity/worktrees/AI-Risk-Based-Inventory-ERP/plasma-venus-dips-18h25/backend/tool_gateway.py),將原本 Console 版的 placeholder 程式碼替換為真正寫入資料庫的 `write_action_log` 與 `create_pending_approval` 呼叫。 +修改了 [tool_gateway.py](backend/tool_gateway.py),將原本 Console 版的 placeholder 程式碼替換為真正寫入資料庫的 `write_action_log` 與 `create_pending_approval` 呼叫。 ### 5. 功能驗證腳本 (Verification Script) -建立了 [verify_day1_logs.py](file:///Users/huangsiqi/.gemini/antigravity/worktrees/AI-Risk-Based-Inventory-ERP/plasma-venus-dips-18h25/scripts/verify_day1_logs.py)。此驗證模擬了: +建立了 [verify_day1_logs.py](scripts/verify_day1_logs.py)。此驗證模擬了: - 呼叫唯讀工具時,系統能正確記錄日誌。 - 呼叫寫入型工具時,系統能成功攔截並將審批單存入 DB。 - 修改審批單狀態的功能可正常運作。 diff --git a/docs/generic_approval_reconciliation.md b/docs/generic_approval_reconciliation.md new file mode 100644 index 0000000..139fb54 --- /dev/null +++ b/docs/generic_approval_reconciliation.md @@ -0,0 +1,42 @@ +# Generic approval reconciliation runbook + +This runbook applies only to legacy generic `write`/`dangerous` tools. Protected purchase-order and ERP-exchange operations use a separate atomic transaction path. + +## Trigger + +Investigate when a generic approval remains `executing` after the request has finished and no matching row exists in `effect_receipts`. This means the process may have stopped after the tool effect but before the receipt and final approval state were committed. + +## Safety rule + +Never replay or automatically retry an `executing` approval. The current design is live at-most-once: retrying could duplicate an effect that already happened. + +## Procedure + +1. Stop the Web and LINE write entry points for the affected operation. Keep the original approval unchanged; do not edit its status or checksum directly. +2. Stop the application, make a filesystem copy of the SQLite database, and record the approval ID, tool name, canonical parameters, requester, approver, operation ID, and last update time. +3. Check for a local receipt with the approval ID: + + ```sql + SELECT p.approval_id, p.tool_name, p.parameters, p.requester_username, + p.approver, p.status, p.operation_id, p.updated_at, + r.receipt_id, r.result, r.created_at + FROM pending_approvals AS p + LEFT JOIN effect_receipts AS r ON r.approval_id = p.approval_id + WHERE p.approval_id = ?; + ``` + +4. Inspect the authoritative business table and its domain history using the exact approved parameters. Action logs are supporting evidence only; absence of a log does not prove that no effect occurred. +5. Classify the incident: + + - **Effect proven applied:** do not replay. Reconcile the business record, and use a separately proposed and approved compensating action if correction is required. + - **Effect proven absent:** do not reuse the old approval. After independent review, create a new proposal with a new operation ID. + - **Outcome uncertain:** keep writes paused for the affected resource and restore from the verified pre-operation backup or escalate to a domain owner. Do not guess. + +6. Keep the original row in `executing` as a quarantine marker. Record the evidence, operator, decision, and any new proposal/compensation ID in the incident record outside the approval table. Resume writes only after a second person verifies the reconciliation. + +## Exit criteria + +- The business state is verified against the approved parameters. +- No automatic replay occurred. +- Any compensation or replacement proposal has its own approval and audit trail. +- A second person reviewed the reconciliation evidence. diff --git a/frontend/access_navigation.py b/frontend/access_navigation.py new file mode 100644 index 0000000..95cfcf6 --- /dev/null +++ b/frontend/access_navigation.py @@ -0,0 +1,176 @@ +"""Pure navigation contracts derived from a live authorization principal.""" + +from __future__ import annotations + +from collections.abc import MutableMapping + +from backend.access_control import ( + APPROVAL_QUEUE_READ, + ERP_EXCHANGE_EXPORT, + ERP_EXCHANGE_PROPOSE, + ERP_EXCHANGE_RECONCILE, + PROPOSAL_EVIDENCE_READ, + RISK_ANALYSIS_READ, + RISK_OVERVIEW_READ, + RISK_WHAT_IF_RUN, + AccessContext, +) + + +FULL_MENU = { + "📊 營運分析看板": [], + "🤖 AI 智能助理": ["對話介面", "LINE 客服記錄", "Agent Dashboard"], + "📦 進銷存": ["商品管理", "庫存數量", "入庫/出庫", "條碼掃描", "倉庫管理"], + "🛒 採購管理": ["採購單", "供應商管理", "進貨成本", "採購歷史", "ERP CSV 交換"], + "💰 銷售管理": ["報價單", "銷售單", "客戶消費視覺化", "客戶個人消費分析", "收款管理"], + "📒 財務會計": ["應收/應付", "總帳", "成本分析", "財報"], + "👥 人資": ["員工資料", "薪資", "出勤"], + "🌿 碳排放管理": ["碳排放總覽", "碳足跡追蹤", "減量目標", "年度碳目標分析", "ESG 報告", "供應商風險與碳排"], + "🌱 供應鏈與風險": [], +} + +_LEGACY_ROLE_MENUS = { + "admin": tuple(FULL_MENU), + "warehouse": ( + "📊 營運分析看板", + "🤖 AI 智能助理", + "📦 進銷存", + "🛒 採購管理", + "🌱 供應鏈與風險", + ), + "sales": ("📊 營運分析看板", "🤖 AI 智能助理", "💰 銷售管理", "🌿 碳排放管理"), + "hr": ("📊 營運分析看板", "🤖 AI 智能助理", "👥 人資"), +} + +ROLE_NAMES = { + "admin": "系統管理員", + "warehouse": "倉管部", + "hr": "人資部", + "sales": "業務部", + "risk_viewer": "風險觀測員", + "supply_planner": "供應鏈規劃員", + "procurement_approver": "採購核准主管", +} + + +def risk_sections(principal: AccessContext) -> tuple[str, ...]: + sections: list[str] = [] + if principal.can(RISK_OVERVIEW_READ): + sections.append("overview") + if principal.can(RISK_ANALYSIS_READ): + sections.append("analysis") + if principal.can(RISK_WHAT_IF_RUN): + sections.append("what_if") + return tuple(sections) + + +def exchange_sections(principal: AccessContext) -> tuple[str, ...]: + sections: list[str] = [] + if principal.can(ERP_EXCHANGE_PROPOSE): + sections.append("proposal") + if principal.can(ERP_EXCHANGE_EXPORT): + sections.append("export") + if principal.can(ERP_EXCHANGE_RECONCILE): + sections.append("reconcile") + return tuple(sections) + + +def dashboard_mode(principal: AccessContext) -> str: + if not principal.can(APPROVAL_QUEUE_READ): + return "none" + if principal.role in {"admin", "warehouse"}: + return "full" + if principal.can(PROPOSAL_EVIDENCE_READ): + return "approvals" + return "none" + + +def effective_product_levels(principal: AccessContext) -> tuple[str, ...]: + levels: list[str] = [] + if principal.can(RISK_OVERVIEW_READ): + levels.append("L1") + if principal.can(RISK_ANALYSIS_READ) or principal.can(ERP_EXCHANGE_PROPOSE): + levels.append("L2") + if ( + principal.can(APPROVAL_QUEUE_READ) + or principal.can(ERP_EXCHANGE_EXPORT) + or principal.can(ERP_EXCHANGE_RECONCILE) + ): + levels.append("L3") + return tuple(levels) + + +def build_menu_structure(principal: AccessContext) -> dict[str, list[str]]: + if principal.role == "risk_viewer": + return {"🌱 供應鏈與風險": []} if risk_sections(principal) else {} + if principal.role == "supply_planner": + menu: dict[str, list[str]] = {} + if risk_sections(principal): + menu["🌱 供應鏈與風險"] = [] + if exchange_sections(principal): + menu["🛒 採購管理"] = ["ERP CSV 交換"] + return menu + if principal.role == "procurement_approver": + menu = {} + if risk_sections(principal): + menu["🌱 供應鏈與風險"] = [] + if dashboard_mode(principal) == "approvals": + menu["🤖 AI 智能助理"] = ["Agent Dashboard"] + if exchange_sections(principal): + menu["🛒 採購管理"] = ["ERP CSV 交換"] + return menu + + allowed = _LEGACY_ROLE_MENUS.get(principal.role, ()) + menu = {item: list(FULL_MENU[item]) for item in allowed} + if not risk_sections(principal): + menu.pop("🌱 供應鏈與風險", None) + if not exchange_sections(principal) and "🛒 採購管理" in menu: + menu["🛒 採購管理"] = [ + item for item in menu["🛒 採購管理"] if item != "ERP CSV 交換" + ] + if dashboard_mode(principal) == "none" and "🤖 AI 智能助理" in menu: + menu["🤖 AI 智能助理"] = [ + item for item in menu["🤖 AI 智能助理"] if item != "Agent Dashboard" + ] + return menu + + +def clear_identity_session_state(state: MutableMapping[str, object]) -> None: + """Remove authentication identity and page state without touching API config.""" + for key in ("username", "role", "name"): + state.pop(key, None) + for key in list(state): + if ( + key.startswith("erp_csv_") + or key.startswith("po_") + or key.startswith("radio_") + ): + state.pop(key, None) + state["logged_in"] = False + state["menu_selection"] = None + state["sub_menu"] = None + if "messages" in state: + state["messages"] = [] + + +def normalize_navigation_state( + state: MutableMapping[str, object], menu: dict[str, list[str]] +) -> None: + """Replace stale main/submenu values after a live role or entitlement change.""" + if not menu: + state["menu_selection"] = None + state["sub_menu"] = None + return + + selected = state.get("menu_selection") + if selected not in menu: + selected = next(iter(menu)) + state["menu_selection"] = selected + + submenus = menu[selected] + selected_submenu = state.get("sub_menu") + if selected_submenu not in submenus: + selected_submenu = submenus[0] if submenus else None + state["sub_menu"] = selected_submenu + if submenus: + state[f"radio_{selected}"] = selected_submenu diff --git a/frontend/components/risk_dashboard.py b/frontend/components/risk_dashboard.py index a6de6ac..1731a53 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -1,6 +1,7 @@ import streamlit as st import re import pandas as pd +from backend.access_control import ERP_POLICY_WRITE, has_capability from backend.supply_chain_news import get_news_from_db, refresh_news_for_countries from backend.supply_chain_risk import ( translate_to_chinese_traditional, @@ -22,6 +23,12 @@ get_risk_heatmap_data, ) + +def can_write_erp_policy(actor: str) -> bool: + """Resolve ERP policy write visibility from the live principal.""" + return has_capability(actor, ERP_POLICY_WRITE) + + def _auto_refresh_heatmap_ai(api_key, gemini_model): from backend.supply_chain_risk import get_heatmap_ai_summary from datetime import datetime @@ -41,7 +48,13 @@ def _auto_refresh_heatmap_ai(api_key, gemini_model): if "heatmap_needs_refresh" in st.session_state: del st.session_state["heatmap_needs_refresh"] -def render_intelligence_gathering(api_key: str = "", gnews_api_key: str = "", gemini_model: str = "gemini-2.5-flash"): +def render_intelligence_gathering( + api_key: str = "", + gnews_api_key: str = "", + gemini_model: str = "gemini-2.5-flash", + *, + actor: str, +): """ 第一階段:🔍 即時全球情報 (Intelligence) 職責:抓取全球新聞、AI 自動歸類與風險等級評估、登錄為正式風險事件。 @@ -100,7 +113,8 @@ def render_intelligence_gathering(api_key: str = "", gnews_api_key: str = "", ge gnews_api_key=gnews_api_key or None, max_per_country=8, within_days=within_days, - gemini_model=gemini_model + gemini_model=gemini_model, + actor=actor, ) fetched = res.get("fetched_count", 0) filtered = res.get("filtered_count", 0) @@ -187,7 +201,8 @@ def render_intelligence_gathering(api_key: str = "", gnews_api_key: str = "", ge country=n.get("country") or "", impact_days=n.get("estimated_delay") or 7, description=f"【一鍵批量登錄】{n.get('title')}", - news_id=n.get('id') + news_id=n.get('id'), + actor=actor, ) bulk_count += 1 st.session_state["heatmap_needs_refresh"] = True @@ -220,7 +235,15 @@ def render_intelligence_gathering(api_key: str = "", gnews_api_key: str = "", ge def_delay = chosen.get("estimated_delay") or 0 if st.button(f"🚀 一鍵登錄:{def_etype}風險 (預估延遲 {def_delay} 天)", type="primary", use_container_width=True): - add_risk_event(def_etype, def_region, def_country, def_delay, f"【自動登錄】{chosen.get('title')}", news_id=chosen.get('id')) + add_risk_event( + def_etype, + def_region, + def_country, + def_delay, + f"【自動登錄】{chosen.get('title')}", + news_id=chosen.get('id'), + actor=actor, + ) st.session_state["heatmap_needs_refresh"] = True st.success("事件已登錄!記得至地圖區更新 AI 摘要。") st.rerun() @@ -238,12 +261,20 @@ def render_intelligence_gathering(api_key: str = "", gnews_api_key: str = "", ge m_impact = st.number_input("預估延遲天數", min_value=0, value=0) m_desc = st.text_area("事件說明") if st.form_submit_button("新增事件"): - add_risk_event(m_etype, m_region, m_country, m_impact, m_desc) + add_risk_event( + m_etype, m_region, m_country, m_impact, m_desc, actor=actor + ) st.session_state["heatmap_needs_refresh"] = True st.success("手動事件已登錄!記得至地圖區更新 AI 摘要。") st.rerun() -def render_response_execution(api_key: str = "", gnews_api_key: str = "", gemini_model: str = "gemini-2.5-flash"): +def render_response_execution( + api_key: str = "", + gnews_api_key: str = "", + gemini_model: str = "gemini-2.5-flash", + *, + actor: str, +): """ 第二階段:🚨 執行應變與衝擊分析 (Action) 職責:針對已登錄的風險事件,快速分析其對供應商、庫存、銷售訂單的實際衝擊。 @@ -340,7 +371,7 @@ def render_response_execution(api_key: str = "", gnews_api_key: str = "", gemini st.warning("確認移除此事件?此操作無法復原。") ev_id = int(active_ev["id"]) if st.button("🔴 確認點擊刪除", key=f"del_btn_{ev_id}", type="primary", use_container_width=True): - delete_risk_event(ev_id) + delete_risk_event(ev_id, actor=actor) st.success("事件已從清單中移除。") st.rerun() @@ -354,22 +385,35 @@ def get_ai_safety_multiplier(etype): if stock_alerts: etype = active_ev.get('event_type', '其他') st.caption(f"針對此 **{etype}** 事件造成的預計 **{impact_days} 天** 延期,系統建議動態調整受影響物料的安全水位。") - - ai_mult = get_ai_safety_multiplier(etype) - btn_label = f"🤖 AI 建議:一鍵動態調高受影響物料安全水位 (+{impact_days}天需求 ⚡)" - - if st.button(btn_label, key="adj_stock_btn_dynamic", type="primary"): - from backend.supply_chain_risk import increase_safety_stock_for_event - cnt = increase_safety_stock_for_event(region, country, impact_days=impact_days, multiplier=ai_mult) - st.success(f"✅ 已依據預期延遲與日銷量,完成 {cnt} 項物料的安全水位動態調整!") - st.rerun() - - with st.popover("🔄 重設風險緩衝 (Restore Baseline)", use_container_width=True): - st.warning("這將把所有物料的安全水位恢復至原始基準值 (Baseline)。") - if st.button("🔴 確認還原所有基準水位", key="restore_baseline_btn"): - restore_all_rop_to_baseline() - st.success("已還原所有物料至基準水位。") + + can_write_policy = can_write_erp_policy(actor) + if can_write_policy: + ai_mult = get_ai_safety_multiplier(etype) + btn_label = f"🤖 AI 建議:一鍵動態調高受影響物料安全水位 (+{impact_days}天需求 ⚡)" + + if st.button(btn_label, key="adj_stock_btn_dynamic", type="primary"): + from backend.supply_chain_risk import increase_safety_stock_for_event + cnt = increase_safety_stock_for_event( + region, + country, + impact_days=impact_days, + multiplier=ai_mult, + actor=actor, + ) + st.success(f"✅ 已依據預期延遲與日銷量,完成 {cnt} 項物料的安全水位動態調整!") st.rerun() + + with st.popover("🔄 重設風險緩衝 (Restore Baseline)", use_container_width=True): + st.warning("這將把所有物料的安全水位恢復至原始基準值 (Baseline)。") + if st.button("🔴 確認還原所有基準水位", key="restore_baseline_btn"): + restore_all_rop_to_baseline(actor=actor) + st.success("已還原所有物料至基準水位。") + st.rerun() + else: + st.info( + "目前帳號可分析風險,但不能直接修改 ERP 安全庫存政策;" + "請透過受治理提案送交具權限人員審核。" + ) df_stk = pd.DataFrame(stock_alerts).rename(columns={ "product_name": "物料名稱", "stock": "現有庫存", "projected_stock": "延期後剩餘", "reorder_point": "原安全水位", "suggestion": "建議" diff --git a/frontend/components/supply_map.py b/frontend/components/supply_map.py index 61bb35d..0511191 100644 --- a/frontend/components/supply_map.py +++ b/frontend/components/supply_map.py @@ -70,7 +70,7 @@ def render_risk_heatmap(key: str = "risk_heatmap", heatmap_rows=None): ) st.plotly_chart(fig, use_container_width=True, key=key) -def render_risk_shortcuts(key: str, heatmap_rows=None): +def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): """區域風險快速分析小卡。""" if heatmap_rows is None: heatmap_rows = get_risk_heatmap_data() @@ -179,7 +179,9 @@ def render_risk_shortcuts(key: str, heatmap_rows=None): 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) + 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": @@ -192,7 +194,9 @@ def render_risk_shortcuts(key: str, heatmap_rows=None): 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) + 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() @@ -204,7 +208,9 @@ def render_risk_shortcuts(key: str, heatmap_rows=None): 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) + add_risk_event( + etype, ev_r, ev_c, impact_days, desc, actor=actor + ) st.session_state["heatmap_needs_refresh"] = True st.rerun() @@ -243,12 +249,25 @@ def find_match(r_name, evs): 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) + 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() -def render_supply_chain_map(api_key: str, gnews_api_key: str, gemini_model: str = "gemini-2.5-flash"): +def render_supply_chain_map( + api_key: str, + gnews_api_key: str, + gemini_model: str = "gemini-2.5-flash", + *, + actor: str, +): """供應鏈地圖:第一層即時風險熱圖 + AI 摘要,第二層受災採購清單,第三層 What-If 模擬。""" st.subheader("🌍 原物料風險管理地圖") st.caption("熱圖顯示與管理、AI 深度摘要。") @@ -286,7 +305,7 @@ def render_supply_chain_map(api_key: str, gnews_api_key: str, gemini_model: str st.rerun() with col_reset: if st.button("🔄 重置為初始熱圖", key="reset_heatmap_btn"): - reset_risk_heatmap_to_initial() + reset_risk_heatmap_to_initial(actor=actor) for key in ["heatmap_ai_summary", "heatmap_updates", "suggested_events"]: if key in st.session_state: del st.session_state[key] st.success("已重置為初始熱圖。") @@ -376,7 +395,11 @@ def render_supply_chain_map(api_key: str, gnews_api_key: str, gemini_model: str break st.session_state["suggested_events"] = current_suggested - cnt = apply_heatmap_updates(final_updates, st.session_state["heatmap_ai_summary"]) + cnt = apply_heatmap_updates( + final_updates, + st.session_state["heatmap_ai_summary"], + actor=actor, + ) st.session_state["heatmap_apply_success"] = f"✅ 已成功同步 {cnt} 個地區的風險等級與天數設定!" if "heatmap_updates" in st.session_state: del st.session_state["heatmap_updates"] @@ -396,7 +419,9 @@ def render_supply_chain_map(api_key: str, gnews_api_key: str, gemini_model: str st.markdown("
", unsafe_allow_html=True) # ── 🔍 區域風險摘要與快速分析 (Regional Impact Shortcuts) ────────── - render_risk_shortcuts(key="detail_shortcuts", heatmap_rows=heatmap_rows) + render_risk_shortcuts( + key="detail_shortcuts", heatmap_rows=heatmap_rows, actor=actor + ) # ── 手動調節熱圖風險% (使用 st.data_editor) ──────────────────────── if heatmap_rows: @@ -429,10 +454,16 @@ def render_supply_chain_map(api_key: str, gnews_api_key: str, gemini_model: str orig_row["longitude"], float(new_val), (orig_row.get("ai_summary") or "")[:500], + actor=actor, ) st.success("地圖已更新。") -def render_what_if_analysis(api_key: str, gemini_model: str = "gemini-2.5-flash"): +def render_what_if_analysis( + api_key: str, + gemini_model: str = "gemini-2.5-flash", + *, + actor: str, +): """模擬情境分析 (What-If Simulation)。""" st.markdown("---") with st.expander("🔮 模擬情境分析 (What-If Simulation)", expanded=True): @@ -460,7 +491,9 @@ def render_what_if_analysis(api_key: str, gemini_model: str = "gemini-2.5-flash" ) if st.button("執行 What-If 模擬分析", key="whatif_btn"): with st.spinner("AI 正在依供應商、採購單與庫存資料分析情境…"): - answer = what_if_simulation(api_key, user_question, model=gemini_model) + answer = what_if_simulation( + api_key, user_question, model=gemini_model, actor=actor + ) st.markdown("**AI 回覆**") # 隱藏技術後綴 clean_answer = answer.split("【自動化指令】")[0].strip() diff --git a/frontend/page_agent_dashboard.py b/frontend/page_agent_dashboard.py index 9f9c8ca..ae59bd0 100644 --- a/frontend/page_agent_dashboard.py +++ b/frontend/page_agent_dashboard.py @@ -9,6 +9,7 @@ import json import os from datetime import datetime +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_pending_list, @@ -19,6 +20,21 @@ write_action_log, ) from backend.database import run_query +from frontend.access_navigation import dashboard_mode + + +_PURCHASE_PROPOSAL_TOOLS = frozenset( + {"create_purchase_order", "sync_external_purchase_order"} +) + + +def _filter_purchase_proposals(records): + """Limit the L3 approver surface to governed purchase proposals.""" + return [ + item + for item in records + if item.get("tool", item.get("tool_name")) in _PURCHASE_PROPOSAL_TOOLS + ] def _history_action_kind(status: str, tool_name: str, role: str) -> str: @@ -122,16 +138,117 @@ def format_parameters_to_chinese(tool_name: str, args) -> str: return ", ".join(parts) -def render(): - st.markdown("
🕵️ Agent Dashboard
", unsafe_allow_html=True) - st.markdown("

即時監控專責 AI Agent 的運行狀態、總管派工決策、工具呼叫記錄與敏感操作的審批管理。

", unsafe_allow_html=True) +def _render_purchase_approval_dashboard(principal, pending_list, approval_history): + """Focused L3 surface: proposal evidence and decisions, without global logs.""" + st.markdown( + "
✅ L3 採購提案核准
", + unsafe_allow_html=True, + ) + st.caption( + "只顯示採購單與 ERP CSV 交換提案。核准後才會產生受治理的執行結果," + "所有決策保留稽核紀錄。" + ) + st.metric("待核准採購提案", f"{len(pending_list)} 筆") + + if not pending_list: + st.success("目前沒有待核准的採購提案。") + for item in pending_list: + with st.container(border=True): + st.markdown(f"##### 📋 提案單號:`{item['id']}`") + st.caption( + f"🕒 {item['time']}|申請角色:`{item['role']}`|" + f"申請人:`{item.get('requester_username') or '舊資料未記錄'}`" + ) + st.markdown(f"**提案類型**:`{item['tool']}`") + st.markdown( + f"**核准證據**:`{format_parameters_to_chinese(item['tool'], item['args'])}`" + ) + if item.get("operation_id"): + st.caption(f"🔗 操作識別碼:`{item['operation_id']}`") + + if item.get("requester_username") == principal.username: + st.warning("提案人不得核准自己的提案,請由另一位核准者處理。") + continue - # 示範資料必須明確 opt-in,正常運行不得自動寫入假紀錄。 - if _demo_seed_enabled(): - _initialize_demo_data_if_empty() + reason = st.text_input( + "拒絕原因(核准時免填)", + key=f"tier_reason_{item['id']}", + ) + approve_col, reject_col = st.columns(2) + with approve_col: + if st.button( + "✅ 核准", + key=f"tier_approve_{item['id']}", + use_container_width=True, + ): + result = approve_action(item["id"], approver=principal.username) + if result.get("status") in {"ok", "pending"}: + st.toast(f"提案 {item['id']} 已核准。") + else: + st.error(result.get("message") or "核准失敗。") + st.rerun() + with reject_col: + if st.button( + "❌ 拒絕", + key=f"tier_reject_{item['id']}", + use_container_width=True, + ): + if not reason.strip(): + st.warning("請先填寫拒絕原因。") + else: + result = reject_action( + item["id"], reason, approver=principal.username + ) + if result.get("status") == "denied": + st.toast(f"提案 {item['id']} 已拒絕。") + else: + st.error(result.get("message") or "拒絕失敗。") + st.rerun() + + st.markdown("---") + st.markdown("### 🕒 採購提案審批歷史") + if not approval_history: + st.info("目前沒有採購提案審批歷史。") + return + for item in approval_history: + with st.container(border=True): + status_emoji = "✅" if item["status"] == "approved" else "❌" + st.markdown( + f"**{status_emoji} `{item['id']}`|{item['status']}|`{item['tool']}`**" + ) + st.caption( + f"申請:{item['time']}|處理:{item['processed_time']}|" + f"申請人:`{item.get('requester_username') or '舊資料未記錄'}`" + ) + st.markdown( + f"**提案內容**:`{format_parameters_to_chinese(item['tool'], item['raw_args'])}`" + ) + if item["reason"]: + st.markdown(f"**拒絕原因**:{item['reason']}") + + +def render(username: str = ""): + principal = load_principal(username) + if principal is None: + st.error("登入身分已失效,無法開啟審批頁。") + return + mode = dashboard_mode(principal) + if mode == "none": + st.error("此帳號沒有審批佇列的檢視權限。") + return + + if mode == "full": + st.markdown("
🕵️ Agent Dashboard
", unsafe_allow_html=True) + st.markdown("

即時監控專責 AI Agent 的運行狀態、總管派工決策、工具呼叫記錄與敏感操作的審批管理。

", unsafe_allow_html=True) + + # 示範資料必須明確 opt-in,正常運行不得自動寫入假紀錄。 + if _demo_seed_enabled(): + _initialize_demo_data_if_empty() # ── 從資料庫取得最新審批資料 ────────────────────────────── pending_list = get_pending_list() + if mode == "approvals": + pending_list = _filter_purchase_proposals(pending_list) pending_count = len(pending_list) # 讀取歷史審批紀錄 @@ -152,8 +269,17 @@ def render(): "reason": app["reason"] or "", "processed_time": app["updated_at"], "operation_id": app.get("operation_id"), + "requester_username": app.get("requester_username"), }) + if mode == "approvals": + _render_purchase_approval_dashboard( + principal, + pending_list, + _filter_purchase_proposals(approval_history), + ) + return + # ── 頂部 Metrics 卡片 ─────────────────────────────────────────────── total_agents = len(AGENTS) active_agents = 3 # 模擬運作中 @@ -238,8 +364,8 @@ def render(): with col_item2: st.markdown("
", unsafe_allow_html=True) # 檢查目前登入角色是否為 admin - current_role = st.session_state.get("role", "guest") - current_username = st.session_state.get("username", "") + current_role = principal.role + current_username = principal.username if current_role == "admin": # 輸入拒絕原因的文字框 @@ -282,7 +408,7 @@ def render(): if not approval_history: st.caption("尚無審批歷史紀錄。") else: - current_role = st.session_state.get("role", "guest") + current_role = principal.role for item in approval_history: with st.container(border=True): col_hist_info, col_hist_act = st.columns([4, 1.2]) @@ -490,17 +616,21 @@ def _initialize_demo_data_if_empty(): # 1. 檢查並寫入待審批項目 pending_count = run_query("SELECT COUNT(*) FROM pending_approvals")[0][0] if pending_count == 0: - now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # 建立兩筆待審批 - run_query( - "INSERT INTO pending_approvals (approval_id, tool_name, parameters, requester, status, approver, created_at, updated_at, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ("PENDING-20260604-001", "update_inventory", '{"product_id": "P001", "quantity_change": 120}', "warehouse", "pending", None, now_str, now_str, None), - fetch=False + from backend.agent_logger import create_pending_approval + + create_pending_approval( + "update_inventory", + {"product_id": "P001", "quantity_change": 120}, + "warehouse", + requester_username="wh1", + operation_id="dashboard-demo-update-inventory-v1", ) - run_query( - "INSERT INTO pending_approvals (approval_id, tool_name, parameters, requester, status, approver, created_at, updated_at, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ("PENDING-20260604-002", "create_order", '{"customer_id": "C001", "product_id": "P003", "quantity": 15}', "sales", "pending", None, now_str, now_str, None), - fetch=False + create_pending_approval( + "create_order", + {"customer_id": "C001", "product_id": "P003", "quantity": 15}, + "sales", + requester_username="sales1", + operation_id="dashboard-demo-create-order-v1", ) # 2. 檢查並寫入派工決策紀錄 (B 的派工) diff --git a/frontend/page_ai_assistant.py b/frontend/page_ai_assistant.py index 30491be..18740db 100644 --- a/frontend/page_ai_assistant.py +++ b/frontend/page_ai_assistant.py @@ -274,7 +274,12 @@ def _run_agent(api_key, role_names: dict, final_input: str): # history:帶入本 session 的近期對話(排除剛加入的本輪 user 訊息), # orchestrator 內部會做 sliding window 修剪。 hist = st.session_state.messages[:-1] - res = orchestrate(final_input, role=st.session_state.role, history=hist) + res = orchestrate( + final_input, + role=st.session_state.role, + actor=st.session_state.get("username"), + history=hist, + ) reply_text = res.get("reply", "(無回覆內容)") status_box.empty() diff --git a/frontend/page_erp_csv_exchange.py b/frontend/page_erp_csv_exchange.py index 52c8a42..9c24829 100644 --- a/frontend/page_erp_csv_exchange.py +++ b/frontend/page_erp_csv_exchange.py @@ -14,6 +14,7 @@ import streamlit as st from backend import DB_FILE +from backend.access_control import load_principal from backend.erp_exchange import ( build_exchange_operation_id, build_purchase_order_template_csv, @@ -26,6 +27,7 @@ stage_purchase_order_rows, ) from backend.tool_gateway import gateway +from frontend.access_navigation import exchange_sections IMPORT_DISPLAY_COLUMNS = [ @@ -82,25 +84,9 @@ def build_preview_rows(rows: list[dict], supplier_risks: dict[str, dict]) -> lis return preview -def render() -> None: - st.subheader("ERP CSV 交換", divider="blue") - st.caption( - "固定欄位交換原型:一列代表一張採購單,且每張單只含一個品項。" - "上傳只做驗證與風險預覽;按下「寫入暫存區」後才會留下資料。" - ) - - notice = st.session_state.pop("erp_csv_notice", None) - if notice: - st.success(notice) - current_actor = st.session_state.get("username", "") - - source_system = st.text_input( - "外部 ERP 來源識別碼", - value="demo-erp", - key="erp_csv_source_system", - help="例如 odoo-prod;僅允許英數字、點、底線與連字號。", - ).strip() - +def _render_proposal_section( + source_system: str, current_actor: str, current_role: str +) -> None: st.download_button( "下載採購單 CSV 範本", data=build_purchase_order_template_csv(), @@ -108,281 +94,310 @@ def render() -> None: mime="text/csv", key="erp_csv_download_template", ) - - import_tab, export_tab, receipt_tab = st.tabs( - ["匯入與風險預覽", "核准後匯出", "ERP 回執對帳"] + st.markdown("#### 1. 驗證與預覽") + uploaded = st.file_uploader( + "上傳外部 ERP 採購單 CSV", + type=["csv"], + key="erp_csv_purchase_order_upload", + help="檔案必須為 UTF-8;上傳本身不會修改 ERP。", ) - with import_tab: - st.markdown("#### 1. 驗證與預覽") - uploaded = st.file_uploader( - "上傳外部 ERP 採購單 CSV", - type=["csv"], - key="erp_csv_purchase_order_upload", - help="檔案必須為 UTF-8;上傳本身不會修改 ERP。", - ) - - parsed_rows = None - if uploaded is not None: - try: - parsed_rows = parse_purchase_order_csv(uploaded.getvalue()) - supplier_ids = sorted({row["supplier_id"] for row in parsed_rows}) - supplier_risks: dict[str, dict] = {} - if supplier_ids: - placeholders = ",".join("?" for _ in supplier_ids) - with sqlite3.connect(DB_FILE) as conn: - conn.row_factory = sqlite3.Row - risk_rows = conn.execute( - "SELECT supplier_id, country, region, risk_level " - f"FROM suppliers WHERE supplier_id IN ({placeholders})", - tuple(supplier_ids), - ).fetchall() - supplier_risks = { - row["supplier_id"]: dict(row) for row in risk_rows - } - preview = build_preview_rows(parsed_rows, supplier_risks) - st.success(f"格式驗證通過,共 {len(preview)} 列;目前尚未寫入。") - st.dataframe( - pd.DataFrame(preview)[IMPORT_DISPLAY_COLUMNS], - use_container_width=True, - hide_index=True, - ) - except ValueError as exc: - st.error(f"CSV 驗證失敗:{exc}") - parsed_rows = None - except sqlite3.Error: - st.error("目前無法讀取供應商風險資料,請稍後再試。") - parsed_rows = None - - if parsed_rows is not None: - if st.button( - "寫入暫存區", - type="primary", - key="erp_csv_stage_batch", - ): - try: - summary = stage_purchase_order_rows(source_system, parsed_rows) - st.success( - "暫存完成:" - f"新增 {summary['inserted']}、更新 {summary['updated']}、" - f"未變更 {summary['unchanged']}。尚未送審或同步。" - ) - except ValueError as exc: - st.error(f"無法寫入暫存區:{exc}") - except sqlite3.Error: - st.error("暫存區寫入失敗,沒有資料被同步至 ERP。") - - st.markdown("#### 2. 暫存資料與送審") - records: list[dict] = [] + parsed_rows = None + if uploaded is not None: try: - records = list_exchange_records(source_system) + parsed_rows = parse_purchase_order_csv(uploaded.getvalue()) + supplier_ids = sorted({row["supplier_id"] for row in parsed_rows}) + supplier_risks: dict[str, dict] = {} + if supplier_ids: + placeholders = ",".join("?" for _ in supplier_ids) + with sqlite3.connect(DB_FILE) as conn: + conn.row_factory = sqlite3.Row + risk_rows = conn.execute( + "SELECT supplier_id, country, region, risk_level " + f"FROM suppliers WHERE supplier_id IN ({placeholders})", + tuple(supplier_ids), + ).fetchall() + supplier_risks = { + row["supplier_id"]: dict(row) for row in risk_rows + } + preview = build_preview_rows(parsed_rows, supplier_risks) + st.success(f"格式驗證通過,共 {len(preview)} 列;目前尚未寫入。") + st.dataframe( + pd.DataFrame(preview)[IMPORT_DISPLAY_COLUMNS], + use_container_width=True, + hide_index=True, + ) except ValueError as exc: - st.warning(f"來源識別碼無法使用:{exc}") + st.error(f"CSV 驗證失敗:{exc}") + parsed_rows = None except sqlite3.Error: - st.error("目前無法讀取 ERP 交換暫存區。") + st.error("目前無法讀取供應商風險資料,請稍後再試。") + parsed_rows = None - if not records: - st.info("此來源尚無暫存資料。") - else: - record_rows = [] - for record in records: - record_rows.append( - { - "外部識別碼": record["external_id"], - "採購單號": record["po_id"], - "版本": record["version"], - "供應商": record["supplier_id"], - "風險": record.get("supplier_risk_level") or "未設定", - "狀態": describe_sync_state(record), - } - ) - st.dataframe( - pd.DataFrame(record_rows), use_container_width=True, hide_index=True + if parsed_rows is not None and st.button( + "寫入暫存區", type="primary", key="erp_csv_stage_batch" + ): + try: + summary = stage_purchase_order_rows( + source_system, parsed_rows, actor=current_actor ) + st.success( + "暫存完成:" + f"新增 {summary['inserted']}、更新 {summary['updated']}、" + f"未變更 {summary['unchanged']}。尚未送審或同步。" + ) + except (ValueError, PermissionError) as exc: + st.error(f"無法寫入暫存區:{exc}") + except sqlite3.Error: + st.error("暫存區寫入失敗,沒有資料被同步至 ERP。") - for record in records: - state = record.get("sync_state") or "staged" - label = ( - f"{record['external_id']}|{record['po_id']}|" - f"v{record['version']}|{describe_sync_state(record)}" - ) - with st.expander(label): - st.write( - { - "供應商": record["supplier_id"], - "品項": record["product_id"], - "數量": record["qty"], - "單價": record["unit_price"], - "國家/地區": " / ".join( - filter( - None, - [ - record.get("supplier_country"), - record.get("supplier_region"), - ], - ) - ) - or "未設定", - "供應商風險": record.get("supplier_risk_level") - or "未設定", - } - ) - if state == "staged": - operation_id = build_exchange_operation_id( - record["source_system"], - record["external_id"], - record["version"], - ) - if st.button( - "送人工審批", - key=( - "erp_csv_submit_" - f"{record['source_system']}_{record['external_id']}_" - f"{record['version']}" - ), - ): - result = gateway.call( - "sync_external_purchase_order", - { - "source_system": record["source_system"], - "external_id": record["external_id"], - }, - role=st.session_state.get("role", "guest"), - agent_name="procurement_agent", - operation_id=operation_id, - ) - if result.status == "pending": - st.session_state["erp_csv_notice"] = ( - f"{record['external_id']} 已送審;審批單 " - f"{result.approval_id}。尚未同步。" - ) - st.rerun() - elif result.status == "ok": - st.session_state["erp_csv_notice"] = ( - f"{record['external_id']} 已完成既有核准操作。" - ) - st.rerun() - else: - st.error(result.message or "送審失敗,未同步任何資料。") - elif state == "pending": - st.info("等待人工核准;此時尚未同步,也不會出現在動作檔。") - elif state == "approved": - st.success("已核准,可到「核准後匯出」下載動作檔;仍待 ERP 回執。") - elif state == "acknowledged": - st.success(describe_sync_state(record)) - elif state == "rejected": - st.warning("此版本已拒絕。請修正 CSV 並以同一 external_id 匯入新版。") + st.markdown("#### 2. 暫存資料與送審") + records: list[dict] = [] + try: + records = list_exchange_records(source_system, actor=current_actor) + except (ValueError, PermissionError) as exc: + st.warning(f"目前無法讀取暫存資料:{exc}") + except sqlite3.Error: + st.error("目前無法讀取 ERP 交換暫存區。") + + if not records: + st.info("此來源尚無暫存資料。") + return + + record_rows = [ + { + "外部識別碼": record["external_id"], + "採購單號": record["po_id"], + "版本": record["version"], + "供應商": record["supplier_id"], + "風險": record.get("supplier_risk_level") or "未設定", + "狀態": describe_sync_state(record), + } + for record in records + ] + st.dataframe(pd.DataFrame(record_rows), use_container_width=True, hide_index=True) - with export_tab: - st.markdown("#### 核准後動作檔") - st.caption( - "只有已核准且已產生本機執行收據的版本會被匯出;" - "每次核准都固定為不可變快照。" - "下載不代表外部 ERP 已接收;收到回執前狀態仍是待確認。" + for record in records: + state = record.get("sync_state") or "staged" + label = ( + f"{record['external_id']}|{record['po_id']}|" + f"v{record['version']}|{describe_sync_state(record)}" ) - try: - export_records = list_exchange_records(source_system) - export_rows = [ + with st.expander(label): + st.write( { - "外部識別碼": row["external_id"], - "版本": row["version"], - "採購單號": row["po_id"], - "狀態": describe_sync_state(row), + "供應商": record["supplier_id"], + "品項": record["product_id"], + "數量": record["qty"], + "單價": record["unit_price"], + "國家/地區": " / ".join( + filter( + None, + [ + record.get("supplier_country"), + record.get("supplier_region"), + ], + ) + ) + or "未設定", + "供應商風險": record.get("supplier_risk_level") or "未設定", } - for row in export_records - ] - if export_rows: - st.dataframe( - pd.DataFrame(export_rows), - use_container_width=True, - hide_index=True, - ) - action_csv = export_approved_actions_csv( - source_system, actor=current_actor - ) - st.download_button( - "下載已核准 ERP 動作 CSV", - data=action_csv, - file_name=f"approved_erp_actions_{source_system or 'source'}.csv", - mime="text/csv", - key="erp_csv_download_approved", ) - except (ValueError, PermissionError) as exc: - st.warning(f"目前無法匯出:{exc}") - except sqlite3.Error: - st.error("目前無法建立核准後動作檔。") + if state == "staged": + operation_id = build_exchange_operation_id( + record["source_system"], + record["external_id"], + record["version"], + ) + if st.button( + "送人工審批", + key=( + "erp_csv_submit_" + f"{record['source_system']}_{record['external_id']}_" + f"{record['version']}" + ), + ): + result = gateway.call( + "sync_external_purchase_order", + { + "source_system": record["source_system"], + "external_id": record["external_id"], + }, + role=current_role, + actor=current_actor, + agent_name="procurement_agent", + operation_id=operation_id, + ) + if result.status == "pending": + st.session_state["erp_csv_notice"] = ( + f"{record['external_id']} 已送審;審批單 " + f"{result.approval_id}。尚未同步。" + ) + st.rerun() + elif result.status == "ok": + st.session_state["erp_csv_notice"] = ( + f"{record['external_id']} 已完成既有核准操作。" + ) + st.rerun() + else: + st.error(result.message or "送審失敗,未同步任何資料。") + elif state == "pending": + st.info("等待人工核准;此時尚未同步,也不會出現在動作檔。") + elif state == "approved": + st.success("已核准,等待 L3 人員匯出動作檔與處理 ERP 回執。") + elif state == "acknowledged": + st.success(describe_sync_state(record)) + elif state == "rejected": + st.warning("此版本已拒絕。請修正 CSV 並以同一 external_id 匯入新版。") - with receipt_tab: - st.markdown("#### ERP 回執對帳") - st.caption( - "回執固定欄位:source_system、external_id、operation_id、approval_id、" - "payload_digest、receipt_attempt_id、receipt_status、message、key_id、signature。" - "receipt_status 僅接受 accepted、rejected 或 error;" - "signature 必須由外部 ERP 連接器使用預先配置的 HMAC 金鑰產生," - "未簽章的人工填表不會被接受。" - ) - try: - st.download_button( - "下載待填寫 ERP 回執範本", - data=build_receipt_template_csv( - source_system, actor=current_actor - ), - file_name=f"erp_receipt_template_{source_system or 'source'}.csv", - mime="text/csv", - key="erp_csv_download_receipt_template", - help="由目前已核准動作產生;請填 receipt_status 與 message 後回傳。", + +def _render_export_section(source_system: str, current_actor: str) -> None: + st.markdown("#### 核准後動作檔") + st.caption( + "只有已核准且已產生本機執行收據的版本會被匯出;" + "每次核准都固定為不可變快照。" + "下載不代表外部 ERP 已接收;收到回執前狀態仍是待確認。" + ) + try: + export_records = list_exchange_records(source_system, actor=current_actor) + export_rows = [ + { + "外部識別碼": row["external_id"], + "版本": row["version"], + "採購單號": row["po_id"], + "狀態": describe_sync_state(row), + } + for row in export_records + ] + if export_rows: + st.dataframe( + pd.DataFrame(export_rows), use_container_width=True, hide_index=True ) - except (ValueError, PermissionError, RuntimeError, sqlite3.Error) as exc: - st.info(f"目前無法建立回執範本:{exc}") - receipt_upload = st.file_uploader( - "上傳 ERP 回執 CSV", - type=["csv"], - key="erp_csv_receipt_upload", - help="選取檔案不會自動對帳,仍需按下確認按鈕。", + action_csv = export_approved_actions_csv(source_system, actor=current_actor) + st.download_button( + "下載已核准 ERP 動作 CSV", + data=action_csv, + file_name=f"approved_erp_actions_{source_system or 'source'}.csv", + mime="text/csv", + key="erp_csv_download_approved", ) - if st.button( - "確認並寫入回執對帳", - type="primary", - key="erp_csv_reconcile_receipt", - ): - if receipt_upload is None: - st.warning("請先選取 ERP 回執 CSV。") - else: - try: - summary = reconcile_receipt_csv( - receipt_upload.getvalue(), actor=current_actor - ) - st.success( - "回執對帳完成:" - f"新增 {summary['inserted']}、未變更 {summary['unchanged']}。" - ) - except (ValueError, PermissionError, RuntimeError) as exc: - st.error(f"回執對帳失敗:{exc}") - except sqlite3.Error: - st.error("回執對帳失敗,沒有寫入不完整資料。") + except (ValueError, PermissionError) as exc: + st.warning(f"目前無法匯出:{exc}") + except sqlite3.Error: + st.error("目前無法建立核准後動作檔。") - try: - receipt_records = list_exchange_receipts( - source_system, actor=current_actor + +def _render_reconcile_section(source_system: str, current_actor: str) -> None: + st.markdown("#### ERP 回執對帳") + st.caption( + "回執固定欄位:source_system、external_id、operation_id、approval_id、" + "payload_digest、receipt_attempt_id、receipt_status、message、key_id、signature。" + "receipt_status 僅接受 accepted、rejected 或 error;" + "signature 必須由外部 ERP 連接器使用預先配置的 HMAC 金鑰產生," + "未簽章的人工填表不會被接受。" + ) + try: + st.download_button( + "下載待填寫 ERP 回執範本", + data=build_receipt_template_csv(source_system, actor=current_actor), + file_name=f"erp_receipt_template_{source_system or 'source'}.csv", + mime="text/csv", + key="erp_csv_download_receipt_template", + help="由目前已核准動作產生;請填 receipt_status 與 message 後回傳。", + ) + except (ValueError, PermissionError, RuntimeError, sqlite3.Error) as exc: + st.info(f"目前無法建立回執範本:{exc}") + receipt_upload = st.file_uploader( + "上傳 ERP 回執 CSV", + type=["csv"], + key="erp_csv_receipt_upload", + help="選取檔案不會自動對帳,仍需按下確認按鈕。", + ) + if st.button( + "確認並寫入回執對帳", + type="primary", + key="erp_csv_reconcile_receipt", + ): + if receipt_upload is None: + st.warning("請先選取 ERP 回執 CSV。") + else: + try: + summary = reconcile_receipt_csv( + receipt_upload.getvalue(), actor=current_actor + ) + st.success( + "回執對帳完成:" + f"新增 {summary['inserted']}、未變更 {summary['unchanged']}。" + ) + except (ValueError, PermissionError, RuntimeError) as exc: + st.error(f"回執對帳失敗:{exc}") + except sqlite3.Error: + st.error("回執對帳失敗,沒有寫入不完整資料。") + + try: + receipt_records = list_exchange_receipts(source_system, actor=current_actor) + acknowledged = [ + { + "外部識別碼": row["external_id"], + "操作識別碼": row["operation_id"], + "回執嘗試": row.get("attempt_count"), + "回執狀態": row.get("receipt_status"), + "驗證狀態": row.get("trust_state"), + "最後提交者": row.get("received_by"), + } + for row in receipt_records + ] + if acknowledged: + st.dataframe( + pd.DataFrame(acknowledged), use_container_width=True, hide_index=True ) - acknowledged = [ - { - "外部識別碼": row["external_id"], - "操作識別碼": row["operation_id"], - "回執嘗試": row.get("attempt_count"), - "回執狀態": row.get("receipt_status"), - "驗證狀態": row.get("trust_state"), - "最後提交者": row.get("received_by"), - } - for row in receipt_records - ] - if acknowledged: - st.dataframe( - pd.DataFrame(acknowledged), - use_container_width=True, - hide_index=True, + else: + st.info("目前尚未收到此來源的 ERP 回執。") + except (ValueError, PermissionError, sqlite3.Error): + st.info("目前無法讀取回執狀態。") + + +def render(username: str = "") -> None: + principal = load_principal(username) + if principal is None: + st.error("登入身分已失效,無法開啟 ERP CSV 交換。") + return + sections = exchange_sections(principal) + if not sections: + st.error("此帳號沒有 ERP CSV 交換權限。") + return + + st.subheader("ERP CSV 交換", divider="blue") + st.caption( + "固定欄位交換原型:一列代表一張採購單,且每張單只含一個品項。" + "上傳只做驗證與風險預覽;按下「寫入暫存區」後才會留下資料。" + ) + + notice = st.session_state.pop("erp_csv_notice", None) + if notice: + st.success(notice) + current_actor = principal.username + + source_system = st.text_input( + "外部 ERP 來源識別碼", + value="demo-erp", + key="erp_csv_source_system", + help="例如 odoo-prod;僅允許英數字、點、底線與連字號。", + ).strip() + + labels = { + "proposal": "L2 匯入、風險預覽與提案", + "export": "L3 核准後匯出", + "reconcile": "L3 ERP 回執對帳", + } + tabs = st.tabs([labels[section] for section in sections]) + for section, tab in zip(sections, tabs): + with tab: + if section == "proposal": + _render_proposal_section( + source_system, current_actor, principal.role ) - else: - st.info("目前尚未收到此來源的 ERP 回執。") - except (ValueError, PermissionError, sqlite3.Error): - st.info("目前無法讀取回執狀態。") + elif section == "export": + _render_export_section(source_system, current_actor) + elif section == "reconcile": + _render_reconcile_section(source_system, current_actor) diff --git a/frontend/page_procurement.py b/frontend/page_procurement.py index b950e13..fc7357a 100644 --- a/frontend/page_procurement.py +++ b/frontend/page_procurement.py @@ -9,8 +9,10 @@ import pandas as pd from datetime import datetime from backend import DB_FILE, run_query +from backend.access_control import load_principal from backend.agent_logger import get_pending_approval_by_id from backend.tool_gateway import gateway +from frontend.access_navigation import build_menu_structure def ensure_po_operation_id(state) -> str: @@ -43,16 +45,24 @@ def resolve_po_approval_state( return approval_id, approval.get("status") -def render(sub_menu: str): +def render(sub_menu: str, username: str = ""): + principal = load_principal(username) + if principal is None: + st.error("登入身分已失效,無法開啟採購功能。") + return + menus = build_menu_structure(principal).get("🛒 採購管理", []) + if sub_menu not in menus: + st.error("此帳號沒有這項採購功能的權限。") + return + st.markdown("
🛒 採購管理
", unsafe_allow_html=True) - menus = ['採購單', '供應商管理', '進貨成本', '採購歷史', 'ERP CSV 交換'] styled_menus = [f"🌟 **{m}**" if m == sub_menu else f"{m}" for m in menus] st.markdown(" | ".join(styled_menus)) if sub_menu == "ERP CSV 交換": from frontend.page_erp_csv_exchange import render as render_erp_csv_exchange - render_erp_csv_exchange() + render_erp_csv_exchange(username=principal.username) return if sub_menu == "採購單": @@ -113,7 +123,8 @@ def render(sub_menu: str): "status": "待入庫", "note": note or "", }, - role=st.session_state.get("role", "guest"), + role=principal.role, + actor=principal.username, agent_name="procurement_agent", operation_id=operation_id, ) diff --git a/frontend/page_supply_chain_risk.py b/frontend/page_supply_chain_risk.py index 900ee20..88365c0 100644 --- a/frontend/page_supply_chain_risk.py +++ b/frontend/page_supply_chain_risk.py @@ -6,42 +6,82 @@ """ import streamlit as st +from backend.access_control import load_principal +from frontend.access_navigation import risk_sections from frontend.components.supply_map import render_supply_chain_map, render_what_if_analysis from frontend.components.risk_dashboard import render_intelligence_gathering, render_response_execution from frontend.components.risk_overview import render_risk_overview -def render(sub_menu: str, api_key: str, gnews_api_key: str = "", gemini_model: str = "gemini-2.5-flash"): +def render( + sub_menu: str, + api_key: str, + gnews_api_key: str = "", + gemini_model: str = "gemini-2.5-flash", + username: str = "", +): + principal = load_principal(username) + if principal is None: + st.error("登入身分已失效,無法讀取供應鏈風險資料。") + return + sections = risk_sections(principal) + if "overview" not in sections: + st.error("此帳號沒有供應鏈風險檢視權限。") + return + st.markdown("
🌱 供應鏈與風險監控
", unsafe_allow_html=True) - - # 使用 Tabs 切換總覽與詳細分析 - tab1, tab2 = st.tabs(["📊 風險總覽", "🌍 詳細監控與分析"]) - - with tab1: + + if "analysis" not in sections and "what_if" not in sections: render_risk_overview() - - with tab2: + return + + overview_tab, analysis_tab = st.tabs(["📊 L1 風險總覽", "🧭 L2 情報與決策"]) + + with overview_tab: + render_risk_overview() + + with analysis_tab: # Step 1: Intelligence Hub st.markdown("### 📡 步驟 1: 即時情報獲取與 AI 摘要") - render_intelligence_gathering(api_key=api_key, gnews_api_key=gnews_api_key, gemini_model=gemini_model) + render_intelligence_gathering( + api_key=api_key, + gnews_api_key=gnews_api_key, + gemini_model=gemini_model, + actor=principal.username, + ) st.markdown("
", unsafe_allow_html=True) st.markdown("---") # Step 2: Global Risk Monitoring st.markdown("### 🌍 步驟 2: 原物料風險管理地圖") - render_supply_chain_map(api_key, gnews_api_key, gemini_model) + render_supply_chain_map( + api_key, + gnews_api_key, + gemini_model, + actor=principal.username, + ) st.markdown("
", unsafe_allow_html=True) st.markdown("---") # Step 3: Response Execution st.markdown("### ⚡ 步驟 3: 風險分析與應變執行") - render_response_execution(api_key=api_key, gnews_api_key=gnews_api_key, gemini_model=gemini_model) + render_response_execution( + api_key=api_key, + gnews_api_key=gnews_api_key, + gemini_model=gemini_model, + actor=principal.username, + ) st.markdown("
", unsafe_allow_html=True) st.markdown("---") - # Step 4: What-If Simulation - st.markdown("### 🔮 步驟 4: 情境模擬分析") - render_what_if_analysis(api_key=api_key, gemini_model=gemini_model) + if "what_if" in sections: + # Step 4: What-If Simulation + st.markdown("### 🔮 步驟 4: 情境模擬分析") + render_what_if_analysis( + api_key=api_key, + gemini_model=gemini_model, + actor=principal.username, + ) diff --git a/line bot/bot_server.py b/line bot/bot_server.py index b3972c5..bcef2c0 100644 --- a/line bot/bot_server.py +++ b/line bot/bot_server.py @@ -48,7 +48,12 @@ from backend.tool_registry import registry from backend.flex_builder import build_low_stock_flex, build_risk_events_flex from backend.chart_builder import build_carbon_trend_chart, build_finance_pie_chart -from line_access import build_line_tools, env_flag, parse_line_user_ids +from line_access import ( + build_line_tools, + env_flag, + is_line_tool_allowed, + parse_line_user_ids, +) # 確保資料庫初始化 init_db() @@ -108,6 +113,14 @@ def _get_line_user_role(line_user_id: str) -> str: def _build_gateway_function_response(tool_name: str, args: dict, role: str = None) -> tuple[dict, bool]: if role is None: role = _LINE_GATEWAY_DEFAULT_ROLE + if not is_line_tool_allowed(tool_name, registry, role): + return ( + { + "status": "denied", + "error": "LINE 入口僅允許唯讀或建議工具;寫入操作請由已登入的 Web 介面送審。", + }, + False, + ) gw_result = gateway.call(tool_name, args or {}, role=role) payload = gw_result.to_dict() diff --git a/line bot/line_access.py b/line bot/line_access.py index f4becb0..ab6ce58 100644 --- a/line bot/line_access.py +++ b/line bot/line_access.py @@ -14,6 +14,18 @@ _BLOCKED_MODULES = {"hr", "finance"} +def is_line_tool_allowed(tool_name: str, registry, role: str) -> bool: + """Apply the LINE boundary again at execution time, not only schema build.""" + info = registry.get_tool_info(str(tool_name or "")) + if not info: + return False + if info.get("module") in _BLOCKED_MODULES: + return False + if info.get("risk_level") not in _SAFE_RISK_LEVELS: + return False + return bool(registry.is_allowed(tool_name, role)) + + def env_flag(value: str | None, default: bool = False) -> bool: """Parse an opt-in environment flag; unknown values use ``default``.""" if value is None or not value.strip(): @@ -48,14 +60,7 @@ def build_line_tools(all_tools: Iterable, registry, role: str) -> list: allowed = [] for tool in all_tools: tool_name = getattr(tool, "__name__", "") - info = registry.get_tool_info(tool_name) if tool_name else None - if not info: - continue - if info.get("module") in _BLOCKED_MODULES: - continue - if info.get("risk_level") not in _SAFE_RISK_LEVELS: - continue - if not registry.is_allowed(tool_name, role): + if not is_line_tool_allowed(tool_name, registry, role): continue allowed.append(tool) return allowed diff --git a/line bot/setup_rich_menu.py b/line bot/setup_rich_menu.py index 15e94a3..529ee04 100644 --- a/line bot/setup_rich_menu.py +++ b/line bot/setup_rich_menu.py @@ -60,13 +60,20 @@ def create_rich_menu(): print(f"Rich Menu ID: {rich_menu_id}") print("[2] 調整並上傳 Rich Menu 圖片 ...") - image_path = r"C:\Users\User\.gemini\antigravity\brain\6c4ac423-da28-4a64-b17b-cbd59b41e3a3\erp_rich_menu_flat_1775320445371.png" + image_path = os.getenv("LINE_RICH_MENU_IMAGE_PATH", "").strip() + if not image_path: + print("請先設定 LINE_RICH_MENU_IMAGE_PATH 指向 Rich Menu PNG 圖片。") + return + if not os.path.isfile(image_path): + print("找不到 LINE_RICH_MENU_IMAGE_PATH 指定的圖片。") + return try: from PIL import Image with Image.open(image_path) as img: img_resized = img.resize((2500, 1686)).convert('RGB') - resized_path = image_path.replace(".png", "_resized.jpg") + stem, _ = os.path.splitext(image_path) + resized_path = f"{stem}_resized.jpg" img_resized.save(resized_path, "JPEG", quality=85) except ImportError: print("請先安裝 Pillow: pip install Pillow") diff --git a/tests/conftest.py b/tests/conftest.py index 6cda63f..649c295 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,3 +18,5 @@ # 2) 測試專用 DB 路徑(模組載入期設定,早於任何 backend import) _TMP_DIR = tempfile.mkdtemp(prefix="erp_test_") os.environ["ERP_DB_PATH"] = os.path.join(_TMP_DIR, "test_erp.db") +# 測試套件明確啟用合成資料;正式執行的安全預設維持關閉。 +os.environ["ERP_DEMO_MODE"] = "1" diff --git a/tests/test_agent_dashboard_display.py b/tests/test_agent_dashboard_display.py index baa5239..8589a57 100644 --- a/tests/test_agent_dashboard_display.py +++ b/tests/test_agent_dashboard_display.py @@ -1,8 +1,11 @@ import pytest +from pathlib import Path from frontend.page_agent_dashboard import ( _demo_seed_enabled, + _filter_purchase_proposals, _history_action_kind, + _initialize_demo_data_if_empty, format_parameters_to_chinese, ) @@ -43,3 +46,66 @@ def test_demo_seed_is_opt_in(monkeypatch): monkeypatch.setenv("ERP_ENABLE_DEMO_SEED", "true") assert _demo_seed_enabled() is True + + +def test_demo_pending_records_have_verifiable_originators(tmp_path, monkeypatch): + from backend import database + from backend.tool_gateway import gateway + + monkeypatch.setattr(database, "DB_FILE", str(tmp_path / "dashboard-demo.db")) + database.init_db() + + _initialize_demo_data_if_empty() + + rows = database.run_query( + "SELECT approval_id, tool_name, requester_username " + "FROM pending_approvals ORDER BY tool_name" + ) + assert [(row[1], row[2]) for row in rows] == [ + ("create_order", "sales1"), + ("update_inventory", "wh1"), + ] + update_approval_id = next( + row[0] for row in rows if row[1] == "update_inventory" + ) + assert gateway.approve_action( + update_approval_id, approver="admin" + ).status == "ok" + + +def test_approver_queue_only_contains_governed_purchase_proposals(): + records = [ + {"id": "po", "tool": "create_purchase_order"}, + {"id": "csv", "tool": "sync_external_purchase_order"}, + {"id": "stock", "tool": "update_inventory"}, + {"id": "sale", "tool": "create_order"}, + ] + + assert [item["id"] for item in _filter_purchase_proposals(records)] == [ + "po", + "csv", + ] + + +def test_approver_history_filter_accepts_logger_field_name(): + records = [ + {"approval_id": "csv", "tool_name": "sync_external_purchase_order"}, + {"approval_id": "other", "tool_name": "update_inventory"}, + ] + + assert [ + item["approval_id"] for item in _filter_purchase_proposals(records) + ] == ["csv"] + + +def test_dashboard_uses_live_principal_and_separate_approver_surface(): + source = ( + Path(__file__).resolve().parents[1] / "frontend/page_agent_dashboard.py" + ).read_text(encoding="utf-8") + + assert "principal = load_principal(username)" in source + assert "mode = dashboard_mode(principal)" in source + assert "_render_purchase_approval_dashboard(" in source + assert "current_role = principal.role" in source + assert "current_username = principal.username" in source + assert 'if mode == "full":\n st.markdown("
🕵️ Agent Dashboard' in source diff --git a/tests/test_erp_exchange.py b/tests/test_erp_exchange.py index 195dbca..1238834 100644 --- a/tests/test_erp_exchange.py +++ b/tests/test_erp_exchange.py @@ -72,10 +72,10 @@ def _row(**overrides): return row -def _stage_one(source_system="odoo-demo", **overrides): +def _stage_one(source_system="odoo-demo", *, actor="wh1", **overrides): exchange = _exchange_module() rows = exchange.parse_purchase_order_csv(_csv_bytes([_row(**overrides)])) - return exchange.stage_purchase_order_rows(source_system, rows) + return exchange.stage_purchase_order_rows(source_system, rows, actor=actor) def _submit_one(source_system="odoo-demo", external_id="odoo.purchase_order_1001"): @@ -90,6 +90,7 @@ def _submit_one(source_system="odoo-demo", external_id="odoo.purchase_order_1001 "sync_external_purchase_order", {"source_system": source_system, "external_id": external_id}, role="warehouse", + actor="wh1", agent_name="procurement_agent", operation_id=operation_id, ) @@ -191,7 +192,7 @@ def test_stage_preview_joins_supplier_risk_without_inventing_ai_result(exchange_ exchange = _exchange_module() _stage_one() - records = exchange.list_exchange_records("odoo-demo") + records = exchange.list_exchange_records("odoo-demo", actor="wh1") assert len(records) == 1 assert records[0]["supplier_country"] == "日本" @@ -214,13 +215,13 @@ def test_stage_rejects_unknown_product_and_non_official_supplier(exchange_db): _csv_bytes([_row(supplier_id="SUP02")]) ) with pytest.raises(ValueError, match="正式供應商"): - exchange.stage_purchase_order_rows("odoo-demo", non_official) + exchange.stage_purchase_order_rows("odoo-demo", non_official, actor="wh1") unknown_product = exchange.parse_purchase_order_csv( _csv_bytes([_row(product_id="MISSING")]) ) with pytest.raises(ValueError, match="product_id"): - exchange.stage_purchase_order_rows("odoo-demo", unknown_product) + exchange.stage_purchase_order_rows("odoo-demo", unknown_product, actor="wh1") def test_stage_rejects_duplicate_po_identity_without_partial_write(exchange_db): @@ -235,7 +236,7 @@ def test_stage_rejects_duplicate_po_identity_without_partial_write(exchange_db): ) with pytest.raises(ValueError, match="重複 po_id"): - exchange.stage_purchase_order_rows("odoo-demo", rows) + exchange.stage_purchase_order_rows("odoo-demo", rows, actor="wh1") with sqlite3.connect(exchange_db) as conn: assert conn.execute( diff --git a/tests/test_erp_exchange_ui.py b/tests/test_erp_exchange_ui.py index 8716c59..6f19050 100644 --- a/tests/test_erp_exchange_ui.py +++ b/tests/test_erp_exchange_ui.py @@ -35,9 +35,11 @@ def _call_is_guarded_by_button(tree: ast.AST, function_name: str) -> bool: def test_procurement_menu_routes_to_erp_csv_exchange_page(): app_source = _source("app.py") + navigation_source = _source("frontend/access_navigation.py") procurement_source = _source("frontend/page_procurement.py") - assert '"ERP CSV 交換"' in app_source + assert "build_menu_structure(principal)" in app_source + assert '"ERP CSV 交換"' in navigation_source assert '"ERP CSV 交換"' in procurement_source assert "page_erp_csv_exchange" in procurement_source @@ -50,10 +52,11 @@ def test_logout_clears_all_erp_csv_session_keys(): if isinstance(node, ast.FunctionDef) and node.name == "logout" ) normalized = ast.dump(logout) + navigation_source = _source("frontend/access_navigation.py") - assert "erp_csv_" in normalized - assert "startswith" in normalized - assert "Delete" in normalized + assert "clear_identity_session_state" in normalized + assert 'key.startswith("erp_csv_")' in navigation_source + assert "state.pop(key, None)" in navigation_source def test_csv_writes_and_receipt_reconciliation_require_explicit_buttons(): diff --git a/tests/test_line_access.py b/tests/test_line_access.py index e4e5777..58af196 100644 --- a/tests/test_line_access.py +++ b/tests/test_line_access.py @@ -56,6 +56,27 @@ def test_line_tool_filter_is_fail_closed(): ] +def test_line_execution_guard_rejects_write_even_if_model_names_it(): + registry = FakeRegistry() + + assert line_access.is_line_tool_allowed( + "inventory_read", registry, "warehouse" + ) + assert not line_access.is_line_tool_allowed( + "inventory_write", registry, "warehouse" + ) + assert not line_access.is_line_tool_allowed("unknown", registry, "warehouse") + + +def test_line_gateway_rechecks_execution_boundary(): + source = ( + Path(__file__).resolve().parents[1] / "line bot" / "bot_server.py" + ).read_text(encoding="utf-8") + + assert "is_line_tool_allowed(tool_name, registry, role)" in source + assert "gateway.call(tool_name, args or {}, role=role)" in source + + def test_briefing_user_ids_are_trimmed_and_deduplicated(): assert line_access.parse_line_user_ids(" U1, U2,U1, ,U3 ") == ("U1", "U2", "U3") assert line_access.parse_line_user_ids(None) == () diff --git a/tests/test_pending_gate.py b/tests/test_pending_gate.py index 1cb4f85..5d41a2b 100644 --- a/tests/test_pending_gate.py +++ b/tests/test_pending_gate.py @@ -70,3 +70,33 @@ def test_no_pending_reply_untouched(monkeypatch): assert result["pending"] == [] assert result["reply"] == "目前庫存共 150 件。" assert "審批" not in result["reply"] + + +def test_verified_actor_is_forwarded_to_tool_gateway(monkeypatch): + observed = {} + + def fake_gateway_call(tool_name, args, role, **kwargs): + observed.update( + { + "tool_name": tool_name, + "args": args, + "role": role, + **kwargs, + } + ) + return SimpleNamespace(status="denied", message="test denial", data=None) + + monkeypatch.setattr(orch.gateway, "call", fake_gateway_call) + + result = orch.execute_tool_call( + "create_purchase_order", + {"po_id": "PO-ACTOR-PROPAGATION"}, + "warehouse", + agent_id="procurement_agent", + actor="wh1", + operation_id="agent:tc-actor", + ) + + assert result["status"] == "denied" + assert observed["actor"] == "wh1" + assert observed["agent_name"] == "procurement_agent" diff --git a/tests/test_purchase_order_approval.py b/tests/test_purchase_order_approval.py index d8780e8..a538393 100644 --- a/tests/test_purchase_order_approval.py +++ b/tests/test_purchase_order_approval.py @@ -139,6 +139,7 @@ def _submit_po(*, operation_id="po-submit-session-1", args=None): "create_purchase_order", submitted_args, role="warehouse", + actor="wh1", agent_name="procurement_agent", operation_id=operation_id, ) @@ -293,7 +294,7 @@ def test_same_operation_id_returns_the_same_pending_po_approval(isolated_db): assert rows[0][0] == first_id assert rows[0][1] == operation_id assert rows[0][2] - assert rows[0][3:] == ("absent", "po-approval-v1", 0) + assert rows[0][3:] == ("absent", "po-approval-v2", 0) def test_conditional_approval_transition_allows_only_one_winner(isolated_db): @@ -355,6 +356,7 @@ def test_canonical_digest_covers_every_effectful_value(): "args": base_args, "resource_version": "absent", "policy_version": "po-approval-v1", + "requester_username": "wh1", } baseline = canonical_payload_digest(**digest_kwargs) @@ -373,6 +375,10 @@ def test_canonical_digest_covers_every_effectful_value(): "note": {**digest_kwargs, "args": {**base_args, "note": "risk-event-43"}}, "resource_version": {**digest_kwargs, "resource_version": "present:v1"}, "policy_version": {**digest_kwargs, "policy_version": "po-approval-v2"}, + "requester_username": { + **digest_kwargs, + "requester_username": "warehouse-peer", + }, } ignored = [ name @@ -382,6 +388,43 @@ def test_canonical_digest_covers_every_effectful_value(): assert ignored == [], f"digest ignored effectful values: {ignored}" +def test_legacy_protected_pending_can_only_be_closed_by_rejection(isolated_db): + from backend.agent_logger import create_pending_approval + from backend.tool_gateway import gateway + + database.init_db() + approval_id = create_pending_approval( + "create_purchase_order", + { + "po_id": "PO-LEGACY-NO-ORIGIN", + "supplier_id": "SUP01", + "product_id": "P001", + "qty": 1, + "unit_price": 100.0, + "order_date": "2026-07-20", + "status": "pending_review", + "note": "legacy originator missing", + }, + "warehouse", + requester_username=None, + operation_id="legacy-no-origin", + resource_version="absent", + ) + + assert gateway.approve_action(approval_id, approver="admin").status == "denied" + rejected = gateway.reject_action( + approval_id, "migration closeout", approver="admin" + ) + + assert rejected.status == "denied" + row = database.run_query( + "SELECT status, reason FROM pending_approvals WHERE approval_id = ?", + (approval_id,), + )[0] + assert row[0] == "rejected" + assert "legacy" in row[1].lower() + + def test_po_tool_is_registered_as_governed_write_and_hidden_from_line(): from backend import ALL_TOOLS, tools_mapping from backend.agent_registry import AGENTS, get_tools_for_agent @@ -934,8 +977,8 @@ def test_ui_sources_use_gateway_operation_id_and_current_approver(): for value in approver_values ), f"{function_name} hard-codes admin" - assert "session_state" in dashboard_source - assert "username" in dashboard_source + assert "load_principal" in dashboard_source + assert "principal.username" in dashboard_source def test_protected_decisions_require_an_explicit_current_actor(isolated_db): @@ -1041,7 +1084,9 @@ def test_agent_tool_call_propagates_a_stable_operation_id(monkeypatch): captured = [] - def fake_execute(tool_name, args, role, agent_id="", *, operation_id=None): + def fake_execute( + tool_name, args, role, agent_id="", *, actor=None, operation_id=None + ): captured.append((tool_name, operation_id)) return { "status": "pending", diff --git a/tests/test_scheduler_authorization.py b/tests/test_scheduler_authorization.py new file mode 100644 index 0000000..2701f24 --- /dev/null +++ b/tests/test_scheduler_authorization.py @@ -0,0 +1,69 @@ +import pandas as pd +import pytest + +from backend import scheduler + + +def test_scheduler_refresh_passes_configured_actor(monkeypatch): + observed = {} + monkeypatch.setattr( + scheduler, + "require_capability", + lambda actor, capability: observed.update( + authorized=(actor, capability) + ), + ) + monkeypatch.setattr( + scheduler, + "get_suppliers_for_map", + lambda: pd.DataFrame({"country": ["台灣", "日本", "台灣"]}), + ) + + def fake_refresh(countries, **kwargs): + observed["countries"] = countries + observed.update(kwargs) + return {"saved": 2} + + monkeypatch.setattr(scheduler, "refresh_news_for_countries", fake_refresh) + + result = scheduler.refresh_supply_chain_news_once(actor="planner") + + assert result == {"saved": 2} + assert observed == { + "authorized": ("planner", scheduler.RISK_WORKSPACE_WRITE), + "countries": ["台灣", "日本"], + "max_per_country": 5, + "actor": "planner", + } + + +def test_scheduler_refresh_requires_actor(monkeypatch): + monkeypatch.setattr( + scheduler, "get_suppliers_for_map", lambda: pytest.fail("must not read") + ) + + with pytest.raises(PermissionError, match="ERP_SCHEDULER_ACTOR"): + scheduler.refresh_supply_chain_news_once(actor="") + + +def test_scheduler_rejects_unauthorized_actor_before_supplier_read( + tmp_path, monkeypatch +): + from backend import database + + monkeypatch.setattr(database, "DB_FILE", str(tmp_path / "scheduler.db")) + database.init_db() + monkeypatch.setattr( + scheduler, "get_suppliers_for_map", lambda: pytest.fail("must not read") + ) + + with pytest.raises(PermissionError, match="權限"): + scheduler.refresh_supply_chain_news_once(actor="viewer") + + +def test_scheduler_stays_disabled_without_service_identity(monkeypatch): + monkeypatch.setattr(scheduler, "_scheduler_started", False) + monkeypatch.delenv("ERP_SCHEDULER_ACTOR", raising=False) + + assert scheduler.start_background_jobs() is False + assert scheduler._scheduler_started is False diff --git a/tests/test_supply_chain_authorization.py b/tests/test_supply_chain_authorization.py new file mode 100644 index 0000000..9cda089 --- /dev/null +++ b/tests/test_supply_chain_authorization.py @@ -0,0 +1,498 @@ +"""Server-side authorization contracts for L2 supply-chain actions.""" + +from __future__ import annotations + +import ast +import sqlite3 +from pathlib import Path + +import pytest + +from backend import database +from backend import access_control +from backend import supply_chain_news as news +from backend import supply_chain_risk as risk + + +@pytest.fixture +def supply_db(tmp_path, monkeypatch): + db_path = tmp_path / "supply-authorization.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.execute( + """ + INSERT OR REPLACE INTO inventory + (product_id, name, stock, reorder_point, + baseline_reorder_point, daily_sales) + VALUES ('P-AUTH', 'Authorization fixture', 50, 10, 8, 2) + """ + ) + conn.execute( + """ + INSERT OR REPLACE INTO suppliers + (supplier_id, name, country, region, is_official) + VALUES ('S-AUTH', 'Authorization supplier', 'Taiwan', 'Taichung', 1) + """ + ) + conn.execute( + """ + INSERT OR REPLACE INTO purchase_orders + (po_id, supplier_id, status, estimated_delay_days, + alternative_suggestion) + VALUES ('PO-AUTH', 'S-AUTH', 'pending', 1, 'original') + """ + ) + conn.execute( + """ + INSERT INTO purchase_order_items (po_id, product_id, qty, unit_price) + VALUES ('PO-AUTH', 'P-AUTH', 5, 10) + """ + ) + conn.execute( + """ + INSERT INTO supply_chain_events + (event_type, region, country, impact_days, description, created_at) + VALUES ('delay', 'Taichung', 'Taiwan', 4, 'fixture', '2026-07-20 00:00') + """ + ) + conn.execute( + """ + INSERT INTO risk_heatmap + (region_key, display_name, latitude, longitude, risk_pct, + ai_summary, updated_at) + VALUES ('Taiwan|Taichung', 'Taiwan Taichung', 24.15, 120.68, + 25, 'fixture', '2026-07-20 00:00') + """ + ) + conn.execute( + """ + INSERT OR REPLACE INTO esg_risk_factors + (risk_type, risk_key, risk_score, weight, note, updated_at) + VALUES ('region', 'fixture', 20, 1, 'fixture', '2026-07-20 00:00') + """ + ) + conn.commit() + return db_path + + +def _snapshot(db_path): + tables = ( + "supply_chain_events", + "risk_heatmap", + "inventory", + "purchase_orders", + "esg_risk_factors", + ) + with sqlite3.connect(db_path) as conn: + return { + table: conn.execute(f"SELECT * FROM {table} ORDER BY rowid").fetchall() + for table in tables + } + + +def _mutation(name: str, actor: str | None): + event_id = risk.get_risk_events_list(limit=1).iloc[0]["id"] + factor_id = risk.get_risk_factors().iloc[0]["id"] + operations = { + "add_event": lambda: risk.add_risk_event( + "strike", "Kaohsiung", "Taiwan", 7, "new", actor=actor + ), + "delete_event": lambda: risk.delete_risk_event(event_id, actor=actor), + "upsert_heatmap": lambda: risk.upsert_risk_heatmap( + "Japan|Tokyo", "Japan Tokyo", 35.68, 139.69, 80, + "new", actor=actor + ), + "reset_heatmap": lambda: risk.reset_risk_heatmap_to_initial(actor=actor), + "apply_heatmap": lambda: risk.apply_heatmap_updates( + [{"display_name": "Taiwan Taichung", "risk_pct": 90}], + "new", actor=actor + ), + "update_po_impact": lambda: risk.update_po_impact( + "PO-AUTH", 9, "replacement", actor=actor + ), + "increase_stock": lambda: risk.increase_safety_stock_for_event( + "Taichung", "Taiwan", 5, actor=actor + ), + "restore_stock": lambda: risk.restore_all_rop_to_baseline(actor=actor), + "update_rop": lambda: risk.update_reorder_point("P-AUTH", 99, actor=actor), + "save_factor": lambda: risk.save_risk_factor( + "region", "new", 70, 1, actor=actor + ), + "delete_factor": lambda: risk.delete_risk_factor(factor_id, actor=actor), + "clear_factors": lambda: risk.clear_all_risk_factors(actor=actor), + "load_presets": lambda: risk.load_preset_risk_factors(actor=actor), + } + return operations[name]() + + +_WORKSPACE_MUTATIONS = ( + "add_event", + "delete_event", + "upsert_heatmap", + "reset_heatmap", + "apply_heatmap", + "save_factor", + "delete_factor", + "clear_factors", + "load_presets", +) + +_ERP_POLICY_MUTATIONS = ( + "update_po_impact", + "increase_stock", + "restore_stock", + "update_rop", +) + +_MUTATIONS = _WORKSPACE_MUTATIONS + _ERP_POLICY_MUTATIONS + + +def test_supply_capabilities_separate_workspace_from_erp_policy(): + workspace_write = access_control.RISK_WORKSPACE_WRITE + erp_policy_write = access_control.ERP_POLICY_WRITE + + planner = access_control.capabilities_for_role("supply_planner") + warehouse = access_control.capabilities_for_role("warehouse") + admin = access_control.capabilities_for_role("admin") + viewer = access_control.capabilities_for_role("risk_viewer") + approver = access_control.capabilities_for_role("procurement_approver") + + assert workspace_write in planner + assert erp_policy_write not in planner + assert {workspace_write, erp_policy_write} <= warehouse + assert {workspace_write, erp_policy_write} <= admin + assert workspace_write not in viewer | approver + assert erp_policy_write not in viewer | approver + + +@pytest.mark.parametrize("actor", [None, "viewer", "approver"]) +@pytest.mark.parametrize("operation", _MUTATIONS) +def test_l2_mutations_deny_before_any_database_side_effect( + supply_db, operation, actor +): + before = _snapshot(supply_db) + + with pytest.raises(PermissionError): + _mutation(operation, actor) + + assert _snapshot(supply_db) == before + + +@pytest.mark.parametrize("actor", ["planner", "admin", "wh1"]) +@pytest.mark.parametrize("operation", _WORKSPACE_MUTATIONS) +def test_authorized_l2_roles_can_write_workspace( + supply_db, operation, actor +): + _mutation(operation, actor) + + +@pytest.mark.parametrize("actor", ["admin", "wh1"]) +@pytest.mark.parametrize("operation", _ERP_POLICY_MUTATIONS) +def test_authorized_erp_policy_roles_can_execute_mutation( + supply_db, operation, actor +): + _mutation(operation, actor) + + +@pytest.mark.parametrize("operation", _ERP_POLICY_MUTATIONS) +def test_planner_cannot_write_erp_policy_and_has_no_database_side_effect( + supply_db, operation +): + before = _snapshot(supply_db) + + with pytest.raises(PermissionError): + _mutation(operation, "planner") + + assert _snapshot(supply_db) == before + + +@pytest.mark.parametrize("actor", [None, "viewer", "approver"]) +def test_what_if_denies_before_sensitive_reads_or_llm( + supply_db, monkeypatch, actor +): + calls = {"read": 0, "llm": 0} + + def forbidden_read(*args, **kwargs): + calls["read"] += 1 + raise AssertionError("sensitive ERP data was read before authorization") + + def forbidden_llm(*args, **kwargs): + calls["llm"] += 1 + raise AssertionError("LLM was called before authorization") + + monkeypatch.setattr(risk, "__pd_read", forbidden_read) + monkeypatch.setattr("backend.llm_client.complete_text", forbidden_llm) + + with pytest.raises(PermissionError): + risk.what_if_simulation("", "What happens?", actor=actor) + + assert calls == {"read": 0, "llm": 0} + + +@pytest.mark.parametrize("actor", ["planner", "admin", "wh1"]) +def test_authorized_roles_can_run_what_if(supply_db, monkeypatch, actor): + llm_calls = [] + + def fake_llm(*args, **kwargs): + llm_calls.append((args, kwargs)) + return "authorized result" + + monkeypatch.setattr("backend.llm_client.complete_text", fake_llm) + + assert risk.what_if_simulation( + "", "What happens?", actor=actor + ) == "authorized result" + assert len(llm_calls) == 1 + + +def test_entitlement_revocation_is_immediate(supply_db, monkeypatch): + llm_calls = [] + + def fake_llm(*args, **kwargs): + llm_calls.append((args, kwargs)) + return "authorized result" + + monkeypatch.setattr("backend.llm_client.complete_text", fake_llm) + + assert risk.what_if_simulation( + "", "What happens?", actor="planner" + ) == "authorized result" + + database.run_query( + "UPDATE organization_entitlements SET enabled = 0 " + "WHERE organization_id = 'demo-org' AND entitlement_key = 'l2_decision'", + fetch=False, + ) + + before = _snapshot(supply_db) + with pytest.raises(PermissionError): + risk.add_risk_event( + "strike", "Kaohsiung", "Taiwan", 7, "revoked", + actor="planner" + ) + with pytest.raises(PermissionError): + risk.what_if_simulation("", "Try again", actor="planner") + + assert _snapshot(supply_db) == before + assert len(llm_calls) == 1 + + +@pytest.mark.parametrize("actor", [None, "viewer", "approver"]) +def test_news_refresh_denies_before_fetch_or_database_write( + supply_db, monkeypatch, actor +): + fetch_calls = [] + + def forbidden_fetch(*args, **kwargs): + fetch_calls.append((args, kwargs)) + raise AssertionError("external news fetch ran before authorization") + + monkeypatch.setattr(news, "fetch_country_news", forbidden_fetch) + before = _snapshot(supply_db) + with sqlite3.connect(supply_db) as conn: + news_count = conn.execute( + "SELECT COUNT(*) FROM supply_chain_news" + ).fetchone()[0] + + with pytest.raises(PermissionError): + news.refresh_news_for_countries(["Taiwan"], actor=actor) + + with sqlite3.connect(supply_db) as conn: + assert conn.execute( + "SELECT COUNT(*) FROM supply_chain_news" + ).fetchone()[0] == news_count + assert _snapshot(supply_db) == before + assert fetch_calls == [] + + +def test_planner_news_refresh_applies_heatmap_update(supply_db, monkeypatch): + monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) + monkeypatch.setattr( + news, + "fetch_country_news", + lambda *args, **kwargs: [ + { + "country": "Taiwan", + "region": "Taichung", + "title": "Port disruption", + "summary": "Delay expected", + "url": "https://example.test/news", + "source": "test", + "published_at": "2026-07-20 00:00", + "relevance_tag": "supply_chain", + } + ], + ) + monkeypatch.setattr( + risk, + "batch_infer_affected_region_from_news", + lambda **kwargs: [ + { + "is_relevant": True, + "estimated_delay": 5, + "event_type": "delay", + "country": "Taiwan", + "region": "Taichung", + "chinese_summary": "Test summary", + } + ], + ) + monkeypatch.setattr( + risk, + "get_heatmap_ai_summary", + lambda **kwargs: ( + "Authorized update", + [{"display_name": "Taiwan Taichung", "risk_pct": 88}], + [], + ), + ) + + result = news.refresh_news_for_countries(["Taiwan"], actor="planner") + + assert result["saved_count"] == 1 + with sqlite3.connect(supply_db) as conn: + assert conn.execute( + "SELECT COUNT(*) FROM supply_chain_news" + ).fetchone()[0] == 1 + assert conn.execute( + "SELECT risk_pct FROM risk_heatmap " + "WHERE region_key = 'Taiwan|Taichung'" + ).fetchone()[0] == 88 + + +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: []) + monkeypatch.setattr( + risk, + "get_heatmap_ai_summary", + lambda **kwargs: ( + "Update", + [{"display_name": "Taiwan Taichung", "risk_pct": 88}], + [], + ), + ) + + def revoked_during_refresh(*args, **kwargs): + raise PermissionError("entitlement was revoked") + + monkeypatch.setattr(risk, "apply_heatmap_updates", revoked_during_refresh) + + with pytest.raises(PermissionError, match="revoked"): + news.refresh_news_for_countries(["Taiwan"], actor="planner") + + +def test_supply_components_require_and_forward_live_actor(): + component_paths = ( + Path("frontend/components/supply_map.py"), + Path("frontend/components/risk_dashboard.py"), + ) + required_renderers = { + "render_risk_shortcuts", + "render_supply_chain_map", + "render_what_if_analysis", + "render_intelligence_gathering", + "render_response_execution", + } + protected_calls = { + "add_risk_event", + "delete_risk_event", + "upsert_risk_heatmap", + "reset_risk_heatmap_to_initial", + "apply_heatmap_updates", + "update_po_impact", + "what_if_simulation", + "increase_safety_stock_for_event", + "restore_all_rop_to_baseline", + "update_reorder_point", + "save_risk_factor", + "delete_risk_factor", + "clear_all_risk_factors", + "load_preset_risk_factors", + "refresh_news_for_countries", + } + + found_renderers = set() + protected_invocations = [] + for path in component_paths: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name in required_renderers: + found_renderers.add(node.name) + parameter_names = { + argument.arg + for argument in ( + node.args.posonlyargs + + node.args.args + + node.args.kwonlyargs + ) + } + assert "actor" in parameter_names + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in protected_calls + ): + protected_invocations.append((path, node)) + assert any(keyword.arg == "actor" for keyword in node.keywords), ( + f"{path}:{node.lineno} must pass actor to {node.func.id}" + ) + + assert found_renderers == required_renderers + assert protected_invocations + + +def test_planner_component_hides_direct_erp_policy_actions(supply_db): + from frontend.components import risk_dashboard + + assert not risk_dashboard.can_write_erp_policy("planner") + assert not risk_dashboard.can_write_erp_policy("viewer") + assert not risk_dashboard.can_write_erp_policy("approver") + assert risk_dashboard.can_write_erp_policy("admin") + assert risk_dashboard.can_write_erp_policy("wh1") + + path = Path("frontend/components/risk_dashboard.py") + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + parents = {} + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + parents[child] = parent + + policy_calls = [] + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id + in { + "increase_safety_stock_for_event", + "restore_all_rop_to_baseline", + "update_reorder_point", + } + ): + continue + policy_calls.append(node) + cursor = node + guarded = False + while cursor in parents: + cursor = parents[cursor] + if ( + isinstance(cursor, ast.If) + and "can_write_policy" in ast.unparse(cursor.test) + ): + guarded = True + break + assert guarded, ( + f"{path}:{node.lineno} ERP policy mutation must be hidden " + "behind can_write_policy" + ) + + assert policy_calls + assert "受治理提案" in source diff --git a/tests/test_tier_authorization.py b/tests/test_tier_authorization.py new file mode 100644 index 0000000..98c3e9e --- /dev/null +++ b/tests/test_tier_authorization.py @@ -0,0 +1,605 @@ +"""Authorization contracts for the L1/L2/L3 demo accounts.""" + +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +import pytest + +from backend import database + +from backend.access_control import ( + APPROVAL_DECIDE, + APPROVAL_QUEUE_READ, + ERP_EXCHANGE_EXPORT, + ERP_EXCHANGE_PROPOSE, + ERP_EXCHANGE_RECONCILE, + PROPOSAL_EVIDENCE_READ, + RISK_ANALYSIS_READ, + RISK_OVERVIEW_READ, + RISK_WHAT_IF_RUN, + capabilities_for_role, + has_capability, + load_principal, + require_capability, +) + + +@pytest.fixture +def tier_db(tmp_path, monkeypatch): + db_path = tmp_path / "tier-access.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + database.init_db() + return db_path + + +def test_known_demo_accounts_require_explicit_demo_mode(tmp_path, monkeypatch): + from backend.auth import check_login + + db_path = tmp_path / "production-default.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.delenv("ERP_DEMO_MODE", raising=False) + + database.init_db() + + assert database.run_query("SELECT COUNT(*) FROM users")[0][0] == 0 + assert database.run_query("SELECT COUNT(*) FROM user_organizations")[0][0] == 0 + assert database.run_query("SELECT COUNT(*) FROM organization_entitlements")[0][0] == 0 + assert check_login("viewer", "viewer") is None + assert check_login("admin", "admin") is None + + +def test_runtime_init_does_not_restore_revoked_access(tier_db): + database.run_query( + "DELETE FROM user_organizations WHERE username = 'planner'", fetch=False + ) + database.run_query( + "DELETE FROM organization_entitlements " + "WHERE organization_id = 'demo-org' AND entitlement_key = 'l3_governed_action'", + fetch=False, + ) + + database.init_db() + + assert load_principal("planner") is None + assert database.run_query( + "SELECT COUNT(*) FROM organization_entitlements " + "WHERE organization_id = 'demo-org' AND entitlement_key = 'l3_governed_action'" + )[0][0] == 0 + + +def test_demo_seed_does_not_grant_existing_unrelated_users(tier_db, monkeypatch): + from backend.passwords import hash_password + + database.run_query( + "INSERT INTO users (username, password, role, name) VALUES (?, ?, ?, ?)", + ("existing-user", hash_password("private"), "sales", "既有使用者"), + fetch=False, + ) + database.run_query( + "DELETE FROM app_metadata WHERE key = 'tier_demo_seed_v1'", fetch=False + ) + database.run_query("DELETE FROM user_organizations", fetch=False) + monkeypatch.setenv("ERP_DEMO_MODE", "1") + + database.init_db() + + assert database.run_query( + "SELECT COUNT(*) FROM user_organizations WHERE username = 'existing-user'" + )[0][0] == 0 + + +def test_demo_roles_have_context_visibility_without_inheriting_actions(): + viewer = capabilities_for_role("risk_viewer") + planner = capabilities_for_role("supply_planner") + approver = capabilities_for_role("procurement_approver") + + assert viewer == {RISK_OVERVIEW_READ} + + assert {RISK_OVERVIEW_READ, RISK_ANALYSIS_READ, RISK_WHAT_IF_RUN} <= planner + assert ERP_EXCHANGE_PROPOSE in planner + assert APPROVAL_DECIDE not in planner + assert ERP_EXCHANGE_EXPORT not in planner + + assert RISK_OVERVIEW_READ in approver + assert PROPOSAL_EVIDENCE_READ in approver + assert APPROVAL_QUEUE_READ in approver + assert APPROVAL_DECIDE in approver + assert ERP_EXCHANGE_EXPORT in approver + assert ERP_EXCHANGE_RECONCILE in approver + assert RISK_ANALYSIS_READ not in approver + assert RISK_WHAT_IF_RUN not in approver + assert ERP_EXCHANGE_PROPOSE not in approver + + warehouse = capabilities_for_role("warehouse") + assert APPROVAL_QUEUE_READ in warehouse + assert APPROVAL_DECIDE not in warehouse + + +def test_demo_accounts_are_seeded_with_live_entitlements(tier_db): + from backend.auth import check_login + + viewer = load_principal("viewer") + planner = load_principal("planner") + approver = load_principal("approver") + + assert viewer is not None and viewer.role == "risk_viewer" + assert planner is not None and planner.role == "supply_planner" + assert approver is not None and approver.role == "procurement_approver" + + assert viewer.can(RISK_OVERVIEW_READ) + assert not viewer.can(RISK_ANALYSIS_READ) + assert planner.can(RISK_ANALYSIS_READ) + assert planner.can(ERP_EXCHANGE_PROPOSE) + assert approver.can(APPROVAL_DECIDE) + assert approver.can(ERP_EXCHANGE_EXPORT) + + with sqlite3.connect(tier_db) as conn: + memberships = dict( + conn.execute( + "SELECT username, organization_id FROM user_organizations " + "WHERE username IN ('viewer', 'planner', 'approver')" + ) + ) + entitlements = { + row[0] + for row in conn.execute( + "SELECT entitlement_key FROM organization_entitlements " + "WHERE organization_id = 'demo-org' AND enabled = 1" + ) + } + assert memberships == { + "viewer": "demo-org", + "planner": "demo-org", + "approver": "demo-org", + } + assert entitlements == {"l1_monitor", "l2_decision", "l3_governed_action"} + assert check_login("viewer", "viewer")["role"] == "risk_viewer" + assert check_login("planner", "planner")["role"] == "supply_planner" + assert check_login("approver", "approver")["role"] == "procurement_approver" + + stored_passwords = { + row[0]: row[1] + for row in database.run_query( + "SELECT username, password FROM users " + "WHERE username IN ('viewer', 'planner', 'approver')" + ) + } + assert all(stored_passwords[user] != user for user in stored_passwords) + + +def test_live_entitlement_and_role_changes_fail_closed(tier_db): + assert has_capability("planner", RISK_WHAT_IF_RUN) + + database.run_query( + "UPDATE organization_entitlements SET enabled = 0 " + "WHERE organization_id = 'demo-org' AND entitlement_key = 'l2_decision'", + fetch=False, + ) + assert not has_capability("planner", RISK_WHAT_IF_RUN) + with pytest.raises(PermissionError, match="權限"): + require_capability("planner", RISK_WHAT_IF_RUN) + + database.run_query( + "UPDATE users SET role = 'unknown_role' WHERE username = 'approver'", + fetch=False, + ) + assert not has_capability("approver", APPROVAL_DECIDE) + assert not has_capability("missing-user", RISK_OVERVIEW_READ) + + +@pytest.mark.parametrize("actor", [None, "viewer"]) +def test_protected_write_requires_live_actor_matching_claimed_role( + tier_db, actor +): + from backend.tool_gateway import gateway + + result = gateway.call( + "create_purchase_order", + { + "po_id": f"PO-ACTOR-{actor or 'missing'}", + "supplier_id": "SUP01", + "product_id": "P001", + "qty": 2, + "unit_price": 500.0, + "order_date": "2026-07-20", + "status": "pending_review", + "note": "authorization test", + }, + role="admin", + actor=actor, + agent_name="procurement_agent", + operation_id=f"actor-check-{actor or 'missing'}", + ) + + assert result.status == "denied" + assert database.run_query("SELECT COUNT(*) FROM pending_approvals")[0][0] == 0 + + +@pytest.mark.parametrize( + ("actor", "role"), + [ + (None, "warehouse"), + ("missing-user", "warehouse"), + ("wh1", "admin"), + ], +) +def test_generic_write_requires_live_actor_matching_claimed_role( + tier_db, actor, role +): + from backend.tool_gateway import gateway + + result = gateway.call( + "update_inventory", + {"product_id": "P001", "quantity_change": 1}, + role=role, + actor=actor, + agent_name="inventory_agent", + operation_id=f"generic-actor-check-{actor or 'missing'}-{role}", + ) + + assert result.status == "denied" + assert database.run_query("SELECT COUNT(*) FROM pending_approvals")[0][0] == 0 + + +def test_generic_write_persists_canonical_actor(tier_db): + from backend.tool_gateway import gateway + + pending = gateway.call( + "update_inventory", + {"product_id": "P001", "quantity_change": 1}, + role="warehouse", + actor=" wh1 ", + agent_name="inventory_agent", + operation_id="generic-canonical-actor", + ) + + assert pending.status == "pending" + assert database.run_query( + "SELECT requester_username FROM pending_approvals WHERE approval_id = ?", + (pending.approval_id,), + )[0][0] == "wh1" + + +def test_originator_is_recorded_and_cannot_approve_own_proposal(tier_db): + from backend.tool_gateway import gateway + + database.run_query( + "UPDATE suppliers SET is_official = 1 WHERE supplier_id = 'SUP01'", + fetch=False, + ) + pending = gateway.call( + "create_purchase_order", + { + "po_id": "PO-SELF-APPROVAL", + "supplier_id": "SUP01", + "product_id": "P001", + "qty": 2, + "unit_price": 500.0, + "order_date": "2026-07-20", + "status": "pending_review", + "note": "self approval test", + }, + role="admin", + actor="admin", + agent_name="procurement_agent", + operation_id="self-approval-operation", + ) + assert pending.status == "pending" + requester = database.run_query( + "SELECT requester_username FROM pending_approvals WHERE approval_id = ?", + (pending.approval_id,), + )[0][0] + assert requester == "admin" + + for submitted_identity in ("admin", " admin "): + denied = gateway.approve_action( + pending.approval_id, approver=submitted_identity + ) + assert denied.status == "denied" + self_reject = gateway.reject_action( + pending.approval_id, "self reject bypass", approver=" admin " + ) + assert self_reject.status == "denied" + assert database.run_query("SELECT status FROM pending_approvals")[0][0] == "pending" + assert database.run_query("SELECT COUNT(*) FROM purchase_orders")[0][0] == 0 + + approved = gateway.approve_action( + pending.approval_id, approver=" approver " + ) + assert approved.status == "ok" + assert database.run_query("SELECT COUNT(*) FROM purchase_orders")[0][0] == 1 + assert database.run_query( + "SELECT approver FROM pending_approvals WHERE approval_id = ?", + (pending.approval_id,), + )[0][0] == "approver" + + +def test_protected_operation_replay_is_bound_to_original_requester(tier_db): + from backend.passwords import hash_password + from backend.tool_gateway import gateway + + database.run_query( + "UPDATE suppliers SET is_official = 1 WHERE supplier_id = 'SUP01'", + fetch=False, + ) + args = { + "po_id": "PO-ORIGIN-BOUND", + "supplier_id": "SUP01", + "product_id": "P001", + "qty": 2, + "unit_price": 500.0, + "order_date": "2026-07-20", + "status": "pending_review", + "note": "origin binding test", + } + first = gateway.call( + "create_purchase_order", + args, + role="admin", + actor="admin", + agent_name="procurement_agent", + operation_id="origin-bound-operation", + ) + assert first.status == "pending" + + database.run_query( + "INSERT INTO users (username, password, role, name) VALUES (?, ?, ?, ?)", + ("admin2", hash_password("admin2"), "admin", "第二管理員"), + fetch=False, + ) + database.run_query( + "INSERT INTO user_organizations (username, organization_id) " + "VALUES ('admin2', 'demo-org')", + fetch=False, + ) + + replay = gateway.call( + "create_purchase_order", + args, + role="admin", + actor="admin2", + agent_name="procurement_agent", + operation_id="origin-bound-operation", + ) + + assert replay.status == "denied" + assert database.run_query( + "SELECT requester_username FROM pending_approvals WHERE approval_id = ?", + (first.approval_id,), + )[0][0] == "admin" + + +def _exchange_row(**overrides): + row = { + "external_id": "odoo.purchase_order_tier_1", + "po_id": "EXT-TIER-PO-1", + "supplier_id": "SUP01", + "product_id": "P001", + "qty": 2, + "unit_price": 500.0, + "order_date": "2026-07-20", + "status": "pending_review", + "note": "tier authorization contract", + } + row.update(overrides) + return row + + +def test_exchange_staging_is_planner_only_and_denies_before_writing(tier_db): + from backend import erp_exchange + + database.run_query( + "UPDATE suppliers SET is_official = 1 WHERE supplier_id = 'SUP01'", + fetch=False, + ) + + with pytest.raises(PermissionError): + erp_exchange.stage_purchase_order_rows( + "odoo-demo", [_exchange_row()], actor="viewer" + ) + assert database.run_query("SELECT COUNT(*) FROM erp_exchange_records")[0][0] == 0 + + summary = erp_exchange.stage_purchase_order_rows( + "odoo-demo", [_exchange_row()], actor="planner" + ) + assert summary["inserted"] == 1 + + with pytest.raises(PermissionError): + erp_exchange.stage_purchase_order_rows( + "odoo-demo", + [_exchange_row(external_id="second", po_id="EXT-TIER-PO-2")], + actor="approver", + ) + assert database.run_query("SELECT COUNT(*) FROM erp_exchange_records")[0][0] == 1 + + +def test_exchange_read_and_l3_actions_use_separate_capabilities(tier_db): + from backend import erp_exchange + + database.run_query( + "UPDATE suppliers SET is_official = 1 WHERE supplier_id = 'SUP01'", + fetch=False, + ) + erp_exchange.stage_purchase_order_rows( + "odoo-demo", [_exchange_row()], actor="planner" + ) + + assert len(erp_exchange.list_exchange_records("odoo-demo", actor="planner")) == 1 + assert len(erp_exchange.list_exchange_records("odoo-demo", actor="approver")) == 1 + with pytest.raises(PermissionError): + erp_exchange.list_exchange_records("odoo-demo", actor="viewer") + + with pytest.raises(PermissionError): + erp_exchange.export_approved_actions_csv("odoo-demo", actor="planner") + exported = erp_exchange.export_approved_actions_csv( + "odoo-demo", actor="approver" + ) + assert exported.decode("utf-8-sig").startswith("source_system,") + + with pytest.raises(PermissionError): + erp_exchange.reconcile_receipt_csv(b"not-a-csv", actor="planner") + assert erp_exchange.list_exchange_receipts( + "odoo-demo", actor="approver" + ) == [] + + +def test_exchange_entitlement_revocation_is_effective_immediately(tier_db): + from backend import erp_exchange + + database.run_query( + "UPDATE suppliers SET is_official = 1 WHERE supplier_id = 'SUP01'", + fetch=False, + ) + database.run_query( + "UPDATE organization_entitlements SET enabled = 0 " + "WHERE organization_id = 'demo-org' AND entitlement_key = 'l2_decision'", + fetch=False, + ) + + with pytest.raises(PermissionError): + erp_exchange.stage_purchase_order_rows( + "odoo-demo", [_exchange_row()], actor="planner" + ) + assert database.run_query("SELECT COUNT(*) FROM erp_exchange_records")[0][0] == 0 + + +@pytest.mark.parametrize("approver", ["viewer", "planner", "approver"]) +def test_tier_accounts_cannot_decide_unrelated_global_approvals( + tier_db, approver +): + from backend.tool_gateway import gateway + + before = database.run_query( + "SELECT stock FROM inventory WHERE product_id = 'P001'" + )[0][0] + pending = gateway.call( + "update_inventory", + {"product_id": "P001", "quantity_change": 1}, + role="warehouse", + actor="wh1", + agent_name="inventory_agent", + operation_id=f"global-approval-{approver}", + ) + assert pending.status == "pending" + + denied = gateway.approve_action(pending.approval_id, approver=approver) + + assert denied.status == "denied" + assert database.run_query( + "SELECT stock FROM inventory WHERE product_id = 'P001'" + )[0][0] == before + + +def test_generic_approval_rejects_known_self_approval(tier_db): + from backend.tool_gateway import gateway + + before = database.run_query( + "SELECT stock FROM inventory WHERE product_id = 'P001'" + )[0][0] + pending = gateway.call( + "update_inventory", + {"product_id": "P001", "quantity_change": 1}, + role="admin", + actor="admin", + agent_name="inventory_agent", + operation_id="generic-self-approval", + ) + assert pending.status == "pending" + + denied = gateway.approve_action(pending.approval_id, approver=" admin ") + + assert denied.status == "denied" + rejected = gateway.reject_action( + pending.approval_id, "self reject", approver=" admin " + ) + assert rejected.status == "denied" + assert database.run_query( + "SELECT status FROM pending_approvals WHERE approval_id = ?", + (pending.approval_id,), + )[0][0] == "pending" + assert database.run_query( + "SELECT stock FROM inventory WHERE product_id = 'P001'" + )[0][0] == before + + +def test_legacy_generic_approval_is_reject_only(tier_db): + from backend.agent_logger import create_pending_approval + from backend.tool_gateway import gateway + + approval_id = create_pending_approval( + "update_inventory", + {"product_id": "P001", "quantity_change": 1}, + "warehouse", + requester_username=None, + operation_id="legacy-generic-no-originator", + ) + + denied = gateway.approve_action(approval_id, approver="admin") + assert denied.status == "denied" + + rejected = gateway.reject_action( + approval_id, "重新送審", approver="admin" + ) + assert rejected.status == "denied" + row = database.run_query( + "SELECT status, reason FROM pending_approvals WHERE approval_id = ?", + (approval_id,), + )[0] + assert row[0] == "rejected" + assert "legacy originator unavailable" in row[1] + + +def test_generic_approval_claim_allows_only_one_execution( + tier_db, monkeypatch +): + from backend import agent_logger + from backend.tool_gateway import gateway + + before = database.run_query( + "SELECT stock FROM inventory WHERE product_id = 'P001'" + )[0][0] + pending = gateway.call( + "update_inventory", + {"product_id": "P001", "quantity_change": 1}, + role="warehouse", + actor="wh1", + agent_name="inventory_agent", + operation_id="generic-concurrent-approval", + ) + assert pending.status == "pending" + + original_get = agent_logger.get_pending_approval_by_id + both_loaded_pending = Barrier(2) + + def synchronized_get(approval_id): + item = original_get(approval_id) + both_loaded_pending.wait(timeout=5) + return item + + monkeypatch.setattr( + agent_logger, "get_pending_approval_by_id", synchronized_get + ) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list( + pool.map( + lambda _: gateway.approve_action( + pending.approval_id, approver="admin" + ), + range(2), + ) + ) + + assert [result.status for result in results].count("ok") == 1 + assert database.run_query( + "SELECT stock FROM inventory WHERE product_id = 'P001'" + )[0][0] == before + 1 + row = database.run_query( + "SELECT status, version FROM pending_approvals WHERE approval_id = ?", + (pending.approval_id,), + )[0] + assert row == ("approved", 2) + assert database.run_query( + "SELECT COUNT(*) FROM effect_receipts WHERE approval_id = ?", + (pending.approval_id,), + )[0][0] == 1 diff --git a/tests/test_tier_navigation.py b/tests/test_tier_navigation.py new file mode 100644 index 0000000..ba3772b --- /dev/null +++ b/tests/test_tier_navigation.py @@ -0,0 +1,187 @@ +from backend.access_control import AccessContext, capabilities_for_role +from frontend.access_navigation import ( + build_menu_structure, + clear_identity_session_state, + dashboard_mode, + effective_product_levels, + exchange_sections, + normalize_navigation_state, + risk_sections, +) +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _principal(username: str, role: str) -> AccessContext: + return AccessContext( + username=username, + role=role, + name=username, + organization_id="demo-org", + entitlements=frozenset({"l1_monitor", "l2_decision", "l3_governed_action"}), + capabilities=frozenset(capabilities_for_role(role)), + ) + + +def test_viewer_only_sees_l1_risk_overview(): + principal = _principal("viewer", "risk_viewer") + + assert build_menu_structure(principal) == {"🌱 供應鏈與風險": []} + assert risk_sections(principal) == ("overview",) + assert exchange_sections(principal) == () + assert dashboard_mode(principal) == "none" + assert effective_product_levels(principal) == ("L1",) + + +def test_planner_sees_l1_l2_and_proposal_only(): + principal = _principal("planner", "supply_planner") + + assert build_menu_structure(principal) == { + "🌱 供應鏈與風險": [], + "🛒 採購管理": ["ERP CSV 交換"], + } + assert risk_sections(principal) == ("overview", "analysis", "what_if") + assert exchange_sections(principal) == ("proposal",) + assert dashboard_mode(principal) == "none" + assert effective_product_levels(principal) == ("L1", "L2") + + +def test_approver_sees_l1_approval_evidence_and_l3_execution(): + principal = _principal("approver", "procurement_approver") + + assert build_menu_structure(principal) == { + "🌱 供應鏈與風險": [], + "🤖 AI 智能助理": ["Agent Dashboard"], + "🛒 採購管理": ["ERP CSV 交換"], + } + assert risk_sections(principal) == ("overview",) + assert exchange_sections(principal) == ("export", "reconcile") + assert dashboard_mode(principal) == "approvals" + assert effective_product_levels(principal) == ("L1", "L3") + + +def test_admin_keeps_full_existing_navigation_and_dashboard(): + principal = _principal("admin", "admin") + + menu = build_menu_structure(principal) + assert "📊 營運分析看板" in menu + assert menu["🤖 AI 智能助理"] == [ + "對話介面", + "LINE 客服記錄", + "Agent Dashboard", + ] + assert menu["🛒 採購管理"][-1] == "ERP CSV 交換" + assert dashboard_mode(principal) == "full" + + +def test_warehouse_keeps_monitor_only_agent_dashboard(): + principal = _principal("warehouse", "warehouse") + + menu = build_menu_structure(principal) + assert "Agent Dashboard" in menu["🤖 AI 智能助理"] + assert dashboard_mode(principal) == "full" + + +def test_legacy_navigation_removes_tier_surfaces_after_live_entitlement_loss(): + principal = AccessContext( + username="admin", + role="admin", + name="admin", + organization_id="demo-org", + entitlements=frozenset(), + capabilities=frozenset(), + ) + + menu = build_menu_structure(principal) + + assert "🌱 供應鏈與風險" not in menu + assert "ERP CSV 交換" not in menu["🛒 採購管理"] + assert "Agent Dashboard" not in menu["🤖 AI 智能助理"] + + +def test_logout_clears_identity_and_tier_page_state(): + state = { + "logged_in": True, + "username": "planner", + "role": "supply_planner", + "name": "供應鏈規劃員", + "menu_selection": "🛒 採購管理", + "sub_menu": "ERP CSV 交換", + "erp_csv_notice": "sent", + "po_operation_id": "old-user-operation", + "po_last_approval_id": "old-user-approval", + "messages": ["hello"], + "gemini_key": "keep", + } + + clear_identity_session_state(state) + + assert state["logged_in"] is False + assert state["menu_selection"] is None + assert state["sub_menu"] is None + assert state["messages"] == [] + assert state["gemini_key"] == "keep" + assert "username" not in state + assert "role" not in state + assert "name" not in state + assert "erp_csv_notice" not in state + assert "po_operation_id" not in state + assert "po_last_approval_id" not in state + + +def test_live_role_change_replaces_stale_submenu_state(): + state = { + "menu_selection": "🛒 採購管理", + "sub_menu": "採購單", + "radio_🛒 採購管理": "採購單", + } + planner_menu = { + "🌱 供應鏈與風險": [], + "🛒 採購管理": ["ERP CSV 交換"], + } + + normalize_navigation_state(state, planner_menu) + + assert state["menu_selection"] == "🛒 採購管理" + assert state["sub_menu"] == "ERP CSV 交換" + assert state["radio_🛒 採購管理"] == "ERP CSV 交換" + + +def test_app_reloads_live_principal_and_passes_username_to_tier_pages(): + source = (ROOT / "app.py").read_text(encoding="utf-8") + + assert "load_principal(" in source + assert "build_menu_structure(principal)" in source + assert "normalize_navigation_state(st.session_state, MENU_STRUCTURE)" in source + assert "clear_identity_session_state(st.session_state)" in source + assert "viewer / viewer" in source + assert "planner / planner" in source + assert "approver / approver" in source + assert "render_agent_dashboard(username=principal.username)" in source + assert "render_procurement(sub_menu=sub_menu, username=principal.username)" in source + assert "render_supply_chain_risk(" in source + assert "username=principal.username" in source + + +def test_tier_pages_derive_sections_from_live_principal(): + risk_source = (ROOT / "frontend/page_supply_chain_risk.py").read_text( + encoding="utf-8" + ) + exchange_source = (ROOT / "frontend/page_erp_csv_exchange.py").read_text( + encoding="utf-8" + ) + procurement_source = (ROOT / "frontend/page_procurement.py").read_text( + encoding="utf-8" + ) + + assert "principal = load_principal(username)" in risk_source + assert "sections = risk_sections(principal)" in risk_source + assert risk_source.count("actor=principal.username") == 4 + assert "principal = load_principal(username)" in exchange_source + assert "sections = exchange_sections(principal)" in exchange_source + assert "actor=current_actor" in exchange_source + assert "principal = load_principal(username)" in procurement_source + assert "role=principal.role" in procurement_source + assert "actor=principal.username" in procurement_source