first commit
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
backend/static/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
data/uploads/*
|
||||
!data/uploads/.gitkeep
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
202
README.md
Normal file
202
README.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# SalesPipeline - 銷售管線管理系統
|
||||
|
||||
一個完整的銷售管線追蹤系統,用於管理 DIT (Design-In Tracking) 案件、樣品紀錄、訂單明細,並提供智慧模糊比對與分析儀表板。
|
||||
|
||||
## 系統架構
|
||||
|
||||
```
|
||||
Frontend (React + TypeScript + i18n)
|
||||
│
|
||||
│ REST API (Single Port)
|
||||
▼
|
||||
Backend (FastAPI + SQLAlchemy)
|
||||
│
|
||||
▼
|
||||
Database (MySQL)
|
||||
```
|
||||
|
||||
## 功能特色
|
||||
|
||||
- **使用者驗證**:JWT 登入/註冊、角色權限管理
|
||||
- **多語言支援**:繁體中文、英文 (i18next)
|
||||
- **資料匯入**:自動偵測 Excel 表頭位置,支援中文欄位對應
|
||||
- **智慧模糊比對**:使用 rapidfuzz 計算客戶名稱相似度
|
||||
- ≥95%:自動匹配
|
||||
- 80-95%:需人工審核
|
||||
- <80%:不匹配
|
||||
- **分析儀表板**:KPI 統計、轉換漏斗圖、歸因明細表
|
||||
- **報表匯出**:支援 Excel 和 PDF 格式
|
||||
- **單一 Port 部署**:前後端整合於同一服務
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 前置需求
|
||||
|
||||
- Node.js 18+
|
||||
- Python 3.10+
|
||||
- MySQL 5.7+ / 8.0+
|
||||
|
||||
### 安裝步驟
|
||||
|
||||
1. **Clone 專案**
|
||||
```bash
|
||||
git clone <your-repo>
|
||||
cd SampleOrderAssistant
|
||||
```
|
||||
|
||||
2. **設定後端**
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
|
||||
# Windows
|
||||
venv\Scripts\activate
|
||||
|
||||
# Linux/Mac
|
||||
source venv/bin/activate
|
||||
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **設定環境變數**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 編輯 .env 檔案設定資料庫連線
|
||||
```
|
||||
|
||||
4. **設定前端**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 開發模式
|
||||
|
||||
1. **啟動後端** (Port 8000)
|
||||
```bash
|
||||
cd backend
|
||||
python run.py
|
||||
```
|
||||
|
||||
2. **啟動前端** (Port 3000)
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. 打開瀏覽器前往 http://localhost:3000
|
||||
|
||||
### 生產環境部署
|
||||
|
||||
1. **建置前端**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. **啟動服務**
|
||||
```bash
|
||||
cd backend
|
||||
python run.py
|
||||
```
|
||||
|
||||
3. 打開瀏覽器前往 http://localhost:8000
|
||||
|
||||
詳細部署說明請參考 [deploy/1panel-setup.md](deploy/1panel-setup.md)
|
||||
|
||||
## API 文件
|
||||
|
||||
後端啟動後 (DEBUG=True),可前往以下網址查看 API 文件:
|
||||
- Swagger UI: http://localhost:8000/api/docs
|
||||
- ReDoc: http://localhost:8000/api/redoc
|
||||
|
||||
## 專案結構
|
||||
|
||||
```
|
||||
SampleOrderAssistant/
|
||||
├── frontend/ # React 前端
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI 元件
|
||||
│ │ ├── services/ # API 服務
|
||||
│ │ ├── hooks/ # Custom Hooks
|
||||
│ │ ├── i18n/ # 多語言設定
|
||||
│ │ │ └── locales/ # 語言檔案 (zh-TW, en)
|
||||
│ │ └── types/ # TypeScript 類型
|
||||
│ └── package.json
|
||||
│
|
||||
├── backend/ # FastAPI 後端
|
||||
│ ├── app/
|
||||
│ │ ├── models/ # 資料模型
|
||||
│ │ ├── routers/ # API 路由
|
||||
│ │ ├── services/ # 業務邏輯
|
||||
│ │ └── utils/ # 工具函數
|
||||
│ ├── static/ # 前端建置輸出
|
||||
│ ├── .env # 環境變數 (不進版控)
|
||||
│ ├── .env.example # 環境變數範本
|
||||
│ └── requirements.txt
|
||||
│
|
||||
├── data/ # 資料目錄
|
||||
│ └── uploads/ # 上傳檔案
|
||||
│
|
||||
├── deploy/ # 部署設定
|
||||
│ ├── 1panel-setup.md # 1Panel 部署指南
|
||||
│ ├── salespipeline.service # Systemd 服務檔
|
||||
│ └── deploy.sh # 自動部署腳本
|
||||
│
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 資料庫表格
|
||||
|
||||
| 表格名稱 | 說明 |
|
||||
|---------|------|
|
||||
| PJ_SOA_Users | 使用者帳戶 |
|
||||
| PJ_SOA_DIT_Records | DIT 案件資料 |
|
||||
| PJ_SOA_Sample_Records | 樣品記錄 |
|
||||
| PJ_SOA_Order_Records | 訂單明細 |
|
||||
| PJ_SOA_Match_Results | 配對結果 |
|
||||
| PJ_SOA_Review_Logs | 審核日誌 |
|
||||
|
||||
## 技術棧
|
||||
|
||||
### 前端
|
||||
- React 18 + TypeScript
|
||||
- Vite
|
||||
- TailwindCSS
|
||||
- Recharts
|
||||
- React Query
|
||||
- React Router DOM
|
||||
- react-i18next
|
||||
- Axios
|
||||
|
||||
### 後端
|
||||
- FastAPI
|
||||
- SQLAlchemy 2.0
|
||||
- MySQL (PyMySQL)
|
||||
- rapidfuzz
|
||||
- openpyxl + pandas
|
||||
- reportlab
|
||||
- python-jose (JWT)
|
||||
|
||||
## 環境變數
|
||||
|
||||
| 變數名稱 | 說明 | 預設值 |
|
||||
|---------|------|--------|
|
||||
| DB_HOST | 資料庫主機 | localhost |
|
||||
| DB_PORT | 資料庫端口 | 3306 |
|
||||
| DB_USER | 資料庫使用者 | root |
|
||||
| DB_PASSWORD | 資料庫密碼 | - |
|
||||
| DB_DATABASE | 資料庫名稱 | sales_pipeline |
|
||||
| SECRET_KEY | JWT 密鑰 | - |
|
||||
| ALGORITHM | JWT 演算法 | HS256 |
|
||||
| ACCESS_TOKEN_EXPIRE_MINUTES | Token 過期時間(分鐘) | 1440 |
|
||||
| APP_HOST | 應用監聽地址 | 0.0.0.0 |
|
||||
| APP_PORT | 應用監聽端口 | 8000 |
|
||||
| WORKERS | 工作進程數 | 1 |
|
||||
| DEBUG | 開發模式 | False |
|
||||
| TABLE_PREFIX | 資料表前綴 | PJ_SOA_ |
|
||||
| CORS_ORIGINS | 允許的跨域來源 (逗號分隔) | - |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
654
SampleOrderAssistant.txt
Normal file
654
SampleOrderAssistant.txt
Normal file
@@ -0,0 +1,654 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Upload, FileText, Database, CheckCircle, XCircle,
|
||||
AlertTriangle, BarChart2, PieChart, Activity,
|
||||
ArrowRight, Search, Filter, Download, RefreshCw, FileSpreadsheet,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, ResponsiveContainer,
|
||||
PieChart as RePieChart, Pie, Cell
|
||||
} from 'recharts';
|
||||
|
||||
// --- 真實模擬資料 (源自使用者上傳檔案) ---
|
||||
|
||||
// 1. DIT Report (模擬解析後的結構)
|
||||
// 來源: DIT report (by Focus Item)_app_樣本.xlsx
|
||||
const PARSED_DIT_DATA = [
|
||||
{ id: 'OP0000012868', customer: '光寶科技股份有限公司', pn: 'GBU610', eau: 20000, stage: 'Design Win', date: '2025-02-04' },
|
||||
{ id: 'OP0000012869', customer: 'LITEON (PMS)', pn: 'PMSM808LL_R2', eau: 55000, stage: 'Design-In', date: '2025-07-28' },
|
||||
{ id: 'OP0000012870', customer: '台達電子', pn: 'PEC3205ES-AU', eau: 100000, stage: 'Sample Provide', date: '2025-08-15' }, // 故意用簡稱測試模糊比對
|
||||
{ id: 'OP0000012871', customer: '合美企業', pn: 'PJT7828', eau: 5000, stage: 'Negotiation', date: '2025-08-10' },
|
||||
{ id: 'OP0000012872', customer: '申浦电子', pn: 'ER1604FCT', eau: 30000, stage: 'Design Win', date: '2025-07-30' },
|
||||
];
|
||||
|
||||
// 2. Sample Data (模擬解析後的結構)
|
||||
// 來源: 樣品申請紀錄_樣本.xlsx
|
||||
const PARSED_SAMPLE_DATA = [
|
||||
{ id: 'S202512490-02', order_no: 'S202512490', customer: '台達電子工業股份有限公司', pn: 'PEC3205ES-AU', qty: 20, date: '2025-12-19' },
|
||||
{ id: 'S202512490-03', order_no: 'S202512490', customer: '台達電子工業股份有限公司', pn: 'PDZ9.1B-AU', qty: 20, date: '2025-12-19' },
|
||||
];
|
||||
|
||||
// 3. Order Data (模擬解析後的結構)
|
||||
// 來源: 訂單樣本_20251217.xlsx
|
||||
const PARSED_ORDER_DATA = [
|
||||
{ id: '1125025312-1', order_no: '1125025312', customer: '合美企業有限公司', pn: 'PJT7828', qty: 1200, status: 'Shipped', amount: 10800 },
|
||||
{ id: '1125062105-1', order_no: '1125062105', customer: '申浦电子', pn: 'ER1604FCT', qty: 800, status: 'Backlog', amount: 3200 },
|
||||
];
|
||||
|
||||
// --- 模糊比對模擬結果 ---
|
||||
const SIMULATED_REVIEWS = [
|
||||
{
|
||||
id: 101,
|
||||
score: 88,
|
||||
dit: { id: 'OP0000012870', cust: '台達電子', pn: 'PEC3205ES-AU' },
|
||||
match_target: { id: 'S202512490', cust: '台達電子工業股份有限公司', pn: 'PEC3205ES-AU', type: 'SAMPLE' },
|
||||
type: 'SAMPLE',
|
||||
reason: 'Customer Name Partial Match (88%)'
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
score: 92,
|
||||
dit: { id: 'OP0000012871', cust: '合美企業', pn: 'PJT7828' },
|
||||
match_target: { id: '1125025312', cust: '合美企業有限公司', pn: 'PJT7828', type: 'ORDER' },
|
||||
type: 'ORDER',
|
||||
reason: 'Corporate Suffix Mismatch'
|
||||
}
|
||||
];
|
||||
|
||||
// --- 輔助元件 ---
|
||||
|
||||
const Card = ({ children, className = "" }) => (
|
||||
<div className={`bg-white rounded-lg border border-slate-200 shadow-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Badge = ({ children, type = "neutral" }) => {
|
||||
const styles = {
|
||||
neutral: "bg-slate-100 text-slate-600",
|
||||
success: "bg-emerald-100 text-emerald-700",
|
||||
warning: "bg-amber-100 text-amber-700",
|
||||
danger: "bg-rose-100 text-rose-700",
|
||||
info: "bg-blue-100 text-blue-700"
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[type]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// --- 主應用程式 ---
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('import');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingStep, setProcessingStep] = useState('');
|
||||
const [filesLoaded, setFilesLoaded] = useState(false);
|
||||
|
||||
// 狀態管理
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [processedCount, setProcessedCount] = useState(0);
|
||||
|
||||
// Dashboard 數據
|
||||
const [dashboardData, setDashboardData] = useState({
|
||||
totalDit: 5,
|
||||
matchedSample: 0,
|
||||
matchedOrder: 1, // '申浦电子' 是 Exact Match
|
||||
conversionRate: 20.0
|
||||
});
|
||||
|
||||
// 模擬載入使用者的真實檔案
|
||||
const loadUserFiles = () => {
|
||||
setFilesLoaded(true);
|
||||
};
|
||||
|
||||
// 模擬 ETL 執行
|
||||
const runEtl = () => {
|
||||
if (!filesLoaded) {
|
||||
alert("請先載入檔案!");
|
||||
return;
|
||||
}
|
||||
setIsProcessing(true);
|
||||
|
||||
// 模擬後端處理步驟
|
||||
const steps = [
|
||||
"正在讀取 DIT Report... 偵測到表頭在第 16 行",
|
||||
"正在讀取 樣品紀錄... 偵測到表頭在第 9 行",
|
||||
"正在讀取 訂單明細... 編碼識別為 CP950",
|
||||
"執行資料標準化 (Normalization)...",
|
||||
"執行模糊比對 (Fuzzy Matching)...",
|
||||
"運算完成!"
|
||||
];
|
||||
|
||||
let stepIndex = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (stepIndex >= steps.length) {
|
||||
clearInterval(interval);
|
||||
setIsProcessing(false);
|
||||
setReviews(SIMULATED_REVIEWS); // 載入模擬的比對結果
|
||||
setActiveTab('review');
|
||||
} else {
|
||||
setProcessingStep(steps[stepIndex]);
|
||||
stepIndex++;
|
||||
}
|
||||
}, 800);
|
||||
};
|
||||
|
||||
// 處理審核動作
|
||||
const handleReviewAction = (id, action) => {
|
||||
const reviewItem = reviews.find(r => r.id === id);
|
||||
setReviews(prev => prev.filter(r => r.id !== id));
|
||||
|
||||
if (action === 'accept') {
|
||||
setProcessedCount(prev => prev + 1);
|
||||
|
||||
// 更新 Dashboard 數據 (模擬)
|
||||
if (reviewItem.type === 'ORDER') {
|
||||
setDashboardData(prev => ({
|
||||
...prev,
|
||||
matchedOrder: prev.matchedOrder + 1,
|
||||
conversionRate: ((prev.matchedOrder + 1) / prev.totalDit * 100).toFixed(1)
|
||||
}));
|
||||
} else if (reviewItem.type === 'SAMPLE') {
|
||||
setDashboardData(prev => ({
|
||||
...prev,
|
||||
matchedSample: prev.matchedSample + 1
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 漏斗圖資料
|
||||
const funnelData = [
|
||||
{ name: 'DIT 案件', value: dashboardData.totalDit, fill: '#6366f1' },
|
||||
{ name: '成功送樣', value: dashboardData.matchedSample, fill: '#8b5cf6' },
|
||||
{ name: '取得訂單', value: dashboardData.matchedOrder, fill: '#10b981' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-800">
|
||||
{/* Top Navigation */}
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center text-white font-bold">
|
||||
S
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-lg text-slate-800 block leading-tight">SalesPipeline</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium">On-Premise Simulator</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg">
|
||||
{[
|
||||
{ id: 'import', icon: Database, label: '資料匯入' },
|
||||
{ id: 'review', icon: CheckCircle, label: '比對審核', badge: reviews.length },
|
||||
{ id: 'dashboard', icon: BarChart2, label: '分析儀表板' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
|
||||
${activeTab === tab.id
|
||||
? 'bg-white text-indigo-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/50'}
|
||||
`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
{tab.label}
|
||||
{tab.badge > 0 && (
|
||||
<span className="bg-rose-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
系統運作中
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
|
||||
{/* --- View 1: Import --- */}
|
||||
{activeTab === 'import' && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">原始資料匯入中心</h1>
|
||||
<p className="text-slate-500 mt-1">系統將自動偵測 Excel/CSV 檔頭位置並進行智慧欄位對應。</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={loadUserFiles}
|
||||
disabled={filesLoaded || isProcessing}
|
||||
className={`flex items-center gap-2 px-4 py-3 rounded-lg font-medium border transition-all ${filesLoaded ? 'bg-slate-100 text-slate-400 border-slate-200' : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-50'}`}
|
||||
>
|
||||
<FileSpreadsheet size={18} />
|
||||
{filesLoaded ? '檔案已就緒' : '載入範本檔案'}
|
||||
</button>
|
||||
<button
|
||||
onClick={runEtl}
|
||||
disabled={isProcessing || !filesLoaded}
|
||||
className={`flex items-center gap-2 px-6 py-3 rounded-lg text-white font-bold shadow-lg transition-all ${isProcessing || !filesLoaded ? 'bg-slate-400 cursor-not-allowed shadow-none' : 'bg-indigo-600 hover:bg-indigo-700 hover:scale-105 shadow-indigo-200'}`}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RefreshCw size={18} className="animate-spin" />
|
||||
運算中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Activity size={18} />
|
||||
開始 ETL 運算
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Log */}
|
||||
{isProcessing && (
|
||||
<div className="bg-slate-800 text-green-400 font-mono p-4 rounded-lg text-sm shadow-inner">
|
||||
<p className="flex items-center gap-2">
|
||||
<span className="animate-pulse">▶</span> {processingStep}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ type: 'dit', title: '1. DIT Report', file: 'DIT report_app_樣本.xlsx', desc: '含 Metadata (前16行)', count: '5 筆 (測試)' },
|
||||
{ type: 'sample', title: '2. 樣品紀錄', file: '樣品申請紀錄_樣本.xlsx', desc: '含檔頭 (前9行)', count: '2 筆 (測試)' },
|
||||
{ type: 'order', title: '3. 訂單明細', file: '訂單樣本_20251217.xlsx', desc: '標準格式', count: '2 筆 (測試)' },
|
||||
].map(file => (
|
||||
<Card key={file.type} className={`p-6 border-dashed border-2 transition-colors ${filesLoaded ? 'border-emerald-300 bg-emerald-50/30' : 'border-slate-300'}`}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-3 rounded-lg ${filesLoaded ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||
{filesLoaded ? <CheckCircle size={24} /> : <FileText size={24} />}
|
||||
</div>
|
||||
{filesLoaded && <Badge type="success">Ready</Badge>}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-800">{file.title}</h3>
|
||||
<p className="text-xs text-slate-500 font-mono mt-1 mb-3 truncate">{filesLoaded ? file.file : "等待上傳..."}</p>
|
||||
<p className="text-sm text-slate-600">{file.desc}</p>
|
||||
|
||||
{filesLoaded && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-200/60 flex justify-between text-sm">
|
||||
<span className="text-slate-500">預覽筆數</span>
|
||||
<span className="font-bold font-mono text-slate-700">{file.count}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Data Preview (Mocked from User Files) */}
|
||||
{filesLoaded && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="bg-slate-50 px-4 py-3 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-700 text-sm flex items-center gap-2">
|
||||
<Database size={16} />
|
||||
DIT 解析結果 (預覽)
|
||||
</h3>
|
||||
<Badge type="info">Auto-Skipped Header</Badge>
|
||||
</div>
|
||||
<table className="w-full text-xs text-left">
|
||||
<thead className="bg-white text-slate-500 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2">Customer</th>
|
||||
<th className="px-4 py-2">Part No</th>
|
||||
<th className="px-4 py-2">Stage</th>
|
||||
<th className="px-4 py-2 text-right">EAU</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{PARSED_DIT_DATA.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2 font-medium text-slate-700">{row.customer}</td>
|
||||
<td className="px-4 py-2 font-mono text-slate-500">{row.pn}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{row.stage}</td>
|
||||
<td className="px-4 py-2 text-right font-mono">{row.eau.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="bg-slate-50 px-4 py-3 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-700 text-sm flex items-center gap-2">
|
||||
<Database size={16} />
|
||||
訂單解析結果 (預覽)
|
||||
</h3>
|
||||
<Badge type="info">Detected CP950</Badge>
|
||||
</div>
|
||||
<table className="w-full text-xs text-left">
|
||||
<thead className="bg-white text-slate-500 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2">Customer</th>
|
||||
<th className="px-4 py-2">Part No</th>
|
||||
<th className="px-4 py-2">Status</th>
|
||||
<th className="px-4 py-2 text-right">Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{PARSED_ORDER_DATA.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2 font-medium text-slate-700">{row.customer}</td>
|
||||
<td className="px-4 py-2 font-mono text-slate-500">{row.pn}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] ${row.status === 'Shipped' ? 'bg-green-100 text-green-700' : 'bg-pink-100 text-pink-700'}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono">{row.qty.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- View 2: Review --- */}
|
||||
{activeTab === 'review' && (
|
||||
<div className="space-y-6 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
|
||||
模糊比對審核工作台
|
||||
<span className="text-sm bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full font-normal">待審核: {reviews.length}</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 mt-1">系統發現以下案件名稱相似,請人工確認關聯性。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-white rounded-lg border border-dashed border-slate-300">
|
||||
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mb-4">
|
||||
<CheckCircle size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800">所有案件已審核完畢!</h3>
|
||||
<p className="text-slate-500 mt-2">您的資料比對已完成,請查看分析儀表板。</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className="mt-6 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
前往儀表板
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{reviews.map(item => (
|
||||
<Card key={item.id} className="p-0 overflow-hidden group hover:shadow-md transition-all border-l-4 border-l-amber-400">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Left: DIT */}
|
||||
<div className="flex-1 p-5 border-b md:border-b-0 md:border-r border-slate-100 bg-slate-50/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge type="info">DIT (設計導入)</Badge>
|
||||
<span className="text-xs text-slate-400">OP編號: {item.dit.id}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-slate-400 uppercase">Customer Name</div>
|
||||
<div className="font-bold text-slate-800 text-lg">{item.dit.cust}</div>
|
||||
<div className="text-xs text-slate-400 uppercase mt-2">Part Number</div>
|
||||
<div className="font-mono text-slate-700 bg-white border border-slate-200 px-2 py-1 rounded inline-block text-sm">
|
||||
{item.dit.pn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: Score & Reason */}
|
||||
<div className="w-full md:w-48 p-4 flex flex-col items-center justify-center bg-white z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-amber-500 mb-1">{item.score}%</div>
|
||||
<div className="text-[10px] font-medium text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full mb-2">
|
||||
相似度
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 text-center px-2 leading-tight">
|
||||
{item.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Target (Sample/Order) */}
|
||||
<div className="flex-1 p-5 border-t md:border-t-0 md:border-l border-slate-100 bg-indigo-50/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge type={item.type === 'ORDER' ? 'warning' : 'success'}>
|
||||
{item.type === 'ORDER' ? 'Order (訂單)' : 'Sample (樣品)'}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-400">來源單號: {item.match_target.id}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-slate-400 uppercase">Customer Name</div>
|
||||
<div className="font-bold text-slate-800 text-lg">{item.match_target.cust}</div>
|
||||
<div className="text-xs text-slate-400 uppercase mt-2">Part Number</div>
|
||||
<div className="font-mono text-slate-700 bg-white border border-slate-200 px-2 py-1 rounded inline-block text-sm">
|
||||
{item.match_target.pn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="w-full md:w-40 p-4 bg-slate-50 flex flex-row md:flex-col gap-3 justify-center items-center border-t md:border-t-0 md:border-l border-slate-200">
|
||||
<button
|
||||
onClick={() => handleReviewAction(item.id, 'accept')}
|
||||
className="flex-1 md:flex-none w-full py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors shadow-sm"
|
||||
>
|
||||
<CheckCircle size={16} /> 確認關聯
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReviewAction(item.id, 'reject')}
|
||||
className="flex-1 md:flex-none w-full py-2 bg-white border border-slate-300 text-slate-600 hover:bg-slate-50 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<XCircle size={16} /> 駁回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- View 3: Dashboard --- */}
|
||||
{activeTab === 'dashboard' && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">業務轉換率戰情室</h1>
|
||||
<p className="text-slate-500 mt-1">數據來源:DIT Report, 樣品紀錄, 訂單明細 (2025-12-17)</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-slate-600 hover:bg-slate-50 text-sm font-medium">
|
||||
<Download size={16} />
|
||||
匯出報表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="p-5 border-l-4 border-l-indigo-500">
|
||||
<div className="text-sm text-slate-500 mb-1">DIT 總案件數</div>
|
||||
<div className="text-3xl font-bold text-slate-800">{dashboardData.totalDit}</div>
|
||||
<div className="text-xs text-slate-400 mt-2">Raw Data Count</div>
|
||||
</Card>
|
||||
<Card className="p-5 border-l-4 border-l-purple-500">
|
||||
<div className="text-sm text-slate-500 mb-1">成功送樣數</div>
|
||||
<div className="text-3xl font-bold text-slate-800">{dashboardData.matchedSample}</div>
|
||||
<div className="text-xs text-emerald-600 mt-2">
|
||||
{processedCount > 0 ? `(含人工審核 +1)` : ''}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-5 border-l-4 border-l-pink-500">
|
||||
<div className="text-sm text-slate-500 mb-1">取得訂單 (Backlog)</div>
|
||||
<div className="text-3xl font-bold text-slate-800">{dashboardData.matchedOrder}</div>
|
||||
<div className="text-xs text-emerald-600 mt-2">
|
||||
Match Rate: {dashboardData.conversionRate}%
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-5 border-l-4 border-l-emerald-500">
|
||||
<div className="text-sm text-slate-500 mb-1">預估營收 (Revenue)</div>
|
||||
<div className="text-3xl font-bold text-emerald-600">$14,000</div>
|
||||
<div className="text-xs text-slate-400 mt-2">Based on matched orders</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Funnel Chart */}
|
||||
<Card className="lg:col-span-2 p-6">
|
||||
<h3 className="font-bold text-slate-700 mb-6 flex items-center gap-2">
|
||||
<Filter size={18} />
|
||||
DIT 轉換漏斗 (Funnel Analysis)
|
||||
</h3>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
layout="vertical"
|
||||
data={funnelData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" />
|
||||
<YAxis dataKey="name" type="category" width={100} />
|
||||
<RechartsTooltip cursor={{fill: '#f1f5f9'}} />
|
||||
<Bar dataKey="value" barSize={30} radius={[0, 4, 4, 0]} label={{ position: 'right' }}>
|
||||
{funnelData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status Pie Chart */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-bold text-slate-700 mb-6 flex items-center gap-2">
|
||||
<PieChart size={18} />
|
||||
訂單狀態佔比
|
||||
</h3>
|
||||
<div className="h-64 w-full flex justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RePieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: 'Backlog', value: 800 },
|
||||
{ name: 'Shipped', value: 1200 },
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
<Cell fill="#ec4899" />
|
||||
<Cell fill="#10b981" />
|
||||
</Pie>
|
||||
<RechartsTooltip />
|
||||
<Legend verticalAlign="bottom" height={36}/>
|
||||
</RePieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Attribution Table Preview */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Activity size={18} />
|
||||
DIT 歸因明細表 (LIFO 邏輯)
|
||||
</h3>
|
||||
<div className="text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Info size={12} />
|
||||
Hover to see order details
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-white text-slate-500 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 w-32">OP編號</th>
|
||||
<th className="px-6 py-3">Customer</th>
|
||||
<th className="px-6 py-3">Part No.</th>
|
||||
<th className="px-6 py-3 text-right">EAU</th>
|
||||
<th className="px-6 py-3 text-center">Sample Order</th>
|
||||
<th className="px-6 py-3 text-center">Order Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{PARSED_DIT_DATA.map((row, i) => {
|
||||
// 簡單模擬關聯邏輯 (Find exact matches or partial matches)
|
||||
const matchedOrder = PARSED_ORDER_DATA.find(o => o.customer.includes(row.customer) || row.customer.includes(o.customer.substring(0, 2)));
|
||||
const matchedSample = PARSED_SAMPLE_DATA.find(s => s.customer.includes(row.customer) || row.customer.includes(s.customer.substring(0, 2)));
|
||||
|
||||
// 人工審核修正 (台達電子 & 合美)
|
||||
const isReviewedSample = (row.customer === '台達電子' && processedCount > 0) ? {order_no: 'S202512490'} : null;
|
||||
const isReviewedOrder = (row.customer === '合美企業' && processedCount > 1) ? {order_no: '1125025312'} : null;
|
||||
|
||||
// 決定顯示內容
|
||||
const finalSample = matchedSample || isReviewedSample;
|
||||
const finalOrder = matchedOrder || isReviewedOrder;
|
||||
|
||||
return (
|
||||
<tr key={i} className="hover:bg-slate-50 group transition-colors">
|
||||
<td className="px-6 py-3 font-mono text-xs text-slate-500 font-bold">
|
||||
{row.id}
|
||||
</td>
|
||||
<td className="px-6 py-3 font-medium text-slate-800">
|
||||
{row.customer}
|
||||
<div className="text-[10px] text-slate-400 font-light">{row.stage}</div>
|
||||
</td>
|
||||
<td className="px-6 py-3 font-mono text-slate-600 text-xs">{row.pn}</td>
|
||||
<td className="px-6 py-3 text-right font-mono text-slate-600">{row.eau.toLocaleString()}</td>
|
||||
|
||||
{/* Sample Column */}
|
||||
<td className="px-6 py-3 text-center">
|
||||
{finalSample ? (
|
||||
<div className="inline-flex items-center gap-1 px-2 py-1 bg-purple-50 text-purple-700 rounded-md text-xs font-mono border border-purple-100 cursor-help" title={`送樣單號: ${finalSample.order_no}`}>
|
||||
<CheckCircle size={12} />
|
||||
{finalSample.order_no}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-300">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Order Column */}
|
||||
<td className="px-6 py-3 text-center">
|
||||
{finalOrder ? (
|
||||
<div className="inline-flex items-center gap-1 px-2 py-1 bg-emerald-50 text-emerald-700 rounded-md text-xs font-mono border border-emerald-100 cursor-help" title={`訂單單號: ${finalOrder.order_no}`}>
|
||||
<CheckCircle size={12} />
|
||||
{finalOrder.order_no}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-300">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
backend/.env.example
Normal file
24
backend/.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=your_user
|
||||
DB_PASSWORD=your_password
|
||||
DB_DATABASE=your_database
|
||||
|
||||
# JWT Configuration
|
||||
SECRET_KEY=your-super-secret-key-change-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Application Settings
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
WORKERS=1
|
||||
DEBUG=False
|
||||
|
||||
# Default Admin Account (created on first startup)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# CORS Settings (comma separated, for development)
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# SalesPipeline Backend
|
||||
58
backend/app/config.py
Normal file
58
backend/app/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 載入環境變數
|
||||
load_dotenv()
|
||||
|
||||
# 專案路徑
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
STATIC_DIR = BASE_DIR / "backend" / "static"
|
||||
|
||||
# 確保目錄存在
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
STATIC_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# MySQL 資料庫設定
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||
DB_USER = os.getenv("DB_USER", "root")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
|
||||
DB_DATABASE = os.getenv("DB_DATABASE", "sales_pipeline")
|
||||
|
||||
# MySQL 連線字串
|
||||
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_DATABASE}?charset=utf8mb4"
|
||||
|
||||
# 資料表前綴
|
||||
TABLE_PREFIX = os.getenv("TABLE_PREFIX", "PJ_SOA_")
|
||||
|
||||
# JWT 設定
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production-12345678")
|
||||
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440"))
|
||||
|
||||
# 模糊比對閾值
|
||||
MATCH_THRESHOLD_AUTO = int(os.getenv("MATCH_THRESHOLD_AUTO", "95"))
|
||||
MATCH_THRESHOLD_REVIEW = int(os.getenv("MATCH_THRESHOLD_REVIEW", "80"))
|
||||
|
||||
# Excel 解析設定
|
||||
MAX_HEADER_SCAN_ROWS = int(os.getenv("MAX_HEADER_SCAN_ROWS", "20"))
|
||||
|
||||
# 應用設定
|
||||
APP_HOST = os.getenv("APP_HOST", "0.0.0.0")
|
||||
APP_PORT = int(os.getenv("APP_PORT", "8000"))
|
||||
WORKERS = int(os.getenv("WORKERS", "1"))
|
||||
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
|
||||
|
||||
# CORS 設定
|
||||
CORS_ORIGINS = [
|
||||
origin.strip()
|
||||
for origin in os.getenv(
|
||||
"CORS_ORIGINS",
|
||||
"http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173"
|
||||
).split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
54
backend/app/init_admin.py
Normal file
54
backend/app/init_admin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
初始化管理員帳號腳本
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import engine, Base
|
||||
from app.models.user import User, UserRole
|
||||
from app.utils.security import get_password_hash
|
||||
import os
|
||||
|
||||
def create_admin_user(db: Session):
|
||||
"""建立預設管理員帳號"""
|
||||
admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
|
||||
|
||||
# 檢查是否已存在
|
||||
existing = db.query(User).filter(User.email == admin_email).first()
|
||||
if existing:
|
||||
print(f"Admin user already exists: {admin_email}")
|
||||
return existing
|
||||
|
||||
# 建立管理員
|
||||
admin = User(
|
||||
email=admin_email,
|
||||
password_hash=get_password_hash(admin_password),
|
||||
display_name="Administrator",
|
||||
language="zh-TW",
|
||||
role=UserRole.admin
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
|
||||
print(f"Admin user created: {admin_email}")
|
||||
return admin
|
||||
|
||||
def init_database():
|
||||
"""初始化資料庫並建立預設帳號"""
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# 建立所有資料表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("Database tables created.")
|
||||
|
||||
# 建立 session
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
create_admin_user(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_database()
|
||||
71
backend/app/main.py
Normal file
71
backend/app/main.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from app.models import init_db
|
||||
from app.routers import auth, etl, match, dashboard, report, lab
|
||||
from app.config import STATIC_DIR, DEBUG, CORS_ORIGINS, APP_HOST, APP_PORT
|
||||
|
||||
# 初始化資料庫
|
||||
init_db()
|
||||
|
||||
app = FastAPI(
|
||||
title="SalesPipeline API",
|
||||
description="銷售管線管理系統 API",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs" if DEBUG else None,
|
||||
redoc_url="/api/redoc" if DEBUG else None,
|
||||
)
|
||||
|
||||
# CORS 設定 (開發模式需要)
|
||||
if DEBUG and CORS_ORIGINS:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 註冊 API 路由
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(etl.router, prefix="/api")
|
||||
app.include_router(match.router, prefix="/api")
|
||||
app.include_router(dashboard.router, prefix="/api")
|
||||
app.include_router(report.router, prefix="/api")
|
||||
app.include_router(lab.router, prefix="/api")
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
return {"status": "healthy", "version": "1.0.0"}
|
||||
|
||||
# 靜態檔案服務 (前端 build 後的檔案)
|
||||
static_path = STATIC_DIR
|
||||
if static_path.exists():
|
||||
assets_dir = static_path / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
# SPA 路由處理 - 所有非 API 路由都返回 index.html
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(request: Request, full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
return {"error": "Not Found"}, 404
|
||||
|
||||
static_file = static_path / full_path
|
||||
if static_file.exists() and static_file.is_file():
|
||||
return FileResponse(static_file)
|
||||
|
||||
index_file = static_path / "index.html"
|
||||
if index_file.exists():
|
||||
return FileResponse(index_file)
|
||||
|
||||
return {
|
||||
"message": "SalesPipeline API is running",
|
||||
"docs": "/api/docs" if DEBUG else "Disabled in production",
|
||||
"health": "/api/health"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host=APP_HOST, port=APP_PORT)
|
||||
64
backend/app/models/__init__.py
Normal file
64
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from app.config import DATABASE_URL
|
||||
import os
|
||||
|
||||
# MySQL 連線引擎設定
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
echo=False
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Import models to register them
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.dit import DitRecord
|
||||
from app.models.sample import SampleRecord
|
||||
from app.models.order import OrderRecord
|
||||
from app.models.match import MatchResult, ReviewLog
|
||||
|
||||
def init_db():
|
||||
"""初始化資料庫並建立預設管理員"""
|
||||
from app.utils.security import get_password_hash
|
||||
|
||||
# 建立所有資料表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 建立預設管理員帳號
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
|
||||
|
||||
existing = db.query(User).filter(User.email == admin_email).first()
|
||||
if not existing:
|
||||
admin = User(
|
||||
email=admin_email,
|
||||
password_hash=get_password_hash(admin_password),
|
||||
display_name="Administrator",
|
||||
language="zh-TW",
|
||||
role=UserRole.admin
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print(f"[Init] Admin user created: {admin_email}")
|
||||
else:
|
||||
print(f"[Init] Admin user exists: {admin_email}")
|
||||
except Exception as e:
|
||||
print(f"[Init] Error creating admin: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
22
backend/app/models/dit.py
Normal file
22
backend/app/models/dit.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Float, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from app.models import Base
|
||||
from app.config import TABLE_PREFIX
|
||||
|
||||
class DitRecord(Base):
|
||||
__tablename__ = f"{TABLE_PREFIX}DIT_Records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('op_id', 'pn', name='uix_dit_op_pn'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
op_id = Column(String(255), index=True, nullable=False) # 移除 unique,因為同一 op_id 可有多個 pn
|
||||
erp_account = Column(String(100), index=True) # AQ 欄
|
||||
customer = Column(String(255), nullable=False, index=True)
|
||||
customer_normalized = Column(String(255), index=True)
|
||||
pn = Column(String(100), nullable=False, index=True)
|
||||
eau = Column(Integer, default=0)
|
||||
stage = Column(String(50))
|
||||
date = Column(String(20))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
49
backend/app/models/match.py
Normal file
49
backend/app/models/match.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Float, Enum, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.models import Base
|
||||
from app.config import TABLE_PREFIX
|
||||
import enum
|
||||
|
||||
class TargetType(str, enum.Enum):
|
||||
SAMPLE = "SAMPLE"
|
||||
ORDER = "ORDER"
|
||||
|
||||
class MatchStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
auto_matched = "auto_matched"
|
||||
|
||||
class ReviewAction(str, enum.Enum):
|
||||
accept = "accept"
|
||||
reject = "reject"
|
||||
|
||||
class MatchResult(Base):
|
||||
__tablename__ = f"{TABLE_PREFIX}Match_Results"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
dit_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}DIT_Records.id"), nullable=False)
|
||||
target_type = Column(Enum(TargetType), nullable=False)
|
||||
target_id = Column(Integer, nullable=False)
|
||||
score = Column(Float, nullable=False)
|
||||
match_priority = Column(Integer, default=3) # 1: Oppy ID, 2: Account, 3: Name
|
||||
match_source = Column(String(255)) # e.g., "Matched via Opportunity ID: OP12345"
|
||||
reason = Column(String(255))
|
||||
status = Column(Enum(MatchStatus), default=MatchStatus.pending)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
dit = relationship("DitRecord", backref="matches")
|
||||
|
||||
class ReviewLog(Base):
|
||||
__tablename__ = f"{TABLE_PREFIX}Review_Logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
match_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}Match_Results.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}users.id"), nullable=False)
|
||||
action = Column(Enum(ReviewAction), nullable=False)
|
||||
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
match_result = relationship("MatchResult", backref="review_logs")
|
||||
user = relationship("User", backref="review_logs")
|
||||
20
backend/app/models/order.py
Normal file
20
backend/app/models/order.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Float
|
||||
from sqlalchemy.sql import func
|
||||
from app.models import Base
|
||||
from app.config import TABLE_PREFIX
|
||||
|
||||
class OrderRecord(Base):
|
||||
__tablename__ = f"{TABLE_PREFIX}Order_Records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(String(50), index=True, nullable=False) # 移除 unique,訂單可能有多個項次
|
||||
order_no = Column(String(50), index=True)
|
||||
cust_id = Column(String(100), index=True)
|
||||
customer = Column(String(255), nullable=False, index=True)
|
||||
customer_normalized = Column(String(255), index=True)
|
||||
pn = Column(String(100), nullable=False, index=True)
|
||||
qty = Column(Integer, default=0)
|
||||
status = Column(String(50), default='Backlog') # 改為 String 以支援中文狀態
|
||||
amount = Column(Float, default=0.0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
20
backend/app/models/sample.py
Normal file
20
backend/app/models/sample.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.models import Base
|
||||
from app.config import TABLE_PREFIX
|
||||
|
||||
class SampleRecord(Base):
|
||||
__tablename__ = f"{TABLE_PREFIX}Sample_Records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sample_id = Column(String(50), unique=True, index=True, nullable=False)
|
||||
order_no = Column(String(50), index=True)
|
||||
oppy_no = Column(String(100), index=True) # AU 欄
|
||||
cust_id = Column(String(100), index=True) # G 欄
|
||||
customer = Column(String(255), nullable=False, index=True)
|
||||
customer_normalized = Column(String(255), index=True)
|
||||
pn = Column(String(100), nullable=False, index=True)
|
||||
qty = Column(Integer, default=0)
|
||||
date = Column(String(20))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
23
backend/app/models/user.py
Normal file
23
backend/app/models/user.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from app.models import Base
|
||||
from app.config import TABLE_PREFIX
|
||||
import enum
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = f"{TABLE_PREFIX}users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(200), unique=True, index=True, nullable=False)
|
||||
ad_username = Column(String(100), nullable=True) # Added to satisfy DB constraint
|
||||
department = Column(String(100), nullable=True) # Added to satisfy DB constraint
|
||||
password_hash = Column("local_password", String(255), nullable=True)
|
||||
display_name = Column(String(100), nullable=True)
|
||||
# language = Column(String(10), default="zh-TW") # Not in DB
|
||||
role = Column(String(20), default="user") # Simplified from Enum
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
84
backend/app/routers/auth.py
Normal file
84
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.models import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.utils.security import (
|
||||
get_password_hash, verify_password,
|
||||
create_access_token, get_current_user
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
role: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user: UserResponse
|
||||
|
||||
def get_role_value(role) -> str:
|
||||
"""取得 role 的字串值,相容 Enum 和字串"""
|
||||
if hasattr(role, 'value'):
|
||||
return role.value
|
||||
return str(role) if role else 'user'
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""註冊新使用者"""
|
||||
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
password_hash=get_password_hash(user_data.password),
|
||||
role=UserRole.user
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return UserResponse(id=user.id, email=user.email, role=get_role_value(user.role))
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
"""登入取得 JWT Token"""
|
||||
user = db.query(User).filter(User.email == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user=UserResponse(id=user.id, email=user.email, role=get_role_value(user.role))
|
||||
)
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""取得當前使用者資訊"""
|
||||
return UserResponse(
|
||||
id=current_user.id,
|
||||
email=current_user.email,
|
||||
role=get_role_value(current_user.role)
|
||||
)
|
||||
225
backend/app/routers/dashboard.py
Normal file
225
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from pydantic import BaseModel
|
||||
from app.models import get_db
|
||||
from app.models.dit import DitRecord
|
||||
from app.models.sample import SampleRecord
|
||||
from app.models.order import OrderRecord
|
||||
from app.models.match import MatchResult, MatchStatus, TargetType
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
class KPIResponse(BaseModel):
|
||||
total_dit: int
|
||||
sample_rate: float # 送樣轉換率
|
||||
hit_rate: float # 訂單命中率
|
||||
fulfillment_rate: float # EAU 達成率
|
||||
orphan_sample_rate: float # 無效送樣率
|
||||
total_revenue: float
|
||||
|
||||
class FunnelItem(BaseModel):
|
||||
name: str
|
||||
value: int
|
||||
fill: str
|
||||
|
||||
class AttributionDit(BaseModel):
|
||||
op_id: str
|
||||
customer: str
|
||||
pn: str
|
||||
eau: int
|
||||
stage: str
|
||||
date: str
|
||||
|
||||
class AttributionSample(BaseModel):
|
||||
order_no: str
|
||||
date: str
|
||||
|
||||
class AttributionOrder(BaseModel):
|
||||
order_no: str
|
||||
status: str
|
||||
qty: int
|
||||
amount: float
|
||||
|
||||
class AttributionRow(BaseModel):
|
||||
dit: AttributionDit
|
||||
sample: AttributionSample | None
|
||||
order: AttributionOrder | None
|
||||
match_source: str | None
|
||||
attributed_qty: int
|
||||
fulfillment_rate: float
|
||||
|
||||
def get_lifo_attribution(db: Session):
|
||||
"""執行 LIFO 業績分配邏輯"""
|
||||
# 1. 取得所有 DIT,按日期由新到舊排序 (LIFO)
|
||||
dits = db.query(DitRecord).order_by(DitRecord.date.desc()).all()
|
||||
|
||||
# 2. 取得所有已匹配且接受的訂單
|
||||
matched_orders = db.query(MatchResult, OrderRecord).join(
|
||||
OrderRecord, MatchResult.target_id == OrderRecord.id
|
||||
).filter(
|
||||
MatchResult.target_type == TargetType.ORDER,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).all()
|
||||
|
||||
# 3. 建立業績池 (Revenue Pool) - 按 (客戶, 料號) 分組
|
||||
order_pools = {}
|
||||
for match, order in matched_orders:
|
||||
key = (order.customer_normalized, order.pn)
|
||||
if key not in order_pools:
|
||||
order_pools[key] = 0
|
||||
order_pools[key] += (order.qty or 0)
|
||||
|
||||
# 4. 進行分配
|
||||
attribution_map = {} # dit_id -> {qty, total_eau}
|
||||
for dit in dits:
|
||||
key = (dit.customer_normalized, dit.pn)
|
||||
eau = dit.eau or 0
|
||||
allocated = 0
|
||||
|
||||
if key in order_pools and order_pools[key] > 0:
|
||||
allocated = min(eau, order_pools[key])
|
||||
order_pools[key] -= allocated
|
||||
|
||||
attribution_map[dit.id] = {
|
||||
"qty": allocated,
|
||||
"eau": eau
|
||||
}
|
||||
|
||||
return attribution_map
|
||||
|
||||
@router.get("/kpi", response_model=KPIResponse)
|
||||
def get_kpi(db: Session = Depends(get_db)):
|
||||
"""取得 KPI 統計 (符合規格書 v1.0)"""
|
||||
total_dit = db.query(DitRecord).count()
|
||||
if total_dit == 0:
|
||||
return KPIResponse(total_dit=0, sample_rate=0, hit_rate=0, fulfillment_rate=0, orphan_sample_rate=0, total_revenue=0)
|
||||
|
||||
# 1. 送樣轉換率 (Sample Rate): (有匹配到樣品的 DIT 數) / (總 DIT 數)
|
||||
dits_with_sample = db.query(func.count(func.distinct(MatchResult.dit_id))).filter(
|
||||
MatchResult.target_type == TargetType.SAMPLE,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).scalar() or 0
|
||||
sample_rate = (dits_with_sample / total_dit * 100)
|
||||
|
||||
# 2. 訂單命中率 (Hit Rate): (有匹配到訂單的 DIT 數) / (總 DIT 數)
|
||||
dits_with_order = db.query(func.count(func.distinct(MatchResult.dit_id))).filter(
|
||||
MatchResult.target_type == TargetType.ORDER,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).scalar() or 0
|
||||
hit_rate = (dits_with_order / total_dit * 100)
|
||||
|
||||
# 3. EAU 達成率 (Fulfillment Rate): (歸因之訂單總量) / (DIT 預估 EAU)
|
||||
attribution_map = get_lifo_attribution(db)
|
||||
total_attributed_qty = sum(item['qty'] for item in attribution_map.values())
|
||||
total_eau = sum(item['eau'] for item in attribution_map.values())
|
||||
fulfillment_rate = (total_attributed_qty / total_eau * 100) if total_eau > 0 else 0
|
||||
|
||||
# 4. 無效送樣率 (Orphan Sample Rate): (未匹配到 DIT 的送樣數) / (總送樣數)
|
||||
total_samples = db.query(SampleRecord).count()
|
||||
matched_sample_ids = db.query(func.distinct(MatchResult.target_id)).filter(
|
||||
MatchResult.target_type == TargetType.SAMPLE
|
||||
).all()
|
||||
matched_sample_count = len(matched_sample_ids)
|
||||
orphan_sample_rate = ((total_samples - matched_sample_count) / total_samples * 100) if total_samples > 0 else 0
|
||||
|
||||
# 5. 總營收
|
||||
total_revenue = db.query(func.sum(OrderRecord.amount)).join(
|
||||
MatchResult, MatchResult.target_id == OrderRecord.id
|
||||
).filter(
|
||||
MatchResult.target_type == TargetType.ORDER,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).scalar() or 0
|
||||
|
||||
return KPIResponse(
|
||||
total_dit=total_dit,
|
||||
sample_rate=round(sample_rate, 1),
|
||||
hit_rate=round(hit_rate, 1),
|
||||
fulfillment_rate=round(fulfillment_rate, 1),
|
||||
orphan_sample_rate=round(orphan_sample_rate, 1),
|
||||
total_revenue=total_revenue
|
||||
)
|
||||
|
||||
@router.get("/funnel", response_model=List[FunnelItem])
|
||||
def get_funnel(db: Session = Depends(get_db)):
|
||||
"""取得漏斗數據"""
|
||||
total_dit = db.query(DitRecord).count()
|
||||
|
||||
dits_with_sample = db.query(func.count(func.distinct(MatchResult.dit_id))).filter(
|
||||
MatchResult.target_type == TargetType.SAMPLE,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).scalar() or 0
|
||||
|
||||
dits_with_order = db.query(func.count(func.distinct(MatchResult.dit_id))).filter(
|
||||
MatchResult.target_type == TargetType.ORDER,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).scalar() or 0
|
||||
|
||||
return [
|
||||
FunnelItem(name='DIT 案件', value=total_dit, fill='#6366f1'),
|
||||
FunnelItem(name='成功送樣', value=dits_with_sample, fill='#8b5cf6'),
|
||||
FunnelItem(name='取得訂單', value=dits_with_order, fill='#10b981'),
|
||||
]
|
||||
|
||||
@router.get("/attribution", response_model=List[AttributionRow])
|
||||
def get_attribution(db: Session = Depends(get_db)):
|
||||
"""取得歸因明細 (含 LIFO 分配與追溯資訊)"""
|
||||
dit_records = db.query(DitRecord).order_by(DitRecord.date.desc()).all()
|
||||
attribution_map = get_lifo_attribution(db)
|
||||
result = []
|
||||
|
||||
for dit in dit_records:
|
||||
# 找到樣品匹配 (取分數最高的一個)
|
||||
sample_match = db.query(MatchResult).filter(
|
||||
MatchResult.dit_id == dit.id,
|
||||
MatchResult.target_type == TargetType.SAMPLE,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).order_by(MatchResult.score.desc()).first()
|
||||
|
||||
sample_info = None
|
||||
if sample_match:
|
||||
sample = db.query(SampleRecord).filter(SampleRecord.id == sample_match.target_id).first()
|
||||
if sample:
|
||||
sample_info = AttributionSample(order_no=sample.order_no, date=sample.date or '')
|
||||
|
||||
# 找到訂單匹配 (取分數最高的一個)
|
||||
order_match = db.query(MatchResult).filter(
|
||||
MatchResult.dit_id == dit.id,
|
||||
MatchResult.target_type == TargetType.ORDER,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).order_by(MatchResult.score.desc()).first()
|
||||
|
||||
order_info = None
|
||||
match_source = None
|
||||
if order_match:
|
||||
order = db.query(OrderRecord).filter(OrderRecord.id == order_match.target_id).first()
|
||||
if order:
|
||||
order_info = AttributionOrder(
|
||||
order_no=order.order_no,
|
||||
status=order.status or 'Unknown',
|
||||
qty=order.qty or 0,
|
||||
amount=order.amount or 0
|
||||
)
|
||||
match_source = order_match.match_source
|
||||
|
||||
attr_data = attribution_map.get(dit.id, {"qty": 0, "eau": dit.eau or 0})
|
||||
fulfillment = (attr_data['qty'] / attr_data['eau'] * 100) if attr_data['eau'] > 0 else 0
|
||||
|
||||
result.append(AttributionRow(
|
||||
dit=AttributionDit(
|
||||
op_id=dit.op_id,
|
||||
customer=dit.customer,
|
||||
pn=dit.pn,
|
||||
eau=dit.eau,
|
||||
stage=dit.stage or '',
|
||||
date=dit.date or ''
|
||||
),
|
||||
sample=sample_info,
|
||||
order=order_info,
|
||||
match_source=match_source,
|
||||
attributed_qty=attr_data['qty'],
|
||||
fulfillment_rate=round(fulfillment, 1)
|
||||
))
|
||||
|
||||
return result
|
||||
246
backend/app/routers/etl.py
Normal file
246
backend/app/routers/etl.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from app.models import get_db
|
||||
from app.models.dit import DitRecord
|
||||
from app.models.sample import SampleRecord
|
||||
from app.models.order import OrderRecord
|
||||
from app.models.match import MatchResult, TargetType, ReviewLog
|
||||
from app.config import UPLOAD_DIR
|
||||
from app.services.excel_parser import excel_parser
|
||||
from app.services.fuzzy_matcher import normalize_customer_name, sanitize_pn
|
||||
|
||||
router = APIRouter(prefix="/etl", tags=["ETL"])
|
||||
|
||||
class ParsedFileResponse(BaseModel):
|
||||
file_id: str
|
||||
file_type: str
|
||||
filename: str
|
||||
header_row: int
|
||||
row_count: int
|
||||
preview: List[dict]
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
file_id: str
|
||||
|
||||
class ImportResponse(BaseModel):
|
||||
imported_count: int
|
||||
|
||||
@router.post("/upload", response_model=ParsedFileResponse)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
file_type: str = Form(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""上傳並解析 Excel 檔案"""
|
||||
if file_type not in ['dit', 'sample', 'order']:
|
||||
raise HTTPException(status_code=400, detail="Invalid file type")
|
||||
|
||||
# 儲存檔案
|
||||
file_path = UPLOAD_DIR / file.filename
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
try:
|
||||
# 解析檔案
|
||||
file_id, file_info = excel_parser.parse_file(file_path, file_type)
|
||||
|
||||
return ParsedFileResponse(
|
||||
file_id=file_id,
|
||||
file_type=file_info['file_type'],
|
||||
filename=file_info['filename'],
|
||||
header_row=file_info['header_row'],
|
||||
row_count=file_info['row_count'],
|
||||
preview=file_info['preview']
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
|
||||
|
||||
@router.get("/preview/{file_id}", response_model=ParsedFileResponse)
|
||||
def get_preview(file_id: str):
|
||||
"""取得檔案預覽"""
|
||||
file_info = excel_parser.get_file_info(file_id)
|
||||
if not file_info:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return ParsedFileResponse(
|
||||
file_id=file_info['file_id'],
|
||||
file_type=file_info['file_type'],
|
||||
filename=file_info['filename'],
|
||||
header_row=file_info['header_row'],
|
||||
row_count=file_info['row_count'],
|
||||
preview=file_info['preview']
|
||||
)
|
||||
|
||||
def clean_value(val, default=''):
|
||||
"""清理欄位值,處理 nan 和空值"""
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
return default
|
||||
str_val = str(val).strip()
|
||||
if str_val.lower() in ('nan', 'none', 'null', ''):
|
||||
return default
|
||||
return str_val
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportResponse)
|
||||
def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
||||
"""匯入資料到資料庫"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
file_info = excel_parser.get_file_info(request.file_id)
|
||||
if not file_info:
|
||||
print(f"[ETL Import] Error: File not found for file_id={request.file_id}")
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
df = excel_parser.get_parsed_data(request.file_id)
|
||||
if df is None:
|
||||
print(f"[ETL Import] Error: Parsed data not found for file_id={request.file_id}")
|
||||
raise HTTPException(status_code=404, detail="Parsed data not found")
|
||||
|
||||
print(f"[ETL Import] Starting import: file_type={file_info['file_type']}, rows={len(df)}")
|
||||
|
||||
file_type = file_info['file_type']
|
||||
imported_count = 0
|
||||
seen_ids = set() # 追蹤已處理的 ID,避免檔案內重複
|
||||
|
||||
# 清除該類型的舊資料,避免重複鍵衝突
|
||||
try:
|
||||
if file_type == 'dit':
|
||||
print("[ETL Import] Clearing old DIT records and dependent matches/logs...")
|
||||
# 先清除與 DIT 相關的審核日誌與比對結果
|
||||
db.query(ReviewLog).delete()
|
||||
db.query(MatchResult).delete()
|
||||
db.query(DitRecord).delete()
|
||||
elif file_type == 'sample':
|
||||
print("[ETL Import] Clearing old Sample records and dependent matches/logs...")
|
||||
# 先清除與 Sample 相關的比對結果 (及其日誌)
|
||||
# 這裡比較複雜,因為 ReviewLog 是透過 MatchResult 關聯的
|
||||
# 但既然我們是清空整個類別,直接清空所有 ReviewLog 和對應的 MatchResult 是最安全的
|
||||
db.query(ReviewLog).delete()
|
||||
db.query(MatchResult).filter(MatchResult.target_type == TargetType.SAMPLE).delete()
|
||||
db.query(SampleRecord).delete()
|
||||
elif file_type == 'order':
|
||||
print("[ETL Import] Clearing old Order records and dependent matches/logs...")
|
||||
db.query(ReviewLog).delete()
|
||||
db.query(MatchResult).filter(MatchResult.target_type == TargetType.ORDER).delete()
|
||||
db.query(OrderRecord).delete()
|
||||
db.flush() # 使用 flush 而非 commit,保持在同一個事務中
|
||||
print("[ETL Import] Old data cleared successfully.")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"[ETL Import] Error clearing old data: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear old data: {str(e)}")
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
try:
|
||||
if file_type == 'dit':
|
||||
op_id = clean_value(row.get('op_id'), '')
|
||||
erp_account = clean_value(row.get('erp_account'), '')
|
||||
customer = clean_value(row.get('customer'))
|
||||
pn = clean_value(row.get('pn'))
|
||||
# 跳過無效資料列或重複的 op_id + pn 組合
|
||||
unique_key = f"{op_id}|{pn}"
|
||||
if not op_id or unique_key in seen_ids:
|
||||
continue
|
||||
seen_ids.add(unique_key)
|
||||
record = DitRecord(
|
||||
op_id=op_id,
|
||||
erp_account=erp_account,
|
||||
customer=customer,
|
||||
customer_normalized=normalize_customer_name(customer),
|
||||
pn=sanitize_pn(pn),
|
||||
eau=int(row.get('eau', 0)) if row.get('eau') and not pd.isna(row.get('eau')) else 0,
|
||||
stage=clean_value(row.get('stage')),
|
||||
date=clean_value(row.get('date'))
|
||||
)
|
||||
elif file_type == 'sample':
|
||||
sample_id = clean_value(row.get('sample_id'), f'S{idx}')
|
||||
oppy_no = clean_value(row.get('oppy_no'), '')
|
||||
cust_id = clean_value(row.get('cust_id'), '')
|
||||
customer = clean_value(row.get('customer'))
|
||||
pn = clean_value(row.get('pn'))
|
||||
# 跳過重複的 sample_id
|
||||
if sample_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(sample_id)
|
||||
record = SampleRecord(
|
||||
sample_id=sample_id,
|
||||
order_no=clean_value(row.get('order_no')),
|
||||
oppy_no=oppy_no,
|
||||
cust_id=cust_id,
|
||||
customer=customer,
|
||||
customer_normalized=normalize_customer_name(customer),
|
||||
pn=sanitize_pn(pn),
|
||||
qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0,
|
||||
date=clean_value(row.get('date'))
|
||||
)
|
||||
elif file_type == 'order':
|
||||
order_id = clean_value(row.get('order_id'), f'O{idx}')
|
||||
cust_id = clean_value(row.get('cust_id'), '')
|
||||
customer = clean_value(row.get('customer'))
|
||||
pn = clean_value(row.get('pn'))
|
||||
# 跳過重複的 order_id
|
||||
if order_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(order_id)
|
||||
record = OrderRecord(
|
||||
order_id=order_id,
|
||||
order_no=clean_value(row.get('order_no')),
|
||||
cust_id=cust_id,
|
||||
customer=customer,
|
||||
customer_normalized=normalize_customer_name(customer),
|
||||
pn=sanitize_pn(pn),
|
||||
qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0,
|
||||
status=clean_value(row.get('status'), 'Backlog'),
|
||||
amount=float(row.get('amount', 0)) if row.get('amount') and not pd.isna(row.get('amount')) else 0
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
db.add(record)
|
||||
imported_count += 1
|
||||
if imported_count % 500 == 0:
|
||||
print(f"[ETL Import] Processed {imported_count} rows...")
|
||||
except Exception as e:
|
||||
print(f"[ETL Import] Error importing row {idx}: {e}")
|
||||
continue
|
||||
|
||||
try:
|
||||
print(f"[ETL Import] Committing {imported_count} records...")
|
||||
db.commit()
|
||||
print(f"[ETL Import] Import successful: {imported_count} records.")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"[ETL Import] Commit Error: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to commit data: {str(e)}")
|
||||
|
||||
return ImportResponse(imported_count=imported_count)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[ETL Import] Unhandled Exception: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
|
||||
|
||||
@router.get("/data/{data_type}")
|
||||
def get_data(data_type: str, db: Session = Depends(get_db)):
|
||||
"""取得已匯入的資料"""
|
||||
if data_type == 'dit':
|
||||
records = db.query(DitRecord).all()
|
||||
elif data_type == 'sample':
|
||||
records = db.query(SampleRecord).all()
|
||||
elif data_type == 'order':
|
||||
records = db.query(OrderRecord).all()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid data type")
|
||||
|
||||
return [
|
||||
{
|
||||
**{c.name: getattr(record, c.name) for c in record.__table__.columns}
|
||||
}
|
||||
for record in records
|
||||
]
|
||||
181
backend/app/routers/lab.py
Normal file
181
backend/app/routers/lab.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from pydantic import BaseModel
|
||||
from app.models import get_db
|
||||
from app.models.sample import SampleRecord
|
||||
from app.models.order import OrderRecord
|
||||
|
||||
router = APIRouter(prefix="/lab", tags=["Lab"])
|
||||
|
||||
class LabKPI(BaseModel):
|
||||
avg_velocity: float # 平均轉換時間 (天)
|
||||
conversion_rate: float # 轉換比例 (%)
|
||||
orphan_count: int # 孤兒樣品總數
|
||||
|
||||
class ScatterPoint(BaseModel):
|
||||
customer: str
|
||||
pn: str
|
||||
sample_qty: int
|
||||
order_qty: int
|
||||
|
||||
class OrphanSample(BaseModel):
|
||||
customer: str
|
||||
pn: str
|
||||
days_since_sent: int
|
||||
order_no: str
|
||||
date: str
|
||||
|
||||
def parse_date(date_str: str) -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except:
|
||||
return None
|
||||
|
||||
@router.get("/kpi", response_model=LabKPI)
|
||||
def get_lab_kpi(
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 1. 取得所有樣品與訂單
|
||||
samples_query = db.query(SampleRecord)
|
||||
orders_query = db.query(OrderRecord)
|
||||
|
||||
if start_date:
|
||||
samples_query = samples_query.filter(SampleRecord.date >= start_date)
|
||||
orders_query = orders_query.filter(OrderRecord.created_at >= start_date) # 訂單使用 created_at or date? OrderRecord 只有 created_at 欄位是 DateTime
|
||||
|
||||
if end_date:
|
||||
samples_query = samples_query.filter(SampleRecord.date <= end_date)
|
||||
# Note: OrderRecord 只有 created_at
|
||||
|
||||
samples = samples_query.all()
|
||||
orders = orders_query.all()
|
||||
|
||||
# 建立群組 (ERP Code + PN)
|
||||
# ERP Code correspond to cust_id
|
||||
sample_groups = {}
|
||||
for s in samples:
|
||||
key = (s.cust_id, s.pn)
|
||||
if key not in sample_groups:
|
||||
sample_groups[key] = []
|
||||
sample_groups[key].append(s)
|
||||
|
||||
order_groups = {}
|
||||
for o in orders:
|
||||
key = (o.cust_id, o.pn)
|
||||
if key not in order_groups:
|
||||
order_groups[key] = []
|
||||
order_groups[key].append(o)
|
||||
|
||||
# 計算 Velocity 與 轉換率
|
||||
velocities = []
|
||||
converted_samples_count = 0
|
||||
total_samples_count = len(samples)
|
||||
|
||||
for key, group_samples in sample_groups.items():
|
||||
if key in order_groups:
|
||||
# 轉換成功
|
||||
converted_samples_count += len(group_samples)
|
||||
|
||||
# 計算 Velocity: First Order Date - Earliest Sample Date
|
||||
earliest_sample_date = min([parse_date(s.date) for s in group_samples if s.date] or [datetime.max])
|
||||
first_order_date = min([o.created_at for o in order_groups[key] if o.created_at] or [datetime.max])
|
||||
|
||||
if earliest_sample_date != datetime.max and first_order_date != datetime.max:
|
||||
diff = (first_order_date - earliest_sample_date).days
|
||||
if diff >= 0:
|
||||
velocities.append(diff)
|
||||
|
||||
avg_velocity = sum(velocities) / len(velocities) if velocities else 0
|
||||
conversion_rate = (converted_samples_count / total_samples_count * 100) if total_samples_count > 0 else 0
|
||||
|
||||
# 孤兒樣品: > 90天且無訂單
|
||||
now = datetime.now()
|
||||
orphan_count = 0
|
||||
for key, group_samples in sample_groups.items():
|
||||
if key not in order_groups:
|
||||
for s in group_samples:
|
||||
s_date = parse_date(s.date)
|
||||
if s_date and (now - s_date).days > 90:
|
||||
orphan_count += 1
|
||||
|
||||
return LabKPI(
|
||||
avg_velocity=round(avg_velocity, 1),
|
||||
conversion_rate=round(conversion_rate, 1),
|
||||
orphan_count=orphan_count
|
||||
)
|
||||
|
||||
@router.get("/scatter", response_model=List[ScatterPoint])
|
||||
def get_scatter_data(
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
samples_query = db.query(SampleRecord)
|
||||
orders_query = db.query(OrderRecord)
|
||||
|
||||
if start_date:
|
||||
samples_query = samples_query.filter(SampleRecord.date >= start_date)
|
||||
if end_date:
|
||||
samples_query = samples_query.filter(SampleRecord.date <= end_date)
|
||||
|
||||
samples = samples_query.all()
|
||||
orders = orders_query.all()
|
||||
|
||||
# 聚合資料
|
||||
data_map = {} # (cust_id, pn) -> {sample_qty, order_qty, customer_name}
|
||||
|
||||
for s in samples:
|
||||
key = (s.cust_id, s.pn)
|
||||
if key not in data_map:
|
||||
data_map[key] = {"sample_qty": 0, "order_qty": 0, "customer": s.customer}
|
||||
data_map[key]["sample_qty"] += (s.qty or 0)
|
||||
|
||||
for o in orders:
|
||||
key = (o.cust_id, o.pn)
|
||||
if key in data_map:
|
||||
data_map[key]["order_qty"] += (o.qty or 0)
|
||||
# 如果有訂單但沒樣品,我們在 ROI 分析中可能不顯示,或者顯示在 Y 軸上 X=0。
|
||||
# 根據需求:分析「樣品寄送」與「訂單接收」的關聯,通常以有送樣的為基底。
|
||||
|
||||
return [
|
||||
ScatterPoint(
|
||||
customer=v["customer"],
|
||||
pn=key[1],
|
||||
sample_qty=v["sample_qty"],
|
||||
order_qty=v["order_qty"]
|
||||
)
|
||||
for key, v in data_map.items()
|
||||
]
|
||||
|
||||
@router.get("/orphans", response_model=List[OrphanSample])
|
||||
def get_orphans(db: Session = Depends(get_db)):
|
||||
now = datetime.now()
|
||||
threshold_date = now - timedelta(days=90)
|
||||
|
||||
# 找出所有樣品
|
||||
samples = db.query(SampleRecord).all()
|
||||
|
||||
# 找出有訂單的人 (cust_id, pn)
|
||||
orders_keys = set(db.query(OrderRecord.cust_id, OrderRecord.pn).distinct().all())
|
||||
|
||||
orphans = []
|
||||
for s in samples:
|
||||
key = (s.cust_id, s.pn)
|
||||
s_date = parse_date(s.date)
|
||||
|
||||
if key not in orders_keys:
|
||||
if s_date and s_date < threshold_date:
|
||||
orphans.append(OrphanSample(
|
||||
customer=s.customer,
|
||||
pn=s.pn,
|
||||
days_since_sent=(now - s_date).days,
|
||||
order_no=s.order_no,
|
||||
date=s.date
|
||||
))
|
||||
|
||||
return sorted(orphans, key=lambda x: x.days_since_sent, reverse=True)
|
||||
171
backend/app/routers/match.py
Normal file
171
backend/app/routers/match.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from app.models import get_db
|
||||
from app.models.dit import DitRecord
|
||||
from app.models.sample import SampleRecord
|
||||
from app.models.order import OrderRecord
|
||||
from app.models.match import MatchResult, MatchStatus, TargetType
|
||||
from app.services.fuzzy_matcher import FuzzyMatcher
|
||||
|
||||
router = APIRouter(prefix="/match", tags=["Matching"])
|
||||
|
||||
class MatchRunResponse(BaseModel):
|
||||
match_count: int
|
||||
auto_matched: int
|
||||
pending_review: int
|
||||
|
||||
class DitInfo(BaseModel):
|
||||
id: int
|
||||
op_id: str
|
||||
customer: str
|
||||
pn: str
|
||||
eau: int
|
||||
stage: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TargetInfo(BaseModel):
|
||||
id: int
|
||||
customer: str
|
||||
pn: str
|
||||
order_no: Optional[str]
|
||||
qty: Optional[int]
|
||||
|
||||
class MatchResultResponse(BaseModel):
|
||||
id: int
|
||||
dit_id: int
|
||||
target_type: str
|
||||
target_id: int
|
||||
score: float
|
||||
reason: str
|
||||
status: str
|
||||
dit: Optional[DitInfo]
|
||||
target: Optional[TargetInfo]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
action: str # 'accept' or 'reject'
|
||||
|
||||
@router.post("/run", response_model=MatchRunResponse)
|
||||
def run_matching(db: Session = Depends(get_db)):
|
||||
"""執行模糊比對"""
|
||||
matcher = FuzzyMatcher(db)
|
||||
result = matcher.run_matching()
|
||||
return MatchRunResponse(**result)
|
||||
|
||||
@router.get("/results", response_model=List[MatchResultResponse])
|
||||
def get_results(db: Session = Depends(get_db)):
|
||||
"""取得所有比對結果"""
|
||||
matches = db.query(MatchResult).all()
|
||||
|
||||
results = []
|
||||
for match in matches:
|
||||
# 取得 DIT 資訊
|
||||
dit = db.query(DitRecord).filter(DitRecord.id == match.dit_id).first()
|
||||
dit_info = DitInfo(
|
||||
id=dit.id,
|
||||
op_id=dit.op_id,
|
||||
customer=dit.customer,
|
||||
pn=dit.pn,
|
||||
eau=dit.eau,
|
||||
stage=dit.stage
|
||||
) if dit else None
|
||||
|
||||
# 取得目標資訊
|
||||
target_info = None
|
||||
if match.target_type == TargetType.SAMPLE:
|
||||
sample = db.query(SampleRecord).filter(SampleRecord.id == match.target_id).first()
|
||||
if sample:
|
||||
target_info = TargetInfo(
|
||||
id=sample.id,
|
||||
customer=sample.customer,
|
||||
pn=sample.pn,
|
||||
order_no=sample.order_no,
|
||||
qty=sample.qty
|
||||
)
|
||||
elif match.target_type == TargetType.ORDER:
|
||||
order = db.query(OrderRecord).filter(OrderRecord.id == match.target_id).first()
|
||||
if order:
|
||||
target_info = TargetInfo(
|
||||
id=order.id,
|
||||
customer=order.customer,
|
||||
pn=order.pn,
|
||||
order_no=order.order_no,
|
||||
qty=order.qty
|
||||
)
|
||||
|
||||
results.append(MatchResultResponse(
|
||||
id=match.id,
|
||||
dit_id=match.dit_id,
|
||||
target_type=match.target_type.value,
|
||||
target_id=match.target_id,
|
||||
score=match.score,
|
||||
reason=match.reason,
|
||||
status=match.status.value,
|
||||
dit=dit_info,
|
||||
target=target_info
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
@router.put("/{match_id}/review", response_model=MatchResultResponse)
|
||||
def review_match(match_id: int, request: ReviewRequest, db: Session = Depends(get_db)):
|
||||
"""審核比對結果"""
|
||||
if request.action not in ['accept', 'reject']:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
matcher = FuzzyMatcher(db)
|
||||
match = matcher.review_match(match_id, request.action)
|
||||
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
# 取得相關資訊
|
||||
dit = db.query(DitRecord).filter(DitRecord.id == match.dit_id).first()
|
||||
dit_info = DitInfo(
|
||||
id=dit.id,
|
||||
op_id=dit.op_id,
|
||||
customer=dit.customer,
|
||||
pn=dit.pn,
|
||||
eau=dit.eau,
|
||||
stage=dit.stage
|
||||
) if dit else None
|
||||
|
||||
target_info = None
|
||||
if match.target_type == TargetType.SAMPLE:
|
||||
sample = db.query(SampleRecord).filter(SampleRecord.id == match.target_id).first()
|
||||
if sample:
|
||||
target_info = TargetInfo(
|
||||
id=sample.id,
|
||||
customer=sample.customer,
|
||||
pn=sample.pn,
|
||||
order_no=sample.order_no,
|
||||
qty=sample.qty
|
||||
)
|
||||
elif match.target_type == TargetType.ORDER:
|
||||
order = db.query(OrderRecord).filter(OrderRecord.id == match.target_id).first()
|
||||
if order:
|
||||
target_info = TargetInfo(
|
||||
id=order.id,
|
||||
customer=order.customer,
|
||||
pn=order.pn,
|
||||
order_no=order.order_no,
|
||||
qty=order.qty
|
||||
)
|
||||
|
||||
return MatchResultResponse(
|
||||
id=match.id,
|
||||
dit_id=match.dit_id,
|
||||
target_type=match.target_type.value,
|
||||
target_id=match.target_id,
|
||||
score=match.score,
|
||||
reason=match.reason,
|
||||
status=match.status.value,
|
||||
dit=dit_info,
|
||||
target=target_info
|
||||
)
|
||||
32
backend/app/routers/report.py
Normal file
32
backend/app/routers/report.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import get_db
|
||||
from app.services.report_generator import ReportGenerator
|
||||
|
||||
router = APIRouter(prefix="/report", tags=["Report"])
|
||||
|
||||
@router.get("/export")
|
||||
def export_report(format: str = "xlsx", db: Session = Depends(get_db)):
|
||||
"""匯出報表"""
|
||||
if format not in ['xlsx', 'pdf']:
|
||||
raise HTTPException(status_code=400, detail="Invalid format. Use 'xlsx' or 'pdf'")
|
||||
|
||||
generator = ReportGenerator(db)
|
||||
|
||||
if format == 'xlsx':
|
||||
output = generator.generate_excel()
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
filename = "dit_attribution_report.xlsx"
|
||||
else:
|
||||
output = generator.generate_pdf()
|
||||
media_type = "application/pdf"
|
||||
filename = "dit_attribution_report.pdf"
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}"
|
||||
}
|
||||
)
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
175
backend/app/services/excel_parser.py
Normal file
175
backend/app/services/excel_parser.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import re
|
||||
import uuid
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import pandas as pd
|
||||
import chardet
|
||||
from openpyxl import load_workbook
|
||||
from app.config import MAX_HEADER_SCAN_ROWS, UPLOAD_DIR
|
||||
|
||||
|
||||
def clean_value(val):
|
||||
"""清理單一值,將 NaN/Inf 轉換為 None 以便 JSON 序列化"""
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, float):
|
||||
if math.isnan(val) or math.isinf(val):
|
||||
return None
|
||||
return val
|
||||
|
||||
|
||||
def clean_dict(d: Dict) -> Dict:
|
||||
"""清理字典中的所有 NaN/Inf 值"""
|
||||
return {k: clean_value(v) for k, v in d.items()}
|
||||
|
||||
|
||||
def clean_records(records: List[Dict]) -> List[Dict]:
|
||||
"""清理記錄列表中的所有 NaN/Inf 值"""
|
||||
return [clean_dict(r) for r in records]
|
||||
|
||||
# 欄位名稱對應表
|
||||
COLUMN_MAPPING = {
|
||||
'dit': {
|
||||
'op_id': ['opportunity name', 'opportunity no', 'opportunity', 'op編號', 'op 編號', 'op_id', 'opid', '案件編號', '案號', 'opportunity id'],
|
||||
'erp_account': ['erp account', 'account no', 'erp account no', '客戶代碼', '客戶編號', 'erp_account'],
|
||||
'customer': ['account name', 'branding customer', '客戶', '客戶名稱', 'customer', 'customer name', '公司名稱'],
|
||||
'pn': ['product name', '料號', 'part number', 'pn', 'part no', 'part_number', '產品料號', 'stage/part'],
|
||||
'eau': ['eau quantity', 'eau quantity (pcs)', 'eau', '年預估量', 'annual usage', '預估用量'],
|
||||
'stage': ['stage', 'oppty product stage', '階段', 'status', '狀態', '專案階段'],
|
||||
'date': ['created date', '日期', 'date', '建立日期', 'create date']
|
||||
},
|
||||
'sample': {
|
||||
'sample_id': ['樣品訂單號碼', 'item', '樣品編號', 'sample_id', 'sample id', '編號'],
|
||||
'order_no': ['樣品訂單號碼', '單號', 'order_no', 'order no', '樣品單號', '申請單號'],
|
||||
'oppy_no': ['oppy no', 'oppy_no', '案號', '案件編號', 'opportunity no'],
|
||||
'cust_id': ['cust id', 'cust_id', '客戶編號', '客戶代碼', '客戶代號'],
|
||||
'customer': ['客戶名稱', '客戶簡稱', '客戶', 'customer', 'customer name'],
|
||||
'pn': ['item', 'type', '料號', 'part number', 'pn', 'part no', '產品料號', '索樣數量'],
|
||||
'qty': ['索樣數量pcs', '索樣數量 k', '數量', 'qty', 'quantity', '申請數量'],
|
||||
'date': ['需求日', '日期', 'date', '申請日期']
|
||||
},
|
||||
'order': {
|
||||
'order_id': ['項次', '訂單編號', 'order_id', 'order id'],
|
||||
'order_no': ['訂單單號', '訂單號', 'order_no', 'order no', '銷貨單號'],
|
||||
'cust_id': ['客戶編號', '客戶代碼', '客戶代號', 'cust_id', 'cust id'],
|
||||
'customer': ['客戶', '客戶名稱', 'customer', 'customer name'],
|
||||
'pn': ['type', '內部料號', '料號', 'part number', 'pn', 'part no', '產品料號'],
|
||||
'qty': ['訂單量', '數量', 'qty', 'quantity', '訂購數量', '出貨數量'],
|
||||
'status': ['狀態', 'status', '訂單狀態'],
|
||||
'amount': ['原幣金額(含稅)', '台幣金額(未稅)', '金額', 'amount', 'total', '訂單金額']
|
||||
}
|
||||
}
|
||||
|
||||
class ExcelParser:
|
||||
def __init__(self):
|
||||
self.parsed_files: Dict[str, Dict] = {}
|
||||
|
||||
def detect_encoding(self, file_path: Path) -> str:
|
||||
"""偵測檔案編碼"""
|
||||
with open(file_path, 'rb') as f:
|
||||
result = chardet.detect(f.read(10000))
|
||||
return result.get('encoding', 'utf-8')
|
||||
|
||||
def find_header_row(self, df: pd.DataFrame, file_type: str) -> int:
|
||||
"""自動偵測表頭位置"""
|
||||
expected_columns = set()
|
||||
for variants in COLUMN_MAPPING[file_type].values():
|
||||
expected_columns.update([v.lower() for v in variants])
|
||||
|
||||
for idx in range(min(MAX_HEADER_SCAN_ROWS, len(df))):
|
||||
row = df.iloc[idx]
|
||||
row_values = [str(v).lower().strip() for v in row.values if pd.notna(v)]
|
||||
|
||||
# 檢查是否有匹配的欄位名稱
|
||||
matches = sum(1 for v in row_values if any(exp in v for exp in expected_columns))
|
||||
if matches >= 2: # 至少匹配 2 個欄位
|
||||
return idx
|
||||
|
||||
return 0 # 預設第一行為表頭
|
||||
|
||||
def map_columns(self, df: pd.DataFrame, file_type: str) -> Dict[str, str]:
|
||||
"""將 DataFrame 欄位對應到標準欄位名稱"""
|
||||
mapping = {}
|
||||
column_map = COLUMN_MAPPING[file_type]
|
||||
|
||||
df_columns = [str(c).lower().strip() for c in df.columns]
|
||||
|
||||
for standard_name, variants in column_map.items():
|
||||
for variant in variants:
|
||||
variant_lower = variant.lower()
|
||||
for idx, col in enumerate(df_columns):
|
||||
if variant_lower in col or col in variant_lower:
|
||||
mapping[df.columns[idx]] = standard_name
|
||||
break
|
||||
if standard_name in mapping.values():
|
||||
break
|
||||
|
||||
return mapping
|
||||
|
||||
def parse_file(self, file_path: Path, file_type: str) -> Tuple[str, Dict[str, Any]]:
|
||||
"""解析 Excel/CSV 檔案"""
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
# 讀取檔案
|
||||
if file_path.suffix.lower() == '.csv':
|
||||
encoding = self.detect_encoding(file_path)
|
||||
df = pd.read_csv(file_path, encoding=encoding, header=None)
|
||||
else:
|
||||
df = pd.read_excel(file_path, header=None)
|
||||
|
||||
# 找到表頭
|
||||
header_row = self.find_header_row(df, file_type)
|
||||
|
||||
# 重新讀取,以正確的表頭
|
||||
if file_path.suffix.lower() == '.csv':
|
||||
df = pd.read_csv(file_path, encoding=encoding, header=header_row)
|
||||
else:
|
||||
df = pd.read_excel(file_path, header=header_row)
|
||||
|
||||
# 欄位對應
|
||||
column_mapping = self.map_columns(df, file_type)
|
||||
df = df.rename(columns=column_mapping)
|
||||
|
||||
# 只保留需要的欄位
|
||||
required_columns = list(COLUMN_MAPPING[file_type].keys())
|
||||
available_columns = [c for c in required_columns if c in df.columns]
|
||||
df = df[available_columns]
|
||||
|
||||
# 清理資料
|
||||
df = df.dropna(how='all')
|
||||
|
||||
# 產生預覽資料(清理 NaN 值以便 JSON 序列化)
|
||||
preview = clean_records(df.head(10).to_dict(orient='records'))
|
||||
|
||||
# 儲存解析結果
|
||||
parsed_data = {
|
||||
'file_id': file_id,
|
||||
'file_type': file_type,
|
||||
'filename': file_path.name,
|
||||
'header_row': header_row,
|
||||
'row_count': len(df),
|
||||
'columns': list(df.columns),
|
||||
'preview': preview,
|
||||
'dataframe': df
|
||||
}
|
||||
|
||||
self.parsed_files[file_id] = parsed_data
|
||||
|
||||
return file_id, {k: v for k, v in parsed_data.items() if k != 'dataframe'}
|
||||
|
||||
def get_parsed_data(self, file_id: str) -> Optional[pd.DataFrame]:
|
||||
"""取得解析後的 DataFrame"""
|
||||
if file_id in self.parsed_files:
|
||||
return self.parsed_files[file_id].get('dataframe')
|
||||
return None
|
||||
|
||||
def get_file_info(self, file_id: str) -> Optional[Dict]:
|
||||
"""取得檔案資訊"""
|
||||
if file_id in self.parsed_files:
|
||||
data = self.parsed_files[file_id]
|
||||
return {k: v for k, v in data.items() if k != 'dataframe'}
|
||||
return None
|
||||
|
||||
# 全域實例
|
||||
excel_parser = ExcelParser()
|
||||
277
backend/app/services/fuzzy_matcher.py
Normal file
277
backend/app/services/fuzzy_matcher.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import re
|
||||
from typing import List, Tuple, Optional
|
||||
from rapidfuzz import fuzz, process
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import MATCH_THRESHOLD_AUTO, MATCH_THRESHOLD_REVIEW
|
||||
from app.models.dit import DitRecord
|
||||
from app.models.sample import SampleRecord
|
||||
from app.models.order import OrderRecord
|
||||
from app.models.match import MatchResult, MatchStatus, TargetType, ReviewLog
|
||||
import pandas as pd
|
||||
from datetime import timedelta
|
||||
|
||||
# 公司後綴清單(用於正規化)
|
||||
COMPANY_SUFFIXES = [
|
||||
'股份有限公司', '有限公司', '公司',
|
||||
'株式会社', '株式會社',
|
||||
'Co., Ltd.', 'Co.,Ltd.', 'Co. Ltd.', 'Co.Ltd.',
|
||||
'Corporation', 'Corp.', 'Corp',
|
||||
'Inc.', 'Inc',
|
||||
'Limited', 'Ltd.', 'Ltd',
|
||||
'LLC', 'L.L.C.',
|
||||
]
|
||||
|
||||
def sanitize_pn(pn: str) -> str:
|
||||
"""去除非字母數字字元並轉大寫 (PMSM-808-LL -> PMSM808LL)"""
|
||||
if not pn:
|
||||
return ""
|
||||
return re.sub(r'[^a-zA-Z0-9]', '', str(pn)).upper()
|
||||
|
||||
def normalize_customer_name(name: str) -> str:
|
||||
"""正規化客戶名稱 (轉大寫)"""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
# 轉換為大寫
|
||||
normalized = name.strip()
|
||||
|
||||
# 移除公司後綴
|
||||
for suffix in COMPANY_SUFFIXES:
|
||||
normalized = re.sub(re.escape(suffix), '', normalized, flags=re.IGNORECASE)
|
||||
|
||||
# 移除括號及其內容
|
||||
normalized = re.sub(r'\([^)]*\)', '', normalized)
|
||||
normalized = re.sub(r'([^)]*)', '', normalized)
|
||||
|
||||
# 全形轉半形
|
||||
normalized = normalized.replace(' ', ' ')
|
||||
|
||||
# 移除多餘空白
|
||||
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
||||
|
||||
return normalized.upper()
|
||||
|
||||
def calculate_similarity(name1: str, name2: str) -> Tuple[float, str]:
|
||||
"""計算兩個名稱的相似度"""
|
||||
# 正規化
|
||||
norm1 = normalize_customer_name(name1)
|
||||
norm2 = normalize_customer_name(name2)
|
||||
|
||||
if not norm1 or not norm2:
|
||||
return 0.0, "Empty name"
|
||||
|
||||
# 完全匹配
|
||||
if norm1 == norm2:
|
||||
return 100.0, "Exact Match"
|
||||
|
||||
# 使用多種比對方法
|
||||
ratio = fuzz.ratio(norm1, norm2)
|
||||
partial_ratio = fuzz.partial_ratio(norm1, norm2)
|
||||
token_sort_ratio = fuzz.token_sort_ratio(norm1, norm2)
|
||||
token_set_ratio = fuzz.token_set_ratio(norm1, norm2)
|
||||
|
||||
# 取最高分
|
||||
best_score = max(ratio, partial_ratio, token_sort_ratio, token_set_ratio)
|
||||
|
||||
# 決定原因
|
||||
if ratio == best_score:
|
||||
reason = "Character Similarity"
|
||||
elif partial_ratio == best_score:
|
||||
reason = "Partial Match"
|
||||
elif token_sort_ratio == best_score:
|
||||
reason = "Token Order Match"
|
||||
else:
|
||||
reason = "Token Set Match"
|
||||
|
||||
# 檢查是否為後綴差異
|
||||
if best_score >= 80:
|
||||
for suffix in COMPANY_SUFFIXES[:3]: # 只檢查常見後綴
|
||||
if (suffix in name1 and suffix not in name2) or \
|
||||
(suffix not in name1 and suffix in name2):
|
||||
reason = "Corporate Suffix Mismatch"
|
||||
break
|
||||
|
||||
return best_score, reason
|
||||
|
||||
class FuzzyMatcher:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def run_matching(self) -> dict:
|
||||
"""執行瀑布式模糊比對 (Waterfall Matching)"""
|
||||
|
||||
# 1. 取得所有 DIT 記錄
|
||||
dit_records = self.db.query(DitRecord).all()
|
||||
|
||||
# 2. 取得所有樣品和訂單記錄並按 PN 分組
|
||||
sample_records = self.db.query(SampleRecord).all()
|
||||
order_records = self.db.query(OrderRecord).all()
|
||||
|
||||
samples_by_pn = {}
|
||||
samples_by_oppy = {}
|
||||
for s in sample_records:
|
||||
if s.pn:
|
||||
if s.pn not in samples_by_pn:
|
||||
samples_by_pn[s.pn] = []
|
||||
samples_by_pn[s.pn].append(s)
|
||||
if s.oppy_no:
|
||||
if s.oppy_no not in samples_by_oppy:
|
||||
samples_by_oppy[s.oppy_no] = []
|
||||
samples_by_oppy[s.oppy_no].append(s)
|
||||
|
||||
orders_by_pn = {}
|
||||
for o in order_records:
|
||||
if o.pn not in orders_by_pn:
|
||||
orders_by_pn[o.pn] = []
|
||||
orders_by_pn[o.pn].append(o)
|
||||
|
||||
# 3. 清除舊的比對結果
|
||||
self.db.query(ReviewLog).delete()
|
||||
self.db.query(MatchResult).delete()
|
||||
|
||||
match_count = 0
|
||||
auto_matched = 0
|
||||
pending_review = 0
|
||||
|
||||
for dit in dit_records:
|
||||
dit_date = pd.to_datetime(dit.date, errors='coerce')
|
||||
|
||||
# --- 比對樣品 (DIT -> Sample) ---
|
||||
# 收集所有可能的樣品 (Priority 1: Oppy ID, Priority 2/3: PN)
|
||||
potential_samples = []
|
||||
if dit.op_id:
|
||||
potential_samples.extend(samples_by_oppy.get(dit.op_id, []))
|
||||
if dit.pn:
|
||||
potential_samples.extend(samples_by_pn.get(dit.pn, []))
|
||||
|
||||
# 去重
|
||||
seen_sample_ids = set()
|
||||
unique_potential_samples = []
|
||||
for s in potential_samples:
|
||||
if s.id not in seen_sample_ids:
|
||||
seen_sample_ids.add(s.id)
|
||||
unique_potential_samples.append(s)
|
||||
|
||||
for sample in unique_potential_samples:
|
||||
sample_date = pd.to_datetime(sample.date, errors='coerce')
|
||||
|
||||
# 時間窗檢查: Sample Date 必須在 DIT Date 的 前 30 天 至 今日 之間
|
||||
if pd.notna(dit_date) and pd.notna(sample_date):
|
||||
if sample_date < (dit_date - timedelta(days=30)):
|
||||
continue
|
||||
|
||||
match_priority = 0
|
||||
match_source = ""
|
||||
score = 0.0
|
||||
reason = ""
|
||||
|
||||
# Priority 1: 案號精準比對 (Golden Key)
|
||||
if dit.op_id and sample.oppy_no and dit.op_id == sample.oppy_no:
|
||||
match_priority = 1
|
||||
match_source = f"Matched via Opportunity ID: {dit.op_id}"
|
||||
score = 100.0
|
||||
reason = "Golden Key Match"
|
||||
|
||||
# Priority 2 & 3 則限制在相同 PN
|
||||
elif dit.pn == sample.pn:
|
||||
# Priority 2: 客戶代碼比對 (Silver Key)
|
||||
if dit.erp_account and sample.cust_id and dit.erp_account == sample.cust_id:
|
||||
match_priority = 2
|
||||
match_source = f"Matched via ERP Account: {dit.erp_account}"
|
||||
score = 99.0
|
||||
reason = "Silver Key Match"
|
||||
|
||||
# Priority 3: 名稱模糊比對 (Fallback)
|
||||
else:
|
||||
score, reason = calculate_similarity(dit.customer, sample.customer)
|
||||
if score >= MATCH_THRESHOLD_REVIEW:
|
||||
match_priority = 3
|
||||
match_source = f"Matched via Name Similarity ({reason})"
|
||||
|
||||
if match_priority > 0:
|
||||
status = MatchStatus.auto_matched if score >= MATCH_THRESHOLD_AUTO else MatchStatus.pending
|
||||
match = MatchResult(
|
||||
dit_id=dit.id,
|
||||
target_type=TargetType.SAMPLE,
|
||||
target_id=sample.id,
|
||||
score=score,
|
||||
match_priority=match_priority,
|
||||
match_source=match_source,
|
||||
reason=reason,
|
||||
status=status
|
||||
)
|
||||
self.db.add(match)
|
||||
match_count += 1
|
||||
if status == MatchStatus.auto_matched:
|
||||
auto_matched += 1
|
||||
else:
|
||||
pending_review += 1
|
||||
|
||||
# --- 比對訂單 (DIT -> Order) ---
|
||||
# 訂單比對通常基於 PN
|
||||
for order in orders_by_pn.get(dit.pn, []):
|
||||
match_priority = 0
|
||||
match_source = ""
|
||||
score = 0.0
|
||||
reason = ""
|
||||
|
||||
# Priority 2: 客戶代碼比對 (Silver Key)
|
||||
if dit.erp_account and order.cust_id and dit.erp_account == order.cust_id:
|
||||
match_priority = 2
|
||||
match_source = f"Matched via ERP Account: {dit.erp_account}"
|
||||
score = 99.0
|
||||
reason = "Silver Key Match"
|
||||
|
||||
# Priority 3: 名稱模糊比對 (Fallback)
|
||||
else:
|
||||
score, reason = calculate_similarity(dit.customer, order.customer)
|
||||
if score >= MATCH_THRESHOLD_REVIEW:
|
||||
match_priority = 3
|
||||
match_source = f"Matched via Name Similarity ({reason})"
|
||||
|
||||
if match_priority > 0:
|
||||
status = MatchStatus.auto_matched if score >= MATCH_THRESHOLD_AUTO else MatchStatus.pending
|
||||
match = MatchResult(
|
||||
dit_id=dit.id,
|
||||
target_type=TargetType.ORDER,
|
||||
target_id=order.id,
|
||||
score=score,
|
||||
match_priority=match_priority,
|
||||
match_source=match_source,
|
||||
reason=reason,
|
||||
status=status
|
||||
)
|
||||
self.db.add(match)
|
||||
match_count += 1
|
||||
if status == MatchStatus.auto_matched:
|
||||
auto_matched += 1
|
||||
else:
|
||||
pending_review += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
'match_count': match_count,
|
||||
'auto_matched': auto_matched,
|
||||
'pending_review': pending_review
|
||||
}
|
||||
|
||||
def get_pending_reviews(self) -> List[MatchResult]:
|
||||
"""取得待審核的比對結果"""
|
||||
return self.db.query(MatchResult).filter(
|
||||
MatchResult.status == MatchStatus.pending
|
||||
).all()
|
||||
|
||||
def review_match(self, match_id: int, action: str) -> Optional[MatchResult]:
|
||||
"""審核比對結果"""
|
||||
match = self.db.query(MatchResult).filter(MatchResult.id == match_id).first()
|
||||
if not match:
|
||||
return None
|
||||
|
||||
if action == 'accept':
|
||||
match.status = MatchStatus.accepted
|
||||
elif action == 'reject':
|
||||
match.status = MatchStatus.rejected
|
||||
|
||||
self.db.commit()
|
||||
return match
|
||||
171
backend/app/services/report_generator.py
Normal file
171
backend/app/services/report_generator.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import io
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.dit import DitRecord
|
||||
from app.models.sample import SampleRecord
|
||||
from app.models.order import OrderRecord
|
||||
from app.models.match import MatchResult, MatchStatus
|
||||
|
||||
class ReportGenerator:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_attribution_data(self) -> List[Dict[str, Any]]:
|
||||
"""取得歸因明細資料"""
|
||||
dit_records = self.db.query(DitRecord).all()
|
||||
result = []
|
||||
|
||||
for dit in dit_records:
|
||||
row = {
|
||||
'op_id': dit.op_id,
|
||||
'customer': dit.customer,
|
||||
'pn': dit.pn,
|
||||
'eau': dit.eau,
|
||||
'stage': dit.stage,
|
||||
'sample_order': None,
|
||||
'order_no': None,
|
||||
'order_status': None,
|
||||
'order_amount': None
|
||||
}
|
||||
|
||||
# 找到已接受的樣品匹配
|
||||
sample_match = self.db.query(MatchResult).filter(
|
||||
MatchResult.dit_id == dit.id,
|
||||
MatchResult.target_type == 'SAMPLE',
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).first()
|
||||
|
||||
if sample_match:
|
||||
sample = self.db.query(SampleRecord).filter(
|
||||
SampleRecord.id == sample_match.target_id
|
||||
).first()
|
||||
if sample:
|
||||
row['sample_order'] = sample.order_no
|
||||
|
||||
# 找到已接受的訂單匹配
|
||||
order_match = self.db.query(MatchResult).filter(
|
||||
MatchResult.dit_id == dit.id,
|
||||
MatchResult.target_type == 'ORDER',
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).first()
|
||||
|
||||
if order_match:
|
||||
order = self.db.query(OrderRecord).filter(
|
||||
OrderRecord.id == order_match.target_id
|
||||
).first()
|
||||
if order:
|
||||
row['order_no'] = order.order_no
|
||||
row['order_status'] = order.status.value if order.status else None
|
||||
row['order_amount'] = order.amount
|
||||
|
||||
result.append(row)
|
||||
|
||||
return result
|
||||
|
||||
def generate_excel(self) -> io.BytesIO:
|
||||
"""產生 Excel 報表"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "DIT Attribution Report"
|
||||
|
||||
# 標題樣式
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4F46E5", end_color="4F46E5", fill_type="solid")
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
# 表頭
|
||||
headers = ['OP編號', '客戶名稱', '料號', 'EAU', '階段', '樣品單號', '訂單單號', '訂單狀態', '訂單金額']
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_alignment
|
||||
|
||||
# 資料
|
||||
data = self.get_attribution_data()
|
||||
for row_idx, row_data in enumerate(data, 2):
|
||||
ws.cell(row=row_idx, column=1, value=row_data['op_id'])
|
||||
ws.cell(row=row_idx, column=2, value=row_data['customer'])
|
||||
ws.cell(row=row_idx, column=3, value=row_data['pn'])
|
||||
ws.cell(row=row_idx, column=4, value=row_data['eau'])
|
||||
ws.cell(row=row_idx, column=5, value=row_data['stage'])
|
||||
ws.cell(row=row_idx, column=6, value=row_data['sample_order'] or '-')
|
||||
ws.cell(row=row_idx, column=7, value=row_data['order_no'] or '-')
|
||||
ws.cell(row=row_idx, column=8, value=row_data['order_status'] or '-')
|
||||
ws.cell(row=row_idx, column=9, value=row_data['order_amount'] or 0)
|
||||
|
||||
# 調整欄寬
|
||||
column_widths = [15, 30, 20, 12, 15, 15, 15, 12, 12]
|
||||
for col, width in enumerate(column_widths, 1):
|
||||
ws.column_dimensions[chr(64 + col)].width = width
|
||||
|
||||
# 儲存到 BytesIO
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
def generate_pdf(self) -> io.BytesIO:
|
||||
"""產生 PDF 報表"""
|
||||
output = io.BytesIO()
|
||||
doc = SimpleDocTemplate(output, pagesize=landscape(A4))
|
||||
elements = []
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# 標題
|
||||
title = Paragraph("DIT Attribution Report", styles['Title'])
|
||||
elements.append(title)
|
||||
elements.append(Spacer(1, 20))
|
||||
|
||||
# 日期
|
||||
date_text = Paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['Normal'])
|
||||
elements.append(date_text)
|
||||
elements.append(Spacer(1, 20))
|
||||
|
||||
# 表格資料
|
||||
data = self.get_attribution_data()
|
||||
|
||||
table_data = [['OP No.', 'Customer', 'P/N', 'EAU', 'Stage', 'Sample', 'Order', 'Status', 'Amount']]
|
||||
|
||||
for row in data:
|
||||
table_data.append([
|
||||
row['op_id'],
|
||||
row['customer'][:20] + '...' if len(row['customer']) > 20 else row['customer'],
|
||||
row['pn'],
|
||||
str(row['eau']),
|
||||
row['stage'] or '-',
|
||||
row['sample_order'] or '-',
|
||||
row['order_no'] or '-',
|
||||
row['order_status'] or '-',
|
||||
f"${row['order_amount']:,.0f}" if row['order_amount'] else '-'
|
||||
])
|
||||
|
||||
# 建立表格
|
||||
table = Table(table_data)
|
||||
table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4F46E5')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||
('FONTSIZE', (0, 1), (-1, -1), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#F8FAFC')]),
|
||||
]))
|
||||
|
||||
elements.append(table)
|
||||
|
||||
doc.build(elements)
|
||||
output.seek(0)
|
||||
return output
|
||||
1
backend/app/utils/__init__.py
Normal file
1
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Utils package
|
||||
64
backend/app/utils/security.py
Normal file
64
backend/app/utils/security.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
from app.models import get_db
|
||||
from app.models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
user_id_raw = payload.get("sub")
|
||||
if user_id_raw is None:
|
||||
raise credentials_exception
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
34
backend/create_admin.py
Normal file
34
backend/create_admin.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from app.models import init_db, SessionLocal
|
||||
from app.models.user import User, UserRole
|
||||
from app.utils.security import get_password_hash
|
||||
|
||||
def create_admin_user():
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
|
||||
email = "admin@example.com"
|
||||
password = "admin"
|
||||
|
||||
# Check if user exists
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user:
|
||||
print(f"User {email} already exists.")
|
||||
return
|
||||
|
||||
# Create new admin user
|
||||
new_user = User(
|
||||
email=email,
|
||||
password_hash=get_password_hash(password),
|
||||
role="admin", # String type now
|
||||
display_name="Administrator",
|
||||
ad_username="admin_local",
|
||||
department="IT"
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
print(f"Admin user created successfully.\nEmail: {email}\nPassword: {password}")
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_admin_user()
|
||||
17
backend/drop_tables.py
Normal file
17
backend/drop_tables.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
from app.config import DATABASE_URL, TABLE_PREFIX
|
||||
|
||||
def drop_user_table():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
table_name = f"{TABLE_PREFIX}Users"
|
||||
lower_table_name = f"{TABLE_PREFIX}users"
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Try dropping both case variants to be sure
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS {table_name}"))
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS {lower_table_name}"))
|
||||
conn.commit()
|
||||
print(f"Dropped table {table_name} (and lowercase variant if existed).")
|
||||
|
||||
if __name__ == "__main__":
|
||||
drop_user_table()
|
||||
25
backend/inspect_db.py
Normal file
25
backend/inspect_db.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from app.config import DATABASE_URL, TABLE_PREFIX
|
||||
|
||||
def inspect_schema():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
inspector = inspect(engine)
|
||||
tables = [
|
||||
f"{TABLE_PREFIX}DIT_Records",
|
||||
f"{TABLE_PREFIX}Sample_Records",
|
||||
f"{TABLE_PREFIX}Order_Records",
|
||||
f"{TABLE_PREFIX}Match_Results"
|
||||
]
|
||||
|
||||
print("All tables:", inspector.get_table_names())
|
||||
for table_name in tables:
|
||||
if table_name in inspector.get_table_names():
|
||||
print(f"\nTable {table_name} exists. Columns:")
|
||||
columns = inspector.get_columns(table_name)
|
||||
for column in columns:
|
||||
print(f"- {column['name']} ({column['type']})")
|
||||
else:
|
||||
print(f"\nTable {table_name} does not exist.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
inspect_schema()
|
||||
27
backend/read_spec.py
Normal file
27
backend/read_spec.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import docx
|
||||
import sys
|
||||
|
||||
def read_docx(file_path):
|
||||
doc = docx.Document(file_path)
|
||||
content = []
|
||||
|
||||
# Iterate through all elements in the document in order
|
||||
for element in doc.element.body:
|
||||
if element.tag.endswith('p'): # Paragraph
|
||||
para = docx.text.paragraph.Paragraph(element, doc)
|
||||
if para.text.strip():
|
||||
content.append(para.text)
|
||||
elif element.tag.endswith('tbl'): # Table
|
||||
table = docx.table.Table(element, doc)
|
||||
for row in table.rows:
|
||||
row_text = [cell.text.strip() for cell in row.cells]
|
||||
content.append(" | ".join(row_text))
|
||||
|
||||
return '\n'.join(content)
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = r"c:\Users\USER\Desktop\SampleOrderAssistant\data\業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0).docx"
|
||||
content = read_docx(path)
|
||||
with open("spec_content.txt", "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print("Content written to spec_content.txt")
|
||||
16
backend/requirements.txt
Normal file
16
backend/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
openpyxl==3.1.2
|
||||
pandas==2.1.3
|
||||
rapidfuzz==3.5.2
|
||||
reportlab==4.0.7
|
||||
chardet==5.2.0
|
||||
opencc-python-reimplemented==0.1.7
|
||||
pymysql==1.1.2
|
||||
cryptography==41.0.7
|
||||
python-dotenv==1.0.0
|
||||
email-validator==2.3.0
|
||||
30
backend/run.py
Normal file
30
backend/run.py
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SalesPipeline 應用程式啟動腳本
|
||||
用於開發與生產環境
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 確保可以匯入 app 模組
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import uvicorn
|
||||
from app.config import APP_HOST, APP_PORT, WORKERS, DEBUG
|
||||
|
||||
def main():
|
||||
"""啟動應用程式"""
|
||||
print(f"Starting SalesPipeline on {APP_HOST}:{APP_PORT}")
|
||||
print(f"Workers: {WORKERS}, Debug: {DEBUG}")
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=APP_HOST,
|
||||
port=APP_PORT,
|
||||
workers=WORKERS if not DEBUG else 1,
|
||||
reload=DEBUG,
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
55
backend/schema_check.txt
Normal file
55
backend/schema_check.txt
Normal file
@@ -0,0 +1,55 @@
|
||||
All tables: ['dit_records', 'match_results', 'order_records', 'pj_abc', 'PJ_SOA_DIT_Records', 'PJ_SOA_Match_Results', 'PJ_SOA_Order_Records', 'PJ_SOA_orders', 'PJ_SOA_Review_Logs', 'PJ_SOA_Sample_Records', 'PJ_SOA_samples', 'PJ_SOA_users', 'review_logs', 'sample_records', 'users']
|
||||
|
||||
Table PJ_SOA_DIT_Records exists. Columns:
|
||||
- id (INTEGER)
|
||||
- op_id (VARCHAR(255))
|
||||
- customer (VARCHAR(255))
|
||||
- customer_normalized (VARCHAR(255))
|
||||
- pn (VARCHAR(100))
|
||||
- eau (INTEGER)
|
||||
- stage (VARCHAR(50))
|
||||
- date (VARCHAR(20))
|
||||
- created_at (DATETIME)
|
||||
- updated_at (DATETIME)
|
||||
- erp_account (VARCHAR(100))
|
||||
|
||||
Table PJ_SOA_Sample_Records exists. Columns:
|
||||
- id (INTEGER)
|
||||
- sample_id (VARCHAR(50))
|
||||
- order_no (VARCHAR(50))
|
||||
- customer (VARCHAR(255))
|
||||
- customer_normalized (VARCHAR(255))
|
||||
- pn (VARCHAR(100))
|
||||
- qty (INTEGER)
|
||||
- date (VARCHAR(20))
|
||||
- created_at (DATETIME)
|
||||
- updated_at (DATETIME)
|
||||
- oppy_no (VARCHAR(100))
|
||||
- cust_id (VARCHAR(100))
|
||||
|
||||
Table PJ_SOA_Order_Records exists. Columns:
|
||||
- id (INTEGER)
|
||||
- order_id (VARCHAR(50))
|
||||
- order_no (VARCHAR(50))
|
||||
- customer (VARCHAR(255))
|
||||
- customer_normalized (VARCHAR(255))
|
||||
- pn (VARCHAR(100))
|
||||
- qty (INTEGER)
|
||||
- status (VARCHAR(50))
|
||||
- amount (FLOAT)
|
||||
- created_at (DATETIME)
|
||||
- updated_at (DATETIME)
|
||||
- cust_id (VARCHAR(100))
|
||||
|
||||
Table PJ_SOA_Match_Results exists. Columns:
|
||||
- id (INTEGER)
|
||||
- dit_id (INTEGER)
|
||||
- target_type (ENUM)
|
||||
- target_id (INTEGER)
|
||||
- score (FLOAT)
|
||||
- reason (VARCHAR(255))
|
||||
- status (ENUM)
|
||||
- created_at (DATETIME)
|
||||
- updated_at (DATETIME)
|
||||
- match_priority (INTEGER)
|
||||
- match_source (VARCHAR(255))
|
||||
63
backend/spec_content.txt
Normal file
63
backend/spec_content.txt
Normal file
@@ -0,0 +1,63 @@
|
||||
業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0)
|
||||
文件日期:2026-01-09
|
||||
適用範圍:半導體製造業銷售漏斗分析 (Sales Pipeline Analysis)
|
||||
部署環境:On-Premise (地端)
|
||||
1. 資料源與 ETL 前處理策略 (Data Ingestion)
|
||||
系統針對三份異質資料來源進行標準化清洗,具備「動態表頭偵測」能力以適應 ERP 匯出的非結構化報表。
|
||||
1.1 資料來源定義
|
||||
資料類型 | 檔案特徵 | 關鍵欄位識別 (Key Columns) | 處理邏輯
|
||||
DIT Report | 含 Metadata (前 ~15 行) | R欄 (Opportunity ID), AQ欄 (ERP Account), Customer, Part No, EAU, Stage | 自動跳過 Metadata,定位至 "Stage/Part" 所在行作為表頭。
|
||||
樣品紀錄 | 含 Metadata (前 ~8 行) | AU欄 (Oppy No), G欄 (Cust ID), Customer, Part No, Qty | 自動跳過 Metadata,定位至 "索樣數量" 所在行。
|
||||
訂單明細 | 標準格式 (第 1 行) | Order No, Customer, Part No, Qty, Status (Backlog/Shipped) | 識別 Big5/CP950 編碼,標準化讀取。
|
||||
1.2 資料清洗規則 (Sanitization)
|
||||
Part Number (PN): 去除所有分隔符 (-, _, ),統一轉大寫。例:PMSM-808-LL ➔ PMSM808LL。
|
||||
Customer Name: 移除法律實體後綴 (如 "Inc.", "Co., Ltd"),全形轉半形,統一轉大寫。
|
||||
日期格式: 統一轉換為 YYYY-MM-DD,無效日期視為 Null。
|
||||
2. 核心比對引擎 (Matching Engine) - 瀑布式邏輯
|
||||
為解決客戶名稱不一致(如別名、子公司)問題,系統採用 三層級瀑布式比對 (Waterfall Matching)。優先級由高至低,一旦上層匹配成功,即鎖定關聯,不再向下尋找。
|
||||
優先級 1:案號精準比對 (Golden Key) 🥇
|
||||
邏輯:直接透過 CRM/ERP 系統生成的唯一案號進行勾稽。
|
||||
對應欄位:
|
||||
DIT Report: R 欄 (Opportunity ID / 案號)
|
||||
Sample Log: AU 欄 (Oppy No)
|
||||
信心水準:100% (絕對準確)
|
||||
適用情境:業務在申請樣品時已正確填寫案號。
|
||||
優先級 2:客戶代碼比對 (Silver Key) 🥈
|
||||
邏輯:若無案號,則比對 ERP 客戶代碼 (Account Number)。
|
||||
對應欄位:
|
||||
DIT Report: AQ 欄 (ERP Account No)
|
||||
Sample Log: G 欄 (客戶編號)
|
||||
信心水準:99% (解決同名異字問題,如 "Liteon" vs "光寶")。
|
||||
限制:需同時滿足 Account Match AND Normalized Part Number Match。
|
||||
優先級 3:名稱模糊比對 (Fallback Mechanism) 🥉
|
||||
邏輯:前兩者皆空值時,使用 Levenshtein Distance 演算法計算名稱相似度。
|
||||
對應欄位:Customer Name vs 客戶名稱
|
||||
信心水準:80% ~ 90% (不確定性高)
|
||||
處理機制:系統標記為 Pending Review,強制進入 Human-in-the-Loop (人工審核) 流程,需人工確認後才計入績效。
|
||||
註:訂單 (Order) 資料通常無 Oppy ID,故訂單比對主要依賴 Priority 2 (Account + PN) 與 Priority 3 (Name + PN)。
|
||||
3. 歸因與時間窗邏輯 (Attribution & Time Window)
|
||||
定義「何時」發生的送樣與訂單可以算在該 DIT 的績效上。
|
||||
3.1 時間窗 (Time Window)
|
||||
DIT → Sample:
|
||||
Sample Date 必須在 DIT Date 的 前 30 天 (容許先跑流程後補單) 至 今日 之間。
|
||||
DIT → Order:
|
||||
Order Date 必須在 DIT Date (或 First Sample Date) 的 前 30 天 之後。
|
||||
目的:排除 DIT 建立很久之前的舊訂單(那些屬於舊案子維護,非新開發案)。
|
||||
3.2 多對多歸因法則 (LIFO Logic)
|
||||
針對「同一客戶、同一料號」有多筆 DIT 的情況,採用 LIFO (Last-In-First-Out) 庫存扣抵法 進行業績分配:
|
||||
將同料號的 DIT 按建立日期 由新到舊 排序。
|
||||
將該料號的總訂單量 (Backlog + Shipped) 放入「業績池 (Revenue Pool)」。
|
||||
優先滿足 最新 的 DIT EAU 額度。
|
||||
若有剩餘業績,再分配給次新的 DIT,依此類推。
|
||||
目的:確保業績優先反映在最新的開發專案上,避免舊案子無限期佔用新訂單的功勞。
|
||||
4. 關鍵績效指標定義 (KPI Definitions)
|
||||
系統最終產出的量化指標,用於衡量業務轉換效率。
|
||||
指標名稱 | 計算公式 | 業務意涵
|
||||
送樣轉換率 (Sample Rate) | (有匹配到樣品的 DIT 數) / (總 DIT 數) | 衡量前端 Design-In 開案後,成功推進到送樣階段的能力。
|
||||
訂單命中率 (Hit Rate) | (有匹配到訂單的 DIT 數) / (總 DIT 數) | 衡量開發案最終轉化為實際營收的成功率 (Binary)。
|
||||
EAU 達成率 (Fulfillment Rate) | (歸因之訂單總量) / (DIT 預估 EAU) | 衡量客戶預估量 (Forecast) 的準確度與實際拉貨力道。
|
||||
無效送樣率 (Orphan Sample) | (未匹配到 DIT 的送樣數) / (總送樣數) | 監控是否有「偷跑」或「未立案」即送樣的資源浪費行為。
|
||||
5. 系統輸出與審核
|
||||
DIT 歸因明細表:每一列 DIT 清楚標示匹配到的 Sample No 與 Order No。
|
||||
可追溯性 (Traceability):滑鼠懸停 (Hover) 可顯示匹配邏輯來源 (如 "Matched via Opportunity ID: OP12345")。
|
||||
人工介入:對於模糊比對的案件,提供 UI 介面供使用者點選 Accept / Reject,並將結果回寫至資料庫,作為日後自動比對的訓練樣本。
|
||||
68
backend/test_etl.py
Normal file
68
backend/test_etl.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.models import SessionLocal, init_db
|
||||
from app.routers.etl import import_data, ImportRequest
|
||||
from app.services.excel_parser import excel_parser
|
||||
|
||||
def test_import():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 1. Upload/Parse DIT
|
||||
base_dir = Path(__file__).resolve().parent.parent
|
||||
dit_path = base_dir / "data" / "uploads" / "DIT report (by Focus Item)_app_樣本 2.xlsx"
|
||||
print(f"Parsing {dit_path}...")
|
||||
file_id, info = excel_parser.parse_file(dit_path, "dit")
|
||||
print(f"File ID: {file_id}, Rows: {info['row_count']}")
|
||||
|
||||
# 2. Import DIT
|
||||
print("Importing DIT...")
|
||||
req = ImportRequest(file_id=file_id)
|
||||
res = import_data(req, db)
|
||||
print(f"Imported {res.imported_count} DIT records.")
|
||||
|
||||
# 3. Upload/Parse Sample
|
||||
sample_path = base_dir / "data" / "uploads" / "樣品申請紀錄_樣本_9月.xlsx"
|
||||
print(f"Parsing {sample_path}...")
|
||||
file_id_s, info_s = excel_parser.parse_file(sample_path, "sample")
|
||||
print(f"File ID: {file_id_s}, Rows: {info_s['row_count']}")
|
||||
|
||||
# 4. Import Sample
|
||||
print("Importing Sample...")
|
||||
req_s = ImportRequest(file_id=file_id_s)
|
||||
res_s = import_data(req_s, db)
|
||||
print(f"Imported {res_s.imported_count} Sample records.")
|
||||
|
||||
# 5. Upload/Parse Order
|
||||
order_path = base_dir / "data" / "uploads" / "訂單樣本_20251217_調整.xlsx"
|
||||
print(f"Parsing {order_path}...")
|
||||
file_id_o, info_o = excel_parser.parse_file(order_path, "order")
|
||||
print(f"File ID: {file_id_o}, Rows: {info_o['row_count']}")
|
||||
|
||||
# 6. Import Order
|
||||
print("Importing Order...")
|
||||
req_o = ImportRequest(file_id=file_id_o)
|
||||
res_o = import_data(req_o, db)
|
||||
print(f"Imported {res_o.imported_count} Order records.")
|
||||
|
||||
# 7. Run Matching
|
||||
from app.services.fuzzy_matcher import FuzzyMatcher
|
||||
print("Running Matching...")
|
||||
matcher = FuzzyMatcher(db)
|
||||
match_res = matcher.run_matching()
|
||||
print(f"Matching completed: {match_res}")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print("Error during test:")
|
||||
print(traceback.format_exc())
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_import()
|
||||
53
backend/update_db.py
Normal file
53
backend/update_db.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
from app.config import DATABASE_URL, TABLE_PREFIX
|
||||
|
||||
def update_schema():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as conn:
|
||||
print("Updating schema...")
|
||||
|
||||
# Add erp_account to DitRecord
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}DIT_Records ADD COLUMN erp_account VARCHAR(100)"))
|
||||
print("Added erp_account to DIT_Records")
|
||||
except Exception as e:
|
||||
print(f"erp_account might already exist: {e}")
|
||||
|
||||
# Add oppy_no and cust_id to SampleRecord
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Sample_Records ADD COLUMN oppy_no VARCHAR(100)"))
|
||||
print("Added oppy_no to Sample_Records")
|
||||
except Exception as e:
|
||||
print(f"oppy_no might already exist: {e}")
|
||||
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Sample_Records ADD COLUMN cust_id VARCHAR(100)"))
|
||||
print("Added cust_id to Sample_Records")
|
||||
except Exception as e:
|
||||
print(f"cust_id might already exist: {e}")
|
||||
|
||||
# Add cust_id to OrderRecord
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Order_Records ADD COLUMN cust_id VARCHAR(100)"))
|
||||
print("Added cust_id to Order_Records")
|
||||
except Exception as e:
|
||||
print(f"cust_id might already exist: {e}")
|
||||
|
||||
# Add match_priority and match_source to MatchResult
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Match_Results ADD COLUMN match_priority INTEGER DEFAULT 3"))
|
||||
print("Added match_priority to Match_Results")
|
||||
except Exception as e:
|
||||
print(f"match_priority might already exist: {e}")
|
||||
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {TABLE_PREFIX}Match_Results ADD COLUMN match_source VARCHAR(255)"))
|
||||
print("Added match_source to Match_Results")
|
||||
except Exception as e:
|
||||
print(f"match_source might already exist: {e}")
|
||||
|
||||
conn.commit()
|
||||
print("Schema update completed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_schema()
|
||||
0
data/uploads/.gitkeep
Normal file
0
data/uploads/.gitkeep
Normal file
BIN
data/業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0).docx
Normal file
BIN
data/業務資料比對與轉換率分析系統 - 邏輯規格書 (v1.0).docx
Normal file
Binary file not shown.
229
deploy/1panel-setup.md
Normal file
229
deploy/1panel-setup.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# 1Panel 部署指南 - SalesPipeline
|
||||
|
||||
## 系統需求
|
||||
- Python 3.10+
|
||||
- Node.js 18+ (僅用於建置前端)
|
||||
- MySQL 5.7+ / 8.0+
|
||||
|
||||
---
|
||||
|
||||
## 部署步驟
|
||||
|
||||
### 1. 上傳專案
|
||||
將專案上傳至伺服器,例如:`/opt/salespipeline`
|
||||
|
||||
```bash
|
||||
# 建立專案目錄
|
||||
mkdir -p /opt/salespipeline
|
||||
cd /opt/salespipeline
|
||||
|
||||
# 上傳或 clone 專案
|
||||
# git clone <your-repo> .
|
||||
```
|
||||
|
||||
### 2. 設定 Python 虛擬環境
|
||||
|
||||
```bash
|
||||
cd /opt/salespipeline/backend
|
||||
|
||||
# 建立虛擬環境
|
||||
python3 -m venv venv
|
||||
|
||||
# 啟動虛擬環境
|
||||
source venv/bin/activate
|
||||
|
||||
# 安裝依賴
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 設定環境變數
|
||||
|
||||
```bash
|
||||
# 複製範本
|
||||
cp .env.example .env
|
||||
|
||||
# 編輯環境變數
|
||||
nano .env
|
||||
```
|
||||
|
||||
編輯 `.env` 檔案:
|
||||
```env
|
||||
# Database Configuration
|
||||
DB_HOST=your_mysql_host
|
||||
DB_PORT=3306
|
||||
DB_USER=your_user
|
||||
DB_PASSWORD=your_password
|
||||
DB_DATABASE=your_database
|
||||
|
||||
# JWT Configuration
|
||||
SECRET_KEY=<更換為強密碼>
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Application Settings
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
WORKERS=2
|
||||
DEBUG=False
|
||||
|
||||
# CORS Settings (開發時使用)
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
### 4. 建置前端
|
||||
|
||||
```bash
|
||||
cd /opt/salespipeline/frontend
|
||||
|
||||
# 安裝依賴
|
||||
npm install
|
||||
|
||||
# 建置 (輸出至 backend/static)
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 5. 初始化資料庫
|
||||
資料庫表會在應用程式首次啟動時自動建立。
|
||||
|
||||
### 6. 設定 Systemd 服務
|
||||
|
||||
建立服務檔案 `/etc/systemd/system/salespipeline.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=SalesPipeline Application
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/salespipeline/backend
|
||||
Environment="PATH=/opt/salespipeline/backend/venv/bin"
|
||||
EnvironmentFile=/opt/salespipeline/backend/.env
|
||||
ExecStart=/opt/salespipeline/backend/venv/bin/python run.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
啟動服務:
|
||||
```bash
|
||||
# 重新載入 systemd
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# 啟動服務
|
||||
sudo systemctl start salespipeline
|
||||
|
||||
# 設定開機自啟
|
||||
sudo systemctl enable salespipeline
|
||||
|
||||
# 查看狀態
|
||||
sudo systemctl status salespipeline
|
||||
|
||||
# 查看日誌
|
||||
sudo journalctl -u salespipeline -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1Panel 設定
|
||||
|
||||
### 方案 A:直接使用 Python 網站 (推薦)
|
||||
|
||||
1. 進入 1Panel 控制台
|
||||
2. 網站 → 建立網站 → Python 專案
|
||||
3. 設定:
|
||||
- 專案路徑:`/opt/salespipeline/backend`
|
||||
- Python 版本:3.10
|
||||
- 啟動命令:`/opt/salespipeline/backend/venv/bin/python run.py`
|
||||
- Port:依 `.env` 中的 `APP_PORT` 設定
|
||||
|
||||
### 方案 B:使用反向代理
|
||||
|
||||
1. 建立 Systemd 服務 (如上述步驟 6)
|
||||
2. 1Panel → 網站 → 建立網站 → 反向代理
|
||||
3. 設定:
|
||||
- 網站域名:your-domain.com
|
||||
- 代理地址:`http://127.0.0.1:<APP_PORT>` (依 .env 設定)
|
||||
|
||||
---
|
||||
|
||||
## 目錄結構
|
||||
|
||||
```
|
||||
/opt/salespipeline/
|
||||
├── backend/
|
||||
│ ├── app/ # 應用程式碼
|
||||
│ ├── data/ # 資料目錄 (上傳檔案)
|
||||
│ ├── static/ # 前端建置檔案
|
||||
│ ├── venv/ # Python 虛擬環境
|
||||
│ ├── .env # 環境變數
|
||||
│ ├── requirements.txt
|
||||
│ └── run.py # 啟動腳本
|
||||
└── frontend/
|
||||
├── src/
|
||||
├── package.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 環境變數說明
|
||||
|
||||
| 變數名稱 | 說明 | 預設值 |
|
||||
|---------|------|--------|
|
||||
| DB_HOST | 資料庫主機 | localhost |
|
||||
| DB_PORT | 資料庫端口 | 3306 |
|
||||
| DB_USER | 資料庫使用者 | root |
|
||||
| DB_PASSWORD | 資料庫密碼 | - |
|
||||
| DB_DATABASE | 資料庫名稱 | sales_pipeline |
|
||||
| SECRET_KEY | JWT 密鑰 | - |
|
||||
| ALGORITHM | JWT 演算法 | HS256 |
|
||||
| ACCESS_TOKEN_EXPIRE_MINUTES | Token 過期時間(分鐘) | 1440 |
|
||||
| APP_HOST | 應用監聽地址 | 0.0.0.0 |
|
||||
| APP_PORT | 應用監聽端口 | 8000 |
|
||||
| WORKERS | 工作進程數 | 1 |
|
||||
| DEBUG | 除錯模式 | False |
|
||||
| TABLE_PREFIX | 資料表前綴 | PJ_SOA_ |
|
||||
| CORS_ORIGINS | 允許的跨域來源 | - |
|
||||
|
||||
---
|
||||
|
||||
## 常用指令
|
||||
|
||||
```bash
|
||||
# 啟動服務
|
||||
sudo systemctl start salespipeline
|
||||
|
||||
# 停止服務
|
||||
sudo systemctl stop salespipeline
|
||||
|
||||
# 重啟服務
|
||||
sudo systemctl restart salespipeline
|
||||
|
||||
# 查看日誌
|
||||
sudo journalctl -u salespipeline -f
|
||||
|
||||
# 更新程式碼後重建
|
||||
cd /opt/salespipeline/frontend && npm run build
|
||||
sudo systemctl restart salespipeline
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 連線資料庫失敗
|
||||
1. 確認 `.env` 中的資料庫連線資訊正確
|
||||
2. 確認防火牆允許連接資料庫 Port
|
||||
|
||||
### 無法存取網站
|
||||
1. 確認服務正在運行:`systemctl status salespipeline`
|
||||
2. 確認 APP_PORT 未被佔用
|
||||
3. 檢查防火牆設定
|
||||
|
||||
### 靜態檔案 404
|
||||
確認前端已建置:`ls /opt/salespipeline/backend/static/`
|
||||
122
deploy/deploy.sh
Normal file
122
deploy/deploy.sh
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
# SalesPipeline 部署腳本
|
||||
# 使用方式: ./deploy.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo " SalesPipeline Deployment Script"
|
||||
echo "=========================================="
|
||||
|
||||
# 設定變數
|
||||
APP_DIR="/opt/salespipeline"
|
||||
BACKEND_DIR="$APP_DIR/backend"
|
||||
FRONTEND_DIR="$APP_DIR/frontend"
|
||||
SERVICE_NAME="salespipeline"
|
||||
LOG_DIR="/var/log/salespipeline"
|
||||
|
||||
# 顏色輸出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_status() {
|
||||
echo -e "${GREEN}[✓]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[!]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[✗]${NC} $1"
|
||||
}
|
||||
|
||||
# 1. 建立目錄
|
||||
echo ""
|
||||
echo "Step 1: Creating directories..."
|
||||
sudo mkdir -p $APP_DIR
|
||||
sudo mkdir -p $LOG_DIR
|
||||
sudo chown -R $USER:$USER $APP_DIR
|
||||
print_status "Directories created"
|
||||
|
||||
# 2. 設定 Python 虛擬環境
|
||||
echo ""
|
||||
echo "Step 2: Setting up Python environment..."
|
||||
cd $BACKEND_DIR
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
print_status "Python environment ready"
|
||||
|
||||
# 3. 設定環境變數
|
||||
echo ""
|
||||
echo "Step 3: Configuring environment..."
|
||||
if [ ! -f "$BACKEND_DIR/.env" ]; then
|
||||
if [ -f "$BACKEND_DIR/.env.example" ]; then
|
||||
cp $BACKEND_DIR/.env.example $BACKEND_DIR/.env
|
||||
print_warning "Created .env from .env.example - please update with production values"
|
||||
else
|
||||
print_error ".env.example not found"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
print_status ".env file exists"
|
||||
fi
|
||||
|
||||
# 4. 建置前端
|
||||
echo ""
|
||||
echo "Step 4: Building frontend..."
|
||||
cd $FRONTEND_DIR
|
||||
if command -v npm &> /dev/null; then
|
||||
npm install
|
||||
npm run build
|
||||
print_status "Frontend built successfully"
|
||||
else
|
||||
print_warning "npm not found - skipping frontend build"
|
||||
fi
|
||||
|
||||
# 5. 設定 Systemd 服務
|
||||
echo ""
|
||||
echo "Step 5: Configuring systemd service..."
|
||||
sudo cp $APP_DIR/deploy/salespipeline.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
print_status "Systemd service configured"
|
||||
|
||||
# 6. 設定目錄權限
|
||||
echo ""
|
||||
echo "Step 6: Setting permissions..."
|
||||
sudo chown -R www-data:www-data $BACKEND_DIR/data
|
||||
sudo chown -R www-data:www-data $BACKEND_DIR/static
|
||||
sudo chown -R www-data:www-data $LOG_DIR
|
||||
print_status "Permissions set"
|
||||
|
||||
# 7. 啟動服務
|
||||
echo ""
|
||||
echo "Step 7: Starting service..."
|
||||
sudo systemctl enable $SERVICE_NAME
|
||||
sudo systemctl restart $SERVICE_NAME
|
||||
print_status "Service started"
|
||||
|
||||
# 8. 檢查狀態
|
||||
echo ""
|
||||
echo "Step 8: Checking status..."
|
||||
sleep 2
|
||||
if sudo systemctl is-active --quiet $SERVICE_NAME; then
|
||||
print_status "Service is running!"
|
||||
else
|
||||
print_error "Service failed to start"
|
||||
sudo journalctl -u $SERVICE_NAME -n 20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Deployment Complete!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Application URL: http://localhost:8000"
|
||||
echo "Logs: sudo journalctl -u $SERVICE_NAME -f"
|
||||
echo ""
|
||||
19
deploy/salespipeline.service
Normal file
19
deploy/salespipeline.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=SalesPipeline - Sales Pipeline Management System
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/salespipeline/backend
|
||||
Environment="PATH=/opt/salespipeline/backend/venv/bin"
|
||||
EnvironmentFile=/opt/salespipeline/backend/.env
|
||||
ExecStart=/opt/salespipeline/backend/venv/bin/python run.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=append:/var/log/salespipeline/app.log
|
||||
StandardError=append:/var/log/salespipeline/error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SalesPipeline - 銷售管線管理系統</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3508
frontend/package-lock.json
generated
Normal file
3508
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "sales-pipeline-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.0",
|
||||
"axios": "^1.6.2",
|
||||
"i18next": "^23.7.6",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^13.5.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
202
frontend/src/App.tsx
Normal file
202
frontend/src/App.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Database, CheckCircle, BarChart2, LogOut, User, FlaskConical } from 'lucide-react';
|
||||
import { ImportView } from './components/ImportView';
|
||||
import { ReviewView } from './components/ReviewView';
|
||||
import { DashboardView } from './components/DashboardView';
|
||||
import { LabView } from './components/LabView';
|
||||
import { LoginPage } from './components/LoginPage';
|
||||
import { LanguageSwitch } from './components/common/LanguageSwitch';
|
||||
import { authApi } from './services/api';
|
||||
|
||||
type TabId = 'import' | 'review' | 'dashboard' | 'lab';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
icon: React.ElementType;
|
||||
labelKey: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const MainLayout: React.FC<{ onLogout: () => void }> = ({ onLogout }) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('import');
|
||||
const [reviewCount, setReviewCount] = useState(0);
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
authApi.me().then(user => {
|
||||
setUserEmail(user.email);
|
||||
}).catch(() => { });
|
||||
}, []);
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'import', icon: Database, labelKey: 'nav.import' },
|
||||
{ id: 'review', icon: CheckCircle, labelKey: 'nav.review', badge: reviewCount },
|
||||
{ id: 'dashboard', icon: BarChart2, labelKey: 'nav.dashboard' },
|
||||
{ id: 'lab', icon: FlaskConical, labelKey: 'nav.lab' },
|
||||
];
|
||||
|
||||
const handleEtlComplete = () => {
|
||||
setReviewCount(2);
|
||||
setActiveTab('review');
|
||||
};
|
||||
|
||||
const handleReviewComplete = () => {
|
||||
setReviewCount(0);
|
||||
setActiveTab('dashboard');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
onLogout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-800">
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center text-white font-bold">
|
||||
S
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-lg text-slate-800 block leading-tight">
|
||||
{t('common.appName')}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium">
|
||||
{t('common.appSubtitle')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
|
||||
${activeTab === tab.id
|
||||
? 'bg-white text-indigo-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/50'}
|
||||
`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
{t(tab.labelKey)}
|
||||
{tab.badge !== undefined && tab.badge > 0 && (
|
||||
<span className="bg-rose-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
{t('common.systemRunning')}
|
||||
</div>
|
||||
|
||||
<LanguageSwitch />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<User size={16} />
|
||||
<span className="hidden sm:inline">{userEmail}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('nav.logout')}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span className="hidden sm:inline">{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{activeTab === 'import' && (
|
||||
<ImportView onEtlComplete={handleEtlComplete} />
|
||||
)}
|
||||
|
||||
{activeTab === 'review' && (
|
||||
<ReviewView onReviewComplete={handleReviewComplete} />
|
||||
)}
|
||||
|
||||
{activeTab === 'dashboard' && (
|
||||
<DashboardView />
|
||||
)}
|
||||
|
||||
{activeTab === 'lab' && (
|
||||
<LabView />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
authApi.me()
|
||||
.then(() => setIsAuthenticated(true))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
setIsAuthenticated(false);
|
||||
});
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<Navigate to="/" replace />
|
||||
) : (
|
||||
<LoginPage onLogin={handleLogin} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<MainLayout onLogout={handleLogout} />
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
279
frontend/src/components/DashboardView.tsx
Normal file
279
frontend/src/components/DashboardView.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer,
|
||||
Cell
|
||||
} from 'recharts';
|
||||
import { Filter, Activity, Download, Info, CheckCircle, HelpCircle, XCircle } from 'lucide-react';
|
||||
import { Card } from './common/Card';
|
||||
import { dashboardApi, reportApi } from '../services/api';
|
||||
import type { DashboardKPI, FunnelData, AttributionRow } from '../types';
|
||||
|
||||
export const DashboardView: React.FC = () => {
|
||||
const [kpi, setKpi] = useState<DashboardKPI>({
|
||||
total_dit: 0,
|
||||
sample_rate: 0,
|
||||
hit_rate: 0,
|
||||
fulfillment_rate: 0,
|
||||
orphan_sample_rate: 0,
|
||||
total_revenue: 0,
|
||||
});
|
||||
const [funnelData, setFunnelData] = useState<FunnelData[]>([]);
|
||||
const [attribution, setAttribution] = useState<AttributionRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterType, setFilterType] = useState<'all' | 'sample' | 'order'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [kpiData, funnelRes, attrRes] = await Promise.all([
|
||||
dashboardApi.getKPI(),
|
||||
dashboardApi.getFunnel(),
|
||||
dashboardApi.getAttribution(),
|
||||
]);
|
||||
setKpi(kpiData);
|
||||
setFunnelData(funnelRes);
|
||||
setAttribution(attrRes);
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (format: 'xlsx' | 'pdf') => {
|
||||
try {
|
||||
const blob = format === 'xlsx'
|
||||
? await reportApi.exportExcel()
|
||||
: await reportApi.exportPdf();
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `report.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
alert('匯出失敗,請稍後再試');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredAttribution = attribution.filter(row => {
|
||||
if (filterType === 'sample') return !!row.sample;
|
||||
if (filterType === 'order') return !!row.order;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">業務轉換率戰情室 (v1.0)</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
數據來源:DIT Report, 樣品紀錄, 訂單明細 | 邏輯:瀑布式比對 + LIFO 歸因
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleExport('xlsx')}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-slate-600 hover:bg-slate-50 text-sm font-medium"
|
||||
>
|
||||
<Download size={16} />
|
||||
匯出報表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card className="p-4 border-l-4 border-l-indigo-500">
|
||||
<div className="text-xs text-slate-500 mb-1">DIT 總案件數</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.total_dit}</div>
|
||||
<div className="text-[10px] text-slate-400 mt-1">Total Pipeline</div>
|
||||
</Card>
|
||||
<Card className="p-4 border-l-4 border-l-purple-500">
|
||||
<div className="text-xs text-slate-500 mb-1">送樣轉換率</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.sample_rate}%</div>
|
||||
<div className="text-[10px] text-purple-600 mt-1">Sample Rate</div>
|
||||
</Card>
|
||||
<Card className="p-4 border-l-4 border-l-emerald-500">
|
||||
<div className="text-xs text-slate-500 mb-1">訂單命中率</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.hit_rate}%</div>
|
||||
<div className="text-[10px] text-emerald-600 mt-1">Hit Rate (Binary)</div>
|
||||
</Card>
|
||||
<Card className="p-4 border-l-4 border-l-amber-500">
|
||||
<div className="text-xs text-slate-500 mb-1">EAU 達成率</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.fulfillment_rate}%</div>
|
||||
<div className="text-[10px] text-amber-600 mt-1">Fulfillment (LIFO)</div>
|
||||
</Card>
|
||||
<Card className="p-4 border-l-4 border-l-rose-500">
|
||||
<div className="text-xs text-slate-500 mb-1">無效送樣率</div>
|
||||
<div className="text-2xl font-bold text-rose-600">{kpi.orphan_sample_rate}%</div>
|
||||
<div className="text-[10px] text-rose-400 mt-1">Orphan Sample</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Funnel Chart */}
|
||||
<Card className="lg:col-span-2 p-6">
|
||||
<h3 className="font-bold text-slate-700 mb-6 flex items-center gap-2">
|
||||
<Filter size={18} />
|
||||
DIT 轉換漏斗 (Funnel Analysis)
|
||||
</h3>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
layout="vertical"
|
||||
data={funnelData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" />
|
||||
<YAxis dataKey="name" type="category" width={100} />
|
||||
<RechartsTooltip cursor={{ fill: '#f1f5f9' }} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
barSize={30}
|
||||
radius={[0, 4, 4, 0]}
|
||||
label={{ position: 'right' }}
|
||||
onClick={(data) => {
|
||||
if (data.name === '成功送樣') setFilterType('sample');
|
||||
else if (data.name === '取得訂單') setFilterType('order');
|
||||
else setFilterType('all');
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{funnelData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.fill}
|
||||
fillOpacity={filterType === 'all' || (filterType === 'sample' && entry.name === '成功送樣') || (filterType === 'order' && entry.name === '取得訂單') ? 1 : 0.3}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Total Revenue Card */}
|
||||
<Card className="p-6 flex flex-col justify-center items-center bg-gradient-to-br from-emerald-500 to-teal-600 text-white">
|
||||
<Activity size={48} className="mb-4 opacity-80" />
|
||||
<h3 className="text-lg font-medium opacity-90">歸因總營收</h3>
|
||||
<div className="text-4xl font-bold my-2">
|
||||
${kpi.total_revenue.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs opacity-70 text-center px-4">
|
||||
基於 LIFO 邏輯分配至各 DIT 案件之實際訂單金額總和
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Attribution Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Activity size={18} />
|
||||
DIT 歸因明細表 (LIFO 業績分配)
|
||||
</h3>
|
||||
{filterType !== 'all' && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs font-bold rounded-full animate-in zoom-in duration-300">
|
||||
篩選:{filterType === 'sample' ? '成功送樣' : '取得訂單'}
|
||||
<button onClick={() => setFilterType('all')} className="hover:text-indigo-900">
|
||||
<XCircle size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Info size={12} />
|
||||
Hover <HelpCircle size={10} /> to see matching logic
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-white text-slate-500 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3">OP編號 / 日期</th>
|
||||
<th className="px-6 py-3">Customer / Stage</th>
|
||||
<th className="px-6 py-3">Part No.</th>
|
||||
<th className="px-6 py-3 text-right">EAU / 歸因量</th>
|
||||
<th className="px-6 py-3 text-center">達成率</th>
|
||||
<th className="px-6 py-3 text-center">送樣 / 訂單</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredAttribution.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50 group transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<div className="font-mono text-xs text-slate-500 font-bold">{row.dit.op_id}</div>
|
||||
<div className="text-[10px] text-slate-400">{row.dit.date}</div>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<div className="font-medium text-slate-800">{row.dit.customer}</div>
|
||||
<div className="text-[10px] text-slate-400 font-light">{row.dit.stage}</div>
|
||||
</td>
|
||||
<td className="px-6 py-3 font-mono text-slate-600 text-xs">{row.dit.pn}</td>
|
||||
<td className="px-6 py-3 text-right">
|
||||
<div className="font-mono text-slate-600">{row.dit.eau.toLocaleString()}</div>
|
||||
<div className="font-mono text-emerald-600 font-bold">+{row.attributed_qty.toLocaleString()}</div>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-center">
|
||||
<div className={`text-xs font-bold ${row.fulfillment_rate >= 100 ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{row.fulfillment_rate}%
|
||||
</div>
|
||||
<div className="w-16 h-1 bg-slate-100 rounded-full mt-1 mx-auto overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${row.fulfillment_rate >= 100 ? 'bg-emerald-500' : 'bg-indigo-500'}`}
|
||||
style={{ width: `${Math.min(row.fulfillment_rate, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{row.sample ? (
|
||||
<div
|
||||
className="p-1 bg-purple-50 text-purple-700 rounded border border-purple-100 cursor-help"
|
||||
title={`送樣單號: ${row.sample.order_no} (${row.sample.date})`}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1 text-slate-200"><CheckCircle size={14} /></div>
|
||||
)}
|
||||
{row.order ? (
|
||||
<div
|
||||
className="p-1 bg-emerald-50 text-emerald-700 rounded border border-emerald-100 cursor-help flex items-center gap-1"
|
||||
title={row.match_source || `訂單單號: ${row.order.order_no}`}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
<HelpCircle size={10} className="text-emerald-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1 text-slate-200"><CheckCircle size={14} /></div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
286
frontend/src/components/ImportView.tsx
Normal file
286
frontend/src/components/ImportView.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
FileText, Database, CheckCircle, RefreshCw, Activity,
|
||||
} from 'lucide-react';
|
||||
import { Card, Badge } from './common/Card';
|
||||
import { etlApi, matchApi } from '../services/api';
|
||||
import type { ParsedFile, DitRecord, OrderRecord } from '../types';
|
||||
|
||||
interface ImportViewProps {
|
||||
onEtlComplete: () => void;
|
||||
}
|
||||
|
||||
type FileType = 'dit' | 'sample' | 'order';
|
||||
|
||||
interface FileState {
|
||||
file: File | null;
|
||||
parsed: ParsedFile | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
|
||||
const [files, setFiles] = useState<Record<FileType, FileState>>({
|
||||
dit: { file: null, parsed: null, loading: false },
|
||||
sample: { file: null, parsed: null, loading: false },
|
||||
order: { file: null, parsed: null, loading: false },
|
||||
});
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingStep, setProcessingStep] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fileInputRefs = {
|
||||
dit: useRef<HTMLInputElement>(null),
|
||||
sample: useRef<HTMLInputElement>(null),
|
||||
order: useRef<HTMLInputElement>(null),
|
||||
};
|
||||
|
||||
const handleFileSelect = async (type: FileType, file: File) => {
|
||||
setFiles(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], file, loading: true }
|
||||
}));
|
||||
|
||||
try {
|
||||
const parsed = await etlApi.upload(file, type);
|
||||
setFiles(prev => ({
|
||||
...prev,
|
||||
[type]: { file, parsed, loading: false }
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error uploading ${type} file:`, error);
|
||||
setFiles(prev => ({
|
||||
...prev,
|
||||
[type]: { file: null, parsed: null, loading: false }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const allFilesReady = files.dit.parsed && files.sample.parsed && files.order.parsed;
|
||||
|
||||
const runEtl = async () => {
|
||||
if (!allFilesReady) {
|
||||
alert("請先上傳所有檔案!");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsProcessing(true);
|
||||
const steps = [
|
||||
"正在匯入 DIT Report...",
|
||||
"正在匯入 樣品紀錄...",
|
||||
"正在匯入 訂單明細...",
|
||||
"執行資料標準化 (Normalization)...",
|
||||
"執行模糊比對 (Fuzzy Matching)...",
|
||||
"運算完成!"
|
||||
];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < steps.length - 2; i++) {
|
||||
setProcessingStep(steps[i]);
|
||||
if (i === 0 && files.dit.parsed) {
|
||||
await etlApi.import(files.dit.parsed.file_id);
|
||||
} else if (i === 1 && files.sample.parsed) {
|
||||
await etlApi.import(files.sample.parsed.file_id);
|
||||
} else if (i === 2 && files.order.parsed) {
|
||||
await etlApi.import(files.order.parsed.file_id);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
setProcessingStep(steps[4]);
|
||||
await matchApi.run();
|
||||
|
||||
setProcessingStep(steps[5]);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
onEtlComplete();
|
||||
} catch (error: any) {
|
||||
console.error('ETL error:', error);
|
||||
const msg = error.response?.data?.detail || error.message || 'ETL 處理失敗,請檢查檔案格式';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const fileConfigs = [
|
||||
{ type: 'dit' as FileType, title: '1. DIT Report', desc: '含 Metadata (前16行)' },
|
||||
{ type: 'sample' as FileType, title: '2. 樣品紀錄', desc: '含檔頭 (前9行)' },
|
||||
{ type: 'order' as FileType, title: '3. 訂單明細', desc: '標準格式' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">原始資料匯入中心</h1>
|
||||
<p className="text-slate-500 mt-1">系統將自動偵測 Excel/CSV 檔頭位置並進行智慧欄位對應。</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={runEtl}
|
||||
disabled={isProcessing || !allFilesReady}
|
||||
className={`flex items-center gap-2 px-6 py-3 rounded-lg text-white font-bold shadow-lg transition-all ${isProcessing || !allFilesReady
|
||||
? 'bg-slate-400 cursor-not-allowed shadow-none'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 hover:scale-105 shadow-indigo-200'
|
||||
}`}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RefreshCw size={18} className="animate-spin" />
|
||||
運算中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Activity size={18} />
|
||||
開始 ETL 運算
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="bg-slate-800 text-green-400 font-mono p-4 rounded-lg text-sm shadow-inner">
|
||||
<p className="flex items-center gap-2">
|
||||
<span className="animate-pulse">▶</span> {processingStep}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg text-sm shadow-sm animate-in fade-in duration-300">
|
||||
<p className="flex items-center gap-2 font-bold">
|
||||
<span className="text-red-500">⚠</span> 錯誤:{error}
|
||||
</p>
|
||||
<p className="mt-1 text-xs opacity-80">請檢查檔案格式或連線狀態,並重新嘗試。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{fileConfigs.map(({ type, title, desc }) => {
|
||||
const fileState = files[type];
|
||||
const isLoaded = !!fileState.parsed;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={type}
|
||||
className={`p-6 border-dashed border-2 transition-colors cursor-pointer hover:border-indigo-300 ${isLoaded ? 'border-emerald-300 bg-emerald-50/30' : 'border-slate-300'
|
||||
}`}
|
||||
onClick={() => fileInputRefs[type].current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRefs[type]}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(type, file);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-3 rounded-lg ${isLoaded ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{fileState.loading ? (
|
||||
<RefreshCw size={24} className="animate-spin" />
|
||||
) : isLoaded ? (
|
||||
<CheckCircle size={24} />
|
||||
) : (
|
||||
<FileText size={24} />
|
||||
)}
|
||||
</div>
|
||||
{isLoaded && <Badge type="success">Ready</Badge>}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-800">{title}</h3>
|
||||
<p className="text-xs text-slate-500 font-mono mt-1 mb-3 truncate">
|
||||
{fileState.file?.name || "點擊上傳..."}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">{desc}</p>
|
||||
|
||||
{fileState.parsed && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-200/60 flex justify-between text-sm">
|
||||
<span className="text-slate-500">預覽筆數</span>
|
||||
<span className="font-bold font-mono text-slate-700">
|
||||
{fileState.parsed.row_count} 筆
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{allFilesReady && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="bg-slate-50 px-4 py-3 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-700 text-sm flex items-center gap-2">
|
||||
<Database size={16} />
|
||||
DIT 解析結果 (預覽)
|
||||
</h3>
|
||||
<Badge type="info">Auto-Skipped Header</Badge>
|
||||
</div>
|
||||
<table className="w-full text-xs text-left">
|
||||
<thead className="bg-white text-slate-500 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2">Customer</th>
|
||||
<th className="px-4 py-2">Part No</th>
|
||||
<th className="px-4 py-2">Stage</th>
|
||||
<th className="px-4 py-2 text-right">EAU</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{(files.dit.parsed?.preview as unknown as DitRecord[] || []).slice(0, 5).map((row, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2 font-medium text-slate-700">{row.customer}</td>
|
||||
<td className="px-4 py-2 font-mono text-slate-500">{row.pn}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{row.stage}</td>
|
||||
<td className="px-4 py-2 text-right font-mono">{row.eau?.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="bg-slate-50 px-4 py-3 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-700 text-sm flex items-center gap-2">
|
||||
<Database size={16} />
|
||||
訂單解析結果 (預覽)
|
||||
</h3>
|
||||
<Badge type="info">Detected CP950</Badge>
|
||||
</div>
|
||||
<table className="w-full text-xs text-left">
|
||||
<thead className="bg-white text-slate-500 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-4 py-2">Customer</th>
|
||||
<th className="px-4 py-2">Part No</th>
|
||||
<th className="px-4 py-2">Status</th>
|
||||
<th className="px-4 py-2 text-right">Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{(files.order.parsed?.preview as unknown as OrderRecord[] || []).slice(0, 5).map((row, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2 font-medium text-slate-700">{row.customer}</td>
|
||||
<td className="px-4 py-2 font-mono text-slate-500">{row.pn}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] ${row.status === 'Shipped' ? 'bg-green-100 text-green-700' : 'bg-pink-100 text-pink-700'
|
||||
}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono">{row.qty?.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
337
frontend/src/components/LabView.tsx
Normal file
337
frontend/src/components/LabView.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer, Label
|
||||
} from 'recharts';
|
||||
import {
|
||||
FlaskConical, Calendar, Clock, Target, AlertTriangle, Copy,
|
||||
Check, TrendingUp, Info, HelpCircle
|
||||
} from 'lucide-react';
|
||||
import { Card } from './common/Card';
|
||||
import { labApi } from '../services/api';
|
||||
import type { LabKPI, ScatterPoint, OrphanSample } from '../types';
|
||||
|
||||
export const LabView: React.FC = () => {
|
||||
const [kpi, setKpi] = useState<LabKPI>({
|
||||
avg_velocity: 0,
|
||||
conversion_rate: 0,
|
||||
orphan_count: 0
|
||||
});
|
||||
const [scatterData, setScatterData] = useState<ScatterPoint[]>([]);
|
||||
const [orphans, setOrphans] = useState<OrphanSample[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dateRange, setDateRange] = useState<'all' | '12m' | '6m' | '3m'>('all');
|
||||
const [useLogScale, setUseLogScale] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadLabData();
|
||||
}, [dateRange]);
|
||||
|
||||
const loadLabData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let start_date = '';
|
||||
const now = new Date();
|
||||
if (dateRange === '12m') {
|
||||
start_date = new Date(now.setFullYear(now.getFullYear() - 1)).toISOString().split('T')[0];
|
||||
} else if (dateRange === '6m') {
|
||||
start_date = new Date(now.setMonth(now.getMonth() - 6)).toISOString().split('T')[0];
|
||||
} else if (dateRange === '3m') {
|
||||
start_date = new Date(now.setMonth(now.getMonth() - 3)).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const params = start_date ? { start_date } : {};
|
||||
|
||||
const [kpiData, scatterRes, orphanRes] = await Promise.all([
|
||||
labApi.getKPI(params),
|
||||
labApi.getScatter(params),
|
||||
labApi.getOrphans()
|
||||
]);
|
||||
|
||||
setKpi(kpiData);
|
||||
setScatterData(scatterRes);
|
||||
setOrphans(orphanRes);
|
||||
} catch (error) {
|
||||
console.error('Error loading lab data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (orphan: OrphanSample, index: number) => {
|
||||
const text = `Customer: ${orphan.customer}\nPart No: ${orphan.pn}\nSent Date: ${orphan.date}\nDays Ago: ${orphan.days_since_sent}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(index);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
|
||||
<FlaskConical className="text-indigo-600" />
|
||||
送樣成效分析戰情室 (Sample Conversion Lab)
|
||||
</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
焦點分析:樣品投資報酬率 (ROI) 與 轉換速度 | 邏輯:ERP Code + PN 直接比對
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-white p-1 rounded-lg border border-slate-200 shadow-sm">
|
||||
{(['all', '12m', '6m', '3m'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setDateRange(r)}
|
||||
className={`px-3 py-1.5 text-xs font-bold rounded-md transition-all ${dateRange === r
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{r === 'all' ? '全部時間' : r === '12m' ? '最近 12 個月' : r === '6m' ? '最近 6 個月' : '最近 3 個月'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="p-6 border-b-4 border-b-indigo-500 bg-gradient-to-br from-white to-indigo-50/30">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 font-medium mb-1">平均轉換速度</div>
|
||||
<div className="text-3xl font-bold text-slate-800">{kpi.avg_velocity} 天</div>
|
||||
<div className="text-xs text-indigo-600 mt-2 flex items-center gap-1 font-bold">
|
||||
<Clock size={12} />
|
||||
Conversion Velocity
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl">
|
||||
<TrendingUp size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 border-b-4 border-b-emerald-500 bg-gradient-to-br from-white to-emerald-50/30">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 font-medium mb-1">整體轉換倍率 (ROI)</div>
|
||||
<div className="text-3xl font-bold text-slate-800">{kpi.conversion_rate}%</div>
|
||||
<div className="text-xs text-emerald-600 mt-2 flex items-center gap-1 font-bold">
|
||||
<Target size={12} />
|
||||
Sample to Order Ratio
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-xl">
|
||||
<FlaskConical size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 border-b-4 border-b-rose-500 bg-gradient-to-br from-white to-rose-50/30">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 font-medium mb-1">孤兒樣品總數</div>
|
||||
<div className="text-3xl font-bold text-rose-600">{kpi.orphan_count} 筆</div>
|
||||
<div className="text-xs text-rose-400 mt-2 flex items-center gap-1 font-bold">
|
||||
<AlertTriangle size={12} />
|
||||
Wait-time > 90 Days
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-rose-100 text-rose-600 rounded-xl">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Scatter Matrix */}
|
||||
<Card className="lg:col-span-2 p-6 overflow-hidden relative">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<FlaskConical size={18} className="text-indigo-500" />
|
||||
送樣量與訂單量分佈矩陣 (Sample ROI Matrix)
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-500 font-medium">Log Scale</label>
|
||||
<button
|
||||
onClick={() => setUseLogScale(!useLogScale)}
|
||||
className={`w-10 h-5 rounded-full transition-colors relative ${useLogScale ? 'bg-indigo-600' : 'bg-slate-200'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${useLogScale ? 'left-6' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="sample_qty"
|
||||
name="Sample Qty"
|
||||
scale={useLogScale ? "log" : "linear"}
|
||||
domain={useLogScale ? ['auto', 'auto'] : [0, 'auto']}
|
||||
>
|
||||
<Label value="樣品端 (Samples Sent)" offset={-10} position="insideBottom" style={{ fontSize: '12px', fill: '#64748b', fontWeight: 600 }} />
|
||||
</XAxis>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="order_qty"
|
||||
name="Order Qty"
|
||||
scale={useLogScale ? "log" : "linear"}
|
||||
domain={useLogScale ? ['auto', 'auto'] : [0, 'auto']}
|
||||
>
|
||||
<Label value="訂單端 (Orders Received)" angle={-90} position="insideLeft" style={{ fontSize: '12px', fill: '#64748b', fontWeight: 600 }} />
|
||||
</YAxis>
|
||||
<ZAxis type="number" range={[60, 400]} />
|
||||
<RechartsTooltip
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload as ScatterPoint;
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 p-3 rounded-lg shadow-xl outline-none ring-0">
|
||||
<p className="font-bold text-slate-800">{data.customer}</p>
|
||||
<p className="text-xs text-indigo-600 font-mono mb-2">{data.pn}</p>
|
||||
<div className="space-y-1 border-t border-slate-100 pt-2">
|
||||
<p className="text-xs flex justify-between gap-4">
|
||||
<span className="text-slate-500">送樣量:</span>
|
||||
<span className="font-bold">{data.sample_qty.toLocaleString()}</span>
|
||||
</p>
|
||||
<p className="text-xs flex justify-between gap-4">
|
||||
<span className="text-slate-500">訂單量:</span>
|
||||
<span className="font-bold text-emerald-600">{data.order_qty.toLocaleString()}</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 mt-2 italic">
|
||||
{data.order_qty > data.sample_qty ? '✨ 高效轉換 (High ROI)' : data.order_qty > 0 ? '穩定轉換' : '尚無訂單 (Orphan?)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Scatter
|
||||
name="Projects"
|
||||
data={scatterData}
|
||||
fill="#6366f1"
|
||||
fillOpacity={0.6}
|
||||
stroke="#4338ca"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-20 right-10 flex flex-col gap-2 text-[10px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-indigo-500 opacity-60"></div>
|
||||
<span className="text-slate-500">案件資料 (Customer/PN Group)</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insight Card */}
|
||||
<Card className="p-6 bg-slate-900 text-white flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-100 mb-4 flex items-center gap-2">
|
||||
<Info size={18} className="text-indigo-400" />
|
||||
戰情室洞察 (Lab Insights)
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<p className="text-xs text-slate-400 mb-1">高效轉換客戶</p>
|
||||
<p className="text-sm font-medium">識別散佈圖中「左上角」點位,代表投入少量樣品即獲得大量訂單。</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<p className="text-xs text-slate-400 mb-1">風險警示</p>
|
||||
<p className="text-sm font-medium">右下角點位代表送樣頻繁但轉換效率低,需檢視應用場景或產品適配度。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 p-4 bg-indigo-600/20 rounded-xl border border-indigo-500/30">
|
||||
<p className="text-[11px] text-indigo-300 leading-relaxed italic">
|
||||
"本模組直接比對 ERP 編號,確保不因專案名稱模糊而漏失任何實際營收數據。"
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Orphan Samples Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-rose-500" />
|
||||
Orphan Alert Table - > 90 Days
|
||||
</h3>
|
||||
<div className="text-[10px] text-slate-400 font-medium">
|
||||
共 {orphans.length} 筆待追蹤案件
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-white text-slate-500 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3">客戶名稱</th>
|
||||
<th className="px-6 py-3">產品料號 (Part No)</th>
|
||||
<th className="px-6 py-3">送樣日期</th>
|
||||
<th className="px-6 py-3 text-center">滯留天數</th>
|
||||
<th className="px-6 py-3 text-center">狀態</th>
|
||||
<th className="px-6 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{orphans.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="px-6 py-4 font-medium text-slate-800">{row.customer}</td>
|
||||
<td className="px-6 py-4 font-mono text-xs text-slate-600">{row.pn}</td>
|
||||
<td className="px-6 py-4 text-slate-500">{row.date}</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span className={`font-bold ${row.days_since_sent > 180 ? 'text-rose-600' : 'text-amber-600'}`}>
|
||||
{row.days_since_sent} 天
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${row.days_since_sent > 180 ? 'bg-rose-100 text-rose-700' : 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
{row.days_since_sent > 180 ? '呆滯庫存 (Dead Stock)' : '需採取行動'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() => handleCopy(row, i)}
|
||||
className="inline-flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-800 font-medium bg-indigo-50 px-2 py-1 rounded"
|
||||
>
|
||||
{copiedId === i ? <Check size={12} /> : <Copy size={12} />}
|
||||
{copiedId === i ? '已複製' : '複製詳情'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{orphans.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-10 text-center text-slate-400">
|
||||
目前沒有孤兒樣品,所有送樣皆在有效時限內或已轉化。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
193
frontend/src/components/LoginPage.tsx
Normal file
193
frontend/src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LogIn, UserPlus, Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
import { authApi } from '../services/api';
|
||||
import { LanguageSwitch } from './common/LanguageSwitch';
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (token: string) => void;
|
||||
}
|
||||
|
||||
export const LoginPage: React.FC<LoginPageProps> = ({ onLogin }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email) {
|
||||
setError(t('auth.emailRequired'));
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setError(t('auth.passwordRequired'));
|
||||
return;
|
||||
}
|
||||
if (!isLogin && password !== confirmPassword) {
|
||||
setError(t('auth.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isLogin) {
|
||||
const response = await authApi.login({ email, password });
|
||||
localStorage.setItem('token', response.access_token);
|
||||
onLogin(response.access_token);
|
||||
navigate('/');
|
||||
} else {
|
||||
await authApi.register({ email, password });
|
||||
setIsLogin(true);
|
||||
setError('');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isLogin) {
|
||||
setError(t('auth.invalidCredentials'));
|
||||
} else {
|
||||
setError(t('auth.registerFailed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-indigo-50 flex flex-col">
|
||||
<header className="flex justify-end p-4">
|
||||
<LanguageSwitch />
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-indigo-600 rounded-2xl flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">
|
||||
S
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">{t('common.appName')}</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">{t('common.appSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-slate-100 rounded-lg p-1 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(true)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
isLogin ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<LogIn size={16} />
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(false)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
!isLogin ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
{t('auth.register')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{t('auth.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 text-sm px-4 py-2 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 text-white py-2.5 rounded-lg font-medium hover:bg-indigo-700 focus:ring-4 focus:ring-indigo-200 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? t('common.loading') : isLogin ? t('auth.login') : t('auth.register')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-slate-500 mt-6">
|
||||
{isLogin ? t('auth.noAccount') : t('auth.hasAccount')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
>
|
||||
{isLogin ? t('auth.register') : t('auth.login')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
157
frontend/src/components/ReviewView.tsx
Normal file
157
frontend/src/components/ReviewView.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Card, Badge } from './common/Card';
|
||||
import { matchApi } from '../services/api';
|
||||
import type { MatchResult, ReviewAction } from '../types';
|
||||
|
||||
interface ReviewViewProps {
|
||||
onReviewComplete: () => void;
|
||||
}
|
||||
|
||||
export const ReviewView: React.FC<ReviewViewProps> = ({ onReviewComplete }) => {
|
||||
const [reviews, setReviews] = useState<MatchResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadReviews();
|
||||
}, []);
|
||||
|
||||
const loadReviews = async () => {
|
||||
try {
|
||||
const results = await matchApi.getResults();
|
||||
setReviews(results.filter(r => r.status === 'pending'));
|
||||
} catch (error) {
|
||||
console.error('Error loading reviews:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewAction = async (id: number, action: ReviewAction) => {
|
||||
try {
|
||||
await matchApi.review(id, action);
|
||||
setReviews(prev => prev.filter(r => r.id !== id));
|
||||
|
||||
if (reviews.length === 1) {
|
||||
onReviewComplete();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting review:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
|
||||
模糊比對審核工作台
|
||||
<span className="text-sm bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full font-normal">
|
||||
待審核: {reviews.length}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 mt-1">系統發現以下案件名稱相似,請人工確認關聯性。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-white rounded-lg border border-dashed border-slate-300">
|
||||
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mb-4">
|
||||
<CheckCircle size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-800">所有案件已審核完畢!</h3>
|
||||
<p className="text-slate-500 mt-2">您的資料比對已完成,請查看分析儀表板。</p>
|
||||
<button
|
||||
onClick={onReviewComplete}
|
||||
className="mt-6 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
前往儀表板
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{reviews.map(item => (
|
||||
<Card key={item.id} className="p-0 overflow-hidden group hover:shadow-md transition-all border-l-4 border-l-amber-400">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Left: DIT */}
|
||||
<div className="flex-1 p-5 border-b md:border-b-0 md:border-r border-slate-100 bg-slate-50/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge type="info">DIT (設計導入)</Badge>
|
||||
<span className="text-xs text-slate-400">OP編號: {item.dit?.op_id}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-slate-400 uppercase">Customer Name</div>
|
||||
<div className="font-bold text-slate-800 text-lg">{item.dit?.customer}</div>
|
||||
<div className="text-xs text-slate-400 uppercase mt-2">Part Number</div>
|
||||
<div className="font-mono text-slate-700 bg-white border border-slate-200 px-2 py-1 rounded inline-block text-sm">
|
||||
{item.dit?.pn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: Score & Reason */}
|
||||
<div className="w-full md:w-48 p-4 flex flex-col items-center justify-center bg-white z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-amber-500 mb-1">{item.score}%</div>
|
||||
<div className="text-[10px] font-medium text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full mb-2">
|
||||
相似度
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 text-center px-2 leading-tight">
|
||||
{item.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Target (Sample/Order) */}
|
||||
<div className="flex-1 p-5 border-t md:border-t-0 md:border-l border-slate-100 bg-indigo-50/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge type={item.target_type === 'ORDER' ? 'warning' : 'success'}>
|
||||
{item.target_type === 'ORDER' ? 'Order (訂單)' : 'Sample (樣品)'}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-400">
|
||||
來源單號: {(item.target as { order_no?: string })?.order_no || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-slate-400 uppercase">Customer Name</div>
|
||||
<div className="font-bold text-slate-800 text-lg">
|
||||
{(item.target as { customer?: string })?.customer}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 uppercase mt-2">Part Number</div>
|
||||
<div className="font-mono text-slate-700 bg-white border border-slate-200 px-2 py-1 rounded inline-block text-sm">
|
||||
{(item.target as { pn?: string })?.pn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="w-full md:w-40 p-4 bg-slate-50 flex flex-row md:flex-col gap-3 justify-center items-center border-t md:border-t-0 md:border-l border-slate-200">
|
||||
<button
|
||||
onClick={() => handleReviewAction(item.id, 'accept')}
|
||||
className="flex-1 md:flex-none w-full py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors shadow-sm"
|
||||
>
|
||||
<CheckCircle size={16} /> 確認關聯
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReviewAction(item.id, 'reject')}
|
||||
className="flex-1 md:flex-none w-full py-2 bg-white border border-slate-300 text-slate-600 hover:bg-slate-50 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<XCircle size={16} /> 駁回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
frontend/src/components/common/Card.tsx
Normal file
37
frontend/src/components/common/Card.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ children, className = "", onClick }) => (
|
||||
<div
|
||||
className={`bg-white rounded-lg border border-slate-200 shadow-sm ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
type?: 'neutral' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({ children, type = "neutral" }) => {
|
||||
const styles: Record<string, string> = {
|
||||
neutral: "bg-slate-100 text-slate-600",
|
||||
success: "bg-emerald-100 text-emerald-700",
|
||||
warning: "bg-amber-100 text-amber-700",
|
||||
danger: "bg-rose-100 text-rose-700",
|
||||
info: "bg-blue-100 text-blue-700"
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[type]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
41
frontend/src/components/common/LanguageSwitch.tsx
Normal file
41
frontend/src/components/common/LanguageSwitch.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
const languages = [
|
||||
{ code: 'zh-TW', label: '繁體中文' },
|
||||
{ code: 'en', label: 'English' },
|
||||
];
|
||||
|
||||
export const LanguageSwitch: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const handleLanguageChange = (langCode: string) => {
|
||||
i18n.changeLanguage(langCode);
|
||||
localStorage.setItem('language', langCode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-2 px-3 py-2 text-sm text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded-lg transition-colors">
|
||||
<Globe size={16} />
|
||||
<span className="hidden sm:inline">
|
||||
{languages.find(l => l.code === i18n.language)?.label || '繁體中文'}
|
||||
</span>
|
||||
</button>
|
||||
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-slate-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-slate-100 first:rounded-t-lg last:rounded-b-lg transition-colors ${
|
||||
i18n.language === lang.code ? 'text-indigo-600 font-medium bg-indigo-50' : 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
69
frontend/src/hooks/useAuth.ts
Normal file
69
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { authApi } from '../services/api';
|
||||
import type { User, LoginRequest } from '../types';
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = await authApi.me();
|
||||
setUser(userData);
|
||||
} catch {
|
||||
localStorage.removeItem('token');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
const login = async (data: LoginRequest) => {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await authApi.login(data);
|
||||
localStorage.setItem('token', response.access_token);
|
||||
setUser(response.user);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError('登入失敗,請檢查帳號密碼');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (data: LoginRequest) => {
|
||||
setError(null);
|
||||
try {
|
||||
await authApi.register(data);
|
||||
return await login(data);
|
||||
} catch (err) {
|
||||
setError('註冊失敗,該 Email 可能已被使用');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
30
frontend/src/i18n/index.ts
Normal file
30
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import zhTW from './locales/zh-TW.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
const resources = {
|
||||
'zh-TW': { translation: zhTW },
|
||||
'en': { translation: en },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'zh-TW',
|
||||
defaultNS: 'translation',
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||
caches: ['localStorage'],
|
||||
lookupLocalStorage: 'language',
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
140
frontend/src/i18n/locales/en.json
Normal file
140
frontend/src/i18n/locales/en.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "SalesPipeline",
|
||||
"appSubtitle": "Sales Pipeline Management System",
|
||||
"systemRunning": "System Running",
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"refresh": "Refresh",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"submit": "Submit",
|
||||
"reset": "Reset",
|
||||
"close": "Close",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"info": "Info"
|
||||
},
|
||||
"nav": {
|
||||
"import": "Data Import",
|
||||
"review": "Match Review",
|
||||
"dashboard": "Dashboard",
|
||||
"lab": "Sample Conversion Lab",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"rememberMe": "Remember me",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"loginSuccess": "Login successful",
|
||||
"loginFailed": "Login failed",
|
||||
"registerSuccess": "Registration successful",
|
||||
"registerFailed": "Registration failed",
|
||||
"invalidCredentials": "Invalid email or password",
|
||||
"emailRequired": "Email is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
},
|
||||
"import": {
|
||||
"title": "Data Import",
|
||||
"subtitle": "Upload Excel files to import DIT, Sample, and Order data",
|
||||
"ditFile": "DIT File",
|
||||
"sampleFile": "Sample File",
|
||||
"orderFile": "Order File",
|
||||
"selectFile": "Select File",
|
||||
"dragDrop": "or drag and drop here",
|
||||
"supportedFormats": "Supported formats: xlsx, xls",
|
||||
"uploading": "Uploading...",
|
||||
"parsing": "Parsing...",
|
||||
"importing": "Importing...",
|
||||
"uploadSuccess": "Upload successful",
|
||||
"uploadFailed": "Upload failed",
|
||||
"importSuccess": "Import successful",
|
||||
"importFailed": "Import failed",
|
||||
"recordsImported": "{{count}} records imported",
|
||||
"preview": "Preview",
|
||||
"startImport": "Start Import",
|
||||
"clearAll": "Clear All"
|
||||
},
|
||||
"review": {
|
||||
"title": "Match Review",
|
||||
"subtitle": "Review automatically matched results",
|
||||
"pendingReview": "Pending Review",
|
||||
"accepted": "Accepted",
|
||||
"rejected": "Rejected",
|
||||
"autoMatched": "Auto Matched",
|
||||
"similarity": "Similarity",
|
||||
"matchReason": "Match Reason",
|
||||
"ditRecord": "DIT Record",
|
||||
"matchedRecord": "Matched Record",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"acceptAll": "Accept All",
|
||||
"rejectAll": "Reject All",
|
||||
"noRecords": "No records pending review",
|
||||
"reviewComplete": "Review Complete",
|
||||
"reviewSuccess": "Review successful",
|
||||
"reviewFailed": "Review failed"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Analytics Dashboard",
|
||||
"subtitle": "Sales Pipeline Overview & Analysis",
|
||||
"totalDit": "Total DIT",
|
||||
"matchedSamples": "Matched Samples",
|
||||
"matchedOrders": "Matched Orders",
|
||||
"conversionRate": "Conversion Rate",
|
||||
"totalRevenue": "Total Revenue",
|
||||
"conversionFunnel": "Conversion Funnel",
|
||||
"attribution": "DIT Attribution",
|
||||
"exportExcel": "Export Excel",
|
||||
"exportPdf": "Export PDF",
|
||||
"customer": "Customer",
|
||||
"partNumber": "Part Number",
|
||||
"stage": "Stage",
|
||||
"eau": "EAU",
|
||||
"sample": "Sample",
|
||||
"order": "Order",
|
||||
"amount": "Amount"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"profile": "Profile",
|
||||
"displayName": "Display Name",
|
||||
"changePassword": "Change Password",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"saveChanges": "Save Changes"
|
||||
},
|
||||
"errors": {
|
||||
"networkError": "Network connection error",
|
||||
"serverError": "Server error",
|
||||
"unauthorized": "Unauthorized, please login again",
|
||||
"forbidden": "No permission to perform this action",
|
||||
"notFound": "Resource not found",
|
||||
"validationError": "Validation error",
|
||||
"unknownError": "Unknown error occurred"
|
||||
}
|
||||
}
|
||||
140
frontend/src/i18n/locales/zh-TW.json
Normal file
140
frontend/src/i18n/locales/zh-TW.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "SalesPipeline",
|
||||
"appSubtitle": "銷售管線管理系統",
|
||||
"systemRunning": "系統運作中",
|
||||
"loading": "載入中...",
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"confirm": "確認",
|
||||
"delete": "刪除",
|
||||
"edit": "編輯",
|
||||
"search": "搜尋",
|
||||
"filter": "篩選",
|
||||
"export": "匯出",
|
||||
"import": "匯入",
|
||||
"refresh": "重新整理",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"close": "關閉",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"success": "成功",
|
||||
"error": "錯誤",
|
||||
"warning": "警告",
|
||||
"info": "資訊"
|
||||
},
|
||||
"nav": {
|
||||
"import": "資料匯入",
|
||||
"review": "比對審核",
|
||||
"dashboard": "分析儀表板",
|
||||
"lab": "送樣成效分析",
|
||||
"settings": "設定",
|
||||
"logout": "登出"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登入",
|
||||
"register": "註冊",
|
||||
"email": "電子郵件",
|
||||
"password": "密碼",
|
||||
"confirmPassword": "確認密碼",
|
||||
"rememberMe": "記住我",
|
||||
"forgotPassword": "忘記密碼?",
|
||||
"noAccount": "還沒有帳號?",
|
||||
"hasAccount": "已有帳號?",
|
||||
"loginSuccess": "登入成功",
|
||||
"loginFailed": "登入失敗",
|
||||
"registerSuccess": "註冊成功",
|
||||
"registerFailed": "註冊失敗",
|
||||
"invalidCredentials": "帳號或密碼錯誤",
|
||||
"emailRequired": "請輸入電子郵件",
|
||||
"passwordRequired": "請輸入密碼",
|
||||
"passwordMismatch": "密碼不一致"
|
||||
},
|
||||
"import": {
|
||||
"title": "資料匯入",
|
||||
"subtitle": "上傳 Excel 檔案以匯入 DIT、樣品和訂單資料",
|
||||
"ditFile": "DIT 檔案",
|
||||
"sampleFile": "樣品檔案",
|
||||
"orderFile": "訂單檔案",
|
||||
"selectFile": "選擇檔案",
|
||||
"dragDrop": "或拖放檔案至此",
|
||||
"supportedFormats": "支援格式:xlsx, xls",
|
||||
"uploading": "上傳中...",
|
||||
"parsing": "解析中...",
|
||||
"importing": "匯入中...",
|
||||
"uploadSuccess": "上傳成功",
|
||||
"uploadFailed": "上傳失敗",
|
||||
"importSuccess": "匯入成功",
|
||||
"importFailed": "匯入失敗",
|
||||
"recordsImported": "已匯入 {{count}} 筆資料",
|
||||
"preview": "預覽",
|
||||
"startImport": "開始匯入",
|
||||
"clearAll": "清除全部"
|
||||
},
|
||||
"review": {
|
||||
"title": "比對審核",
|
||||
"subtitle": "審核系統自動比對的結果",
|
||||
"pendingReview": "待審核",
|
||||
"accepted": "已接受",
|
||||
"rejected": "已拒絕",
|
||||
"autoMatched": "自動配對",
|
||||
"similarity": "相似度",
|
||||
"matchReason": "配對原因",
|
||||
"ditRecord": "DIT 記錄",
|
||||
"matchedRecord": "配對記錄",
|
||||
"accept": "接受",
|
||||
"reject": "拒絕",
|
||||
"acceptAll": "全部接受",
|
||||
"rejectAll": "全部拒絕",
|
||||
"noRecords": "沒有待審核的記錄",
|
||||
"reviewComplete": "審核完成",
|
||||
"reviewSuccess": "審核成功",
|
||||
"reviewFailed": "審核失敗"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "分析儀表板",
|
||||
"subtitle": "銷售管線總覽與分析",
|
||||
"totalDit": "DIT 總數",
|
||||
"matchedSamples": "配對樣品數",
|
||||
"matchedOrders": "配對訂單數",
|
||||
"conversionRate": "轉換率",
|
||||
"totalRevenue": "總營收",
|
||||
"conversionFunnel": "轉換漏斗",
|
||||
"attribution": "DIT 歸因表",
|
||||
"exportExcel": "匯出 Excel",
|
||||
"exportPdf": "匯出 PDF",
|
||||
"customer": "客戶",
|
||||
"partNumber": "料號",
|
||||
"stage": "階段",
|
||||
"eau": "EAU",
|
||||
"sample": "樣品",
|
||||
"order": "訂單",
|
||||
"amount": "金額"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"language": "語言",
|
||||
"theme": "主題",
|
||||
"darkMode": "深色模式",
|
||||
"lightMode": "淺色模式",
|
||||
"profile": "個人資料",
|
||||
"displayName": "顯示名稱",
|
||||
"changePassword": "變更密碼",
|
||||
"currentPassword": "目前密碼",
|
||||
"newPassword": "新密碼",
|
||||
"saveChanges": "儲存變更"
|
||||
},
|
||||
"errors": {
|
||||
"networkError": "網路連線錯誤",
|
||||
"serverError": "伺服器錯誤",
|
||||
"unauthorized": "未經授權,請重新登入",
|
||||
"forbidden": "無權限執行此操作",
|
||||
"notFound": "找不到資源",
|
||||
"validationError": "資料驗證錯誤",
|
||||
"unknownError": "發生未知錯誤"
|
||||
}
|
||||
}
|
||||
57
frontend/src/index.css
Normal file
57
frontend/src/index.css
Normal file
@@ -0,0 +1,57 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-bottom-4 {
|
||||
from { transform: translateY(1rem); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-right-4 {
|
||||
from { transform: translateX(1rem); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes zoom-in-95 {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation-duration: 0.5s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation-name: fade-in;
|
||||
}
|
||||
|
||||
.slide-in-from-bottom-4 {
|
||||
animation-name: slide-in-from-bottom-4;
|
||||
}
|
||||
|
||||
.slide-in-from-bottom-2 {
|
||||
animation-name: slide-in-from-bottom-4;
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
|
||||
.slide-in-from-right-4 {
|
||||
animation-name: slide-in-from-right-4;
|
||||
}
|
||||
|
||||
.zoom-in-95 {
|
||||
animation-name: zoom-in-95;
|
||||
}
|
||||
|
||||
.duration-500 {
|
||||
animation-duration: 0.5s;
|
||||
}
|
||||
|
||||
.duration-300 {
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
26
frontend/src/main.tsx
Normal file
26
frontend/src/main.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './i18n'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
173
frontend/src/services/api.ts
Normal file
173
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
User,
|
||||
MatchResult,
|
||||
DashboardKPI,
|
||||
ParsedFile,
|
||||
ReviewAction,
|
||||
DitRecord,
|
||||
SampleRecord,
|
||||
OrderRecord,
|
||||
LabKPI,
|
||||
ScatterPoint,
|
||||
OrphanSample
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 請求攔截器:加入 JWT Token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 響應攔截器:處理 401 錯誤
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// 只有在非登入/驗證相關的 API 返回 401 時才跳轉
|
||||
const isAuthEndpoint = error.config?.url?.includes('/auth/');
|
||||
if (error.response?.status === 401 && !isAuthEndpoint) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', data.email);
|
||||
formData.append('password', data.password);
|
||||
|
||||
const response = await api.post<LoginResponse>('/auth/login', formData, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
register: async (data: LoginRequest): Promise<User> => {
|
||||
const response = await api.post<User>('/auth/register', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
me: async (): Promise<User> => {
|
||||
const response = await api.get<User>('/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ETL API
|
||||
export const etlApi = {
|
||||
upload: async (file: File, fileType: 'dit' | 'sample' | 'order'): Promise<ParsedFile> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file_type', fileType);
|
||||
const response = await api.post<ParsedFile>('/etl/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
preview: async (fileId: string): Promise<ParsedFile> => {
|
||||
const response = await api.get<ParsedFile>(`/etl/preview/${fileId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
import: async (fileId: string): Promise<{ imported_count: number }> => {
|
||||
const response = await api.post<{ imported_count: number }>('/etl/import', { file_id: fileId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getData: async (type: 'dit' | 'sample' | 'order'): Promise<(DitRecord | SampleRecord | OrderRecord)[]> => {
|
||||
const response = await api.get(`/etl/data/${type}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Match API
|
||||
export const matchApi = {
|
||||
run: async (): Promise<{ match_count: number; auto_matched: number; pending_review: number }> => {
|
||||
const response = await api.post('/match/run');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getResults: async (): Promise<MatchResult[]> => {
|
||||
const response = await api.get<MatchResult[]>('/match/results');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
review: async (id: number, action: ReviewAction): Promise<MatchResult> => {
|
||||
const response = await api.put<MatchResult>(`/match/${id}/review`, { action });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardApi = {
|
||||
getKPI: async (): Promise<DashboardKPI> => {
|
||||
const response = await api.get<DashboardKPI>('/dashboard/kpi');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getFunnel: async () => {
|
||||
const response = await api.get('/dashboard/funnel');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAttribution: async () => {
|
||||
const response = await api.get('/dashboard/attribution');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Report API
|
||||
export const reportApi = {
|
||||
exportExcel: async (): Promise<Blob> => {
|
||||
const response = await api.get('/report/export', {
|
||||
params: { format: 'xlsx' },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportPdf: async (): Promise<Blob> => {
|
||||
const response = await api.get('/report/export', {
|
||||
params: { format: 'pdf' },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Lab API
|
||||
export const labApi = {
|
||||
getKPI: async (params?: { start_date?: string; end_date?: string }): Promise<LabKPI> => {
|
||||
const response = await api.get<LabKPI>('/lab/kpi', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getScatter: async (params?: { start_date?: string; end_date?: string }): Promise<ScatterPoint[]> => {
|
||||
const response = await api.get<ScatterPoint[]>('/lab/scatter', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getOrphans: async (): Promise<OrphanSample[]> => {
|
||||
const response = await api.get<OrphanSample[]>('/lab/orphans');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
146
frontend/src/types/index.ts
Normal file
146
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// 使用者相關類型
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
language: string;
|
||||
role: 'admin' | 'user';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// DIT 資料類型
|
||||
export interface DitRecord {
|
||||
id: number;
|
||||
op_id: string;
|
||||
erp_account?: string;
|
||||
customer: string;
|
||||
pn: string;
|
||||
eau: number;
|
||||
stage: string;
|
||||
date: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 樣品資料類型
|
||||
export interface SampleRecord {
|
||||
id: number;
|
||||
sample_id: string;
|
||||
order_no: string;
|
||||
oppy_no?: string;
|
||||
cust_id?: string;
|
||||
customer: string;
|
||||
pn: string;
|
||||
qty: number;
|
||||
date: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 訂單資料類型
|
||||
export interface OrderRecord {
|
||||
id: number;
|
||||
order_id: string;
|
||||
order_no: string;
|
||||
cust_id?: string;
|
||||
customer: string;
|
||||
pn: string;
|
||||
qty: number;
|
||||
status: string;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 比對結果類型
|
||||
export interface MatchResult {
|
||||
id: number;
|
||||
dit_id: number;
|
||||
target_type: 'SAMPLE' | 'ORDER';
|
||||
target_id: number;
|
||||
score: number;
|
||||
match_priority: number;
|
||||
match_source: string;
|
||||
reason: string;
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'auto_matched';
|
||||
dit: DitRecord;
|
||||
target: SampleRecord | OrderRecord;
|
||||
}
|
||||
|
||||
// 審核動作
|
||||
export type ReviewAction = 'accept' | 'reject';
|
||||
|
||||
// Dashboard KPI
|
||||
export interface DashboardKPI {
|
||||
total_dit: number;
|
||||
sample_rate: number;
|
||||
hit_rate: number;
|
||||
fulfillment_rate: number;
|
||||
orphan_sample_rate: number;
|
||||
total_revenue: number;
|
||||
}
|
||||
|
||||
// 漏斗數據
|
||||
export interface FunnelData {
|
||||
name: string;
|
||||
value: number;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
// 歸因明細
|
||||
export interface AttributionRow {
|
||||
dit: DitRecord;
|
||||
sample?: SampleRecord;
|
||||
order?: OrderRecord;
|
||||
match_source?: string;
|
||||
attributed_qty: number;
|
||||
fulfillment_rate: number;
|
||||
}
|
||||
|
||||
// Excel 上傳預覽
|
||||
export interface ParsedFile {
|
||||
file_id: string;
|
||||
file_type: 'dit' | 'sample' | 'order';
|
||||
filename: string;
|
||||
row_count: number;
|
||||
header_row: number;
|
||||
preview: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
// Lab 分析相關類型
|
||||
export interface LabKPI {
|
||||
avg_velocity: number;
|
||||
conversion_rate: number;
|
||||
orphan_count: number;
|
||||
}
|
||||
|
||||
export interface ScatterPoint {
|
||||
customer: string;
|
||||
pn: string;
|
||||
sample_qty: number;
|
||||
order_qty: number;
|
||||
}
|
||||
|
||||
export interface OrphanSample {
|
||||
customer: string;
|
||||
pn: string;
|
||||
days_since_sent: number;
|
||||
order_no: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
// API 響應包裝
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user