- 完成系统架构和数据模型设计,包括项目、工时记录、休息日和周期表模型 - 设计项目管理模块,支持传统项目与PSI项目管理及批量导入功能 - 规划工时记录模块,含日期、事件描述、项目选择及工时计算规则 - 定义休息日分类,支持周末、国定节假日、个人假期及调休工时管理 - 制定统计分析模块设计,支持按Cut-Off周期的周统计与项目工时分布 - 设计周期管理模块,提供周期设置及预设模板功能 - 制定用户界面布局及各页面表单、样式设计方案 - 规划RESTful API端
1467 lines
48 KiB
Markdown
1467 lines
48 KiB
Markdown
# 个人工时记录网站设计文档
|
||
|
||
## 概述
|
||
|
||
个人工时记录网站是一个简单的Web应用程序,用于记录和管理个人工作时间。系统支持按项目记录工时,并提供每周统计功能,支持Cut-Off Date周期管理。
|
||
|
||
### 核心功能
|
||
- 项目管理(新建、编辑、导入项目)
|
||
- 工时记录管理(增删改查)
|
||
- 按周统计工时
|
||
- Cut-Off Date周期管理
|
||
- 数据导入导出
|
||
|
||
### 技术选型
|
||
- **前端**: HTML5 + CSS3 + JavaScript (Vanilla JS)
|
||
- **后端**: Python + Flask/FastAPI
|
||
- **数据库**: SQLite (本地文件数据库)
|
||
- **ORM**: SQLAlchemy
|
||
- **存储**: localStorage + SQLite文件
|
||
|
||
## 架构设计
|
||
|
||
### 系统架构
|
||
|
||
```mermaid
|
||
graph TB
|
||
A[浏览器客户端] --> B[Flask/FastAPI Web服务器]
|
||
B --> C[SQLite数据库]
|
||
B --> D[静态文件服务]
|
||
E[本地存储] --> A
|
||
|
||
subgraph "前端层"
|
||
F[工时记录页面]
|
||
G[统计分析页面]
|
||
H[项目管理页面]
|
||
I[设置管理页面]
|
||
end
|
||
|
||
A --> F
|
||
A --> G
|
||
A --> H
|
||
A --> I
|
||
```
|
||
|
||
### 数据模型
|
||
|
||
#### SQLAlchemy模型定义
|
||
|
||
**项目表模型 (Project)**
|
||
```python
|
||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Enum
|
||
from sqlalchemy.ext.declarative import declarative_base
|
||
from datetime import datetime
|
||
import enum
|
||
|
||
Base = declarative_base()
|
||
|
||
class ProjectType(enum.Enum):
|
||
TRADITIONAL = "traditional" # 传统项目
|
||
PSI = "psi" # PSI项目
|
||
|
||
class Project(Base):
|
||
__tablename__ = 'projects'
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
project_name = Column(String(200), nullable=False)
|
||
project_type = Column(Enum(ProjectType), nullable=False) # 项目类型
|
||
|
||
# 通用字段
|
||
project_code = Column(String(50), nullable=False) # 项目代码
|
||
customer_name = Column(String(200), nullable=False) # 客户名
|
||
|
||
# PSI项目特有字段
|
||
contract_number = Column(String(100)) # 合同号,PSI项目必填
|
||
|
||
description = Column(Text)
|
||
is_active = Column(Boolean, default=True)
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||
|
||
# 添加唯一性约束
|
||
__table_args__ = (
|
||
# 传统项目:客户名+项目代码唯一
|
||
# PSI项目:客户名+合同号唯一(项目代码统一为PSI-PROJ)
|
||
)
|
||
```
|
||
|
||
**工时记录表模型 (TimeRecord)**
|
||
```python
|
||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Date, Time, ForeignKey
|
||
from sqlalchemy.orm import relationship
|
||
|
||
class TimeRecord(Base):
|
||
__tablename__ = 'time_records'
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
date = Column(Date, nullable=False)
|
||
event_description = Column(String(500)) # 事件描述
|
||
project_id = Column(Integer, ForeignKey('projects.id'))
|
||
start_time = Column(Time) # 可为空,支持"-"占位符
|
||
end_time = Column(Time) # 可为空,支持"-"占位符
|
||
activity_num = Column(String(100)) # Activity Num
|
||
hours = Column(String(20)) # 工时,支持"2:42"或"8:00:00"格式
|
||
is_holiday = Column(Boolean, default=False) # 是否为休息日
|
||
is_working_on_holiday = Column(Boolean, default=False) # 休息日是否工作
|
||
holiday_type = Column(String(50)) # 休息日类型
|
||
week_info = Column(String(50)) # 周信息
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||
|
||
# 关联关系
|
||
project = relationship("Project", back_populates="time_records")
|
||
|
||
# 在Project类中添加反向关系
|
||
Project.time_records = relationship("TimeRecord", back_populates="project")
|
||
```
|
||
|
||
**休息日配置表模型 (Holiday)**
|
||
```python
|
||
class Holiday(Base):
|
||
__tablename__ = 'holidays'
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
date = Column(Date, nullable=False, unique=True)
|
||
holiday_name = Column(String(100)) # 节假日名称
|
||
holiday_type = Column(String(50), nullable=False) # weekend/national_holiday/personal_leave/makeup_day
|
||
is_working_day = Column(Boolean, default=False) # 调休工作日标记
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
```
|
||
|
||
**Cut-Off周期表模型 (CutoffPeriod)**
|
||
```python
|
||
class CutoffPeriod(Base):
|
||
__tablename__ = 'cutoff_periods'
|
||
|
||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||
period_name = Column(String(50), nullable=False)
|
||
start_date = Column(Date, nullable=False)
|
||
end_date = Column(Date, nullable=False)
|
||
target_hours = Column(Integer, default=160)
|
||
weeks = Column(Integer, default=4)
|
||
year = Column(Integer, nullable=False)
|
||
month = Column(Integer, nullable=False)
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
```
|
||
|
||
## 功能模块设计
|
||
|
||
### 1. 项目管理模块
|
||
|
||
#### 1.1 项目管理页面
|
||
- **项目列表**: 显示所有项目的表格
|
||
- **新建项目**: 项目名称和项目代码输入表单
|
||
- **编辑项目**: 修改现有项目信息
|
||
- **导入项目**: 支持CSV/Excel文件批量导入
|
||
- **项目状态**: 启用/禁用项目
|
||
|
||
#### 1.2 项目数据结构
|
||
|
||
**传统项目示例**:
|
||
```javascript
|
||
const traditionalProject = {
|
||
id: 1,
|
||
projectName: "CXMT 2025 MA",
|
||
projectType: "traditional",
|
||
projectCode: "02C-FBV",
|
||
customerName: "长鑫存储",
|
||
contractNumber: null,
|
||
description: "长鑫2025年MA项目",
|
||
isActive: true,
|
||
createdAt: "2024-12-20T10:00:00Z",
|
||
updatedAt: "2024-12-20T10:00:00Z"
|
||
};
|
||
```
|
||
|
||
**PSI项目示例**:
|
||
```javascript
|
||
const psiProject = {
|
||
id: 2,
|
||
projectName: "NexChip PSI项目",
|
||
projectType: "psi",
|
||
projectCode: "PSI-PROJ", // 统一代码
|
||
customerName: "NexChip",
|
||
contractNumber: "ID00462761",
|
||
description: "NexChip客户PSI项目",
|
||
isActive: true,
|
||
createdAt: "2024-12-20T10:00:00Z",
|
||
updatedAt: "2024-12-20T10:00:00Z"
|
||
};
|
||
```
|
||
|
||
#### 1.3 项目导入功能
|
||
|
||
**传统项目CSV模板**:
|
||
```csv
|
||
项目名称,项目类型,客户名,项目代码,合同号,描述
|
||
CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目
|
||
Project Alpha,traditional,客户A,01A-DEV,,Alpha开发项目
|
||
```
|
||
|
||
**PSI项目CSV模板**:
|
||
```csv
|
||
项目名称,项目类型,客户名,项目代码,合同号,描述
|
||
NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目
|
||
Samsung项目,psi,Samsung,PSI-PROJ,SC20241201,Samsung客户项目
|
||
```
|
||
|
||
**混合项目CSV模板**:
|
||
```csv
|
||
项目名称,项目类型,客户名,项目代码,合同号,描述
|
||
CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目
|
||
NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目
|
||
Project Beta,traditional,客户B,01B-TEST,,Beta测试项目
|
||
```
|
||
|
||
#### 1.5 休息日管理功能
|
||
|
||
**休息日类型定义**:
|
||
- **周末**: 周六、周日
|
||
- **国定节假日**: 春节、清明节、劳动节、端午节、中秋节、国庆节等
|
||
- **个人假期**: 年假、病假、事假等
|
||
- **调休**: 因节假日调整的工作日
|
||
|
||
**休息日配置表 (holidays)**:
|
||
```sql
|
||
CREATE TABLE holidays (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
date DATE NOT NULL UNIQUE,
|
||
holiday_name VARCHAR(100), -- 节假日名称
|
||
holiday_type VARCHAR(50) NOT NULL, -- weekend/national_holiday/personal_leave/makeup_day
|
||
is_working_day BOOLEAN DEFAULT 0, -- 调休工作日标记
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
```
|
||
|
||
**休息日处理规则**:
|
||
- 系统自动识别周末
|
||
- 支持手动添加节假日
|
||
- 休息日可以填写工时(加班、值班等)
|
||
- 休息日工时单独统计
|
||
|
||
### 2. 工时记录模块
|
||
|
||
#### 2.1 工时记录表单
|
||
- **日期**: 日期选择器,默认今天,自动识别是否为休息日
|
||
- **休息日标记**: 自动显示或手动设置,支持不同颜色标识
|
||
- **事件**: 文本输入框,描述工作内容(如"浦发银行VIOS升级")
|
||
- **项目**: 下拉选择器,根据项目类型显示不同格式:
|
||
- **传统项目**: "项目名称 (客户名-项目代码)",如"CXMT 2025 MA (长鑫存储-02C-FBV)"
|
||
- **PSI项目**: "项目名称 (客户名-合同号)",如"NexChip PSI项目 (NexChip-ID00462761)"
|
||
- **开始时间**: 时间选择器,休息日可选填
|
||
- **结束时间**: 时间选择器,休息日可选填
|
||
- **Activity Num**: 可选文本输入(如"5307905")
|
||
- **工时**: 支持多种格式输入("2:42"、"8:00:00"、"2.42"),休息日工时单独统计
|
||
|
||
#### 2.2 数据验证规则
|
||
- 日期不能为空
|
||
- 事件描述和项目至少填写一个
|
||
- 当填写开始时间和结束时间时,开始时间必须小于结束时间
|
||
- 休息日记录可以不填写时间,但可以填写工时
|
||
- 工时格式支持:"HH:MM"、"HH:MM:SS"、小数格式
|
||
- 休息日类型自动识别或手动设置
|
||
|
||
#### 2.3 工时计算与格式化逻辑
|
||
```python
|
||
import datetime
|
||
from typing import Optional, Dict, Any
|
||
|
||
def is_holiday(date: datetime.date) -> Dict[str, Any]:
|
||
"""检测指定日期是否为休息日"""
|
||
day_of_week = date.weekday() # 0=周一, 6=周日
|
||
is_weekend = day_of_week >= 5 # 周六、周日
|
||
|
||
# 检查是否为配置的节假日
|
||
is_configured_holiday = check_configured_holiday(date)
|
||
|
||
return {
|
||
'is_holiday': is_weekend or is_configured_holiday,
|
||
'holiday_type': 'weekend' if is_weekend else is_configured_holiday.get('type'),
|
||
'holiday_name': is_configured_holiday.get('name') if is_configured_holiday else None
|
||
}
|
||
|
||
def calculate_hours(start_time: Optional[str], end_time: Optional[str], is_holiday_flag: bool = False) -> str:
|
||
"""工时计算函数"""
|
||
if not start_time or not end_time or start_time == '-' or end_time == '-':
|
||
return '0:00' if is_holiday_flag else '-' # 休息日默认显示0:00而不是-
|
||
|
||
try:
|
||
start = datetime.datetime.strptime(start_time, '%H:%M')
|
||
end = datetime.datetime.strptime(end_time, '%H:%M')
|
||
|
||
# 处理跨日情况
|
||
if end < start:
|
||
end += datetime.timedelta(days=1)
|
||
|
||
diff = end - start
|
||
total_minutes = int(diff.total_seconds() / 60)
|
||
|
||
hours = total_minutes // 60
|
||
minutes = total_minutes % 60
|
||
|
||
return f"{hours}:{minutes:02d}"
|
||
except ValueError:
|
||
return '0:00'
|
||
|
||
def format_hours_to_decimal(hours: str) -> float:
|
||
"""工时格式转换函数:将"2:42"格式转换为小数格式用于计算"""
|
||
if hours == '-' or not hours:
|
||
return 0.0
|
||
|
||
if ':' in hours:
|
||
try:
|
||
h, m = hours.split(':')
|
||
return int(h) + (int(m) / 60)
|
||
except ValueError:
|
||
return 0.0
|
||
|
||
try:
|
||
return float(hours)
|
||
except ValueError:
|
||
return 0.0
|
||
|
||
def calculate_weekly_hours(records: list) -> Dict[str, float]:
|
||
"""工时统计函数(区分工作日和休息日)"""
|
||
workday_hours = sum([
|
||
format_hours_to_decimal(r['hours'])
|
||
for r in records
|
||
if not r.get('is_holiday', False) and r['hours'] not in ['-', '0:00']
|
||
])
|
||
|
||
holiday_hours = sum([
|
||
format_hours_to_decimal(r['hours'])
|
||
for r in records
|
||
if r.get('is_holiday', False) and r['hours'] not in ['-', '0:00']
|
||
])
|
||
|
||
return {
|
||
'workday_hours': workday_hours,
|
||
'holiday_hours': holiday_hours,
|
||
'total_hours': workday_hours + holiday_hours
|
||
}
|
||
|
||
def format_decimal_to_hours(decimal_hours: float) -> str:
|
||
"""将小数工时转换回"HH:MM"格式"""
|
||
hours = int(decimal_hours)
|
||
minutes = int((decimal_hours - hours) * 60)
|
||
return f"{hours}:{minutes:02d}"
|
||
```
|
||
|
||
### 3. 统计分析模块
|
||
|
||
#### 3.1 周统计视图
|
||
```mermaid
|
||
graph LR
|
||
A[选择Cut-Off周期] --> B[显示周统计表]
|
||
B --> C[每日工时汇总]
|
||
B --> D[项目工时分布]
|
||
B --> E[总工时vs目标工时]
|
||
```
|
||
|
||
#### 3.2 统计数据结构
|
||
```javascript
|
||
const weeklyStats = {
|
||
period: {
|
||
name: "51周/53周",
|
||
startDate: "2024-12-11",
|
||
endDate: "2024-12-15",
|
||
targetHours: 40,
|
||
weeks: 1
|
||
},
|
||
dailyRecords: [
|
||
{
|
||
date: "2024-12-11",
|
||
dayOfWeek: "周三",
|
||
event: "-",
|
||
startTime: "-",
|
||
endTime: "-",
|
||
activityNum: "-",
|
||
hours: "-",
|
||
isHoliday: false,
|
||
holidayType: null
|
||
},
|
||
{
|
||
date: "2024-12-14",
|
||
dayOfWeek: "周六",
|
||
event: "浦发银行VIOS升级",
|
||
startTime: "18:00",
|
||
endTime: "20:42",
|
||
activityNum: "5307905",
|
||
hours: "2:42",
|
||
isHoliday: true,
|
||
holidayType: "weekend", // 周末加班
|
||
project: null
|
||
},
|
||
{
|
||
date: "2024-12-15",
|
||
dayOfWeek: "周日",
|
||
event: "-",
|
||
startTime: "-",
|
||
endTime: "-",
|
||
activityNum: "-",
|
||
hours: "0:00",
|
||
isHoliday: true,
|
||
holidayType: "weekend", // 周末休息
|
||
project: null
|
||
}
|
||
// ...
|
||
],
|
||
projectHours: [
|
||
{ project: "长鑫CODE 02C-FBV", hours: "40:00", percentage: 100 },
|
||
// ...
|
||
],
|
||
workdayTotal: "0:00", // 工作日总工时
|
||
holidayTotal: "2:42", // 休息日总工时(加班)
|
||
weeklyTotal: "2:42", // 周总工时
|
||
workingDays: 0, // 实际工作天数
|
||
holidayWorkDays: 1, // 休息日工作天数
|
||
restDays: 4 // 休息天数
|
||
};
|
||
```
|
||
|
||
### 4. Cut-Off周期管理模块
|
||
|
||
#### 4.1 周期配置表单
|
||
- **周期名称**: 例如"2024年12月-2025年1月"
|
||
- **开始日期**: 2024-12-16
|
||
- **结束日期**: 2025-01-12
|
||
- **目标工时**: 160小时
|
||
- **周数**: 4周
|
||
|
||
#### 4.2 预设周期模板
|
||
```javascript
|
||
const periodTemplates = [
|
||
{
|
||
name: "标准月度周期",
|
||
weeks: 4,
|
||
targetHours: 160,
|
||
hoursPerWeek: 40
|
||
},
|
||
{
|
||
name: "短周期",
|
||
weeks: 2,
|
||
targetHours: 80,
|
||
hoursPerWeek: 40
|
||
}
|
||
];
|
||
```
|
||
|
||
## 用户界面设计
|
||
|
||
### 页面布局结构
|
||
|
||
```mermaid
|
||
graph TB
|
||
A[主导航栏] --> B[项目管理]
|
||
A --> C[工时记录]
|
||
A --> D[统计分析]
|
||
A --> E[周期管理]
|
||
A --> F[设置]
|
||
|
||
B --> B1[项目列表]
|
||
B --> B2[新建项目]
|
||
B --> B3[导入项目]
|
||
|
||
C --> C1[记录表单]
|
||
C --> C2[记录列表]
|
||
|
||
D --> D1[周期选择]
|
||
D --> D2[统计图表]
|
||
D --> D3[数据导出]
|
||
|
||
E --> E1[周期列表]
|
||
E --> E2[新建周期]
|
||
E --> E3[编辑周期]
|
||
```
|
||
|
||
### 1. 项目管理页面
|
||
- 顶部:操作按钮区(新建项目、导入项目、导出项目、项目类型筛选)
|
||
- 中部:项目列表表格(项目名称、项目类型、客户名、标识码、状态、操作)
|
||
- **传统项目**: 显示客户名和项目代码列
|
||
- **PSI项目**: 显示客户名和合同号列,项目代码统一显示"PSI-PROJ"
|
||
- 底部:分页控件和项目类型统计
|
||
|
||
#### 1.1 项目新建表单
|
||
```html
|
||
<form id="project-form">
|
||
<div class="form-group">
|
||
<label>项目名称</label>
|
||
<input type="text" name="project_name" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>客户名</label>
|
||
<input type="text" name="customer_name" required placeholder="如:NexChip">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>项目类型</label>
|
||
<select name="project_type" onchange="toggleProjectFields()" required>
|
||
<option value="">请选择项目类型</option>
|
||
<option value="traditional">传统项目</option>
|
||
<option value="psi">PSI项目</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 传统项目字段 -->
|
||
<div id="traditional-fields" style="display:none;">
|
||
<div class="form-group">
|
||
<label>项目代码</label>
|
||
<input type="text" name="project_code" placeholder="如:02C-FBV">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PSI项目字段 -->
|
||
<div id="psi-fields" style="display:none;">
|
||
<div class="form-group">
|
||
<label>合同号</label>
|
||
<input type="text" name="contract_number" placeholder="如:ID00462761">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>项目代码</label>
|
||
<input type="text" value="PSI-PROJ" readonly disabled>
|
||
<small class="form-text text-muted">PSI项目统一使用代码:PSI-PROJ</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>描述</label>
|
||
<textarea name="description"></textarea>
|
||
</div>
|
||
|
||
<button type="submit">保存</button>
|
||
</form>
|
||
```
|
||
|
||
### 2. 工时记录页面
|
||
|
||
#### 2.1 表格式布局
|
||
```
|
||
| 日期 | 事件 | 开始时间 | 结束时间 | Activity Num | 工时 | 备注 |
|
||
|---------|-------------------|----------|----------|--------------|--------|---------|
|
||
| 51周/53周| - | - | - | - | - | |
|
||
| 12月11日 | - | - | - | - | - | 工作日 |
|
||
| 12月12日 | - | - | - | - | - | 工作日 |
|
||
| 12月13日 | - | - | - | - | - | 工作日 |
|
||
| 12月14日 | 浦发银行VIOS升级 | 18:00 | 20:42 | 5307905 | 2:42 | 周末加班 |
|
||
| 12月15日 | - | - | - | - | 0:00 | 周末休息 |
|
||
| 工时: | | | | | 2:42 | 加班工时 |
|
||
```
|
||
|
||
#### 2.2 休息日视觉标识
|
||
- **周末**: 行背景色设为浅蓝色 (#f0f8ff)
|
||
- **国定节假日**: 行背景色设为浅红色 (#fff0f0)
|
||
- **个人假期**: 行背景色设为浅黄色 (#fffacd)
|
||
- **调休工作日**: 行背景色设为浅绿色 (#f0fff0)
|
||
- **休息日加班**: 在备注列显示"加班"标识
|
||
|
||
#### 2.3 CSS样式定义
|
||
```css
|
||
/* 休息日样式 */
|
||
.holiday-weekend {
|
||
background-color: #f0f8ff; /* 浅蓝色 - 周末 */
|
||
}
|
||
|
||
.holiday-national {
|
||
background-color: #fff0f0; /* 浅红色 - 国定节假日 */
|
||
}
|
||
|
||
.holiday-personal {
|
||
background-color: #fffacd; /* 浅黄色 - 个人假期 */
|
||
}
|
||
|
||
.holiday-makeup {
|
||
background-color: #f0fff0; /* 浅绿色 - 调休工作日 */
|
||
}
|
||
|
||
.working-on-holiday {
|
||
font-weight: bold;
|
||
color: #d2691e; /* 橙色字体 - 休息日加班 */
|
||
}
|
||
|
||
.holiday-indicator {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
margin-right: 5px;
|
||
}
|
||
|
||
.weekend-indicator {
|
||
background-color: #4169e1; /* 蓝色圆点 */
|
||
}
|
||
|
||
.holiday-indicator {
|
||
background-color: #dc143c; /* 红色圆点 */
|
||
}
|
||
```
|
||
|
||
### 3. 统计分析页面
|
||
- 左侧:Cut-Off周期选择器
|
||
- 右侧:统计图表和数据表格
|
||
- 底部:导出功能按钮
|
||
|
||
### 4. 周期管理页面
|
||
- 周期列表表格
|
||
- 新建/编辑周期对话框
|
||
- 周期模板选择
|
||
|
||
## API接口设计
|
||
|
||
### RESTful API端点
|
||
|
||
#### 项目管理相关
|
||
```
|
||
GET /api/projects # 获取项目列表
|
||
POST /api/projects # 创建新项目
|
||
PUT /api/projects/:id # 更新项目
|
||
DELETE /api/projects/:id # 删除项目
|
||
POST /api/projects/import # 批量导入项目
|
||
GET /api/projects/export # 导出项目列表
|
||
```
|
||
|
||
#### 休息日相关API
|
||
```
|
||
GET /api/holidays # 获取休息日配置列表
|
||
POST /api/holidays # 添加休息日配置
|
||
PUT /api/holidays/:id # 更新休息日配置
|
||
DELETE /api/holidays/:id # 删除休息日配置
|
||
GET /api/holidays/check/:date # 检查指定日期是否为休息日
|
||
```
|
||
|
||
#### Cut-Off周期相关
|
||
```
|
||
GET /api/cutoff-periods # 获取周期列表
|
||
POST /api/cutoff-periods # 创建新周期
|
||
PUT /api/cutoff-periods/:id # 更新周期
|
||
DELETE /api/cutoff-periods/:id # 删除周期
|
||
```
|
||
|
||
#### 统计分析相关
|
||
```
|
||
GET /api/stats/weekly/:periodId # 获取指定周期的统计数据
|
||
GET /api/stats/projects # 获取项目统计
|
||
GET /api/export/csv/:periodId # 导出CSV格式数据
|
||
```
|
||
|
||
### 请求/响应示例
|
||
|
||
#### 创建传统项目
|
||
```python
|
||
# POST /api/projects
|
||
# 请求体 (JSON)
|
||
{
|
||
"project_name": "CXMT 2025 MA",
|
||
"project_type": "traditional",
|
||
"customer_name": "长鑫存储",
|
||
"project_code": "02C-FBV",
|
||
"description": "长鑫2025年MA项目"
|
||
}
|
||
|
||
# Flask处理函数示例
|
||
@app.route('/api/projects', methods=['POST'])
|
||
def create_project():
|
||
from flask import request, jsonify
|
||
|
||
data = request.get_json()
|
||
|
||
# 数据验证
|
||
if not data.get('project_name') or not data.get('project_type') or not data.get('customer_name'):
|
||
return jsonify({'success': False, 'error': '项目名称、类型和客户名不能为空'}), 400
|
||
|
||
project_type = data['project_type']
|
||
|
||
# 根据项目类型验证必要字段
|
||
if project_type == 'traditional':
|
||
if not data.get('project_code'):
|
||
return jsonify({'success': False, 'error': '传统项目必须填写项目代码'}), 400
|
||
|
||
# 检查传统项目客户名+项目代码唯一性
|
||
existing = db.session.query(Project).filter(
|
||
Project.project_type == ProjectType.TRADITIONAL,
|
||
Project.customer_name == data['customer_name'],
|
||
Project.project_code == data['project_code']
|
||
).first()
|
||
|
||
if existing:
|
||
return jsonify({'success': False, 'error': '该客户的项目代码已存在'}), 409
|
||
|
||
project = Project(
|
||
project_name=data['project_name'],
|
||
project_type=ProjectType.TRADITIONAL,
|
||
customer_name=data['customer_name'],
|
||
project_code=data['project_code'],
|
||
description=data.get('description', '')
|
||
)
|
||
|
||
elif project_type == 'psi':
|
||
if not data.get('contract_number'):
|
||
return jsonify({'success': False, 'error': 'PSI项目必须填写合同号'}), 400
|
||
|
||
# 检查PSI项目客户名+合同号唯一性
|
||
existing = db.session.query(Project).filter(
|
||
Project.project_type == ProjectType.PSI,
|
||
Project.customer_name == data['customer_name'],
|
||
Project.contract_number == data['contract_number']
|
||
).first()
|
||
|
||
if existing:
|
||
return jsonify({'success': False, 'error': '该客户的合同号已存在'}), 409
|
||
|
||
project = Project(
|
||
project_name=data['project_name'],
|
||
project_type=ProjectType.PSI,
|
||
customer_name=data['customer_name'],
|
||
project_code='PSI-PROJ', # 统一代码
|
||
contract_number=data['contract_number'],
|
||
description=data.get('description', '')
|
||
)
|
||
|
||
else:
|
||
return jsonify({'success': False, 'error': '不支持的项目类型'}), 400
|
||
|
||
db.session.add(project)
|
||
db.session.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'id': project.id,
|
||
'project_name': project.project_name,
|
||
'project_type': project.project_type.value,
|
||
'customer_name': project.customer_name,
|
||
'project_code': project.project_code,
|
||
'contract_number': project.contract_number,
|
||
'description': project.description,
|
||
'is_active': project.is_active,
|
||
'created_at': project.created_at.isoformat()
|
||
}
|
||
}), 201
|
||
```
|
||
|
||
#### 创建PSI项目
|
||
```python
|
||
# POST /api/projects
|
||
# 请求体 (JSON)
|
||
{
|
||
"project_name": "NexChip PSI项目",
|
||
"project_type": "psi",
|
||
"customer_name": "NexChip",
|
||
"contract_number": "ID00462761",
|
||
"description": "NexChip客户PSI项目"
|
||
}
|
||
|
||
# Response
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"id": 2,
|
||
"project_name": "NexChip PSI项目",
|
||
"project_type": "psi",
|
||
"customer_name": "NexChip",
|
||
"project_code": "PSI-PROJ",
|
||
"contract_number": "ID00462761",
|
||
"description": "NexChip客户PSI项目",
|
||
"is_active": true,
|
||
"created_at": "2024-12-20T10:00:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 批量导入项目
|
||
```python
|
||
# POST /api/projects/import
|
||
@app.route('/api/projects/import', methods=['POST'])
|
||
def import_projects():
|
||
from flask import request, jsonify
|
||
import pandas as pd
|
||
import io
|
||
|
||
# 处理CSV文件上传
|
||
if 'file' not in request.files:
|
||
return jsonify({'success': False, 'error': '没有上传文件'}), 400
|
||
|
||
file = request.files['file']
|
||
if file.filename == '':
|
||
return jsonify({'success': False, 'error': '文件名为空'}), 400
|
||
|
||
try:
|
||
# 读取CSV文件
|
||
csv_content = file.read().decode('utf-8')
|
||
df = pd.read_csv(io.StringIO(csv_content))
|
||
|
||
imported_count = 0
|
||
failed_count = 0
|
||
errors = []
|
||
|
||
for index, row in df.iterrows():
|
||
try:
|
||
# 验证必要字段
|
||
if pd.isna(row['项目名称']) or pd.isna(row['项目类型']):
|
||
errors.append(f'第{index+2}行:项目名称和类型不能为空')
|
||
failed_count += 1
|
||
continue
|
||
|
||
project_type = row['项目类型'].lower()
|
||
|
||
if project_type == 'traditional':
|
||
# 传统项目验证
|
||
if pd.isna(row['项目代码']):
|
||
errors.append(f'第{index+2}行:传统项目必须填写项目代码')
|
||
failed_count += 1
|
||
continue
|
||
|
||
# 检查重复
|
||
existing = db.session.query(Project).filter(
|
||
Project.project_type == ProjectType.TRADITIONAL,
|
||
Project.customer_name == row['客户名'],
|
||
Project.project_code == row['项目代码']
|
||
).first()
|
||
|
||
if existing:
|
||
errors.append(f'第{index+2}行:该客户的项目代码已存在')
|
||
failed_count += 1
|
||
continue
|
||
|
||
project = Project(
|
||
project_name=row['项目名称'],
|
||
project_type=ProjectType.TRADITIONAL,
|
||
customer_name=row['客户名'],
|
||
project_code=row['项目代码'],
|
||
description=row.get('描述', '')
|
||
)
|
||
|
||
elif project_type == 'psi':
|
||
# PSI项目验证
|
||
if pd.isna(row['合同号']):
|
||
errors.append(f'第{index+2}行:PSI项目必须填写合同号')
|
||
failed_count += 1
|
||
continue
|
||
|
||
# 检查重复
|
||
existing = db.session.query(Project).filter(
|
||
Project.project_type == ProjectType.PSI,
|
||
Project.customer_name == row['客户名'],
|
||
Project.contract_number == row['合同号']
|
||
).first()
|
||
|
||
if existing:
|
||
errors.append(f'第{index+2}行:该客户的合同号已存在')
|
||
failed_count += 1
|
||
continue
|
||
|
||
project = Project(
|
||
project_name=row['项目名称'],
|
||
project_type=ProjectType.PSI,
|
||
customer_name=row['客户名'],
|
||
project_code='PSI-PROJ', # 统一代码
|
||
contract_number=row['合同号'],
|
||
description=row.get('描述', '')
|
||
)
|
||
|
||
else:
|
||
errors.append(f'第{index+2}行:不支持的项目类型: {project_type}')
|
||
failed_count += 1
|
||
continue
|
||
|
||
db.session.add(project)
|
||
imported_count += 1
|
||
|
||
except Exception as e:
|
||
errors.append(f'第{index+2}行:{str(e)}')
|
||
failed_count += 1
|
||
|
||
db.session.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'imported': imported_count,
|
||
'failed': failed_count,
|
||
'errors': errors
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'error': f'文件处理错误:{str(e)}'}), 500
|
||
```
|
||
|
||
#### 创建休息日工时记录
|
||
```python
|
||
# POST /api/time-records
|
||
@app.route('/api/time-records', methods=['POST'])
|
||
def create_time_record():
|
||
from flask import request, jsonify
|
||
from datetime import datetime, date
|
||
|
||
data = request.get_json()
|
||
|
||
# 数据验证
|
||
record_date = datetime.strptime(data['date'], '%Y-%m-%d').date()
|
||
|
||
# 检查是否为休息日
|
||
holiday_info = is_holiday(record_date)
|
||
|
||
# 计算工时
|
||
hours = calculate_hours(
|
||
data.get('start_time'),
|
||
data.get('end_time'),
|
||
holiday_info['is_holiday']
|
||
)
|
||
|
||
# 创建记录
|
||
time_record = TimeRecord(
|
||
date=record_date,
|
||
event_description=data.get('event', ''),
|
||
project_id=data.get('project_id'),
|
||
start_time=datetime.strptime(data['start_time'], '%H:%M').time() if data.get('start_time') and data['start_time'] != '-' else None,
|
||
end_time=datetime.strptime(data['end_time'], '%H:%M').time() if data.get('end_time') and data['end_time'] != '-' else None,
|
||
activity_num=data.get('activity_num', ''),
|
||
hours=hours,
|
||
is_holiday=holiday_info['is_holiday'],
|
||
holiday_type=holiday_info['holiday_type'],
|
||
is_working_on_holiday=holiday_info['is_holiday'] and hours not in ['0:00', '-']
|
||
)
|
||
|
||
db.session.add(time_record)
|
||
db.session.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'id': time_record.id,
|
||
'date': time_record.date.isoformat(),
|
||
'event': time_record.event_description,
|
||
'project_id': time_record.project_id,
|
||
'start_time': time_record.start_time.strftime('%H:%M') if time_record.start_time else '-',
|
||
'end_time': time_record.end_time.strftime('%H:%M') if time_record.end_time else '-',
|
||
'activity_num': time_record.activity_num,
|
||
'hours': time_record.hours,
|
||
'is_holiday': time_record.is_holiday,
|
||
'holiday_type': time_record.holiday_type,
|
||
'is_working_on_holiday': time_record.is_working_on_holiday
|
||
}
|
||
}), 201
|
||
```
|
||
|
||
#### 休息日配置管理
|
||
```python
|
||
# POST /api/holidays
|
||
@app.route('/api/holidays', methods=['POST'])
|
||
def create_holiday():
|
||
data = request.get_json()
|
||
|
||
holiday_date = datetime.strptime(data['date'], '%Y-%m-%d').date()
|
||
|
||
# 检查是否已存在
|
||
existing = db.session.query(Holiday).filter(Holiday.date == holiday_date).first()
|
||
if existing:
|
||
return jsonify({'success': False, 'error': '该日期的休息日配置已存在'}), 409
|
||
|
||
holiday = Holiday(
|
||
date=holiday_date,
|
||
holiday_name=data.get('holiday_name', ''),
|
||
holiday_type=data['holiday_type'],
|
||
is_working_day=data.get('is_working_day', False)
|
||
)
|
||
|
||
db.session.add(holiday)
|
||
db.session.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'id': holiday.id,
|
||
'date': holiday.date.isoformat(),
|
||
'holiday_name': holiday.holiday_name,
|
||
'holiday_type': holiday.holiday_type,
|
||
'is_working_day': holiday.is_working_day,
|
||
'created_at': holiday.created_at.isoformat()
|
||
}
|
||
}), 201
|
||
```
|
||
|
||
## 数据流设计
|
||
|
||
### 项目管理流程
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as 用户
|
||
participant F as 前端
|
||
participant A as API服务
|
||
participant D as SQLite数据库
|
||
|
||
U->>F: 新建项目表单
|
||
F->>F: 客户端验证
|
||
F->>A: POST /api/projects
|
||
A->>A: 验证项目名称和代码唯一性
|
||
A->>D: 插入项目记录
|
||
D-->>A: 返回插入结果
|
||
A-->>F: 返回JSON响应
|
||
F-->>U: 显示成功/错误消息
|
||
```
|
||
|
||
### 项目导入流程
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as 用户
|
||
participant F as 前端
|
||
participant A as API服务
|
||
participant D as SQLite数据库
|
||
|
||
U->>F: 上传CSV文件
|
||
F->>F: 解析CSV文件
|
||
F->>A: POST /api/projects/import
|
||
A->>A: 验证每个项目数据
|
||
A->>D: 批量插入项目
|
||
D-->>A: 返回插入结果
|
||
A-->>F: 返回导入统计
|
||
F-->>U: 显示导入结果
|
||
```
|
||
|
||
### 工时记录流程(含休息日处理)
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as 用户
|
||
participant F as 前端
|
||
participant A as Flask API服务
|
||
participant D as SQLite数据库
|
||
|
||
U->>F: 选择日期,系统自动检测是否为休息日
|
||
F->>A: GET /api/holidays/check/2024-12-14
|
||
A->>D: 查询休息日配置和计算周末
|
||
A-->>F: 返回休息日信息(weekend/节假日)
|
||
F->>F: 设置表单样式(休息日背景色)
|
||
U->>F: 填写工时记录表单(事件+时间,休息日也可填工时)
|
||
F->>F: 客户端验证和工时格式化
|
||
F->>A: POST /api/time-records(包含休息日标记)
|
||
A->>A: Python数据验证和业务规则检查
|
||
A->>A: 处理工时计算(区分工作日和休息日)
|
||
A->>D: 使用SQLAlchemy ORM插入记录
|
||
D-->>A: 返回插入结果
|
||
A-->>F: 返回JSON响应
|
||
F->>F: 刷新周视图表格(应用休息日样式)
|
||
F-->>U: 显示成功/错误消息
|
||
```
|
||
|
||
### 周统计流程(含休息日统计)
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as 用户
|
||
participant F as 前端
|
||
participant A as Flask API服务
|
||
participant D as SQLite数据库
|
||
|
||
U->>F: 选择查看某周记录
|
||
F->>A: GET /api/time-records/weekly?start_date=2024-12-11
|
||
A->>D: 使用SQLAlchemy查询一周内的所有记录
|
||
A->>D: 查询休息日配置信息
|
||
A->>A: Python计算分类工时统计(工作日/休息日加班)
|
||
A->>A: 格式化周视图数据(包含休息日标记)
|
||
A-->>F: 返回周统计和明细(含休息日信息)
|
||
F->>F: 渲染表格式周视图(应用休息日样式)
|
||
F->>F: 显示分类统计(工作日工时、加班工时、总工时)
|
||
F-->>U: 显示带颜色标识的周记录表格
|
||
```
|
||
|
||
### 统计数据流程
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as 用户
|
||
participant F as 前端
|
||
participant A as Flask API服务
|
||
participant D as SQLite数据库
|
||
|
||
U->>F: 选择Cut-Off周期
|
||
F->>A: GET /api/stats/weekly/{period_id}
|
||
A->>D: 使用SQLAlchemy查询周期内工时记录
|
||
A->>A: Python计算统计数据
|
||
A-->>F: 返回统计结果
|
||
F->>F: 渲染图表和表格
|
||
F-->>U: 显示统计界面
|
||
```
|
||
|
||
## 存储设计
|
||
|
||
### Python数据库管理
|
||
```python
|
||
# database.py - 数据库配置和初始化
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.ext.declarative import declarative_base
|
||
from sqlalchemy.orm import sessionmaker
|
||
import os
|
||
|
||
# 数据库配置
|
||
DATABASE_URL = "sqlite:///./time_tracking.db"
|
||
|
||
engine = create_engine(
|
||
DATABASE_URL,
|
||
connect_args={"check_same_thread": False} # SQLite特定配置
|
||
)
|
||
|
||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||
Base = declarative_base()
|
||
|
||
# 创建所有表
|
||
def create_tables():
|
||
Base.metadata.create_all(bind=engine)
|
||
|
||
# 获取数据库会话
|
||
def get_db():
|
||
db = SessionLocal()
|
||
try:
|
||
yield db
|
||
finally:
|
||
db.close()
|
||
```
|
||
|
||
### 数据备份机制
|
||
```python
|
||
# backup.py - 备份管理
|
||
import shutil
|
||
import os
|
||
from datetime import datetime
|
||
import pandas as pd
|
||
from sqlalchemy.orm import Session
|
||
|
||
class BackupManager:
|
||
def __init__(self, db_session: Session):
|
||
self.db = db_session
|
||
self.backup_dir = "./backups"
|
||
os.makedirs(self.backup_dir, exist_ok=True)
|
||
|
||
def daily_backup(self) -> str:
|
||
"""每日自动备份到本地文件"""
|
||
date_str = datetime.now().strftime('%Y%m%d')
|
||
backup_filename = f"backup_{date_str}.db"
|
||
backup_path = os.path.join(self.backup_dir, backup_filename)
|
||
|
||
# 复制数据库文件
|
||
shutil.copy2("./time_tracking.db", backup_path)
|
||
return backup_path
|
||
|
||
def export_to_csv(self, period_id: int = None) -> str:
|
||
"""导出为CSV格式"""
|
||
# 查询数据
|
||
query = self.db.query(TimeRecord)
|
||
if period_id:
|
||
# 根据周期过滤数据
|
||
period = self.db.query(CutoffPeriod).filter(CutoffPeriod.id == period_id).first()
|
||
if period:
|
||
query = query.filter(
|
||
TimeRecord.date >= period.start_date,
|
||
TimeRecord.date <= period.end_date
|
||
)
|
||
|
||
records = query.all()
|
||
|
||
# 转换为DataFrame
|
||
data = []
|
||
for record in records:
|
||
project_info = '-'
|
||
if record.project:
|
||
if record.project.project_type == ProjectType.TRADITIONAL:
|
||
project_info = f"{record.project.project_name} ({record.project.project_code})"
|
||
elif record.project.project_type == ProjectType.PSI:
|
||
project_info = f"{record.project.project_name} ({record.project.customer_name}-{record.project.contract_number})"
|
||
|
||
data.append({
|
||
'日期': record.date.strftime('%Y-%m-%d'),
|
||
'事件': record.event_description or '-',
|
||
'项目': project_info,
|
||
'项目类型': record.project.project_type.value if record.project else '-',
|
||
'开始时间': record.start_time.strftime('%H:%M') if record.start_time else '-',
|
||
'结束时间': record.end_time.strftime('%H:%M') if record.end_time else '-',
|
||
'Activity Num': record.activity_num or '-',
|
||
'工时': record.hours,
|
||
'休息日': '是' if record.is_holiday else '否',
|
||
'休息日类型': record.holiday_type or '-'
|
||
})
|
||
|
||
df = pd.DataFrame(data)
|
||
|
||
# 保存CSV
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
csv_filename = f"time_records_export_{timestamp}.csv"
|
||
csv_path = os.path.join(self.backup_dir, csv_filename)
|
||
df.to_csv(csv_path, index=False, encoding='utf-8-sig')
|
||
|
||
return csv_path
|
||
|
||
def export_to_json(self) -> str:
|
||
"""导出为JSON格式"""
|
||
# 查询所有表数据
|
||
projects = self.db.query(Project).all()
|
||
time_records = self.db.query(TimeRecord).all()
|
||
holidays = self.db.query(Holiday).all()
|
||
cutoff_periods = self.db.query(CutoffPeriod).all()
|
||
|
||
# 序列化数据
|
||
export_data = {
|
||
'export_time': datetime.now().isoformat(),
|
||
'projects': [{
|
||
'id': p.id,
|
||
'project_name': p.project_name,
|
||
'project_type': p.project_type.value,
|
||
'customer_name': p.customer_name,
|
||
'project_code': p.project_code,
|
||
'contract_number': p.contract_number,
|
||
'description': p.description,
|
||
'is_active': p.is_active,
|
||
'created_at': p.created_at.isoformat()
|
||
} for p in projects],
|
||
'time_records': [{
|
||
'id': r.id,
|
||
'date': r.date.isoformat(),
|
||
'event_description': r.event_description,
|
||
'project_id': r.project_id,
|
||
'start_time': r.start_time.strftime('%H:%M') if r.start_time else None,
|
||
'end_time': r.end_time.strftime('%H:%M') if r.end_time else None,
|
||
'activity_num': r.activity_num,
|
||
'hours': r.hours,
|
||
'is_holiday': r.is_holiday,
|
||
'holiday_type': r.holiday_type
|
||
} for r in time_records],
|
||
'holidays': [{
|
||
'id': h.id,
|
||
'date': h.date.isoformat(),
|
||
'holiday_name': h.holiday_name,
|
||
'holiday_type': h.holiday_type,
|
||
'is_working_day': h.is_working_day
|
||
} for h in holidays],
|
||
'cutoff_periods': [{
|
||
'id': c.id,
|
||
'period_name': c.period_name,
|
||
'start_date': c.start_date.isoformat(),
|
||
'end_date': c.end_date.isoformat(),
|
||
'target_hours': c.target_hours,
|
||
'weeks': c.weeks
|
||
} for c in cutoff_periods]
|
||
}
|
||
|
||
# 保存JSON
|
||
import json
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
json_filename = f"full_export_{timestamp}.json"
|
||
json_path = os.path.join(self.backup_dir, json_filename)
|
||
|
||
with open(json_path, 'w', encoding='utf-8') as f:
|
||
json.dump(export_data, f, ensure_ascii=False, indent=2)
|
||
|
||
return json_path
|
||
```
|
||
|
||
## 测试策略
|
||
|
||
### Python单元测试
|
||
```python
|
||
# tests/test_time_calculations.py
|
||
import unittest
|
||
from datetime import date, time
|
||
from app.utils import calculate_hours, format_hours_to_decimal, is_holiday
|
||
from app.models import Project, ProjectType
|
||
|
||
class TestTimeCalculations(unittest.TestCase):
|
||
|
||
def test_normal_hour_calculation(self):
|
||
"""测试正常工时计算"""
|
||
result = calculate_hours('09:00', '17:00')
|
||
self.assertEqual(result, '8:00')
|
||
|
||
result = calculate_hours('09:15', '12:30')
|
||
self.assertEqual(result, '3:15')
|
||
|
||
def test_cross_midnight_calculation(self):
|
||
"""测试跨午夜工时计算"""
|
||
result = calculate_hours('22:00', '02:00')
|
||
self.assertEqual(result, '4:00')
|
||
|
||
def test_holiday_detection(self):
|
||
"""测试休息日检测"""
|
||
# 测试周末
|
||
saturday = date(2024, 12, 14) # 周六
|
||
result = is_holiday(saturday)
|
||
self.assertTrue(result['is_holiday'])
|
||
self.assertEqual(result['holiday_type'], 'weekend')
|
||
|
||
# 测试工作日
|
||
monday = date(2024, 12, 16) # 周一
|
||
result = is_holiday(monday)
|
||
self.assertFalse(result['is_holiday'])
|
||
|
||
def test_format_hours_conversion(self):
|
||
"""测试工时格式转换"""
|
||
self.assertEqual(format_hours_to_decimal('2:30'), 2.5)
|
||
self.assertEqual(format_hours_to_decimal('8:15'), 8.25)
|
||
self.assertEqual(format_hours_to_decimal('-'), 0.0)
|
||
self.assertEqual(format_hours_to_decimal('3.5'), 3.5)
|
||
|
||
def test_project_types(self):
|
||
"""测试项目类型功能"""
|
||
# 测试传统项目
|
||
traditional_project = Project(
|
||
project_name="CXMT 2025 MA",
|
||
project_type=ProjectType.TRADITIONAL,
|
||
customer_name="长鑫存储",
|
||
project_code="02C-FBV"
|
||
)
|
||
self.assertEqual(traditional_project.project_type, ProjectType.TRADITIONAL)
|
||
self.assertIsNotNone(traditional_project.project_code)
|
||
self.assertIsNotNone(traditional_project.customer_name)
|
||
|
||
# 测试PSI项目
|
||
psi_project = Project(
|
||
project_name="NexChip PSI项目",
|
||
project_type=ProjectType.PSI,
|
||
customer_name="NexChip",
|
||
project_code="PSI-PROJ", # 统一代码
|
||
contract_number="ID00462761"
|
||
)
|
||
self.assertEqual(psi_project.project_type, ProjectType.PSI)
|
||
self.assertEqual(psi_project.project_code, "PSI-PROJ")
|
||
self.assertIsNotNone(psi_project.customer_name)
|
||
self.assertIsNotNone(psi_project.contract_number)
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|
||
```
|
||
|
||
### API集成测试
|
||
```python
|
||
# tests/test_api.py
|
||
import unittest
|
||
import json
|
||
from app import create_app
|
||
from app.database import get_db, create_tables
|
||
from app.models import ProjectType
|
||
|
||
class TestAPI(unittest.TestCase):
|
||
|
||
def setUp(self):
|
||
self.app = create_app(testing=True)
|
||
self.client = self.app.test_client()
|
||
create_tables()
|
||
|
||
def test_create_traditional_project(self):
|
||
"""测试创建传统项目API"""
|
||
data = {
|
||
'project_name': 'CXMT 2025 MA',
|
||
'project_type': 'traditional',
|
||
'customer_name': '长鑫存储',
|
||
'project_code': '02C-FBV',
|
||
'description': '长鑫2025年MA项目'
|
||
}
|
||
|
||
response = self.client.post('/api/projects',
|
||
data=json.dumps(data),
|
||
content_type='application/json')
|
||
|
||
self.assertEqual(response.status_code, 201)
|
||
result = json.loads(response.data)
|
||
self.assertTrue(result['success'])
|
||
self.assertEqual(result['data']['project_name'], 'CXMT 2025 MA')
|
||
self.assertEqual(result['data']['project_type'], 'traditional')
|
||
self.assertEqual(result['data']['customer_name'], '长鑫存储')
|
||
self.assertEqual(result['data']['project_code'], '02C-FBV')
|
||
self.assertIsNone(result['data']['contract_number'])
|
||
|
||
def test_create_psi_project(self):
|
||
"""测试创建PSI项目API"""
|
||
data = {
|
||
'project_name': 'NexChip PSI项目',
|
||
'project_type': 'psi',
|
||
'customer_name': 'NexChip',
|
||
'contract_number': 'ID00462761',
|
||
'description': 'NexChip客户PSI项目'
|
||
}
|
||
|
||
response = self.client.post('/api/projects',
|
||
data=json.dumps(data),
|
||
content_type='application/json')
|
||
|
||
self.assertEqual(response.status_code, 201)
|
||
result = json.loads(response.data)
|
||
self.assertTrue(result['success'])
|
||
self.assertEqual(result['data']['project_name'], 'NexChip PSI项目')
|
||
self.assertEqual(result['data']['project_type'], 'psi')
|
||
self.assertEqual(result['data']['customer_name'], 'NexChip')
|
||
self.assertEqual(result['data']['project_code'], 'PSI-PROJ')
|
||
self.assertIsNotNone(result['data']['contract_number'])
|
||
|
||
def test_create_time_record_with_project_types(self):
|
||
"""测试不同项目类型的工时记录API"""
|
||
# 先创建一个传统项目
|
||
traditional_project_data = {
|
||
'project_name': 'Test Traditional Project',
|
||
'project_type': 'traditional',
|
||
'customer_name': '测试客户',
|
||
'project_code': 'TEST-001'
|
||
}
|
||
response = self.client.post('/api/projects',
|
||
data=json.dumps(traditional_project_data),
|
||
content_type='application/json')
|
||
traditional_project_id = json.loads(response.data)['data']['id']
|
||
|
||
# 创建PSI项目
|
||
psi_project_data = {
|
||
'project_name': 'Test PSI Project',
|
||
'project_type': 'psi',
|
||
'customer_name': 'TestCustomer',
|
||
'contract_number': 'TEST123'
|
||
}
|
||
response = self.client.post('/api/projects',
|
||
data=json.dumps(psi_project_data),
|
||
content_type='application/json')
|
||
psi_project_id = json.loads(response.data)['data']['id']
|
||
|
||
# 创建传统项目的工时记录
|
||
traditional_record_data = {
|
||
'date': '2024-12-20',
|
||
'event': 'Traditional Project Work',
|
||
'project_id': traditional_project_id,
|
||
'start_time': '09:00',
|
||
'end_time': '17:00',
|
||
'activity_num': 'ACT-001'
|
||
}
|
||
|
||
response = self.client.post('/api/time-records',
|
||
data=json.dumps(traditional_record_data),
|
||
content_type='application/json')
|
||
|
||
self.assertEqual(response.status_code, 201)
|
||
result = json.loads(response.data)
|
||
self.assertTrue(result['success'])
|
||
self.assertEqual(result['data']['hours'], '8:00')
|
||
|
||
# 创建PSI项目的工时记录
|
||
psi_record_data = {
|
||
'date': '2024-12-21',
|
||
'event': 'PSI Project Work',
|
||
'project_id': psi_project_id,
|
||
'start_time': '10:00',
|
||
'end_time': '16:00',
|
||
'activity_num': 'PSI-001'
|
||
}
|
||
|
||
response = self.client.post('/api/time-records',
|
||
data=json.dumps(psi_record_data),
|
||
content_type='application/json')
|
||
|
||
self.assertEqual(response.status_code, 201)
|
||
result = json.loads(response.data)
|
||
self.assertTrue(result['success'])
|
||
self.assertEqual(result['data']['hours'], '6:00')
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|
||
```
|
||
|
||
### 用户验收测试
|
||
- 工时记录完整流程测试
|
||
- 休息日识别和工时统计准确性测试
|
||
- **传统项目管理功能测试**:客户名+项目代码唯一性验证
|
||
- **PSI项目管理功能测试**:客户名+合同号唯一性验证,项目代码统一为"PSI-PROJ"
|
||
- **混合项目类型测试**:同时管理传统和PSI项目
|
||
- 数据导入导出测试(两种项目类型)
|
||
- 多种工时格式处理测试
|
||
- **项目显示格式测试**:在工时记录中正确显示不同类型项目 |