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("
即時監控專責 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( + "即時監控專責 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("