- 完成系统架构和数据模型设计,包括项目、工时记录、休息日和周期表模型 - 设计项目管理模块,支持传统项目与PSI项目管理及批量导入功能 - 规划工时记录模块,含日期、事件描述、项目选择及工时计算规则 - 定义休息日分类,支持周末、国定节假日、个人假期及调休工时管理 - 制定统计分析模块设计,支持按Cut-Off周期的周统计与项目工时分布 - 设计周期管理模块,提供周期设置及预设模板功能 - 制定用户界面布局及各页面表单、样式设计方案 - 规划RESTful API端
48 KiB
48 KiB
个人工时记录网站设计文档
概述
个人工时记录网站是一个简单的Web应用程序,用于记录和管理个人工作时间。系统支持按项目记录工时,并提供每周统计功能,支持Cut-Off Date周期管理。
核心功能
- 项目管理(新建、编辑、导入项目)
- 工时记录管理(增删改查)
- 按周统计工时
- Cut-Off Date周期管理
- 数据导入导出
技术选型
- 前端: HTML5 + CSS3 + JavaScript (Vanilla JS)
- 后端: Python + Flask/FastAPI
- 数据库: SQLite (本地文件数据库)
- ORM: SQLAlchemy
- 存储: localStorage + SQLite文件
架构设计
系统架构
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)
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)
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)
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)
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 项目数据结构
传统项目示例:
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项目示例:
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模板:
项目名称,项目类型,客户名,项目代码,合同号,描述
CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目
Project Alpha,traditional,客户A,01A-DEV,,Alpha开发项目
PSI项目CSV模板:
项目名称,项目类型,客户名,项目代码,合同号,描述
NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目
Samsung项目,psi,Samsung,PSI-PROJ,SC20241201,Samsung客户项目
混合项目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):
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 工时计算与格式化逻辑
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 周统计视图
graph LR
A[选择Cut-Off周期] --> B[显示周统计表]
B --> C[每日工时汇总]
B --> D[项目工时分布]
B --> E[总工时vs目标工时]
3.2 统计数据结构
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 预设周期模板
const periodTemplates = [
{
name: "标准月度周期",
weeks: 4,
targetHours: 160,
hoursPerWeek: 40
},
{
name: "短周期",
weeks: 2,
targetHours: 80,
hoursPerWeek: 40
}
];
用户界面设计
页面布局结构
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 项目新建表单
<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样式定义
/* 休息日样式 */
.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格式数据
请求/响应示例
创建传统项目
# 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项目
# 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"
}
}
批量导入项目
# 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
创建休息日工时记录
# 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
休息日配置管理
# 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
数据流设计
项目管理流程
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: 显示成功/错误消息
项目导入流程
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: 显示导入结果
工时记录流程(含休息日处理)
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: 显示成功/错误消息
周统计流程(含休息日统计)
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: 显示带颜色标识的周记录表格
统计数据流程
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数据库管理
# 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()
数据备份机制
# 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单元测试
# 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集成测试
# 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项目
- 数据导入导出测试(两种项目类型)
- 多种工时格式处理测试
- 项目显示格式测试:在工时记录中正确显示不同类型项目