first commit

This commit is contained in:
2026-01-09 19:14:41 +08:00
commit 9f3c96ce73
67 changed files with 9636 additions and 0 deletions

41
.gitignore vendored Normal file
View 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
View 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
View 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>
);
}

0
_nul Normal file
View File

24
backend/.env.example Normal file
View 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
View File

@@ -0,0 +1 @@
# SalesPipeline Backend

58
backend/app/config.py Normal file
View 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
View 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
View 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)

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

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

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

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

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

View File

@@ -0,0 +1 @@
# Routers package

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

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

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

View 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}"
}
)

View File

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

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

View 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

View 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

View File

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

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

229
deploy/1panel-setup.md Normal file
View 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
View 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 ""

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

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

202
frontend/src/App.tsx Normal file
View 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;

View 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>
);
};

View 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>
);
};

View 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 &gt; 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 - &gt; 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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,
};
}

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

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

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

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

View 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
View 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" }]
}

View 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
View 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'),
},
},
})