Compare commits

...

2 Commits

Author SHA1 Message Date
b3f2ee4100 Merge branch 'main' of https://gitea.theaken.com/donald/DIT_C 2025-12-12 16:03:19 +08:00
d4ce4f9ed1 Initial commit: DIT_C Flask application
- Flask web application for DIT analysis
- Database models for upload history, analysis results, action cards
- LLM service integration with Ollama API
- Dashboard, upload, and history pages
- RESTful API endpoints for analysis operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 16:00:05 +08:00
33 changed files with 6698 additions and 0 deletions

View 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
View 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。

View 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

File diff suppressed because it is too large Load Diff

BIN
DIT repor 改.xlsx Normal file

Binary file not shown.

20
DIT_C/.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View File

@@ -0,0 +1,3 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()

100
DIT_C/models/dit_models.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# Routes module

234
DIT_C/routes/api.py Normal file
View 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
View 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)

View File

@@ -0,0 +1 @@
# Services module

View 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

View 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
View 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>

View 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 %}

View 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
View 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
View File

View 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
View File

@@ -0,0 +1 @@
# Utils module

0
tests__init__.py Normal file
View File