- Backend (FastAPI):
- Workload heatmap API with load level calculation
- User workload detail endpoint with task breakdown
- Redis caching for workload calculations (1hr TTL)
- Department isolation and access control
- WorkloadSnapshot model for historical data
- Alembic migration for workload_snapshots table
- API Endpoints:
- GET /api/workload/heatmap - Team workload overview
- GET /api/workload/user/{id} - User workload detail
- GET /api/workload/me - Current user workload
- Load Levels:
- normal: <80%, warning: 80-99%, overloaded: >=100%
- Tests:
- 26 unit/API tests
- 15 E2E automated tests
- 77 total tests passing
- OpenSpec:
- add-resource-workload change archived
- resource-management spec updated
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
242 lines
5.8 KiB
Markdown
242 lines
5.8 KiB
Markdown
# Design: add-resource-workload
|
||
|
||
## Architecture Overview
|
||
|
||
```
|
||
┌─────────────┐ ┌─────────────────────┐ ┌───────────────┐
|
||
│ Frontend │────▶│ Workload API │────▶│ MySQL │
|
||
│ (Heatmap) │ │ /api/v1/workload │ │ (Snapshots) │
|
||
└─────────────┘ └─────────────────────┘ └───────────────┘
|
||
│
|
||
▼
|
||
┌───────────────┐
|
||
│ Redis │
|
||
│ (Cache) │
|
||
└───────────────┘
|
||
```
|
||
|
||
## Key Design Decisions
|
||
|
||
### 1. 負載計算策略:即時計算 vs 快照
|
||
|
||
**決定**:採用**混合策略**
|
||
- **即時計算**:API 請求時計算,結果快取 1 小時
|
||
- **快照儲存**:每日凌晨儲存歷史快照供趨勢分析
|
||
|
||
**理由**:
|
||
- 即時計算確保數據新鮮度
|
||
- 快照提供歷史趨勢分析能力
|
||
- Redis 快取減少計算負擔
|
||
|
||
### 2. 週邊界定義
|
||
|
||
**決定**:採用 **ISO 8601 週**(週一至週日)
|
||
|
||
**理由**:
|
||
- 國際標準,避免歧義
|
||
- Python/MySQL 原生支援
|
||
- 便於未來國際化
|
||
|
||
### 3. 負載計算公式
|
||
|
||
```
|
||
週負載 = Σ(該週到期任務的 original_estimate) / 週容量 × 100%
|
||
```
|
||
|
||
**任務計入規則**:
|
||
- `due_date` 在該週範圍內
|
||
- `assignee_id` 為目標使用者
|
||
- `status` 非已完成狀態
|
||
|
||
**邊界情況處理**:
|
||
- `original_estimate` 為空:計為 0(不計入負載)
|
||
- `capacity` 為 0:顯示為 N/A(避免除以零)
|
||
|
||
### 4. 負載等級閾值
|
||
|
||
| 等級 | 範圍 | 顏色 | 描述 |
|
||
|------|------|------|------|
|
||
| normal | 0-79% | green | 正常 |
|
||
| warning | 80-99% | yellow | 警告 |
|
||
| overloaded | ≥100% | red | 超載 |
|
||
|
||
### 5. 快取策略
|
||
|
||
```
|
||
快取鍵格式:workload:{user_id}:{week_start}
|
||
TTL:3600 秒(1 小時)
|
||
```
|
||
|
||
**失效時機**:
|
||
- 任務建立/更新/刪除
|
||
- 使用者容量變更
|
||
|
||
**實作**:暫不實作主動失效,依賴 TTL 自然過期(Phase 1 簡化)
|
||
|
||
### 6. 權限控制
|
||
|
||
| 角色 | 可查看範圍 |
|
||
|------|-----------|
|
||
| super_admin | 所有使用者 |
|
||
| manager | 同部門使用者 |
|
||
| engineer | 僅自己 |
|
||
|
||
## API Design
|
||
|
||
### GET /api/v1/workload/heatmap
|
||
|
||
查詢團隊負載熱圖
|
||
|
||
**Query Parameters**:
|
||
- `week_start`: ISO 日期(預設當週一)
|
||
- `department_id`: 部門篩選(可選)
|
||
- `user_ids`: 使用者 ID 陣列(可選)
|
||
|
||
**Response**:
|
||
```json
|
||
{
|
||
"week_start": "2024-01-01",
|
||
"week_end": "2024-01-07",
|
||
"users": [
|
||
{
|
||
"user_id": "uuid",
|
||
"user_name": "John Doe",
|
||
"department_id": "uuid",
|
||
"department_name": "R&D",
|
||
"capacity_hours": 40.0,
|
||
"allocated_hours": 32.5,
|
||
"load_percentage": 81.25,
|
||
"load_level": "warning",
|
||
"task_count": 5
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### GET /api/v1/workload/user/{user_id}
|
||
|
||
查詢特定使用者的負載詳情
|
||
|
||
**Query Parameters**:
|
||
- `week_start`: ISO 日期
|
||
|
||
**Response**:
|
||
```json
|
||
{
|
||
"user_id": "uuid",
|
||
"week_start": "2024-01-01",
|
||
"capacity_hours": 40.0,
|
||
"allocated_hours": 32.5,
|
||
"load_percentage": 81.25,
|
||
"load_level": "warning",
|
||
"tasks": [
|
||
{
|
||
"task_id": "uuid",
|
||
"title": "Task Name",
|
||
"project_name": "Project A",
|
||
"due_date": "2024-01-05",
|
||
"original_estimate": 8.0,
|
||
"status": "in_progress"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### PUT /api/v1/users/{user_id}/capacity
|
||
|
||
更新使用者容量
|
||
|
||
**Request Body**:
|
||
```json
|
||
{
|
||
"capacity": 32.0,
|
||
"effective_from": "2024-01-08",
|
||
"effective_until": "2024-01-14",
|
||
"reason": "年假"
|
||
}
|
||
```
|
||
|
||
## Data Model
|
||
|
||
### pjctrl_workload_snapshots
|
||
|
||
儲存歷史負載快照
|
||
|
||
```sql
|
||
CREATE TABLE pjctrl_workload_snapshots (
|
||
id VARCHAR(36) PRIMARY KEY,
|
||
user_id VARCHAR(36) NOT NULL,
|
||
week_start DATE NOT NULL,
|
||
allocated_hours DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||
capacity_hours DECIMAL(8,2) NOT NULL DEFAULT 40,
|
||
load_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||
task_count INT NOT NULL DEFAULT 0,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
|
||
FOREIGN KEY (user_id) REFERENCES pjctrl_users(id) ON DELETE CASCADE,
|
||
UNIQUE KEY uk_user_week (user_id, week_start),
|
||
INDEX idx_week_start (week_start)
|
||
);
|
||
```
|
||
|
||
### pjctrl_capacity_adjustments(可選,Phase 1 暫不實作)
|
||
|
||
儲存臨時容量調整(如請假)
|
||
|
||
```sql
|
||
CREATE TABLE pjctrl_capacity_adjustments (
|
||
id VARCHAR(36) PRIMARY KEY,
|
||
user_id VARCHAR(36) NOT NULL,
|
||
week_start DATE NOT NULL,
|
||
adjusted_capacity DECIMAL(5,2) NOT NULL,
|
||
reason VARCHAR(200),
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
||
FOREIGN KEY (user_id) REFERENCES pjctrl_users(id) ON DELETE CASCADE,
|
||
UNIQUE KEY uk_user_week (user_id, week_start)
|
||
);
|
||
```
|
||
|
||
## Implementation Notes
|
||
|
||
### 後端結構
|
||
|
||
```
|
||
backend/app/
|
||
├── api/
|
||
│ └── workload/
|
||
│ ├── __init__.py
|
||
│ └── router.py
|
||
├── services/
|
||
│ └── workload_service.py
|
||
├── models/
|
||
│ └── workload_snapshot.py
|
||
└── schemas/
|
||
└── workload.py
|
||
```
|
||
|
||
### 週起始日計算
|
||
|
||
```python
|
||
from datetime import date, timedelta
|
||
|
||
def get_week_start(d: date) -> date:
|
||
"""取得 ISO 週的週一"""
|
||
return d - timedelta(days=d.weekday())
|
||
```
|
||
|
||
### Redis 快取範例
|
||
|
||
```python
|
||
async def get_user_workload(user_id: str, week_start: date) -> dict:
|
||
cache_key = f"workload:{user_id}:{week_start}"
|
||
cached = redis_client.get(cache_key)
|
||
if cached:
|
||
return json.loads(cached)
|
||
|
||
result = calculate_workload(user_id, week_start)
|
||
redis_client.setex(cache_key, 3600, json.dumps(result))
|
||
return result
|
||
```
|