Compare commits
2 Commits
44cd2f8e76
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b3f2ee4100 | |||
| d4ce4f9ed1 |
39
.01_Vibe_Coding_Constitution.md
Normal file
39
.01_Vibe_Coding_Constitution.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Vibe Coding 核心憲法:行動優先協議 (The "Action First" Protocol)
|
||||||
|
|
||||||
|
> **版本**: v2.0 (整合版)
|
||||||
|
> **狀態**: 啟動 (ACTIVE)
|
||||||
|
|
||||||
|
## 0. 核心宗旨 (Core Doctrine)
|
||||||
|
* **你的角色:** 資深全端工程師 (Senior Full-stack Developer) 與敏捷教練 (Agile Coach)。
|
||||||
|
* **我的角色:** 產品總監 (Product Director)。
|
||||||
|
* **最高指令:** **行動勝於規劃 (Action over Planning)**。
|
||||||
|
* **任務目標:** 以最快速度產出可互動的 MVP (最小可行性產品)。不要讓我等待完美的架構,給我現在就能跑的代碼。
|
||||||
|
|
||||||
|
## 1. 最高授權協議 (Protocol of Maximum Autonomy)
|
||||||
|
我賦予你 **Level 5 執行權限**。在以下情境中,**預設為「我已同意」**,請直接執行,無需詢問:
|
||||||
|
* **技術選型:** 選擇當前最主流、穩定且適合專案規模的技術堆疊 (例如: React, Flask, SQLite 等)。
|
||||||
|
* **程式碼結構:** 自行決定檔案分拆、變數命名與函式寫法。
|
||||||
|
* **視覺決策:** 若指令模糊(如「做漂亮一點」),請依據現代企業審美 (Modern Corporate Style) 直接實作。
|
||||||
|
* **錯誤修復:** 遇到非關鍵性報錯,請自行嘗試修復。
|
||||||
|
|
||||||
|
## 2. 零徵詢同意政策 (ZERO_CONSENT_POLICY)
|
||||||
|
**目的:** 消除所有非必要的暫停與確認對話,加速專案執行。
|
||||||
|
* **禁用話術:** 嚴禁使用「你同意嗎?」、「我可以開始執行嗎?」、「請批准我的修改」等語句。
|
||||||
|
* **執行標準:**
|
||||||
|
1. **直接執行:** 收到指令後,立即在背景生成、修改或重構代碼。
|
||||||
|
2. **結果報告:** 僅在一個完整、可測試的區塊或功能完成後,簡潔地報告結果(例如:「UI 邏輯已完成。請查看 `http://localhost:3000`」)。
|
||||||
|
* **關於 Git:** 除非我明確要求,否則只需提醒「現在是存檔點」,不需詢問批准。
|
||||||
|
|
||||||
|
## 3. 熔斷機制:何時該停下來? (Stop Protocol)
|
||||||
|
僅在觸發以下 **紅線** 時,暫停執行並請求我的指示:
|
||||||
|
* **[資料遺失]:** 操作涉及刪除資料庫 (DROP/DELETE) 或覆蓋重要檔案。
|
||||||
|
* **[資安風險]:** 偵測到 API Key、密碼或 PII 個資可能被明碼寫入 (Hardcode)。
|
||||||
|
* **[成本暴衝]:** 涉及高頻率付費 API 呼叫或高成本雲端資源部署。
|
||||||
|
|
||||||
|
## 4. 時光機與防挫折機制 (Safety Nets)
|
||||||
|
* **主動存檔:** 每完成一個功能模組 (Feature) 或修復關鍵 Bug 後,主動建議存檔。
|
||||||
|
* **三振法則:** 如果同一個錯誤 (Error) 你嘗試修復 **3次** 仍無法解決,**請立即停止嘗試**,並提出「笨但有效」的替代方案。
|
||||||
|
* **溝通語言:** 全程使用 **繁體中文 (Traditional Chinese)**。
|
||||||
|
|
||||||
|
---
|
||||||
|
*憲法載入完畢。系統已就緒,請依照 SOP 執行。*
|
||||||
86
.02_Vibe_Coding_SOP.md
Normal file
86
.02_Vibe_Coding_SOP.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
Markdown
|
||||||
|
|
||||||
|
# Vibe Coding 專案執行手冊 (SOP & Structure)
|
||||||
|
|
||||||
|
> **說明**: 本文件包含標準專案結構與分階段開發流程。
|
||||||
|
|
||||||
|
## 📌 專案結構藍圖 (Project Structure)
|
||||||
|
請依據此結構進行檔案配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
project_name/
|
||||||
|
├── .env # 環境變數(不進版控)
|
||||||
|
├── .env.example # 環境變數範本
|
||||||
|
├── .gitignore
|
||||||
|
├── README.md
|
||||||
|
├── requirements.txt
|
||||||
|
├── app.py # 主程式入口
|
||||||
|
├── config.py # 設定檔
|
||||||
|
├── preview.html # UI 預覽 (先預覽,後實作)
|
||||||
|
│
|
||||||
|
├── docs/ # 文件中心 (先文件,後開發)
|
||||||
|
│ ├── SDD.md # 系統設計文件
|
||||||
|
│ ├── security_audit.md # 資安檢視報告
|
||||||
|
│ ├── user_command_log.md # 用戶指令記錄
|
||||||
|
│ ├── CHANGELOG.md # 版本變更紀錄
|
||||||
|
│ └── API_DOC.md # API 文件
|
||||||
|
│
|
||||||
|
├── models/ # 資料庫模型
|
||||||
|
├── routes/ # 路由模組
|
||||||
|
├── services/ # 商業邏輯
|
||||||
|
├── utils/ # 工具函式
|
||||||
|
├── templates/ # HTML 模板
|
||||||
|
└── static/ # 靜態資源
|
||||||
|
├── css/
|
||||||
|
├── js/
|
||||||
|
└── images/
|
||||||
|
|
||||||
|
|
||||||
|
📌 開發流程標準作業程序 (SOP)
|
||||||
|
Phase 0: 專案初始化
|
||||||
|
結構建立:依照上述藍圖建立資料夾與基礎檔案。
|
||||||
|
|
||||||
|
環境變數:建立 .env,設定 DB 連線、LLM API Key 與 Flask 設定。
|
||||||
|
|
||||||
|
依賴安裝:建立 requirements.txt 並安裝必要套件 (flask, sqlalchemy 等)。
|
||||||
|
|
||||||
|
Git 設定:執行 git init 並建立 .gitignore。
|
||||||
|
|
||||||
|
Phase 1: 資料庫架構
|
||||||
|
Schema 設計:優先建立 users, llm_configs, system_logs 等核心表。
|
||||||
|
|
||||||
|
文件化:建立 docs/db_schema.md 記錄結構。
|
||||||
|
|
||||||
|
種子資料:寫入預設管理員帳號與預設配置。
|
||||||
|
|
||||||
|
Phase 2: UI/UX 預覽 (關鍵檢核點)
|
||||||
|
建立 preview.html:純前端頁面,不連接後端。
|
||||||
|
|
||||||
|
確認事項:配色方案、版面配置、RWD 響應式。
|
||||||
|
|
||||||
|
規則:必須等待使用者確認 UI 滿意後,才可進入後端實作。
|
||||||
|
|
||||||
|
Phase 3: 核心程式開發
|
||||||
|
主程式:設定 app.py 與 Blueprint 路由註冊。
|
||||||
|
|
||||||
|
功能實作:依序開發 auth (認證), admin (後台), api (服務) 模組。
|
||||||
|
|
||||||
|
防挫折:若遇錯 3 次即停止並尋求替代方案。
|
||||||
|
|
||||||
|
Phase 4: 功能完善與優化
|
||||||
|
管理者後台:實作使用者管理與 LLM API 設定測試。
|
||||||
|
|
||||||
|
通用功能:實作全域錯誤視窗、CSV 匯入/匯出、清單排序。
|
||||||
|
|
||||||
|
資安檢視:建立 docs/security_audit.md 並修復高風險項目 (SQL Injection, XSS)。
|
||||||
|
|
||||||
|
Phase 5: 交付與部署
|
||||||
|
文件更新:完善 SDD, CHANGELOG 與 README。
|
||||||
|
|
||||||
|
最終檢查:確認 .env 未進版控,所有功能測試通過。
|
||||||
|
|
||||||
|
Release:標記版本 tag 並推送到版控伺服器。
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
執行手冊已載入。請等待使用者指令開始 Phase 0。
|
||||||
22
.claude/settings.local.json
Normal file
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(git init:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git remote add:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(python tests/test_dit_analyzer.py:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(python -c:*)",
|
||||||
|
"Bash(python app.py:*)",
|
||||||
|
"Bash(netstat:*)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"Bash(timeout:*)",
|
||||||
|
"Bash(ping:*)",
|
||||||
|
"Bash(set FLASK_DEBUG=False)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2908
DIT CCC.csv
Normal file
2908
DIT CCC.csv
Normal file
File diff suppressed because it is too large
Load Diff
BIN
DIT repor 改.xlsx
Normal file
BIN
DIT repor 改.xlsx
Normal file
Binary file not shown.
20
DIT_C/.env.example
Normal file
20
DIT_C/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Flask
|
||||||
|
FLASK_SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
FLASK_DEBUG=True
|
||||||
|
|
||||||
|
# Database (MySQL)
|
||||||
|
DB_HOST=your-db-host
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=your-db-name
|
||||||
|
DB_USER=your-db-user
|
||||||
|
DB_PASSWORD=your-db-password
|
||||||
|
|
||||||
|
# Ollama API
|
||||||
|
OLLAMA_API_URL=https://your-ollama-api-url
|
||||||
|
OLLAMA_DEFAULT_MODEL=deepseek-reasoner
|
||||||
|
OLLAMA_ALT_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
GITEA_URL=https://your-gitea-url
|
||||||
|
GITEA_USER=your-username
|
||||||
|
GITEA_TOKEN=your-token
|
||||||
49
DIT_C/.gitignore
vendored
Normal file
49
DIT_C/.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
96
DIT_C/README.md
Normal file
96
DIT_C/README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# DIT 智能分析系統 (DIT_C)
|
||||||
|
|
||||||
|
DIT (Design-In Tracking) Intelligent Analytics Module - 自動解析銷售 CSV 報表,產出行動建議卡片。
|
||||||
|
|
||||||
|
## 功能特色
|
||||||
|
|
||||||
|
- **Feature 6.2:** 高價值資源分配建議 - 識別高潛力但低勝率的應用領域
|
||||||
|
- **Feature 6.3:** 呆滯案件警示 - 追蹤已承認但未轉單的案件
|
||||||
|
- **LLM 整合:** 支援 Ollama API 進行智慧分析
|
||||||
|
- **Web UI:** 現代化儀表板介面
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安裝依賴
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 設定環境變數
|
||||||
|
cp .env.example .env
|
||||||
|
# 編輯 .env 填入正確的資料庫與 API 資訊
|
||||||
|
|
||||||
|
# 啟動服務
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
服務啟動後訪問: http://localhost:5000
|
||||||
|
|
||||||
|
## 主要端點
|
||||||
|
|
||||||
|
| 端點 | 說明 |
|
||||||
|
|------|------|
|
||||||
|
| `/` | 儀表板 |
|
||||||
|
| `/upload` | 上傳 CSV 分析 |
|
||||||
|
| `/history` | 歷史記錄 |
|
||||||
|
| `/health` | 健康檢查 |
|
||||||
|
| `/api/analyze` | API 分析端點 |
|
||||||
|
|
||||||
|
## 專案結構
|
||||||
|
|
||||||
|
```
|
||||||
|
DIT_C/
|
||||||
|
├── app.py # 主程式入口
|
||||||
|
├── config.py # 設定檔
|
||||||
|
├── preview.html # UI 預覽
|
||||||
|
├── docs/ # 文件
|
||||||
|
│ ├── API_DOC.md # API 文件
|
||||||
|
│ ├── SDD.md # 系統設計文件
|
||||||
|
│ ├── security_audit.md # 資安檢視報告
|
||||||
|
│ └── CHANGELOG.md # 版本變更
|
||||||
|
├── models/ # 資料庫模型
|
||||||
|
│ └── dit_models.py
|
||||||
|
├── routes/ # 路由模組
|
||||||
|
│ ├── api.py # API 路由
|
||||||
|
│ └── main.py # 頁面路由
|
||||||
|
├── services/ # 商業邏輯
|
||||||
|
│ ├── dit_analyzer.py # DIT 分析器
|
||||||
|
│ └── llm_service.py # LLM 服務
|
||||||
|
├── templates/ # HTML 模板
|
||||||
|
│ ├── base.html
|
||||||
|
│ ├── dashboard.html
|
||||||
|
│ ├── upload.html
|
||||||
|
│ └── history.html
|
||||||
|
├── tests/ # 測試
|
||||||
|
│ └── test_dit_analyzer.py
|
||||||
|
└── static/ # 靜態資源
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 使用範例
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# 上傳 CSV 分析
|
||||||
|
with open('DIT_report.csv', 'rb') as f:
|
||||||
|
response = requests.post(
|
||||||
|
'http://localhost:5000/api/analyze',
|
||||||
|
files={'file': f},
|
||||||
|
data={
|
||||||
|
'top_percent': 0.2,
|
||||||
|
'low_win_rate': 0.1,
|
||||||
|
'threshold_days': 60
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
- [API 文件](docs/API_DOC.md)
|
||||||
|
- [系統設計文件](docs/SDD.md)
|
||||||
|
- [資安檢視報告](docs/security_audit.md)
|
||||||
|
- [版本變更](docs/CHANGELOG.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
55
DIT_C/app.py
Normal file
55
DIT_C/app.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
DIT_C 主程式入口
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
from config import Config
|
||||||
|
from models import db
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
"""應用程式工廠"""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# 初始化擴展
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# 建立資料表
|
||||||
|
with app.app_context():
|
||||||
|
from models.dit_models import DITUploadHistory, DITAnalysisResult, DITActionCard, DITSystemLog, DITLLMConfig
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# 註冊 Blueprint
|
||||||
|
from routes.api import api_bp
|
||||||
|
from routes.main import main_bp
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
|
# 健康檢查端點
|
||||||
|
@app.route('/health')
|
||||||
|
def health_check():
|
||||||
|
return jsonify({"status": "ok", "message": "DIT_C is running"})
|
||||||
|
|
||||||
|
# 測試 LLM 連線
|
||||||
|
@app.route('/test-llm')
|
||||||
|
def test_llm():
|
||||||
|
from services.llm_service import llm_service, LLMServiceError
|
||||||
|
try:
|
||||||
|
models = llm_service.get_available_models()
|
||||||
|
return jsonify({
|
||||||
|
"status": "ok",
|
||||||
|
"available_models": models,
|
||||||
|
"default_model": Config.OLLAMA_DEFAULT_MODEL
|
||||||
|
})
|
||||||
|
except LLMServiceError as e:
|
||||||
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5000
|
||||||
|
app = create_app()
|
||||||
|
app.run(host='0.0.0.0', port=port, debug=Config.DEBUG)
|
||||||
38
DIT_C/config.py
Normal file
38
DIT_C/config.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""應用程式設定"""
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
SECRET_KEY = os.getenv('FLASK_SECRET_KEY', 'dev-secret-key')
|
||||||
|
DEBUG = os.getenv('FLASK_DEBUG', 'False').lower() == 'true'
|
||||||
|
|
||||||
|
# Database (MySQL)
|
||||||
|
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||||
|
DB_PORT = int(os.getenv('DB_PORT', 3306))
|
||||||
|
DB_NAME = os.getenv('DB_NAME', 'database')
|
||||||
|
DB_USER = os.getenv('DB_USER', 'root')
|
||||||
|
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
|
||||||
|
|
||||||
|
# SQLAlchemy 連線字串
|
||||||
|
SQLALCHEMY_DATABASE_URI = (
|
||||||
|
f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||||
|
)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# Table 前綴
|
||||||
|
TABLE_PREFIX = 'DIT_C_'
|
||||||
|
|
||||||
|
# Ollama API
|
||||||
|
OLLAMA_API_URL = os.getenv('OLLAMA_API_URL', 'https://ollama_pjapi.theaken.com')
|
||||||
|
OLLAMA_DEFAULT_MODEL = os.getenv('OLLAMA_DEFAULT_MODEL', 'deepseek-reasoner')
|
||||||
|
OLLAMA_ALT_MODEL = os.getenv('OLLAMA_ALT_MODEL', 'deepseek-chat')
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
GITEA_URL = os.getenv('GITEA_URL', '')
|
||||||
|
GITEA_USER = os.getenv('GITEA_USER', '')
|
||||||
|
GITEA_TOKEN = os.getenv('GITEA_TOKEN', '')
|
||||||
235
DIT_C/docs/API_DOC.md
Normal file
235
DIT_C/docs/API_DOC.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# DIT 智能分析系統 API 文件
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
```
|
||||||
|
http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 健康檢查
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
檢查服務是否正常運行
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "DIT_C is running"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 分析 API
|
||||||
|
|
||||||
|
### POST /api/analyze
|
||||||
|
上傳 CSV 或 XLSX 並執行完整分析
|
||||||
|
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
| 參數 | 類型 | 必填 | 預設 | 說明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| file | File | Yes | - | CSV 或 XLSX 檔案 |
|
||||||
|
| top_percent | Float | No | 0.2 | 金額 Top 百分比 (0-1) |
|
||||||
|
| low_win_rate | Float | No | 0.1 | 低勝率門檻 (0-1) |
|
||||||
|
| threshold_days | Int | No | 60 | 呆滯天數門檻 |
|
||||||
|
| skip_rows | Int | No | 16 | 跳過前幾列 (資料從第 17 列開始) |
|
||||||
|
| start_col | Int | No | 1 | 起始欄位索引 (1=B 欄) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"generated_at": "2024-12-12T12:00:00",
|
||||||
|
"summary": {
|
||||||
|
"total_records": 100,
|
||||||
|
"total_value": "$4,350,000",
|
||||||
|
"total_value_raw": 4350000,
|
||||||
|
"active_count": 70,
|
||||||
|
"lost_count": 30,
|
||||||
|
"win_rate": "70.0%",
|
||||||
|
"stage_distribution": {...},
|
||||||
|
"top_applications": {...}
|
||||||
|
},
|
||||||
|
"action_cards": {
|
||||||
|
"resource_allocation": [...],
|
||||||
|
"stagnant_deals": [...]
|
||||||
|
},
|
||||||
|
"total_alerts": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/analyze/resource-allocation
|
||||||
|
僅執行 Feature 6.2 高價值資源分配分析
|
||||||
|
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
| 參數 | 類型 | 必填 | 預設 | 說明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| file | File | Yes | - | CSV 或 XLSX 檔案 |
|
||||||
|
| top_percent | Float | No | 0.2 | 金額 Top 百分比 |
|
||||||
|
| low_win_rate | Float | No | 0.1 | 低勝率門檻 |
|
||||||
|
| skip_rows | Int | No | 16 | 跳過前幾列 |
|
||||||
|
| start_col | Int | No | 1 | 起始欄位索引 (1=B 欄) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"type": "resource_allocation",
|
||||||
|
"count": 3,
|
||||||
|
"action_cards": [
|
||||||
|
{
|
||||||
|
"type": "resource_allocation",
|
||||||
|
"title": "高潛力市場攻堅提醒",
|
||||||
|
"application": "Automotive",
|
||||||
|
"money": "$1,200,000",
|
||||||
|
"money_raw": 1200000,
|
||||||
|
"win_rate": "8.5",
|
||||||
|
"win_rate_raw": 0.085,
|
||||||
|
"top_accounts": ["客戶A", "客戶B"],
|
||||||
|
"suggestion": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/analyze/stagnant-deals
|
||||||
|
僅執行 Feature 6.3 呆滯案件警示
|
||||||
|
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
| 參數 | 類型 | 必填 | 預設 | 說明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| file | File | Yes | - | CSV 或 XLSX 檔案 |
|
||||||
|
| threshold_days | Int | No | 60 | 呆滯天數門檻 |
|
||||||
|
| skip_rows | Int | No | 16 | 跳過前幾列 |
|
||||||
|
| start_col | Int | No | 1 | 起始欄位索引 (1=B 欄) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"type": "stagnant_deals",
|
||||||
|
"count": 2,
|
||||||
|
"action_cards": [
|
||||||
|
{
|
||||||
|
"type": "stagnant_deal",
|
||||||
|
"title": "呆滯案件喚醒",
|
||||||
|
"account": "台積電",
|
||||||
|
"project": "Project Alpha",
|
||||||
|
"approved_date": "2024-09-15",
|
||||||
|
"days_pending": 88,
|
||||||
|
"months_pending": 2,
|
||||||
|
"suggestion": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/action-card/{card_id}/resolve
|
||||||
|
標記 Action Card 為已處理
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. LLM API
|
||||||
|
|
||||||
|
### GET /api/llm/models
|
||||||
|
取得可用 LLM 模型列表
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": ["qwen2.5:3b", "llama3.2:latest"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/llm/chat
|
||||||
|
LLM 聊天 API
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "Hello, how are you?",
|
||||||
|
"system_prompt": "You are a helpful assistant."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"response": "I'm doing well, thank you for asking!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 錯誤回應
|
||||||
|
|
||||||
|
所有 API 錯誤回應格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "錯誤訊息",
|
||||||
|
"code": "ERROR_CODE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**錯誤代碼:**
|
||||||
|
| Code | HTTP Status | 說明 |
|
||||||
|
|------|-------------|------|
|
||||||
|
| NO_FILE | 400 | 未上傳檔案 |
|
||||||
|
| NO_FILENAME | 400 | 未選擇檔案 |
|
||||||
|
| INVALID_TYPE | 400 | 檔案類型不支援 |
|
||||||
|
| ANALYZER_ERROR | 400 | 分析器錯誤 |
|
||||||
|
| INTERNAL_ERROR | 500 | 內部錯誤 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 檔案格式
|
||||||
|
|
||||||
|
支援 CSV 和 XLSX (Excel) 格式。預設從 B17 儲存格開始讀取資料。
|
||||||
|
|
||||||
|
必要欄位:
|
||||||
|
| 欄位名稱 | 型態 | 必填 | 說明 |
|
||||||
|
|---------|------|------|------|
|
||||||
|
| Created Date | Date | Yes | 立案日期 |
|
||||||
|
| Account Name | String | Yes | 客戶名稱 |
|
||||||
|
| Stage | String | Yes | 專案階段 |
|
||||||
|
| Application | String | No | 應用領域 |
|
||||||
|
| Application Detail | String | No | 應用細節 |
|
||||||
|
| Opportunity Name | String | Yes | 專案名稱 |
|
||||||
|
| Total Price | Float | Yes | 預估總金額 |
|
||||||
|
| Approved date | Date | No | 技術承認日期 |
|
||||||
|
| Lost Type | String | No | 敗因 |
|
||||||
51
DIT_C/docs/CHANGELOG.md
Normal file
51
DIT_C/docs/CHANGELOG.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.0.0] - 2024-12-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Phase 0: 專案初始化**
|
||||||
|
- 建立專案結構
|
||||||
|
- 環境變數設定 (.env)
|
||||||
|
- Git + Gitea 版控設定
|
||||||
|
- LLM Service 整合 (Ollama API)
|
||||||
|
|
||||||
|
- **DITAnalyzer 核心模組**
|
||||||
|
- 資料預處理 (欄位清洗、日期轉換、應用領域推導)
|
||||||
|
- Feature 6.2: 高價值資源分配分析
|
||||||
|
- Feature 6.3: 呆滯案件警示
|
||||||
|
- 完整報告生成
|
||||||
|
|
||||||
|
- **API 端點**
|
||||||
|
- POST /api/analyze - 完整分析
|
||||||
|
- POST /api/analyze/resource-allocation - Feature 6.2
|
||||||
|
- POST /api/analyze/stagnant-deals - Feature 6.3
|
||||||
|
- POST /api/action-card/{id}/resolve - 標記已處理
|
||||||
|
- GET /api/llm/models - LLM 模型列表
|
||||||
|
- POST /api/llm/chat - LLM 聊天
|
||||||
|
|
||||||
|
- **前端頁面**
|
||||||
|
- Dashboard (儀表板)
|
||||||
|
- Upload (上傳分析)
|
||||||
|
- History (歷史記錄)
|
||||||
|
- preview.html (UI 預覽)
|
||||||
|
|
||||||
|
- **資料庫模型**
|
||||||
|
- DIT_C_upload_history
|
||||||
|
- DIT_C_analysis_result
|
||||||
|
- DIT_C_action_card
|
||||||
|
- DIT_C_system_log
|
||||||
|
- DIT_C_llm_config
|
||||||
|
|
||||||
|
- **文件**
|
||||||
|
- API_DOC.md
|
||||||
|
- SDD.md
|
||||||
|
- security_audit.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- SQL Injection 防護 (SQLAlchemy ORM)
|
||||||
|
- XSS 防護 (Jinja2 自動轉義)
|
||||||
|
- 檔案上傳驗證 (限制 CSV)
|
||||||
|
- 敏感資訊保護 (.env 不進版控)
|
||||||
247
DIT_C/docs/SDD.md
Normal file
247
DIT_C/docs/SDD.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
# DIT 智能分析系統 - 系統設計文件 (SDD)
|
||||||
|
|
||||||
|
**版本:** v1.2
|
||||||
|
**日期:** 2025-12-12
|
||||||
|
**狀態:** 已實作並測試通過
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 系統概述
|
||||||
|
|
||||||
|
### 1.1 專案名稱
|
||||||
|
DIT (Design-In Tracking) Intelligent Analytics Module
|
||||||
|
|
||||||
|
### 1.2 目標
|
||||||
|
解析 DIT CSV 報表,透過規則引擎自動產出「行動建議卡片 (Action Cards)」
|
||||||
|
|
||||||
|
### 1.3 技術堆疊
|
||||||
|
- **Backend:** Python 3.9+, Flask 3.0+
|
||||||
|
- **Database:** MySQL (SQLAlchemy ORM)
|
||||||
|
- **Frontend:** Jinja2 Templates, Vanilla JS
|
||||||
|
- **LLM Integration:** Ollama API (deepseek-reasoner)
|
||||||
|
- **Version Control:** Git + Gitea
|
||||||
|
- **Server Port:** 9000 (預設)
|
||||||
|
|
||||||
|
### 1.4 支援檔案格式
|
||||||
|
- CSV (.csv) - 預設從 B17 儲存格開始讀取
|
||||||
|
- Excel (.xlsx) - 使用 openpyxl 引擎
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 系統架構
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Dashboard │ │ Upload │ │ History │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
|
└─────────┼────────────────┼────────────────┼─────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Flask App (app.py) │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ routes/ │ │ services/ │ │ models/ │ │
|
||||||
|
│ │ - api.py │ │ - dit_*.py │ │ - dit_*.py │ │
|
||||||
|
│ │ - main.py │ │ - llm_*.py │ │ │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
|
└─────────┼────────────────┼────────────────┼─────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ External Services │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ MySQL │ │ Ollama API │ │
|
||||||
|
│ │ (db_A102) │ │(deepseek- │ │
|
||||||
|
│ │ │ │ reasoner) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心模組
|
||||||
|
|
||||||
|
### 3.1 DITAnalyzer (services/dit_analyzer.py)
|
||||||
|
|
||||||
|
主要分析引擎,負責:
|
||||||
|
1. 資料載入 (CSV/XLSX)
|
||||||
|
2. 資料預處理
|
||||||
|
3. Feature 6.2: 高價值資源分配分析
|
||||||
|
4. Feature 6.3: 呆滯案件警示
|
||||||
|
5. 報告生成
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DITAnalyzer:
|
||||||
|
def __init__(self, file_path, dataframe, skip_rows=16, start_col=1)
|
||||||
|
def load_data(file_path, skip_rows, start_col)
|
||||||
|
def _load_excel(file_path, skip_rows, start_col) # 新增
|
||||||
|
def _load_csv(file_path, skip_rows, start_col) # 新增
|
||||||
|
def _preprocess(self)
|
||||||
|
def analyze_resource_allocation(top_percent, low_win_rate)
|
||||||
|
def analyze_stagnant_deals(threshold_days)
|
||||||
|
def generate_report()
|
||||||
|
```
|
||||||
|
|
||||||
|
**資料載入參數:**
|
||||||
|
- `skip_rows`: 跳過前幾列 (預設 16,即從第 17 列開始)
|
||||||
|
- `start_col`: 起始欄位索引 (預設 1,即 B 欄)
|
||||||
|
|
||||||
|
### 3.2 LLMService (services/llm_service.py)
|
||||||
|
|
||||||
|
Ollama API 封裝:
|
||||||
|
- 模型列表查詢
|
||||||
|
- 一般聊天請求
|
||||||
|
- 串流模式支援
|
||||||
|
- SSL 憑證驗證已停用 (內部 API)
|
||||||
|
|
||||||
|
**預設模型:** deepseek-reasoner
|
||||||
|
**備用模型:** deepseek-chat
|
||||||
|
|
||||||
|
### 3.3 資料模型 (models/dit_models.py)
|
||||||
|
|
||||||
|
| Table | 說明 |
|
||||||
|
|-------|------|
|
||||||
|
| DIT_C_upload_history | 上傳歷史記錄 |
|
||||||
|
| DIT_C_analysis_result | 分析結果 |
|
||||||
|
| DIT_C_action_card | 行動建議卡片 |
|
||||||
|
| DIT_C_system_log | 系統日誌 |
|
||||||
|
| DIT_C_llm_config | LLM 設定 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 資料流程
|
||||||
|
|
||||||
|
### 4.1 CSV 上傳分析流程
|
||||||
|
|
||||||
|
```
|
||||||
|
User Upload CSV/XLSX
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ File Validation │ (check .csv/.xlsx extension)
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Save to uploads/│
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ DITAnalyzer │
|
||||||
|
│ - preprocess │
|
||||||
|
│ - analyze │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Save to DB │
|
||||||
|
│ - upload_history│
|
||||||
|
│ - analysis_result│
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Delete temp file│
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Return JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 端點
|
||||||
|
|
||||||
|
| Method | Path | 說明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | /health | 健康檢查 |
|
||||||
|
| GET | / | 儀表板 |
|
||||||
|
| GET | /upload | 上傳頁面 |
|
||||||
|
| GET | /history | 歷史記錄 |
|
||||||
|
| POST | /api/analyze | 完整分析 |
|
||||||
|
| POST | /api/analyze/resource-allocation | Feature 6.2 |
|
||||||
|
| POST | /api/analyze/stagnant-deals | Feature 6.3 |
|
||||||
|
| POST | /api/action-card/{id}/resolve | 標記已處理 |
|
||||||
|
| GET | /api/llm/models | LLM 模型列表 |
|
||||||
|
| POST | /api/llm/chat | LLM 聊天 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 配置
|
||||||
|
|
||||||
|
### 6.1 環境變數 (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Flask
|
||||||
|
FLASK_SECRET_KEY=xxx
|
||||||
|
FLASK_DEBUG=True
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=mysql.theaken.com
|
||||||
|
DB_PORT=33306
|
||||||
|
DB_NAME=db_A102
|
||||||
|
DB_USER=A102
|
||||||
|
DB_PASSWORD=xxx
|
||||||
|
|
||||||
|
# Ollama
|
||||||
|
OLLAMA_API_URL=https://ollama_pjapi.theaken.com
|
||||||
|
OLLAMA_DEFAULT_MODEL=deepseek-reasoner
|
||||||
|
OLLAMA_ALT_MODEL=deepseek-chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Table 命名規則
|
||||||
|
所有資料表以 `DIT_C_` 為前綴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 部署
|
||||||
|
|
||||||
|
### 7.1 本地開發
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py 9000
|
||||||
|
```
|
||||||
|
|
||||||
|
**存取 URL:** http://127.0.0.1:9000/
|
||||||
|
|
||||||
|
**注意:** Port 5000/5001/8080 可能被其他服務占用,建議使用 9000
|
||||||
|
|
||||||
|
### 7.2 Production
|
||||||
|
- 使用 Gunicorn + Nginx
|
||||||
|
- 環境變數設定 `FLASK_DEBUG=False`
|
||||||
|
- 啟用 HTTPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 版本歷史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 變更 |
|
||||||
|
|------|------|------|
|
||||||
|
| v1.0 | 2024-12-12 | 初始版本 |
|
||||||
|
| v1.1 | 2025-12-12 | LLM 模型更換為 deepseek-reasoner |
|
||||||
|
| v1.2 | 2025-12-12 | 新增 XLSX 檔案支援、修復 CSV 載入邏輯、SSL 憑證問題修復 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 測試結果
|
||||||
|
|
||||||
|
### 9.1 功能測試 (2025-12-12)
|
||||||
|
|
||||||
|
| 測試項目 | 狀態 | 說明 |
|
||||||
|
|---------|------|------|
|
||||||
|
| Health Check | ✅ PASS | `/health` 端點正常 |
|
||||||
|
| Dashboard Page | ✅ PASS | 儀表板頁面載入正常 |
|
||||||
|
| Upload Page | ✅ PASS | 上傳頁面載入正常 |
|
||||||
|
| History Page | ✅ PASS | 歷史記錄頁面正常 |
|
||||||
|
| LLM Connection | ✅ PASS | deepseek-reasoner 連線正常 |
|
||||||
|
| CSV Analysis | ✅ PASS | CSV 分析功能正常 |
|
||||||
|
| XLSX Support | ✅ PASS | Excel 檔案支援正常 |
|
||||||
|
|
||||||
|
### 9.2 可用 LLM 模型
|
||||||
|
|
||||||
|
- deepseek-reasoner (預設)
|
||||||
|
- deepseek-chat (備用)
|
||||||
|
- gpt-oss:120b
|
||||||
125
DIT_C/docs/security_audit.md
Normal file
125
DIT_C/docs/security_audit.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# DIT 智能分析系統 - 資安檢視報告
|
||||||
|
|
||||||
|
**版本:** v1.0
|
||||||
|
**日期:** 2024-12-12
|
||||||
|
**狀態:** 已審視
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 安全措施總覽
|
||||||
|
|
||||||
|
| 項目 | 狀態 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| SQL Injection | ✅ 已防護 | 使用 SQLAlchemy ORM |
|
||||||
|
| XSS | ✅ 已防護 | Jinja2 自動轉義 |
|
||||||
|
| CSRF | ⚠️ 待加強 | 建議加入 Flask-WTF |
|
||||||
|
| 檔案上傳驗證 | ✅ 已防護 | 限制 CSV 格式 |
|
||||||
|
| 敏感資訊 | ✅ 已防護 | .env 不進版控 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 詳細檢視
|
||||||
|
|
||||||
|
### 2.1 SQL Injection 防護
|
||||||
|
- **風險等級:** 低
|
||||||
|
- **防護措施:**
|
||||||
|
- 使用 SQLAlchemy ORM,所有查詢自動參數化
|
||||||
|
- 無直接 SQL 字串拼接
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 安全寫法 (已實作)
|
||||||
|
DITUploadHistory.query.filter_by(id=upload_id).first()
|
||||||
|
|
||||||
|
# 危險寫法 (未使用)
|
||||||
|
# db.execute(f"SELECT * FROM uploads WHERE id = {upload_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 XSS 防護
|
||||||
|
- **風險等級:** 低
|
||||||
|
- **防護措施:**
|
||||||
|
- Jinja2 模板引擎自動 HTML 轉義
|
||||||
|
- 使用者輸入不直接渲染 raw HTML
|
||||||
|
|
||||||
|
### 2.3 檔案上傳安全
|
||||||
|
- **風險等級:** 中
|
||||||
|
- **防護措施:**
|
||||||
|
- 使用 `werkzeug.utils.secure_filename()` 清理檔名
|
||||||
|
- 限制副檔名為 `.csv`
|
||||||
|
- 上傳檔案分析後立即刪除
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 已實作
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({"error": "僅支援 CSV 檔案"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 敏感資訊管理
|
||||||
|
- **風險等級:** 低
|
||||||
|
- **防護措施:**
|
||||||
|
- 資料庫密碼、API Key 存放於 `.env`
|
||||||
|
- `.env` 已加入 `.gitignore`
|
||||||
|
- 提供 `.env.example` 範本
|
||||||
|
|
||||||
|
### 2.5 API 安全
|
||||||
|
- **風險等級:** 中
|
||||||
|
- **待加強:**
|
||||||
|
- 目前無 API 認證機制
|
||||||
|
- 建議後續加入 JWT 或 API Key 驗證
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 建議事項
|
||||||
|
|
||||||
|
### 高優先
|
||||||
|
1. **加入 CSRF 保護**
|
||||||
|
```python
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
csrf = CSRFProtect(app)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **加入 API 認證**
|
||||||
|
- 建議使用 Flask-JWT-Extended
|
||||||
|
|
||||||
|
### 中優先
|
||||||
|
3. **Rate Limiting**
|
||||||
|
- 防止 API 濫用
|
||||||
|
- 建議使用 Flask-Limiter
|
||||||
|
|
||||||
|
4. **日誌記錄**
|
||||||
|
- 記錄所有 API 呼叫
|
||||||
|
- 已建立 `DITSystemLog` 資料表
|
||||||
|
|
||||||
|
### 低優先
|
||||||
|
5. **HTTPS 強制**
|
||||||
|
- 部署時使用 HTTPS
|
||||||
|
- 設定 HSTS Header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. OWASP Top 10 檢查清單
|
||||||
|
|
||||||
|
| # | 風險 | 狀態 | 備註 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| A01 | Broken Access Control | ⚠️ | 無認證機制 |
|
||||||
|
| A02 | Cryptographic Failures | ✅ | 密碼儲存於環境變數 |
|
||||||
|
| A03 | Injection | ✅ | ORM 防護 |
|
||||||
|
| A04 | Insecure Design | ✅ | - |
|
||||||
|
| A05 | Security Misconfiguration | ✅ | - |
|
||||||
|
| A06 | Vulnerable Components | ✅ | 使用主流套件 |
|
||||||
|
| A07 | Auth Failures | ⚠️ | 無認證機制 |
|
||||||
|
| A08 | Data Integrity Failures | ✅ | - |
|
||||||
|
| A09 | Logging Failures | ⚠️ | 待完善日誌 |
|
||||||
|
| A10 | SSRF | ✅ | 無外部請求風險 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 結論
|
||||||
|
|
||||||
|
系統基礎安全防護已到位,建議在正式部署前:
|
||||||
|
1. 加入 API 認證機制
|
||||||
|
2. 啟用 CSRF 保護
|
||||||
|
3. 完善系統日誌記錄
|
||||||
|
|
||||||
|
**審視人員:** Claude AI
|
||||||
|
**審視日期:** 2024-12-12
|
||||||
3
DIT_C/models/__init__.py
Normal file
3
DIT_C/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
100
DIT_C/models/dit_models.py
Normal file
100
DIT_C/models/dit_models.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
DIT 資料庫模型
|
||||||
|
Table 前綴: DIT_C_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from models import db
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class DITUploadHistory(db.Model):
|
||||||
|
"""上傳歷史記錄"""
|
||||||
|
__tablename__ = f'{Config.TABLE_PREFIX}upload_history'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
filename = db.Column(db.String(255), nullable=False)
|
||||||
|
file_size = db.Column(db.Integer)
|
||||||
|
record_count = db.Column(db.Integer)
|
||||||
|
total_value = db.Column(db.Float)
|
||||||
|
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
uploaded_by = db.Column(db.String(100))
|
||||||
|
status = db.Column(db.String(50), default='completed')
|
||||||
|
|
||||||
|
# 關聯
|
||||||
|
analysis_results = db.relationship('DITAnalysisResult', backref='upload', lazy=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DITAnalysisResult(db.Model):
|
||||||
|
"""分析結果記錄"""
|
||||||
|
__tablename__ = f'{Config.TABLE_PREFIX}analysis_result'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
upload_id = db.Column(db.Integer, db.ForeignKey(f'{Config.TABLE_PREFIX}upload_history.id'))
|
||||||
|
analysis_type = db.Column(db.String(50), nullable=False) # resource_allocation / stagnant_deal
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# 統計摘要
|
||||||
|
total_records = db.Column(db.Integer)
|
||||||
|
total_value = db.Column(db.Float)
|
||||||
|
active_count = db.Column(db.Integer)
|
||||||
|
lost_count = db.Column(db.Integer)
|
||||||
|
win_rate = db.Column(db.Float)
|
||||||
|
|
||||||
|
# JSON 存儲完整結果
|
||||||
|
result_json = db.Column(db.Text)
|
||||||
|
|
||||||
|
|
||||||
|
class DITActionCard(db.Model):
|
||||||
|
"""行動建議卡片"""
|
||||||
|
__tablename__ = f'{Config.TABLE_PREFIX}action_card'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
result_id = db.Column(db.Integer, db.ForeignKey(f'{Config.TABLE_PREFIX}analysis_result.id'))
|
||||||
|
card_type = db.Column(db.String(50), nullable=False) # resource_allocation / stagnant_deal
|
||||||
|
title = db.Column(db.String(255))
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# 共用欄位
|
||||||
|
suggestion = db.Column(db.Text)
|
||||||
|
is_resolved = db.Column(db.Boolean, default=False)
|
||||||
|
resolved_at = db.Column(db.DateTime)
|
||||||
|
resolved_by = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# Feature 6.2 欄位
|
||||||
|
application = db.Column(db.String(255))
|
||||||
|
potential_value = db.Column(db.Float)
|
||||||
|
win_rate = db.Column(db.Float)
|
||||||
|
top_accounts = db.Column(db.Text) # JSON array
|
||||||
|
|
||||||
|
# Feature 6.3 欄位
|
||||||
|
account_name = db.Column(db.String(255))
|
||||||
|
project_name = db.Column(db.String(255))
|
||||||
|
approved_date = db.Column(db.Date)
|
||||||
|
days_pending = db.Column(db.Integer)
|
||||||
|
|
||||||
|
|
||||||
|
class DITSystemLog(db.Model):
|
||||||
|
"""系統日誌"""
|
||||||
|
__tablename__ = f'{Config.TABLE_PREFIX}system_log'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
level = db.Column(db.String(20)) # INFO / WARNING / ERROR
|
||||||
|
module = db.Column(db.String(100))
|
||||||
|
message = db.Column(db.Text)
|
||||||
|
user = db.Column(db.String(100))
|
||||||
|
ip_address = db.Column(db.String(50))
|
||||||
|
|
||||||
|
|
||||||
|
class DITLLMConfig(db.Model):
|
||||||
|
"""LLM API 設定"""
|
||||||
|
__tablename__ = f'{Config.TABLE_PREFIX}llm_config'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
api_url = db.Column(db.String(500))
|
||||||
|
model_name = db.Column(db.String(100))
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
879
DIT_C/preview.html
Normal file
879
DIT_C/preview.html
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DIT 智能分析系統 - UI Preview</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-dark: #1d4ed8;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--gray-50: #f9fafb;
|
||||||
|
--gray-100: #f3f4f6;
|
||||||
|
--gray-200: #e5e7eb;
|
||||||
|
--gray-300: #d1d5db;
|
||||||
|
--gray-500: #6b7280;
|
||||||
|
--gray-700: #374151;
|
||||||
|
--gray-900: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: var(--gray-50);
|
||||||
|
color: var(--gray-900);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Container */
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Section */
|
||||||
|
.upload-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--gray-300);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background-color: var(--gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone.dragover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
color: var(--gray-700);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background-color: var(--gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.success { color: var(--success); }
|
||||||
|
.stat-value.warning { color: var(--warning); }
|
||||||
|
.stat-value.danger { color: var(--danger); }
|
||||||
|
|
||||||
|
.stat-change {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-change.positive { color: var(--success); }
|
||||||
|
.stat-change.negative { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Section Title */
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Cards */
|
||||||
|
.action-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-header.resource {
|
||||||
|
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||||
|
border-bottom: 1px solid #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-header.stagnant {
|
||||||
|
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||||
|
border-bottom: 1px solid #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-header.resource .action-card-icon {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-header.stagnant .action-card-icon {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-suggestion {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--gray-100);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-success {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--gray-50);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:not(:last-child) td {
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--gray-200);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.success { background: var(--success); }
|
||||||
|
.progress-fill.warning { background: var(--warning); }
|
||||||
|
.progress-fill.danger { background: var(--danger); }
|
||||||
|
|
||||||
|
/* Filter Bar */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input[type="text"], input[type="number"] {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus, input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--gray-200);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="logo">DIT <span>智能分析系統</span></div>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="#">儀表板</a>
|
||||||
|
<a href="#">分析報告</a>
|
||||||
|
<a href="#">設定</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Upload Section -->
|
||||||
|
<section class="upload-section">
|
||||||
|
<div class="upload-zone" id="uploadZone">
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<div class="upload-text">拖曳 CSV 檔案至此處,或點擊選擇檔案</div>
|
||||||
|
<div class="upload-hint">支援 DIT Report CSV 格式</div>
|
||||||
|
<input type="file" id="fileInput" accept=".csv" hidden>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: center; gap: 1rem; margin-top: 1.5rem;">
|
||||||
|
<button class="btn btn-primary" onclick="document.getElementById('fileInput').click()">
|
||||||
|
選擇檔案
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline" id="analyzeBtn">
|
||||||
|
開始分析
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">總案件數</div>
|
||||||
|
<div class="stat-value">1,234</div>
|
||||||
|
<div class="stat-change positive">+12% vs 上月</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">總潛在金額</div>
|
||||||
|
<div class="stat-value">$4.5M</div>
|
||||||
|
<div class="stat-change positive">+8% vs 上月</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">整體勝率</div>
|
||||||
|
<div class="stat-value success">68%</div>
|
||||||
|
<div class="stat-change negative">-3% vs 上月</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">待處理警示</div>
|
||||||
|
<div class="stat-value danger">12</div>
|
||||||
|
<div class="stat-change">需要關注</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">篩選條件:</span>
|
||||||
|
<select>
|
||||||
|
<option>所有類型</option>
|
||||||
|
<option>高價值資源分配</option>
|
||||||
|
<option>呆滯案件警示</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">金額門檻 Top:</span>
|
||||||
|
<input type="number" value="20" style="width: 80px;"> %
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">呆滯天數:</span>
|
||||||
|
<input type="number" value="60" style="width: 80px;"> 天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Cards Section -->
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>行動建議卡片 (Action Cards)</h2>
|
||||||
|
<span class="tag tag-danger">12 項待處理</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-cards">
|
||||||
|
<!-- Resource Allocation Card -->
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-card-header resource">
|
||||||
|
<div class="action-card-icon">💰</div>
|
||||||
|
<div>
|
||||||
|
<div class="action-card-title">高潛力市場攻堅提醒</div>
|
||||||
|
<div class="action-card-subtitle">Feature 6.2 - Resource Allocation</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-body">
|
||||||
|
<div class="action-card-metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">應用領域</div>
|
||||||
|
<div class="metric-value">Automotive</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">潛在金額</div>
|
||||||
|
<div class="metric-value">$1,200,000</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">當前勝率</div>
|
||||||
|
<div class="metric-value" style="color: var(--danger);">8.5%</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">Top 客戶</div>
|
||||||
|
<div class="metric-value" style="font-size: 0.875rem;">客戶A, 客戶B</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-suggestion">
|
||||||
|
<strong>建議:</strong>Automotive 領域潛在商機巨大 ($1,200,000),但目前勝率偏低 (8.5%)。建議指派資深 FAE 介入該領域的前三大案子。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-footer">
|
||||||
|
<button class="btn btn-outline">標記已處理</button>
|
||||||
|
<button class="btn btn-primary">查看詳情</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stagnant Deal Card -->
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-card-header stagnant">
|
||||||
|
<div class="action-card-icon">⏰</div>
|
||||||
|
<div>
|
||||||
|
<div class="action-card-title">呆滯案件喚醒</div>
|
||||||
|
<div class="action-card-subtitle">Feature 6.3 - Stagnant Deal Alert</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-body">
|
||||||
|
<div class="action-card-metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">客戶名稱</div>
|
||||||
|
<div class="metric-value">台積電</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">專案名稱</div>
|
||||||
|
<div class="metric-value">Project Alpha</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">承認日期</div>
|
||||||
|
<div class="metric-value">2024-09-15</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">呆滯天數</div>
|
||||||
|
<div class="metric-value" style="color: var(--danger);">88 天</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-suggestion">
|
||||||
|
<strong>建議:</strong>客戶 台積電 的 Project Alpha 已承認超過 2 個月 (88 天),仍未轉單。請業務確認是否為「價格」或「庫存」問題。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-footer">
|
||||||
|
<button class="btn btn-outline">標記已處理</button>
|
||||||
|
<button class="btn btn-primary">聯繫業務</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Another Resource Card -->
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-card-header resource">
|
||||||
|
<div class="action-card-icon">💰</div>
|
||||||
|
<div>
|
||||||
|
<div class="action-card-title">高潛力市場攻堅提醒</div>
|
||||||
|
<div class="action-card-subtitle">Feature 6.2 - Resource Allocation</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-body">
|
||||||
|
<div class="action-card-metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">應用領域</div>
|
||||||
|
<div class="metric-value">IoT</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">潛在金額</div>
|
||||||
|
<div class="metric-value">$850,000</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">當前勝率</div>
|
||||||
|
<div class="metric-value" style="color: var(--warning);">5.2%</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">Top 客戶</div>
|
||||||
|
<div class="metric-value" style="font-size: 0.875rem;">客戶C, 客戶D</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-suggestion">
|
||||||
|
<strong>建議:</strong>IoT 領域潛在商機巨大 ($850,000),但目前勝率偏低 (5.2%)。建議指派資深 FAE 介入該領域的前三大案子。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-footer">
|
||||||
|
<button class="btn btn-outline">標記已處理</button>
|
||||||
|
<button class="btn btn-primary">查看詳情</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Another Stagnant Card -->
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-card-header stagnant">
|
||||||
|
<div class="action-card-icon">⏰</div>
|
||||||
|
<div>
|
||||||
|
<div class="action-card-title">呆滯案件喚醒</div>
|
||||||
|
<div class="action-card-subtitle">Feature 6.3 - Stagnant Deal Alert</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-body">
|
||||||
|
<div class="action-card-metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">客戶名稱</div>
|
||||||
|
<div class="metric-value">聯發科</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">專案名稱</div>
|
||||||
|
<div class="metric-value">Project Beta</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">承認日期</div>
|
||||||
|
<div class="metric-value">2024-08-20</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">呆滯天數</div>
|
||||||
|
<div class="metric-value" style="color: var(--danger);">114 天</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-suggestion">
|
||||||
|
<strong>建議:</strong>客戶 聯發科 的 Project Beta 已承認超過 3 個月 (114 天),仍未轉單。請業務確認是否為「價格」或「庫存」問題。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-footer">
|
||||||
|
<button class="btn btn-outline">標記已處理</button>
|
||||||
|
<button class="btn btn-primary">聯繫業務</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Table -->
|
||||||
|
<div class="table-container">
|
||||||
|
<div class="table-header">
|
||||||
|
<span class="table-title">應用領域統計摘要</span>
|
||||||
|
<button class="btn btn-outline">匯出 CSV</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>應用領域</th>
|
||||||
|
<th>案件數</th>
|
||||||
|
<th>總金額</th>
|
||||||
|
<th>勝率</th>
|
||||||
|
<th>狀態</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Automotive</strong></td>
|
||||||
|
<td>156</td>
|
||||||
|
<td>$1,200,000</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<div class="progress-bar" style="width: 100px;">
|
||||||
|
<div class="progress-fill danger" style="width: 8.5%;"></div>
|
||||||
|
</div>
|
||||||
|
<span>8.5%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="tag tag-danger">需關注</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>IoT</strong></td>
|
||||||
|
<td>89</td>
|
||||||
|
<td>$850,000</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<div class="progress-bar" style="width: 100px;">
|
||||||
|
<div class="progress-fill danger" style="width: 5.2%;"></div>
|
||||||
|
</div>
|
||||||
|
<span>5.2%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="tag tag-danger">需關注</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Consumer</strong></td>
|
||||||
|
<td>234</td>
|
||||||
|
<td>$650,000</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<div class="progress-bar" style="width: 100px;">
|
||||||
|
<div class="progress-fill warning" style="width: 35%;"></div>
|
||||||
|
</div>
|
||||||
|
<span>35%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="tag tag-warning">待改善</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Industrial</strong></td>
|
||||||
|
<td>178</td>
|
||||||
|
<td>$920,000</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<div class="progress-bar" style="width: 100px;">
|
||||||
|
<div class="progress-fill success" style="width: 72%;"></div>
|
||||||
|
</div>
|
||||||
|
<span>72%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="tag tag-success">良好</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Power Supply</strong></td>
|
||||||
|
<td>145</td>
|
||||||
|
<td>$780,000</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<div class="progress-bar" style="width: 100px;">
|
||||||
|
<div class="progress-fill success" style="width: 68%;"></div>
|
||||||
|
</div>
|
||||||
|
<span>68%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="tag tag-success">良好</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Upload Zone Interaction
|
||||||
|
const uploadZone = document.getElementById('uploadZone');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
|
||||||
|
uploadZone.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
uploadZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener('dragleave', () => {
|
||||||
|
uploadZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.remove('dragover');
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleFile(files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
handleFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
if (file.name.endsWith('.csv')) {
|
||||||
|
document.querySelector('.upload-text').textContent = `已選擇: ${file.name}`;
|
||||||
|
document.querySelector('.upload-icon').textContent = '✅';
|
||||||
|
} else {
|
||||||
|
alert('請上傳 CSV 格式檔案');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze Button
|
||||||
|
document.getElementById('analyzeBtn').addEventListener('click', () => {
|
||||||
|
alert('分析功能將連接後端 API\nPOST /api/analyze');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
DIT_C/requirements.txt
Normal file
11
DIT_C/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
flask-sqlalchemy>=3.1.0
|
||||||
|
flask-login>=0.6.3
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
pymysql>=1.1.0
|
||||||
|
cryptography>=41.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
pandas>=2.0.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
werkzeug>=3.0.0
|
||||||
|
openpyxl>=3.1.0
|
||||||
1
DIT_C/routes/__init__.py
Normal file
1
DIT_C/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Routes module
|
||||||
234
DIT_C/routes/api.py
Normal file
234
DIT_C/routes/api.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
DIT 分析 API 路由 (含資料庫整合)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from services.dit_analyzer import DITAnalyzer, DITAnalyzerError
|
||||||
|
from models import db
|
||||||
|
from models.dit_models import DITUploadHistory, DITAnalysisResult, DITActionCard
|
||||||
|
|
||||||
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {'csv', 'xlsx'}
|
||||||
|
UPLOAD_FOLDER = 'uploads'
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_file(filename: str) -> bool:
|
||||||
|
"""檢查檔案副檔名"""
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/analyze', methods=['POST'])
|
||||||
|
def analyze_dit():
|
||||||
|
"""
|
||||||
|
分析 DIT CSV 檔案
|
||||||
|
|
||||||
|
接受 multipart/form-data 上傳 CSV
|
||||||
|
回傳 JSON 格式分析結果
|
||||||
|
"""
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({"error": "未上傳檔案", "code": "NO_FILE"}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({"error": "未選擇檔案", "code": "NO_FILENAME"}), 400
|
||||||
|
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({"error": "僅支援 CSV 或 XLSX 檔案", "code": "INVALID_TYPE"}), 400
|
||||||
|
|
||||||
|
top_percent = float(request.form.get('top_percent', 0.2))
|
||||||
|
low_win_rate = float(request.form.get('low_win_rate', 0.1))
|
||||||
|
threshold_days = int(request.form.get('threshold_days', 60))
|
||||||
|
skip_rows = int(request.form.get('skip_rows', 16)) # 預設從第 17 列開始
|
||||||
|
start_col = int(request.form.get('start_col', 1)) # 預設從 B 欄開始
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
filepath = os.path.join(UPLOAD_FOLDER, filename)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
# 記錄上傳
|
||||||
|
upload_record = DITUploadHistory(
|
||||||
|
filename=filename,
|
||||||
|
file_size=os.path.getsize(filepath),
|
||||||
|
status='processing'
|
||||||
|
)
|
||||||
|
db.session.add(upload_record)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# 執行分析 (資料從 B17 開始)
|
||||||
|
analyzer = DITAnalyzer(filepath, skip_rows=skip_rows, start_col=start_col)
|
||||||
|
report = analyzer.generate_report(
|
||||||
|
top_percent=top_percent,
|
||||||
|
low_win_rate=low_win_rate,
|
||||||
|
threshold_days=threshold_days
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新上傳記錄
|
||||||
|
upload_record.record_count = report['summary']['total_records']
|
||||||
|
upload_record.total_value = report['summary']['total_value_raw']
|
||||||
|
upload_record.status = 'completed'
|
||||||
|
|
||||||
|
# 儲存分析結果
|
||||||
|
analysis_result = DITAnalysisResult(
|
||||||
|
upload_id=upload_record.id,
|
||||||
|
analysis_type='full',
|
||||||
|
total_records=report['summary']['total_records'],
|
||||||
|
total_value=report['summary']['total_value_raw'],
|
||||||
|
active_count=report['summary']['active_count'],
|
||||||
|
lost_count=report['summary']['lost_count'],
|
||||||
|
win_rate=float(report['summary']['win_rate'].replace('%', '')) / 100,
|
||||||
|
result_json=json.dumps(report, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
db.session.add(analysis_result)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"data": report
|
||||||
|
})
|
||||||
|
|
||||||
|
except DITAnalyzerError as e:
|
||||||
|
if 'upload_record' in locals():
|
||||||
|
upload_record.status = 'failed'
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"error": str(e), "code": "ANALYZER_ERROR"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
if 'upload_record' in locals():
|
||||||
|
upload_record.status = 'failed'
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"error": f"分析失敗: {str(e)}", "code": "INTERNAL_ERROR"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/analyze/resource-allocation', methods=['POST'])
|
||||||
|
def analyze_resource_allocation():
|
||||||
|
"""僅執行 Feature 6.2: 高價值資源分配分析"""
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({"error": "未上傳檔案", "code": "NO_FILE"}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({"error": "僅支援 CSV 或 XLSX 檔案", "code": "INVALID_TYPE"}), 400
|
||||||
|
|
||||||
|
top_percent = float(request.form.get('top_percent', 0.2))
|
||||||
|
low_win_rate = float(request.form.get('low_win_rate', 0.1))
|
||||||
|
skip_rows = int(request.form.get('skip_rows', 16))
|
||||||
|
start_col = int(request.form.get('start_col', 1))
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
filepath = os.path.join(UPLOAD_FOLDER, filename)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
analyzer = DITAnalyzer(filepath, skip_rows=skip_rows, start_col=start_col)
|
||||||
|
results = analyzer.analyze_resource_allocation(top_percent, low_win_rate)
|
||||||
|
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"type": "resource_allocation",
|
||||||
|
"count": len(results),
|
||||||
|
"action_cards": results
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except DITAnalyzerError as e:
|
||||||
|
return jsonify({"error": str(e), "code": "ANALYZER_ERROR"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"分析失敗: {str(e)}", "code": "INTERNAL_ERROR"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/analyze/stagnant-deals', methods=['POST'])
|
||||||
|
def analyze_stagnant_deals():
|
||||||
|
"""僅執行 Feature 6.3: 呆滯案件警示"""
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({"error": "未上傳檔案", "code": "NO_FILE"}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({"error": "僅支援 CSV 或 XLSX 檔案", "code": "INVALID_TYPE"}), 400
|
||||||
|
|
||||||
|
threshold_days = int(request.form.get('threshold_days', 60))
|
||||||
|
skip_rows = int(request.form.get('skip_rows', 16))
|
||||||
|
start_col = int(request.form.get('start_col', 1))
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
filepath = os.path.join(UPLOAD_FOLDER, filename)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
analyzer = DITAnalyzer(filepath, skip_rows=skip_rows, start_col=start_col)
|
||||||
|
results = analyzer.analyze_stagnant_deals(threshold_days)
|
||||||
|
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"type": "stagnant_deals",
|
||||||
|
"count": len(results),
|
||||||
|
"action_cards": results
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except DITAnalyzerError as e:
|
||||||
|
return jsonify({"error": str(e), "code": "ANALYZER_ERROR"}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"分析失敗: {str(e)}", "code": "INTERNAL_ERROR"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/action-card/<int:card_id>/resolve', methods=['POST'])
|
||||||
|
def resolve_action_card(card_id):
|
||||||
|
"""標記 Action Card 為已處理"""
|
||||||
|
from datetime import datetime
|
||||||
|
card = DITActionCard.query.get_or_404(card_id)
|
||||||
|
card.is_resolved = True
|
||||||
|
card.resolved_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"status": "success"})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/llm/models', methods=['GET'])
|
||||||
|
def get_llm_models():
|
||||||
|
"""取得可用 LLM 模型列表"""
|
||||||
|
from services.llm_service import llm_service, LLMServiceError
|
||||||
|
try:
|
||||||
|
models = llm_service.get_available_models()
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"data": models
|
||||||
|
})
|
||||||
|
except LLMServiceError as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/llm/chat', methods=['POST'])
|
||||||
|
def llm_chat():
|
||||||
|
"""LLM 聊天 API"""
|
||||||
|
from services.llm_service import llm_service, LLMServiceError
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'prompt' not in data:
|
||||||
|
return jsonify({"error": "缺少 prompt 參數"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = llm_service.simple_query(
|
||||||
|
prompt=data['prompt'],
|
||||||
|
system_prompt=data.get('system_prompt')
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"data": {"response": response}
|
||||||
|
})
|
||||||
|
except LLMServiceError as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
85
DIT_C/routes/main.py
Normal file
85
DIT_C/routes/main.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
主要頁面路由
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
|
from models import db
|
||||||
|
from models.dit_models import DITUploadHistory, DITAnalysisResult, DITActionCard
|
||||||
|
import json
|
||||||
|
|
||||||
|
main_bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/')
|
||||||
|
def index():
|
||||||
|
"""首頁重導向到儀表板"""
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/dashboard')
|
||||||
|
def dashboard():
|
||||||
|
"""儀表板頁面"""
|
||||||
|
# 取得最新分析結果
|
||||||
|
latest_result = DITAnalysisResult.query.order_by(
|
||||||
|
DITAnalysisResult.created_at.desc()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
summary = None
|
||||||
|
action_cards = None
|
||||||
|
total_alerts = 0
|
||||||
|
|
||||||
|
if latest_result and latest_result.result_json:
|
||||||
|
try:
|
||||||
|
result_data = json.loads(latest_result.result_json)
|
||||||
|
summary = result_data.get('summary', {})
|
||||||
|
action_cards = result_data.get('action_cards', {})
|
||||||
|
total_alerts = result_data.get('total_alerts', 0)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render_template('dashboard.html',
|
||||||
|
summary=summary,
|
||||||
|
action_cards=action_cards,
|
||||||
|
total_alerts=total_alerts)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/upload')
|
||||||
|
def upload():
|
||||||
|
"""上傳分析頁面"""
|
||||||
|
return render_template('upload.html')
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/history')
|
||||||
|
def history():
|
||||||
|
"""歷史記錄頁面"""
|
||||||
|
uploads = DITUploadHistory.query.order_by(
|
||||||
|
DITUploadHistory.uploaded_at.desc()
|
||||||
|
).limit(50).all()
|
||||||
|
|
||||||
|
return render_template('history.html', uploads=uploads)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/result/<int:upload_id>')
|
||||||
|
def result(upload_id):
|
||||||
|
"""查看特定分析結果"""
|
||||||
|
upload = DITUploadHistory.query.get_or_404(upload_id)
|
||||||
|
result = DITAnalysisResult.query.filter_by(upload_id=upload_id).first()
|
||||||
|
|
||||||
|
summary = None
|
||||||
|
action_cards = None
|
||||||
|
total_alerts = 0
|
||||||
|
|
||||||
|
if result and result.result_json:
|
||||||
|
try:
|
||||||
|
result_data = json.loads(result.result_json)
|
||||||
|
summary = result_data.get('summary', {})
|
||||||
|
action_cards = result_data.get('action_cards', {})
|
||||||
|
total_alerts = result_data.get('total_alerts', 0)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render_template('dashboard.html',
|
||||||
|
summary=summary,
|
||||||
|
action_cards=action_cards,
|
||||||
|
total_alerts=total_alerts,
|
||||||
|
upload=upload)
|
||||||
1
DIT_C/services/__init__.py
Normal file
1
DIT_C/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Services module
|
||||||
400
DIT_C/services/dit_analyzer.py
Normal file
400
DIT_C/services/dit_analyzer.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
DIT 智能分析模組
|
||||||
|
解析 DIT CSV 報表,產出行動建議卡片 (Action Cards)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Optional, Any
|
||||||
|
|
||||||
|
|
||||||
|
class DITAnalyzer:
|
||||||
|
"""DIT 報表分析器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
file_path: Optional[str] = None,
|
||||||
|
dataframe: Optional[pd.DataFrame] = None,
|
||||||
|
skip_rows: int = 16,
|
||||||
|
start_col: int = 1
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化分析器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: CSV 檔案路徑
|
||||||
|
dataframe: 或直接傳入 DataFrame
|
||||||
|
skip_rows: 跳過前幾列 (預設 16,即從第 17 列開始)
|
||||||
|
start_col: 起始欄位索引 (預設 1,即 B 欄)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
預設 CSV 格式為從 B17 儲存格開始讀取資料
|
||||||
|
"""
|
||||||
|
self.df: Optional[pd.DataFrame] = None
|
||||||
|
self.processed: bool = False
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
self.load_data(file_path, skip_rows=skip_rows, start_col=start_col)
|
||||||
|
elif dataframe is not None:
|
||||||
|
self.df = dataframe.copy()
|
||||||
|
self._preprocess()
|
||||||
|
|
||||||
|
def load_data(self, file_path: str, skip_rows: int = 16, start_col: int = 1) -> 'DITAnalyzer':
|
||||||
|
"""
|
||||||
|
載入 CSV 或 Excel 資料
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: CSV 或 XLSX 檔案路徑
|
||||||
|
skip_rows: 跳過前幾列 (預設 16,即從第 17 列開始)
|
||||||
|
start_col: 起始欄位索引 (預設 1,即 B 欄)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self (支援鏈式呼叫)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
預設資料格式為 B17 開始 (skiprows=16, usecols 從 B 欄開始)
|
||||||
|
支援 .csv 和 .xlsx 格式
|
||||||
|
"""
|
||||||
|
file_ext = file_path.lower().split('.')[-1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if file_ext == 'xlsx':
|
||||||
|
# Excel 格式
|
||||||
|
self.df = self._load_excel(file_path, skip_rows, start_col)
|
||||||
|
else:
|
||||||
|
# CSV 格式
|
||||||
|
self.df = self._load_csv(file_path, skip_rows, start_col)
|
||||||
|
except Exception as e:
|
||||||
|
raise DITAnalyzerError(f"無法載入檔案: {e}")
|
||||||
|
|
||||||
|
self._preprocess()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _load_excel(self, file_path: str, skip_rows: int, start_col: int) -> pd.DataFrame:
|
||||||
|
"""載入 Excel 檔案"""
|
||||||
|
# 先讀取以取得欄位總數
|
||||||
|
temp_df = pd.read_excel(file_path, nrows=1, skiprows=skip_rows, header=None, engine='openpyxl')
|
||||||
|
total_cols = len(temp_df.columns)
|
||||||
|
|
||||||
|
# 確保 usecols 不會超出範圍
|
||||||
|
if start_col >= total_cols:
|
||||||
|
return pd.read_excel(file_path, skiprows=skip_rows, engine='openpyxl')
|
||||||
|
|
||||||
|
usecols = list(range(start_col, total_cols))
|
||||||
|
|
||||||
|
return pd.read_excel(
|
||||||
|
file_path,
|
||||||
|
skiprows=skip_rows,
|
||||||
|
usecols=usecols,
|
||||||
|
engine='openpyxl'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_csv(self, file_path: str, skip_rows: int, start_col: int) -> pd.DataFrame:
|
||||||
|
"""載入 CSV 檔案"""
|
||||||
|
try:
|
||||||
|
# 先讀取檔案以取得總欄位數
|
||||||
|
temp_df = pd.read_csv(file_path, encoding='utf-8', nrows=1, skiprows=skip_rows, header=None)
|
||||||
|
total_cols = len(temp_df.columns)
|
||||||
|
|
||||||
|
# 確保 usecols 不會超出範圍
|
||||||
|
if start_col >= total_cols:
|
||||||
|
# 如果起始欄位超出範圍,直接讀取所有欄位
|
||||||
|
return pd.read_csv(file_path, encoding='utf-8', skiprows=skip_rows)
|
||||||
|
|
||||||
|
usecols = list(range(start_col, total_cols))
|
||||||
|
|
||||||
|
return pd.read_csv(
|
||||||
|
file_path,
|
||||||
|
encoding='utf-8',
|
||||||
|
skiprows=skip_rows,
|
||||||
|
usecols=usecols
|
||||||
|
)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
temp_df = pd.read_csv(file_path, encoding='cp950', nrows=1, skiprows=skip_rows, header=None)
|
||||||
|
total_cols = len(temp_df.columns)
|
||||||
|
|
||||||
|
if start_col >= total_cols:
|
||||||
|
return pd.read_csv(file_path, encoding='cp950', skiprows=skip_rows)
|
||||||
|
|
||||||
|
usecols = list(range(start_col, total_cols))
|
||||||
|
|
||||||
|
return pd.read_csv(
|
||||||
|
file_path,
|
||||||
|
encoding='cp950',
|
||||||
|
skiprows=skip_rows,
|
||||||
|
usecols=usecols
|
||||||
|
)
|
||||||
|
|
||||||
|
def _preprocess(self) -> None:
|
||||||
|
"""執行資料清洗與預處理"""
|
||||||
|
if self.df is None:
|
||||||
|
raise DITAnalyzerError("尚未載入資料")
|
||||||
|
|
||||||
|
# 1. 欄位清洗:移除欄位名稱前後空白
|
||||||
|
self.df.columns = self.df.columns.str.strip()
|
||||||
|
|
||||||
|
# 2. 日期轉換
|
||||||
|
date_columns = ['Created Date', 'Approved date', 'Close Date']
|
||||||
|
for col in date_columns:
|
||||||
|
if col in self.df.columns:
|
||||||
|
self.df[col] = pd.to_datetime(self.df[col], errors='coerce')
|
||||||
|
|
||||||
|
# 3. 數值轉換:Total Price
|
||||||
|
if 'Total Price' in self.df.columns:
|
||||||
|
self.df['Total Price'] = pd.to_numeric(
|
||||||
|
self.df['Total Price'].astype(str).str.replace(',', ''),
|
||||||
|
errors='coerce'
|
||||||
|
).fillna(0)
|
||||||
|
|
||||||
|
# 4. 應用領域推導 (Derived_Application)
|
||||||
|
self.df['Derived_Application'] = self._derive_application()
|
||||||
|
|
||||||
|
# 5. 狀態標記
|
||||||
|
if 'Stage' in self.df.columns:
|
||||||
|
self.df['Is_Lost'] = self.df['Stage'].str.contains(
|
||||||
|
'Lost', case=False, na=False
|
||||||
|
)
|
||||||
|
self.df['Is_Active'] = ~self.df['Is_Lost']
|
||||||
|
else:
|
||||||
|
self.df['Is_Lost'] = False
|
||||||
|
self.df['Is_Active'] = True
|
||||||
|
|
||||||
|
self.processed = True
|
||||||
|
|
||||||
|
def _derive_application(self) -> pd.Series:
|
||||||
|
"""
|
||||||
|
推導應用領域
|
||||||
|
優先順序: Application → Application Detail → Opportunity Name → "Unknown"
|
||||||
|
"""
|
||||||
|
def get_app(row):
|
||||||
|
# 檢查 Application
|
||||||
|
if 'Application' in row.index:
|
||||||
|
val = row.get('Application')
|
||||||
|
if pd.notna(val) and str(val).strip():
|
||||||
|
return str(val).strip()
|
||||||
|
|
||||||
|
# 檢查 Application Detail
|
||||||
|
if 'Application Detail' in row.index:
|
||||||
|
val = row.get('Application Detail')
|
||||||
|
if pd.notna(val) and str(val).strip():
|
||||||
|
return str(val).strip()
|
||||||
|
|
||||||
|
# 檢查 Opportunity Name
|
||||||
|
if 'Opportunity Name' in row.index:
|
||||||
|
val = row.get('Opportunity Name')
|
||||||
|
if pd.notna(val) and str(val).strip():
|
||||||
|
return str(val).strip()
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
return self.df.apply(get_app, axis=1)
|
||||||
|
|
||||||
|
def analyze_resource_allocation(
|
||||||
|
self,
|
||||||
|
top_percent: float = 0.2,
|
||||||
|
low_win_rate: float = 0.1
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Feature 6.2: 高價值資源分配建議
|
||||||
|
|
||||||
|
找出「金礦區」— 金額大但勝率低的應用領域
|
||||||
|
|
||||||
|
Args:
|
||||||
|
top_percent: 金額排名前 X% (預設 20%)
|
||||||
|
low_win_rate: 勝率門檻 (預設 10%)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Action Cards 列表
|
||||||
|
"""
|
||||||
|
if not self.processed:
|
||||||
|
raise DITAnalyzerError("資料尚未預處理")
|
||||||
|
|
||||||
|
# 依 Derived_Application 分組
|
||||||
|
grouped = self.df.groupby('Derived_Application').agg({
|
||||||
|
'Total Price': 'sum',
|
||||||
|
'Is_Active': 'mean',
|
||||||
|
'Account Name': lambda x: x.value_counts().head(3).index.tolist()
|
||||||
|
}).reset_index()
|
||||||
|
|
||||||
|
grouped.columns = ['Application', 'Sum_Total_Price', 'Win_Rate', 'Top_Accounts']
|
||||||
|
|
||||||
|
# 排序並取 Top 20%
|
||||||
|
grouped = grouped.sort_values('Sum_Total_Price', ascending=False)
|
||||||
|
top_n = max(1, int(len(grouped) * top_percent))
|
||||||
|
top_apps = grouped.head(top_n)
|
||||||
|
|
||||||
|
# 篩選勝率低於門檻的
|
||||||
|
low_win_apps = top_apps[top_apps['Win_Rate'] < low_win_rate]
|
||||||
|
|
||||||
|
# 產出 Action Cards
|
||||||
|
action_cards = []
|
||||||
|
for _, row in low_win_apps.iterrows():
|
||||||
|
money_formatted = f"${row['Sum_Total_Price']:,.0f}"
|
||||||
|
win_rate_pct = f"{row['Win_Rate'] * 100:.1f}"
|
||||||
|
top_accounts = ', '.join(row['Top_Accounts'][:3]) if row['Top_Accounts'] else '無'
|
||||||
|
|
||||||
|
action_cards.append({
|
||||||
|
"type": "resource_allocation",
|
||||||
|
"title": "高潛力市場攻堅提醒",
|
||||||
|
"application": row['Application'],
|
||||||
|
"money": money_formatted,
|
||||||
|
"money_raw": row['Sum_Total_Price'],
|
||||||
|
"win_rate": win_rate_pct,
|
||||||
|
"win_rate_raw": row['Win_Rate'],
|
||||||
|
"top_accounts": row['Top_Accounts'][:3] if row['Top_Accounts'] else [],
|
||||||
|
"suggestion": (
|
||||||
|
f"{row['Application']} 領域潛在商機巨大 ({money_formatted}),"
|
||||||
|
f"但目前勝率偏低 ({win_rate_pct}%)。"
|
||||||
|
f"建議指派資深 FAE 介入該領域的前三大案子 (如 {top_accounts})。"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return action_cards
|
||||||
|
|
||||||
|
def analyze_stagnant_deals(
|
||||||
|
self,
|
||||||
|
threshold_days: int = 60,
|
||||||
|
reference_date: Optional[datetime] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Feature 6.3: 呆滯案件警示
|
||||||
|
|
||||||
|
針對技術已承認但商務卡關的案子進行催單
|
||||||
|
|
||||||
|
Args:
|
||||||
|
threshold_days: 呆滯天數門檻 (預設 60 天)
|
||||||
|
reference_date: 參考日期 (預設為當前日期)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Action Cards 列表
|
||||||
|
"""
|
||||||
|
if not self.processed:
|
||||||
|
raise DITAnalyzerError("資料尚未預處理")
|
||||||
|
|
||||||
|
if reference_date is None:
|
||||||
|
reference_date = datetime.now()
|
||||||
|
|
||||||
|
# 檢查必要欄位
|
||||||
|
if 'Approved date' not in self.df.columns:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 篩選條件
|
||||||
|
mask = (
|
||||||
|
(self.df['Stage'].str.contains('Negotiation', case=False, na=False)) &
|
||||||
|
(self.df['Approved date'].notna())
|
||||||
|
)
|
||||||
|
filtered = self.df[mask].copy()
|
||||||
|
|
||||||
|
if filtered.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 計算呆滯天數
|
||||||
|
filtered['Days_Since_Approved'] = (
|
||||||
|
reference_date - filtered['Approved date']
|
||||||
|
).dt.days
|
||||||
|
|
||||||
|
# 篩選超過門檻的
|
||||||
|
stagnant = filtered[filtered['Days_Since_Approved'] > threshold_days]
|
||||||
|
|
||||||
|
# 產出 Action Cards
|
||||||
|
action_cards = []
|
||||||
|
for _, row in stagnant.iterrows():
|
||||||
|
days = int(row['Days_Since_Approved'])
|
||||||
|
months = days // 30
|
||||||
|
account = row.get('Account Name', 'Unknown')
|
||||||
|
project = row.get('Opportunity Name', 'Unknown')
|
||||||
|
approved_date = row['Approved date'].strftime('%Y-%m-%d') if pd.notna(row['Approved date']) else 'N/A'
|
||||||
|
|
||||||
|
action_cards.append({
|
||||||
|
"type": "stagnant_deal",
|
||||||
|
"title": "呆滯案件喚醒",
|
||||||
|
"account": account,
|
||||||
|
"project": project,
|
||||||
|
"approved_date": approved_date,
|
||||||
|
"days_pending": days,
|
||||||
|
"months_pending": months,
|
||||||
|
"suggestion": (
|
||||||
|
f"客戶 {account} 的 {project} 已承認超過 {months} 個月 ({days} 天),仍未轉單。"
|
||||||
|
f"請業務確認是否為「價格」或「庫存」問題。若無下文,應要求客戶給出 Forecast。"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 依天數排序 (最久的在前)
|
||||||
|
action_cards.sort(key=lambda x: x['days_pending'], reverse=True)
|
||||||
|
|
||||||
|
return action_cards
|
||||||
|
|
||||||
|
def generate_report(
|
||||||
|
self,
|
||||||
|
top_percent: float = 0.2,
|
||||||
|
low_win_rate: float = 0.1,
|
||||||
|
threshold_days: int = 60
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
彙整所有分析結果
|
||||||
|
|
||||||
|
Args:
|
||||||
|
top_percent: 高價值分析的金額門檻
|
||||||
|
low_win_rate: 高價值分析的勝率門檻
|
||||||
|
threshold_days: 呆滯分析的天數門檻
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
完整分析報告 (Dict)
|
||||||
|
"""
|
||||||
|
if not self.processed:
|
||||||
|
raise DITAnalyzerError("資料尚未預處理")
|
||||||
|
|
||||||
|
allocation_suggestions = self.analyze_resource_allocation(top_percent, low_win_rate)
|
||||||
|
stagnant_alerts = self.analyze_stagnant_deals(threshold_days)
|
||||||
|
|
||||||
|
# 統計摘要
|
||||||
|
summary = self._generate_summary()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"summary": summary,
|
||||||
|
"action_cards": {
|
||||||
|
"resource_allocation": allocation_suggestions,
|
||||||
|
"stagnant_deals": stagnant_alerts
|
||||||
|
},
|
||||||
|
"total_alerts": len(allocation_suggestions) + len(stagnant_alerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_summary(self) -> Dict[str, Any]:
|
||||||
|
"""產生統計摘要"""
|
||||||
|
total_records = int(len(self.df))
|
||||||
|
total_value = float(self.df['Total Price'].sum())
|
||||||
|
active_count = int(self.df['Is_Active'].sum())
|
||||||
|
lost_count = int(self.df['Is_Lost'].sum())
|
||||||
|
|
||||||
|
# 各階段統計 (轉換為 Python 原生類型)
|
||||||
|
stage_stats = {}
|
||||||
|
if 'Stage' in self.df.columns:
|
||||||
|
stage_stats = {str(k): int(v) for k, v in self.df['Stage'].value_counts().to_dict().items()}
|
||||||
|
|
||||||
|
# 應用領域 Top 5 (轉換為 Python 原生類型)
|
||||||
|
app_stats = self.df.groupby('Derived_Application')['Total Price'].sum()
|
||||||
|
top_apps = {str(k): float(v) for k, v in app_stats.nlargest(5).to_dict().items()}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_records": total_records,
|
||||||
|
"total_value": f"${total_value:,.0f}",
|
||||||
|
"total_value_raw": total_value,
|
||||||
|
"active_count": active_count,
|
||||||
|
"lost_count": lost_count,
|
||||||
|
"win_rate": f"{(active_count / total_records * 100):.1f}%" if total_records > 0 else "0%",
|
||||||
|
"stage_distribution": stage_stats,
|
||||||
|
"top_applications": {k: f"${v:,.0f}" for k, v in top_apps.items()}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_dataframe(self) -> pd.DataFrame:
|
||||||
|
"""取得處理後的 DataFrame"""
|
||||||
|
if self.df is None:
|
||||||
|
raise DITAnalyzerError("尚未載入資料")
|
||||||
|
return self.df.copy()
|
||||||
|
|
||||||
|
|
||||||
|
class DITAnalyzerError(Exception):
|
||||||
|
"""DIT 分析器錯誤"""
|
||||||
|
pass
|
||||||
156
DIT_C/services/llm_service.py
Normal file
156
DIT_C/services/llm_service.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
Ollama LLM API 服務模組
|
||||||
|
支援一般請求與串流模式
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import urllib3
|
||||||
|
from typing import Generator, Optional
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
# 忽略 SSL 警告 (內部 API 自簽證書)
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMService:
|
||||||
|
"""Ollama API 服務封裝"""
|
||||||
|
|
||||||
|
def __init__(self, api_url: str = None, default_model: str = None):
|
||||||
|
self.api_url = api_url or Config.OLLAMA_API_URL
|
||||||
|
self.default_model = default_model or Config.OLLAMA_DEFAULT_MODEL
|
||||||
|
|
||||||
|
def get_available_models(self) -> list:
|
||||||
|
"""取得可用模型列表"""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{self.api_url}/v1/models", timeout=10, verify=False)
|
||||||
|
response.raise_for_status()
|
||||||
|
models = response.json()
|
||||||
|
return [m['id'] for m in models.get('data', [])]
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise LLMServiceError(f"無法取得模型列表: {e}")
|
||||||
|
|
||||||
|
def chat(
|
||||||
|
self,
|
||||||
|
messages: list,
|
||||||
|
model: str = None,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
system_prompt: str = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
發送聊天請求 (非串流)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: 訊息列表 [{"role": "user", "content": "..."}]
|
||||||
|
model: 模型名稱
|
||||||
|
temperature: 溫度參數 (0-1)
|
||||||
|
system_prompt: 系統提示詞
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AI 回應內容
|
||||||
|
"""
|
||||||
|
model = model or self.default_model
|
||||||
|
|
||||||
|
# 加入系統提示詞
|
||||||
|
if system_prompt:
|
||||||
|
messages = [{"role": "system", "content": system_prompt}] + messages
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.api_url}/v1/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
timeout=60,
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
return result['choices'][0]['message']['content']
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise LLMServiceError(f"聊天請求失敗: {e}")
|
||||||
|
|
||||||
|
def chat_stream(
|
||||||
|
self,
|
||||||
|
messages: list,
|
||||||
|
model: str = None,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
system_prompt: str = None
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
"""
|
||||||
|
發送聊天請求 (串流模式)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: 訊息列表
|
||||||
|
model: 模型名稱
|
||||||
|
temperature: 溫度參數
|
||||||
|
system_prompt: 系統提示詞
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
串流回應的每個片段
|
||||||
|
"""
|
||||||
|
model = model or self.default_model
|
||||||
|
|
||||||
|
if system_prompt:
|
||||||
|
messages = [{"role": "system", "content": system_prompt}] + messages
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.api_url}/v1/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
stream=True,
|
||||||
|
timeout=120,
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if line:
|
||||||
|
if line.startswith(b"data: "):
|
||||||
|
data_str = line[6:].decode('utf-8')
|
||||||
|
if data_str.strip() != "[DONE]":
|
||||||
|
try:
|
||||||
|
data = json.loads(data_str)
|
||||||
|
if 'choices' in data:
|
||||||
|
delta = data['choices'][0].get('delta', {})
|
||||||
|
if 'content' in delta:
|
||||||
|
yield delta['content']
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise LLMServiceError(f"串流請求失敗: {e}")
|
||||||
|
|
||||||
|
def simple_query(self, prompt: str, system_prompt: str = None) -> str:
|
||||||
|
"""
|
||||||
|
簡單查詢 (單一問題)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 使用者問題
|
||||||
|
system_prompt: 系統提示詞
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AI 回應
|
||||||
|
"""
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
return self.chat(messages, system_prompt=system_prompt)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMServiceError(Exception):
|
||||||
|
"""LLM 服務錯誤"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 全域實例
|
||||||
|
llm_service = LLMService()
|
||||||
131
DIT_C/templates/base.html
Normal file
131
DIT_C/templates/base.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}DIT 智能分析系統{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-dark: #1d4ed8;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--gray-50: #f9fafb;
|
||||||
|
--gray-100: #f3f4f6;
|
||||||
|
--gray-200: #e5e7eb;
|
||||||
|
--gray-300: #d1d5db;
|
||||||
|
--gray-500: #6b7280;
|
||||||
|
--gray-700: #374151;
|
||||||
|
--gray-900: #111827;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: var(--gray-50);
|
||||||
|
color: var(--gray-900);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.logo { font-size: 1.5rem; font-weight: 700; }
|
||||||
|
.logo span { opacity: 0.8; font-weight: 400; }
|
||||||
|
.nav-links { display: flex; gap: 1.5rem; }
|
||||||
|
.nav-links a { color: white; text-decoration: none; opacity: 0.9; }
|
||||||
|
.nav-links a:hover { opacity: 1; }
|
||||||
|
.nav-links a.active { border-bottom: 2px solid white; }
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.btn-primary { background-color: var(--primary); color: white; }
|
||||||
|
.btn-primary:hover { background-color: var(--primary-dark); }
|
||||||
|
.btn-outline { background: transparent; border: 1px solid var(--gray-300); color: var(--gray-700); }
|
||||||
|
.btn-outline:hover { background-color: var(--gray-50); }
|
||||||
|
.btn-success { background-color: var(--success); color: white; }
|
||||||
|
.btn-danger { background-color: var(--danger); color: white; }
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.card-title { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; }
|
||||||
|
.alert {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.alert-success { background: #d1fae5; color: #065f46; }
|
||||||
|
.alert-error { background: #fee2e2; color: #991b1b; }
|
||||||
|
.alert-warning { background: #fef3c7; color: #92400e; }
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.tag-success { background: #d1fae5; color: #065f46; }
|
||||||
|
.tag-warning { background: #fef3c7; color: #92400e; }
|
||||||
|
.tag-danger { background: #fee2e2; color: #991b1b; }
|
||||||
|
.spinner {
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border: 2px solid var(--gray-200);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 1rem; }
|
||||||
|
.header-content { flex-direction: column; gap: 1rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="logo">DIT <span>智能分析系統</span></div>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint == 'main.dashboard' %}active{% endif %}">儀表板</a>
|
||||||
|
<a href="{{ url_for('main.upload') }}" class="{% if request.endpoint == 'main.upload' %}active{% endif %}">上傳分析</a>
|
||||||
|
<a href="{{ url_for('main.history') }}" class="{% if request.endpoint == 'main.history' %}active{% endif %}">歷史記錄</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
233
DIT_C/templates/dashboard.html
Normal file
233
DIT_C/templates/dashboard.html
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}儀表板 - DIT 智能分析系統{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.stat-label { color: var(--gray-500); font-size: 0.875rem; margin-bottom: 0.5rem; }
|
||||||
|
.stat-value { font-size: 2rem; font-weight: 700; }
|
||||||
|
.stat-value.success { color: var(--success); }
|
||||||
|
.stat-value.warning { color: var(--warning); }
|
||||||
|
.stat-value.danger { color: var(--danger); }
|
||||||
|
.action-cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.action-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.action-card:hover { transform: translateY(-2px); }
|
||||||
|
.action-card-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.action-card-header.resource {
|
||||||
|
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||||
|
}
|
||||||
|
.action-card-header.stagnant {
|
||||||
|
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||||
|
}
|
||||||
|
.action-card-icon {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.action-card-header.resource .action-card-icon { background: #f59e0b; color: white; }
|
||||||
|
.action-card-header.stagnant .action-card-icon { background: #ef4444; color: white; }
|
||||||
|
.action-card-body { padding: 1.5rem; }
|
||||||
|
.action-card-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--gray-50);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.metric-label { font-size: 0.75rem; color: var(--gray-500); text-transform: uppercase; }
|
||||||
|
.metric-value { font-size: 1.125rem; font-weight: 600; }
|
||||||
|
.action-card-suggestion {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.action-card-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--gray-100);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.section-title h2 { font-size: 1.25rem; font-weight: 600; }
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">總案件數</div>
|
||||||
|
<div class="stat-value">{{ summary.total_records if summary else 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">總潛在金額</div>
|
||||||
|
<div class="stat-value">{{ summary.total_value if summary else '$0' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">整體勝率</div>
|
||||||
|
<div class="stat-value success">{{ summary.win_rate if summary else '0%' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">待處理警示</div>
|
||||||
|
<div class="stat-value danger">{{ total_alerts if total_alerts else 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Cards -->
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>行動建議卡片</h2>
|
||||||
|
{% if total_alerts %}
|
||||||
|
<span class="tag tag-danger">{{ total_alerts }} 項待處理</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if action_cards %}
|
||||||
|
<div class="action-cards-grid">
|
||||||
|
{% for card in action_cards.resource_allocation %}
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-card-header resource">
|
||||||
|
<div class="action-card-icon">💰</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 600;">{{ card.title }}</div>
|
||||||
|
<div style="font-size: 0.875rem; color: var(--gray-500);">Feature 6.2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-body">
|
||||||
|
<div class="action-card-metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">應用領域</div>
|
||||||
|
<div class="metric-value">{{ card.application }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">潛在金額</div>
|
||||||
|
<div class="metric-value">{{ card.money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">勝率</div>
|
||||||
|
<div class="metric-value" style="color: var(--danger);">{{ card.win_rate }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">Top 客戶</div>
|
||||||
|
<div class="metric-value" style="font-size: 0.875rem;">{{ card.top_accounts | join(', ') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-suggestion">
|
||||||
|
<strong>建議:</strong>{{ card.suggestion }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-footer">
|
||||||
|
<button class="btn btn-outline" onclick="markResolved({{ card.id if card.id else 0 }})">標記已處理</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for card in action_cards.stagnant_deals %}
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-card-header stagnant">
|
||||||
|
<div class="action-card-icon">⏰</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 600;">{{ card.title }}</div>
|
||||||
|
<div style="font-size: 0.875rem; color: var(--gray-500);">Feature 6.3</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-body">
|
||||||
|
<div class="action-card-metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">客戶</div>
|
||||||
|
<div class="metric-value">{{ card.account }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">專案</div>
|
||||||
|
<div class="metric-value">{{ card.project }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">承認日期</div>
|
||||||
|
<div class="metric-value">{{ card.approved_date }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">呆滯天數</div>
|
||||||
|
<div class="metric-value" style="color: var(--danger);">{{ card.days_pending }} 天</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-suggestion">
|
||||||
|
<strong>建議:</strong>{{ card.suggestion }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-card-footer">
|
||||||
|
<button class="btn btn-outline" onclick="markResolved({{ card.id if card.id else 0 }})">標記已處理</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
|
||||||
|
<p>尚無分析結果</p>
|
||||||
|
<p style="margin-top: 0.5rem;"><a href="{{ url_for('main.upload') }}" class="btn btn-primary" style="margin-top: 1rem;">上傳 CSV 開始分析</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function markResolved(cardId) {
|
||||||
|
if (confirm('確定要標記此項目為已處理嗎?')) {
|
||||||
|
fetch('/api/action-card/' + cardId + '/resolve', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
93
DIT_C/templates/history.html
Normal file
93
DIT_C/templates/history.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}歷史記錄 - DIT 智能分析系統{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.table-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--gray-200);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 1rem 1.5rem; text-align: left; }
|
||||||
|
th {
|
||||||
|
background: var(--gray-50);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
tr:not(:last-child) td { border-bottom: 1px solid var(--gray-100); }
|
||||||
|
tr:hover td { background: var(--gray-50); }
|
||||||
|
.empty-state { text-align: center; padding: 4rem; color: var(--gray-500); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 style="margin-bottom: 1.5rem;">上傳歷史記錄</h1>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<div class="table-header">
|
||||||
|
<span style="font-weight: 600;">分析記錄</span>
|
||||||
|
<button class="btn btn-outline" onclick="location.href='{{ url_for('main.upload') }}'">新增分析</button>
|
||||||
|
</div>
|
||||||
|
{% if uploads %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>檔案名稱</th>
|
||||||
|
<th>記錄數</th>
|
||||||
|
<th>總金額</th>
|
||||||
|
<th>上傳時間</th>
|
||||||
|
<th>狀態</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for upload in uploads %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ upload.filename }}</strong></td>
|
||||||
|
<td>{{ upload.record_count or '-' }}</td>
|
||||||
|
<td>{{ '$%s' % '{:,.0f}'.format(upload.total_value) if upload.total_value else '-' }}</td>
|
||||||
|
<td>{{ upload.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if upload.status == 'completed' %}
|
||||||
|
<span class="tag tag-success">完成</span>
|
||||||
|
{% elif upload.status == 'failed' %}
|
||||||
|
<span class="tag tag-danger">失敗</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag tag-warning">處理中</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" onclick="viewResult({{ upload.id }})">查看</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 1rem;">📋</div>
|
||||||
|
<p>尚無上傳記錄</p>
|
||||||
|
<a href="{{ url_for('main.upload') }}" class="btn btn-primary" style="margin-top: 1rem;">開始上傳</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function viewResult(uploadId) {
|
||||||
|
window.location.href = '/result/' + uploadId;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
246
DIT_C/templates/upload.html
Normal file
246
DIT_C/templates/upload.html
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}上傳分析 - DIT 智能分析系統{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--gray-300);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.upload-zone:hover, .upload-zone.dragover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
.upload-icon { font-size: 4rem; margin-bottom: 1rem; }
|
||||||
|
.upload-text { color: var(--gray-700); margin-bottom: 0.5rem; font-size: 1.125rem; }
|
||||||
|
.upload-hint { color: var(--gray-500); font-size: 0.875rem; }
|
||||||
|
.params-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.params-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.param-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.param-group input, .param-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.param-group input:focus, .param-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
.results-section { margin-top: 2rem; display: none; }
|
||||||
|
.results-section.show { display: block; }
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.loading-overlay.show { display: flex; }
|
||||||
|
.loading-box {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem 3rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.loading-box .spinner {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 style="margin-bottom: 1.5rem;">上傳 DIT 報表分析</h1>
|
||||||
|
|
||||||
|
<form id="uploadForm" enctype="multipart/form-data">
|
||||||
|
<div class="upload-zone" id="uploadZone">
|
||||||
|
<div class="upload-icon" id="uploadIcon">📁</div>
|
||||||
|
<div class="upload-text" id="uploadText">拖曳檔案至此處,或點擊選擇檔案</div>
|
||||||
|
<div class="upload-hint">支援 CSV 及 XLSX (Excel) 格式</div>
|
||||||
|
<input type="file" id="fileInput" name="file" accept=".csv,.xlsx" hidden>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="params-card">
|
||||||
|
<h3>分析參數設定</h3>
|
||||||
|
<div class="params-grid">
|
||||||
|
<div class="param-group">
|
||||||
|
<label>金額 Top 百分比 (%)</label>
|
||||||
|
<input type="number" name="top_percent" id="topPercent" value="20" min="1" max="100">
|
||||||
|
</div>
|
||||||
|
<div class="param-group">
|
||||||
|
<label>低勝率門檻 (%)</label>
|
||||||
|
<input type="number" name="low_win_rate" id="lowWinRate" value="10" min="1" max="100">
|
||||||
|
</div>
|
||||||
|
<div class="param-group">
|
||||||
|
<label>呆滯天數門檻</label>
|
||||||
|
<input type="number" name="threshold_days" id="thresholdDays" value="60" min="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
|
||||||
|
<button type="submit" class="btn btn-primary" id="analyzeBtn" disabled>開始分析</button>
|
||||||
|
<button type="button" class="btn btn-outline" onclick="document.getElementById('fileInput').click()">選擇檔案</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="results-section" id="resultsSection">
|
||||||
|
<h2 style="margin-bottom: 1rem;">分析結果</h2>
|
||||||
|
<div id="resultsContent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
|
<div class="loading-box">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div>正在分析中,請稍候...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const uploadZone = document.getElementById('uploadZone');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const uploadIcon = document.getElementById('uploadIcon');
|
||||||
|
const uploadText = document.getElementById('uploadText');
|
||||||
|
const analyzeBtn = document.getElementById('analyzeBtn');
|
||||||
|
const uploadForm = document.getElementById('uploadForm');
|
||||||
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||||
|
const resultsSection = document.getElementById('resultsSection');
|
||||||
|
const resultsContent = document.getElementById('resultsContent');
|
||||||
|
|
||||||
|
let selectedFile = null;
|
||||||
|
|
||||||
|
uploadZone.addEventListener('click', () => fileInput.click());
|
||||||
|
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
||||||
|
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
|
||||||
|
uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.remove('dragover');
|
||||||
|
if (e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', (e) => { if (e.target.files.length > 0) handleFile(e.target.files[0]); });
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
const ext = file.name.toLowerCase();
|
||||||
|
if (!ext.endsWith('.csv') && !ext.endsWith('.xlsx')) {
|
||||||
|
alert('請上傳 CSV 或 XLSX 格式檔案');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedFile = file;
|
||||||
|
uploadIcon.textContent = '✅';
|
||||||
|
uploadText.textContent = '已選擇: ' + file.name;
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
loadingOverlay.classList.add('show');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('top_percent', document.getElementById('topPercent').value / 100);
|
||||||
|
formData.append('low_win_rate', document.getElementById('lowWinRate').value / 100);
|
||||||
|
formData.append('threshold_days', document.getElementById('thresholdDays').value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
loadingOverlay.classList.remove('show');
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
displayResults(data.data);
|
||||||
|
} else {
|
||||||
|
alert('分析失敗: ' + data.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
loadingOverlay.classList.remove('show');
|
||||||
|
alert('請求失敗: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function displayResults(data) {
|
||||||
|
resultsSection.classList.add('show');
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="card">
|
||||||
|
<h3>統計摘要</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
||||||
|
<div><div style="color: var(--gray-500); font-size: 0.875rem;">總案件數</div><div style="font-size: 1.5rem; font-weight: 600;">${data.summary.total_records}</div></div>
|
||||||
|
<div><div style="color: var(--gray-500); font-size: 0.875rem;">總金額</div><div style="font-size: 1.5rem; font-weight: 600;">${data.summary.total_value}</div></div>
|
||||||
|
<div><div style="color: var(--gray-500); font-size: 0.875rem;">勝率</div><div style="font-size: 1.5rem; font-weight: 600; color: var(--success);">${data.summary.win_rate}</div></div>
|
||||||
|
<div><div style="color: var(--gray-500); font-size: 0.875rem;">警示數</div><div style="font-size: 1.5rem; font-weight: 600; color: var(--danger);">${data.total_alerts}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 1.5rem 0 1rem;">高價值資源分配建議 (${data.action_cards.resource_allocation.length})</h3>
|
||||||
|
`;
|
||||||
|
|
||||||
|
data.action_cards.resource_allocation.forEach(card => {
|
||||||
|
html += `
|
||||||
|
<div class="card" style="border-left: 4px solid var(--warning);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||||
|
<div>
|
||||||
|
<strong>${card.application}</strong>
|
||||||
|
<div style="color: var(--gray-500); font-size: 0.875rem;">潛在金額: ${card.money} | 勝率: ${card.win_rate}%</div>
|
||||||
|
</div>
|
||||||
|
<span class="tag tag-warning">需關注</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 0.75rem; font-size: 0.875rem;">${card.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `<h3 style="margin: 1.5rem 0 1rem;">呆滯案件警示 (${data.action_cards.stagnant_deals.length})</h3>`;
|
||||||
|
|
||||||
|
data.action_cards.stagnant_deals.forEach(card => {
|
||||||
|
html += `
|
||||||
|
<div class="card" style="border-left: 4px solid var(--danger);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||||
|
<div>
|
||||||
|
<strong>${card.account}</strong> - ${card.project}
|
||||||
|
<div style="color: var(--gray-500); font-size: 0.875rem;">承認日期: ${card.approved_date} | 呆滯: ${card.days_pending} 天</div>
|
||||||
|
</div>
|
||||||
|
<span class="tag tag-danger">呆滯</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 0.75rem; font-size: 0.875rem;">${card.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
resultsContent.innerHTML = html;
|
||||||
|
resultsSection.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
0
DIT_C/tests/__init__.py
Normal file
0
DIT_C/tests/__init__.py
Normal file
153
DIT_C/tests/test_dit_analyzer.py
Normal file
153
DIT_C/tests/test_dit_analyzer.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
DITAnalyzer Test Script
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from services.dit_analyzer import DITAnalyzer, DITAnalyzerError
|
||||||
|
|
||||||
|
|
||||||
|
def create_sample_data():
|
||||||
|
"""Create sample test data"""
|
||||||
|
days_ago_90 = datetime.now() - timedelta(days=90)
|
||||||
|
days_ago_30 = datetime.now() - timedelta(days=30)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'Created Date': ['2024-01-01', '2024-02-01', '2024-03-01', '2024-04-01', '2024-05-01',
|
||||||
|
'2024-01-15', '2024-02-15', '2024-03-15', '2024-04-15', '2024-05-15'],
|
||||||
|
'Account Name': ['CustomerA', 'CustomerB', 'CustomerA', 'CustomerC', 'CustomerD',
|
||||||
|
'CustomerE', 'CustomerF', 'CustomerA', 'CustomerG', 'CustomerH'],
|
||||||
|
'Stage': ['Won', 'Opportunity Lost', 'Negotiation', 'Won', 'Design-Lost',
|
||||||
|
'Negotiation', 'Mass Production', 'Opportunity Lost', 'Negotiation', 'Won'],
|
||||||
|
'Application': ['Automotive', '', 'Automotive', 'IoT', '',
|
||||||
|
'Automotive', 'Consumer', '', 'Industrial', 'Automotive'],
|
||||||
|
'Application Detail': ['', 'Consumer Electronics', '', '', 'Smart Home',
|
||||||
|
'', '', 'Power Supply', '', ''],
|
||||||
|
'Opportunity Name': ['Project Alpha', 'Project Beta', 'Project Gamma', 'Project Delta', 'Project Epsilon',
|
||||||
|
'Project Zeta', 'Project Eta', 'Project Theta', 'Project Iota', 'Project Kappa'],
|
||||||
|
'Total Price': [500000, 300000, 800000, 150000, 200000,
|
||||||
|
1200000, 50000, 100000, 450000, 600000],
|
||||||
|
'Approved date': [None, None, days_ago_90.strftime('%Y-%m-%d'), None, None,
|
||||||
|
days_ago_90.strftime('%Y-%m-%d'), None, None, days_ago_30.strftime('%Y-%m-%d'), None],
|
||||||
|
'Lost Type': ['', 'Price', '', '', 'Spec',
|
||||||
|
'', '', 'Price', '', '']
|
||||||
|
}
|
||||||
|
|
||||||
|
return pd.DataFrame(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preprocess():
|
||||||
|
"""Test data preprocessing"""
|
||||||
|
print("=" * 50)
|
||||||
|
print("Test 1: Data Preprocessing")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
df = create_sample_data()
|
||||||
|
analyzer = DITAnalyzer(dataframe=df)
|
||||||
|
|
||||||
|
processed_df = analyzer.get_dataframe()
|
||||||
|
|
||||||
|
print(f"Total records: {len(processed_df)}")
|
||||||
|
print(f"Columns: {list(processed_df.columns)}")
|
||||||
|
|
||||||
|
assert 'Derived_Application' in processed_df.columns
|
||||||
|
assert 'Is_Lost' in processed_df.columns
|
||||||
|
assert 'Is_Active' in processed_df.columns
|
||||||
|
print("\n[PASS] Preprocess test passed!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_allocation():
|
||||||
|
"""Test Feature 6.2: High Value Resource Allocation"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Test 2: Feature 6.2 Resource Allocation")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
df = create_sample_data()
|
||||||
|
analyzer = DITAnalyzer(dataframe=df)
|
||||||
|
|
||||||
|
results = analyzer.analyze_resource_allocation(top_percent=0.5, low_win_rate=0.5)
|
||||||
|
|
||||||
|
print(f"Found {len(results)} high-value low-win-rate applications")
|
||||||
|
for card in results:
|
||||||
|
print(f"\n[CARD] {card['title']}")
|
||||||
|
print(f" Application: {card['application']}")
|
||||||
|
print(f" Potential Value: {card['money']}")
|
||||||
|
print(f" Win Rate: {card['win_rate']}%")
|
||||||
|
|
||||||
|
print("\n[PASS] Resource allocation test passed!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stagnant_deals():
|
||||||
|
"""Test Feature 6.3: Stagnant Deal Alert"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Test 3: Feature 6.3 Stagnant Deals")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
df = create_sample_data()
|
||||||
|
analyzer = DITAnalyzer(dataframe=df)
|
||||||
|
|
||||||
|
results = analyzer.analyze_stagnant_deals(threshold_days=60)
|
||||||
|
|
||||||
|
print(f"Found {len(results)} stagnant deals")
|
||||||
|
for card in results:
|
||||||
|
print(f"\n[ALERT] {card['title']}")
|
||||||
|
print(f" Account: {card['account']}")
|
||||||
|
print(f" Project: {card['project']}")
|
||||||
|
print(f" Days Pending: {card['days_pending']}")
|
||||||
|
|
||||||
|
print("\n[PASS] Stagnant deals test passed!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_report():
|
||||||
|
"""Test full report generation"""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("Test 4: Full Report Generation")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
df = create_sample_data()
|
||||||
|
analyzer = DITAnalyzer(dataframe=df)
|
||||||
|
|
||||||
|
report = analyzer.generate_report(top_percent=0.5, low_win_rate=0.5, threshold_days=60)
|
||||||
|
|
||||||
|
print(f"\n[REPORT] Generated at: {report['generated_at']}")
|
||||||
|
summary = report['summary']
|
||||||
|
print(f" Total Records: {summary['total_records']}")
|
||||||
|
print(f" Total Value: {summary['total_value']}")
|
||||||
|
print(f" Win Rate: {summary['win_rate']}")
|
||||||
|
|
||||||
|
print(f"\n[ACTION CARDS] Total: {report['total_alerts']}")
|
||||||
|
print(f" - Resource Allocation: {len(report['action_cards']['resource_allocation'])}")
|
||||||
|
print(f" - Stagnant Deals: {len(report['action_cards']['stagnant_deals'])}")
|
||||||
|
|
||||||
|
print("\n[PASS] Full report test passed!")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run all tests"""
|
||||||
|
print("\n[START] DITAnalyzer Test Suite\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_preprocess()
|
||||||
|
test_resource_allocation()
|
||||||
|
test_stagnant_deals()
|
||||||
|
test_full_report()
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("[SUCCESS] All tests passed!")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[FAIL] Test failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
exit(main())
|
||||||
1
DIT_C/utils/__init__.py
Normal file
1
DIT_C/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Utils module
|
||||||
0
tests__init__.py
Normal file
0
tests__init__.py
Normal file
Binary file not shown.
Reference in New Issue
Block a user