Files
time-tracking-system/.qoder/quests/time-tracking-system.md
bf1942 cda1360ce4 feat(time-tracking): 添加个人工时记录系统设计文档
- 完成系统架构和数据模型设计,包括项目、工时记录、休息日和周期表模型
- 设计项目管理模块,支持传统项目与PSI项目管理及批量导入功能
- 规划工时记录模块,含日期、事件描述、项目选择及工时计算规则
- 定义休息日分类,支持周末、国定节假日、个人假期及调休工时管理
- 制定统计分析模块设计,支持按Cut-Off周期的周统计与项目工时分布
- 设计周期管理模块,提供周期设置及预设模板功能
- 制定用户界面布局及各页面表单、样式设计方案
- 规划RESTful API端
2025-09-04 15:19:30 +08:00

1467 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 个人工时记录网站设计文档
## 概述
个人工时记录网站是一个简单的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项目
- 数据导入导出测试(两种项目类型)
- 多种工时格式处理测试
- **项目显示格式测试**:在工时记录中正确显示不同类型项目