Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d0baf28
feat(l1): make latest event alerts a live, read-only feed
ewiwi22255 Sep 12, 2026
5c9aa36
Fix first-batch risk pipeline and add isolated review environment
wweyuiop1205-lgtm Sep 13, 2026
e46e209
Add six-entry live RSS acceptance and offline replay report
wweyuiop1205-lgtm Sep 13, 2026
e9fe57d
Expose live news acceptance snapshot in isolated UI
wweyuiop1205-lgtm Sep 13, 2026
b258d44
Fix GNews search scope and country filtering
wweyuiop1205-lgtm Sep 13, 2026
407bf05
feat(llm): support extra headers and request timeout for OpenAI-compa…
ewiwi22255 Sep 13, 2026
51d731e
feat(l2): make the risk workspace explainable, persistent and evidenc…
ewiwi22255 Sep 13, 2026
d4fc8f5
fix(l2): stamp AI summary generated_at after the model replies
ewiwi22255 Sep 13, 2026
b9c755f
feat(l2): reconnect impacted-PO marking so Step 5 proposals have real…
ewiwi22255 Sep 13, 2026
39383dc
feat(l3): close the proposal loop — event evidence, decision feedback…
ewiwi22255 Sep 13, 2026
0bc642f
feat(l1): alert acknowledgement, L1→L2 handoff, live PO mapping, prop…
ewiwi22255 Sep 13, 2026
7538a41
Document batch-one scope and draft PR review workflow
wweyuiop1205-lgtm Sep 13, 2026
2932e1a
Integrate PR17 tiers with PR16 analysis, geography and persistence co…
wweyuiop1205-lgtm Sep 14, 2026
891172e
Document PR16 PR17 integration verification
wweyuiop1205-lgtm Sep 14, 2026
253c01d
Add integration regression coverage and strict event filters
wweyuiop1205-lgtm Sep 14, 2026
3985a17
Record final integration test commit
wweyuiop1205-lgtm Sep 14, 2026
6b0feb0
Clarify local integration commit history
wweyuiop1205-lgtm Sep 14, 2026
5036663
Document final local integration history
wweyuiop1205-lgtm Sep 14, 2026
933443c
Normalize integration report formatting
wweyuiop1205-lgtm Sep 14, 2026
df84e64
Prepare integration report for consolidated draft review
wweyuiop1205-lgtm Sep 14, 2026
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
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ LLM_FALLBACK_MODELS=openai/kimi-k2.6,gemini/gemini-2.5-flash
# ── 供應商金鑰(依你選的模型填對應的)──
# OPENAI_API_BASE=https://opencode.ai/zen/go/v1
# OPENAI_API_KEY=replace_with_key
# OpenCode Go 端點(/zen/go/v1)每個請求都要帶 x-opencode-session,否則回 MissingSessionID。
# JSON 物件;其他端點不需要時留空。
# LLM_EXTRA_HEADERS={"x-opencode-session": "erp-inventory"}
# 單次 LLM 請求逾時秒數(預設 120);超時視為該模型失敗,改打 LLM_FALLBACK_MODELS
# LLM_TIMEOUT=120
GEMINI_API_KEY=replace_with_gemini_api_key

# ── 新聞來源(供應鏈風險頁)──
Expand All @@ -37,8 +42,13 @@ ERP_DEMO_MODE=false
# then provision user_organizations and organization_entitlements before startup.
# ERP_ORGANIZATION_ID=your-organization-id

# Optional service identity for the 24-hour supply-chain news refresh.
# Supply-chain scheduler: background execution is opt-in.
# The account must have risk.workspace.write; for the local demo use planner.
ERP_SCHEDULER_ENABLED=0
ERP_SCHEDULER_INTERVAL_SECONDS=86400
ERP_SCHEDULER_INITIAL_DELAY_SECONDS=10
ERP_SCHEDULER_MAX_ATTEMPTS=3
ERP_SCHEDULER_RETRY_SECONDS=30
ERP_SCHEDULER_ACTOR=

# Optional: seed Agent Dashboard with synthetic demo records. Keep disabled for real data.
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,8 @@ line bot/ngrok.exe
# Local AI-assistant and planning state must never enter the public repository.
.claude/
.planning/

# Isolated batch-one runtime state
.isolated/
*.db.news.lock
*.db.scheduler.lock
7 changes: 6 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
所有商業邏輯均位於 backend/,所有 UI 頁面均位於 frontend/
"""

import os
import streamlit as st

# ── 載入 .env(模型設定 LLM_MODEL / 各供應商金鑰,issue #25)────────────
try:
from dotenv import load_dotenv
load_dotenv()
if os.getenv("ERP_ISOLATED_TEST") != "1":
load_dotenv()
except Exception:
pass

Expand All @@ -30,6 +32,9 @@

# ── 頁面設定 ────────────────────────────────────────────────────────
st.set_page_config(page_title="進銷存安全系統", page_icon="🛡️", layout="wide")
if os.getenv("ERP_ISOLATED_TEST") == "1":
news_mode = "真實新聞快照" if os.getenv("ERP_NEWS_CAPTURE") else "固定新聞"
st.warning(f"隔離測試環境|{news_mode}與模擬 AI|不發送通知|背景排程關閉")

# ── 全域 CSS ────────────────────────────────────────────────────────
st.markdown("""
Expand Down
8 changes: 7 additions & 1 deletion backend/access_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@


RISK_OVERVIEW_READ = "risk.overview.read"
# L1 告警的已讀/處理中/通知 L2:監控狀態,不是 ERP 資料,L1 唯讀原則不受影響
RISK_ALERT_ACK = "risk.alert.ack"
RISK_ANALYSIS_READ = "risk.analysis.read"
RISK_WHAT_IF_RUN = "risk.what_if.run"
RISK_WORKSPACE_WRITE = "risk.workspace.write"
Expand All @@ -32,6 +34,7 @@

_CAPABILITY_ENTITLEMENT = {
RISK_OVERVIEW_READ: L1_MONITOR,
RISK_ALERT_ACK: L1_MONITOR,
RISK_ANALYSIS_READ: L2_DECISION,
RISK_WHAT_IF_RUN: L2_DECISION,
RISK_WORKSPACE_WRITE: L2_DECISION,
Expand All @@ -49,10 +52,11 @@


_ROLE_CAPABILITIES = {
"risk_viewer": frozenset({RISK_OVERVIEW_READ}),
"risk_viewer": frozenset({RISK_OVERVIEW_READ, RISK_ALERT_ACK}),
"supply_planner": frozenset(
{
RISK_OVERVIEW_READ,
RISK_ALERT_ACK,
RISK_ANALYSIS_READ,
RISK_WHAT_IF_RUN,
RISK_WORKSPACE_WRITE,
Expand All @@ -62,6 +66,7 @@
"procurement_approver": frozenset(
{
RISK_OVERVIEW_READ,
RISK_ALERT_ACK,
PROPOSAL_EVIDENCE_READ,
APPROVAL_QUEUE_READ,
APPROVAL_DECIDE,
Expand All @@ -74,6 +79,7 @@
"warehouse": frozenset(
{
RISK_OVERVIEW_READ,
RISK_ALERT_ACK,
RISK_ANALYSIS_READ,
RISK_WHAT_IF_RUN,
RISK_WORKSPACE_WRITE,
Expand Down
30 changes: 30 additions & 0 deletions backend/agent_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import hmac
import json
from datetime import datetime
import sqlite3
from backend import database as _database
from backend.database import run_query, transaction, tx_run
from backend.log_checksum import _get_prev_checksum, compute_checksum

Expand Down Expand Up @@ -413,3 +415,31 @@ def reject_action(approval_id: str, reason: str, approver: str) -> dict:
from backend.tool_gateway import gateway
res = gateway.reject_action(approval_id, reason, approver=approver)
return res.to_dict()


def get_reversal_record(approval_id: str, *, conn=None) -> dict | None:
"""某審批單是否已成功沖銷過;回 {"timestamp", "result", "caller"} 或 None。

沖銷是補償交易,重按會再扣一次庫存/再取消一次訂單,所以前端要先查這裡。
"""
if not approval_id:
return None
needle = json.dumps({"approval_id": approval_id}, ensure_ascii=False)[1:-1] # "approval_id": "…"
query = """
SELECT timestamp, result, caller FROM agent_action_logs
WHERE tool_name = 'retry_approval' AND success = 1 AND parameters LIKE ?
ORDER BY id DESC LIMIT 1
"""
owned = conn is None
conn = conn or sqlite3.connect(_database.DB_FILE) # 動態讀,測試可改路徑
try:
receipt = conn.execute("SELECT created_at,result,actor FROM approval_reversals WHERE approval_id=?", (approval_id,)).fetchone()
if receipt:
return {"timestamp": receipt[0], "result": receipt[1], "caller": receipt[2]}
row = conn.execute(query, (f"%{needle}%",)).fetchone()
finally:
if owned:
conn.close()
if not row:
return None
return {"timestamp": row[0], "result": row[1], "caller": row[2]}
45 changes: 44 additions & 1 deletion backend/agent_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,42 @@
]


# ── 額外 HTTP header(選填,JSON 物件):部分 OpenAI 相容端點要求自報身分 ──
# 例:OpenCode Go 每個請求都要帶 x-opencode-session,否則回 MissingSessionID。
# 格式錯誤時視為未設定(啟動時印警告),不讓 .env 打錯字把整個 LLM 層拖垮。
def _load_extra_headers() -> dict[str, str]:
raw = os.getenv("LLM_EXTRA_HEADERS", "").strip()
if not raw:
return {}
try:
headers = json.loads(raw)
if not isinstance(headers, dict):
raise ValueError("must be a JSON object")
return {str(k): str(v) for k, v in headers.items()}
except ValueError as e:
print(f"[llm] ignoring LLM_EXTRA_HEADERS: {e}")
return {}


_EXTRA_HEADERS = _load_extra_headers()


# ── 單次請求逾時(秒):上游卡住時及早放棄、讓 fallback 接手 ──────────────
# litellm 預設 600 秒;OpenCode 這類代理端點偶爾會吞掉請求不回應,
# 現場等 10 分鐘不如 2 分鐘換一家。非法值視為預設。
def _load_timeout(default: float = 120.0) -> float:
raw = os.getenv("LLM_TIMEOUT", "").strip()
try:
value = float(raw) if raw else default
except ValueError:
print(f"[llm] ignoring LLM_TIMEOUT={raw!r}: not a number")
return default
return value if value > 0 else default


_LLM_TIMEOUT = _load_timeout()


# ════════════════════════════════════════════════════════════════════════
# 0) 各 Agent 的 system prompt(由 registry 組出,DRY + 單一真實來源)
# ════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -201,7 +237,12 @@ def _llm(messages, model=None, tools=None, temperature=0.2, json_mode=False,
用量記帳:每次成功呼叫記一列 llm_usage_logs(tokens + 成本),
usage_tag 標記用途(route / agent:<id> / aggregate / smalltalk)供歸因。
"""
kw = {"messages": messages, "temperature": temperature}
if os.getenv("ERP_ISOLATED_TEST") == "1":
from types import SimpleNamespace
from .isolated_runtime import fixture_completion
msg = SimpleNamespace(content=fixture_completion(messages, usage_tag), tool_calls=None)
return SimpleNamespace(choices=[SimpleNamespace(message=msg)])
kw = {"messages": messages, "temperature": temperature, "timeout": _LLM_TIMEOUT}
if tools:
kw["tools"] = tools
kw["tool_choice"] = "auto"
Expand All @@ -211,6 +252,8 @@ def _llm(messages, model=None, tools=None, temperature=0.2, json_mode=False,
kw["api_key"] = api_key
if api_base:
kw["api_base"] = api_base
if _EXTRA_HEADERS:
kw["extra_headers"] = dict(_EXTRA_HEADERS)

explicit = bool(model or api_key or api_base)
chain = [model or DEFAULT_MODEL]
Expand Down
59 changes: 59 additions & 0 deletions backend/approval_reversal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Exactly-once compensation of a recorded approval, inside one SQLite transaction."""
import json
import math
import re
from datetime import datetime
from . import database
from .access_control import GLOBAL_APPROVAL_DECIDE, require_capability


def migrate(conn):
conn.execute("""CREATE TABLE IF NOT EXISTS approval_reversals (
approval_id TEXT PRIMARY KEY, tool_name TEXT NOT NULL,
actor TEXT NOT NULL, created_at TEXT NOT NULL, result TEXT NOT NULL)""")


def reverse_approval(approval_id, *, actor):
from .agent_logger import get_reversal_record, write_action_log
# BEGIN IMMEDIATE serializes readers/compensators across processes. The
# receipt, stock/order changes, stock move and audit share this commit.
with database.transaction(immediate=True) as conn:
principal = require_capability(actor, GLOBAL_APPROVAL_DECIDE, conn=conn)
if principal.role != "admin":
raise PermissionError("僅管理員可沖銷")
existing = get_reversal_record(approval_id, conn=conn)
if existing:
return dict(status="already_reversed", message=existing["result"])
row = conn.execute("""SELECT p.tool_name,p.parameters,p.status,r.result
FROM pending_approvals p LEFT JOIN effect_receipts r ON r.approval_id=p.approval_id
WHERE p.approval_id=?""", (approval_id,)).fetchone()
if not row or row[2] != "approved" or row[0] not in {"update_inventory", "create_order"}:
raise ValueError("審批未成功或不支援沖銷")
if row[3] is None:
raise ValueError("舊審批缺少執行收據,需人工對帳後處理")
args = json.loads(row[1])
product_id = args.get("product_id")
value = args.get("quantity_change") if row[0] == "update_inventory" else args.get("quantity")
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value == 0:
raise ValueError("原始審批數量無效")
delta = -value if row[0] == "update_inventory" else value
if row[0] == "create_order":
ids = set(re.findall(r"ORD-\d{8}-\d{6}", row[3]))
if len(ids) != 1 or value <= 0:
raise ValueError("執行收據無法唯一識別訂單,需人工對帳")
order_id = ids.pop()
order = conn.execute("SELECT product_id,quantity,status FROM orders WHERE order_id=?", (order_id,)).fetchone()
if not order or order[0] != product_id or order[1] != value or order[2] != "處理中":
raise ValueError("訂單狀態或內容已異動,需人工對帳")
conn.execute("UPDATE orders SET status='已取消' WHERE order_id=?", (order_id,))
stock = conn.execute("SELECT stock,warehouse_id FROM inventory WHERE product_id=?", (product_id,)).fetchone()
if not stock or stock[0] + delta < 0:
raise ValueError("品項不存在或沖銷後庫存不足")
conn.execute("UPDATE inventory SET stock=stock+? WHERE product_id=?", (delta,product_id))
now = datetime.now().isoformat()
conn.execute("INSERT INTO stock_moves(product_id,warehouse_id,qty,move_type,ref_no,move_date,note) VALUES(?,?,?,?,?,?,?)",
(product_id, stock[1] or "WH01", abs(delta), "入庫" if delta>0 else "出庫", approval_id, now, "核准紀錄沖銷"))
message = f"已沖銷 {approval_id};{product_id} 庫存異動 {delta:+g}。"
conn.execute("INSERT INTO approval_reversals VALUES(?,?,?,?,?)", (approval_id,row[0],actor,now,message))
write_action_log("retry_approval", {"approval_id":approval_id}, actor, message, True, conn=conn)
return dict(status="ok", message=message)
72 changes: 72 additions & 0 deletions backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,44 @@ def _ensure_db_dir():
os.makedirs(d, exist_ok=True)


def is_demo_seed_enabled() -> bool:
"""合成示範資料(採購單等)只能由環境變數明確啟用,避免污染真實資料。"""
return os.getenv("ERP_ENABLE_DEMO_SEED", "").strip().lower() in {"1", "true", "yes", "on"}


def _seed_demo_purchase_orders(c) -> int:
"""為每家正式供應商建一張進行中採購單(含明細)。表非空或無商品時不動作。"""
if c.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] > 0:
return 0
products = c.execute(
"SELECT product_id, COALESCE(cost, price, 1000) FROM inventory ORDER BY product_id"
).fetchall()
suppliers = c.execute(
"SELECT supplier_id FROM suppliers WHERE is_official = 1 ORDER BY supplier_id"
).fetchall()
if not products or not suppliers:
return 0
statuses = ("已下單", "生產中", "運送中")
created = 0
for idx, (supplier_id,) in enumerate(suppliers):
product_id, unit_cost = products[idx % len(products)]
qty = 20 + (idx % 5) * 10
unit_price = round(float(unit_cost or 1000), 2)
po_id = f"PO-DEMO-{idx + 1:03d}"
order_date = (datetime.now() - timedelta(days=3 + idx % 12)).strftime("%Y-%m-%d")
c.execute(
"INSERT OR IGNORE INTO purchase_orders (po_id, supplier_id, order_date, status, total_amount, note) "
"VALUES (?,?,?,?,?,?)",
(po_id, supplier_id, order_date, statuses[idx % len(statuses)], qty * unit_price, "demo seed"),
)
c.execute(
"INSERT INTO purchase_order_items (po_id, product_id, qty, unit_price) VALUES (?,?,?,?)",
(po_id, product_id, qty, unit_price),
)
created += 1
return created


def init_db():
_ensure_db_dir()
conn = sqlite3.connect(DB_FILE)
Expand Down Expand Up @@ -170,6 +208,34 @@ def init_db():
ai_summary TEXT,
updated_at TEXT
)''')
from .news_store import migrate as migrate_news
migrate_news(conn)
from .risk_intelligence import migrate as migrate_intelligence
migrate_intelligence(conn)
from .approval_reversal import migrate as migrate_reversals
migrate_reversals(conn)

c.execute('''CREATE TABLE IF NOT EXISTS risk_alert_states (
alert_key TEXT PRIMARY KEY,
kind TEXT NOT NULL,
ref_id INTEGER NOT NULL,
status TEXT NOT NULL,
note TEXT,
updated_by TEXT,
updated_at TEXT NOT NULL
)''')
c.execute('''CREATE TABLE IF NOT EXISTS risk_ai_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
actor TEXT,
reference_date TEXT,
summary TEXT NOT NULL,
updates_json TEXT NOT NULL,
events_json TEXT NOT NULL,
audit_json TEXT NOT NULL,
news_count INTEGER DEFAULT 0,
event_count INTEGER DEFAULT 0
)''')
c.execute('''CREATE TABLE IF NOT EXISTS esg_targets (id INTEGER PRIMARY KEY AUTOINCREMENT, target_year INTEGER, scope INTEGER, baseline_kg_co2 REAL, target_kg_co2 REAL, note TEXT)''')
# 永續 ESG:風險管理係數(地區/事件類型/供應商類別 → 風險分數 0–100、權重)
c.execute('''CREATE TABLE IF NOT EXISTS esg_risk_factors (id INTEGER PRIMARY KEY AUTOINCREMENT, risk_type TEXT, risk_key TEXT, risk_score REAL, weight REAL, note TEXT, updated_at TEXT, UNIQUE(risk_type, risk_key))''')
Expand Down Expand Up @@ -740,6 +806,12 @@ def init_db():
for row in top20:
c.execute("UPDATE suppliers SET is_official = 1 WHERE supplier_id=?", (row[0],))

# Demo 曝險資料:供應鏈風險卡片的「曝險金額」算的是未結採購單,demo 供應商
# 原本沒有任何採購單,所有據點永遠 $0。只在明確 opt-in(ERP_ENABLE_DEMO_SEED)
# 且採購單表為空時,替正式供應商各建一張進行中的採購單。
if is_demo_mode_enabled() and is_demo_seed_enabled():
_seed_demo_purchase_orders(c)

# N3:既有 DB 的 legacy 明文密碼一次性升級為 salted hash(自我修復式遷移)
try:
from backend.passwords import hash_password, is_hashed
Expand Down
14 changes: 6 additions & 8 deletions backend/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,12 @@ def update_inventory(product_id: str, quantity_change: int) -> str:
return f"找不到產品編號 {product_id}。"


def rollback_inventory(product_id: str, quantity_change: int) -> str:
"""
沖銷先前的庫存異動(補償交易)。
將 update_inventory 的異動量反向執行,需 admin 權限。
"""
if not check_permission(["admin"]):
return "權限不足:只有『管理員』可以執行庫存沖銷。"
return update_inventory(product_id=product_id, quantity_change=-quantity_change)
def rollback_inventory(product_id: str, quantity_change: int, *, approval_id=None, actor=None):
"""Compensation must resolve its original parameters from a durable approval."""
if not approval_id:
raise ValueError("沖銷需要原始 approval_id 與管理員身份")
from .approval_reversal import reverse_approval
return reverse_approval(approval_id, actor=actor)["message"]


def get_inventory_total_value(use_cost: bool = True) -> str:
Expand Down
Loading
Loading