Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,17 @@ 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
```

登入後左側選單進入「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(選用)

Expand All @@ -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` | 分析副任務別名(選填;新聞歸類/翻譯可指到較便宜模型) | 未設=用主模型鏈 |
Expand All @@ -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
Expand Down
97 changes: 53 additions & 44 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand All @@ -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("""
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -309,15 +312,15 @@ 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)

elif menu_selection == "📦 進銷存":
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)
Expand All @@ -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,
)
191 changes: 191 additions & 0 deletions backend/access_control.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading