feat(time-tracking): 添加个人工时记录系统设计文档

- 完成系统架构和数据模型设计,包括项目、工时记录、休息日和周期表模型
- 设计项目管理模块,支持传统项目与PSI项目管理及批量导入功能
- 规划工时记录模块,含日期、事件描述、项目选择及工时计算规则
- 定义休息日分类,支持周末、国定节假日、个人假期及调休工时管理
- 制定统计分析模块设计,支持按Cut-Off周期的周统计与项目工时分布
- 设计周期管理模块,提供周期设置及预设模板功能
- 制定用户界面布局及各页面表单、样式设计方案
- 规划RESTful API端点,涵盖项目、工时记录、休息日、周期及统计数据操作
- 设计数据流示意,阐明操作流程及前后端交互逻辑
- 制定数据存储方案,包括SQLite数据库配置及备份导出机制
This commit is contained in:
2025-09-04 15:19:35 +08:00
parent cda1360ce4
commit ef9432f6da
11 changed files with 1163 additions and 0 deletions

1
backend/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
# API包初始化文件

245
backend/api/projects.py Normal file
View File

@@ -0,0 +1,245 @@
from flask import Blueprint, request, jsonify
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine
from backend.models.models import Project, ProjectType
from backend.models.utils import *
import csv
import io
projects_bp = Blueprint('projects', __name__)
def get_db_session():
"""获取数据库会话"""
engine = create_engine('sqlite:///data/timetrack.db')
Session = sessionmaker(bind=engine)
return Session()
@projects_bp.route('/api/projects', methods=['GET'])
def get_projects():
"""获取所有项目列表"""
try:
session = get_db_session()
projects = session.query(Project).filter_by(is_active=True).all()
result = []
for project in projects:
project_dict = project.to_dict()
# 为前端显示格式化项目信息
if project.project_type == ProjectType.TRADITIONAL:
project_dict['display_name'] = f"{project.project_name} ({project.customer_name}-{project.project_code})"
else: # PSI项目
project_dict['display_name'] = f"{project.project_name} ({project.customer_name}-{project.contract_number})"
result.append(project_dict)
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@projects_bp.route('/api/projects', methods=['POST'])
def create_project():
"""创建新项目"""
try:
data = request.json
session = get_db_session()
# 验证必填字段
if not data.get('project_name') or not data.get('customer_name') or not data.get('project_type'):
return jsonify({'success': False, 'error': '项目名称、客户名和项目类型为必填项'}), 400
# 根据项目类型设置字段
if data['project_type'] == 'traditional':
if not data.get('project_code'):
return jsonify({'success': False, 'error': '传统项目需要填写项目代码'}), 400
project_code = data['project_code']
contract_number = None
else: # PSI项目
if not data.get('contract_number'):
return jsonify({'success': False, 'error': 'PSI项目需要填写合同号'}), 400
project_code = 'PSI-PROJ' # PSI项目统一代码
contract_number = data['contract_number']
# 检查唯一性约束
existing_project = None
if data['project_type'] == 'traditional':
# 传统项目:客户名+项目代码唯一
existing_project = session.query(Project).filter_by(
customer_name=data['customer_name'],
project_code=project_code,
project_type=ProjectType.TRADITIONAL
).first()
else:
# PSI项目客户名+合同号唯一
existing_project = session.query(Project).filter_by(
customer_name=data['customer_name'],
contract_number=contract_number,
project_type=ProjectType.PSI
).first()
if existing_project:
session.close()
return jsonify({'success': False, 'error': '项目已存在'}), 400
# 创建新项目
project = Project(
project_name=data['project_name'],
project_type=ProjectType(data['project_type']),
project_code=project_code,
customer_name=data['customer_name'],
contract_number=contract_number,
description=data.get('description', '')
)
session.add(project)
session.commit()
result = project.to_dict()
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@projects_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
def update_project(project_id):
"""更新项目信息"""
try:
data = request.json
session = get_db_session()
project = session.query(Project).get(project_id)
if not project:
session.close()
return jsonify({'success': False, 'error': '项目不存在'}), 404
# 更新字段
if 'project_name' in data:
project.project_name = data['project_name']
if 'description' in data:
project.description = data['description']
session.commit()
result = project.to_dict()
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@projects_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
def delete_project(project_id):
"""删除项目(软删除)"""
try:
session = get_db_session()
project = session.query(Project).get(project_id)
if not project:
session.close()
return jsonify({'success': False, 'error': '项目不存在'}), 404
project.is_active = False
session.commit()
session.close()
return jsonify({'success': True, 'message': '项目已删除'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@projects_bp.route('/api/projects/import', methods=['POST'])
def import_projects():
"""批量导入项目"""
try:
if 'file' not in request.files:
return jsonify({'success': False, 'error': '请选择CSV文件'}), 400
file = request.files['file']
if file.filename == '' or not file.filename.endswith('.csv'):
return jsonify({'success': False, 'error': '请选择有效的CSV文件'}), 400
session = get_db_session()
# 读取CSV文件
stream = io.StringIO(file.stream.read().decode("utf-8"))
csv_reader = csv.DictReader(stream)
created_count = 0
errors = []
for row_num, row in enumerate(csv_reader, start=2):
try:
# 验证必填字段
if not row.get('项目名称') or not row.get('客户名') or not row.get('项目类型'):
errors.append(f"{row_num}行:项目名称、客户名和项目类型为必填项")
continue
project_type = row['项目类型'].lower()
if project_type not in ['traditional', 'psi']:
errors.append(f"{row_num}行:项目类型只能是 traditional 或 psi")
continue
# 根据项目类型设置字段
if project_type == 'traditional':
if not row.get('项目代码'):
errors.append(f"{row_num}行:传统项目需要填写项目代码")
continue
project_code = row['项目代码']
contract_number = None
else: # PSI项目
if not row.get('合同号'):
errors.append(f"{row_num}PSI项目需要填写合同号")
continue
project_code = 'PSI-PROJ'
contract_number = row['合同号']
# 检查重复
existing_project = None
if project_type == 'traditional':
existing_project = session.query(Project).filter_by(
customer_name=row['客户名'],
project_code=project_code,
project_type=ProjectType.TRADITIONAL
).first()
else:
existing_project = session.query(Project).filter_by(
customer_name=row['客户名'],
contract_number=contract_number,
project_type=ProjectType.PSI
).first()
if existing_project:
errors.append(f"{row_num}行:项目已存在")
continue
# 创建项目
project = Project(
project_name=row['项目名称'],
project_type=ProjectType(project_type),
project_code=project_code,
customer_name=row['客户名'],
contract_number=contract_number,
description=row.get('描述', '')
)
session.add(project)
created_count += 1
except Exception as e:
errors.append(f"{row_num}行:{str(e)}")
session.commit()
session.close()
return jsonify({
'success': True,
'message': f'成功导入{created_count}个项目',
'created_count': created_count,
'errors': errors
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500

267
backend/api/statistics.py Normal file
View File

@@ -0,0 +1,267 @@
from flask import Blueprint, request, jsonify
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine, and_
from backend.models.models import TimeRecord, Project, CutoffPeriod
from backend.models.utils import *
from datetime import datetime, date, timedelta
from collections import defaultdict
statistics_bp = Blueprint('statistics', __name__)
def get_db_session():
"""获取数据库会话"""
engine = create_engine('sqlite:///data/timetrack.db')
Session = sessionmaker(bind=engine)
return Session()
@statistics_bp.route('/api/statistics/weekly', methods=['GET'])
def get_weekly_statistics():
"""获取周统计数据"""
try:
session = get_db_session()
# 获取查询参数
period_id = request.args.get('period_id')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
# 如果指定了周期ID使用周期的日期范围
if period_id:
period = session.query(CutoffPeriod).get(int(period_id))
if not period:
session.close()
return jsonify({'success': False, 'error': '周期不存在'}), 404
start_date = period.start_date
end_date = period.end_date
target_hours = period.target_hours
period_info = period.to_dict()
else:
# 使用指定的日期范围
if not start_date or not end_date:
session.close()
return jsonify({'success': False, 'error': '请提供开始日期和结束日期'}), 400
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
target_hours = 40 # 默认目标工时
period_info = {
'period_name': f"{start_date.strftime('%m月%d')}-{end_date.strftime('%m月%d')}",
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'target_hours': target_hours
}
# 查询该时间范围内的所有工时记录
records = session.query(TimeRecord).filter(
and_(TimeRecord.date >= start_date, TimeRecord.date <= end_date)
).order_by(TimeRecord.date).all()
# 生成完整的日期范围
current_date = start_date
daily_records = []
record_dict = {}
# 将记录按日期分组
for record in records:
if record.date not in record_dict:
record_dict[record.date] = []
record_dict[record.date].append(record)
# 生成每日汇总
while current_date <= end_date:
day_records = record_dict.get(current_date, [])
if day_records:
# 如果有记录,汇总该日的数据
total_hours = sum([format_hours_to_decimal(r.hours) for r in day_records if r.hours not in ['-', '0:00']])
event_descriptions = [r.event_description for r in day_records if r.event_description and r.event_description != '-']
activity_nums = [r.activity_num for r in day_records if r.activity_num and r.activity_num != '-']
project_names = []
for r in day_records:
if r.project and r.project.project_name:
if r.project.project_type.value == 'traditional':
project_names.append(f"{r.project.customer_name} {r.project.project_code}")
else:
project_names.append(f"{r.project.customer_name} {r.project.contract_number}")
# 获取第一条记录的时间信息
first_record = day_records[0]
daily_record = {
'date': current_date.isoformat(),
'day_of_week': get_day_of_week_chinese(current_date),
'event': '; '.join(event_descriptions) if event_descriptions else '-',
'project': '; '.join(set(project_names)) if project_names else '-',
'start_time': first_record.start_time.strftime('%H:%M') if first_record.start_time else '-',
'end_time': first_record.end_time.strftime('%H:%M') if first_record.end_time else '-',
'activity_num': '; '.join(activity_nums) if activity_nums else '-',
'hours': format_decimal_to_hours(total_hours) if total_hours > 0 else ('0:00' if first_record.is_holiday else '-'),
'is_holiday': first_record.is_holiday,
'holiday_type': first_record.holiday_type,
'is_working_on_holiday': any(r.is_working_on_holiday for r in day_records)
}
else:
# 如果没有记录,生成默认记录
holidays = session.query(Holiday).all()
holiday_info = is_holiday(current_date, holidays)
daily_record = {
'date': current_date.isoformat(),
'day_of_week': get_day_of_week_chinese(current_date),
'event': '-',
'project': '-',
'start_time': '-',
'end_time': '-',
'activity_num': '-',
'hours': '0:00' if holiday_info['is_holiday'] else '-',
'is_holiday': holiday_info['is_holiday'],
'holiday_type': holiday_info['holiday_type'],
'is_working_on_holiday': False
}
daily_records.append(daily_record)
current_date += timedelta(days=1)
# 计算统计数据
workday_total = 0
holiday_total = 0
working_days = 0
holiday_work_days = 0
rest_days = 0
for daily in daily_records:
hours_decimal = format_hours_to_decimal(daily['hours'])
if daily['is_holiday']:
if daily['is_working_on_holiday']:
holiday_total += hours_decimal
holiday_work_days += 1
else:
rest_days += 1
else:
if hours_decimal > 0:
workday_total += hours_decimal
working_days += 1
else:
rest_days += 1
# 项目工时统计
project_hours = defaultdict(float)
for record in records:
if record.project and record.hours not in ['-', '0:00']:
hours_decimal = format_hours_to_decimal(record.hours)
if record.project.project_type.value == 'traditional':
project_key = f"{record.project.customer_name} {record.project.project_code}"
else:
project_key = f"{record.project.customer_name} {record.project.contract_number}"
project_hours[project_key] += hours_decimal
total_project_hours = sum(project_hours.values())
project_stats = []
for project_name, hours in project_hours.items():
percentage = (hours / total_project_hours * 100) if total_project_hours > 0 else 0
project_stats.append({
'project': project_name,
'hours': format_decimal_to_hours(hours),
'percentage': round(percentage, 1)
})
project_stats.sort(key=lambda x: x['percentage'], reverse=True)
result = {
'period': period_info,
'daily_records': daily_records,
'project_hours': project_stats,
'workday_total': format_decimal_to_hours(workday_total),
'holiday_total': format_decimal_to_hours(holiday_total),
'weekly_total': format_decimal_to_hours(workday_total + holiday_total),
'working_days': working_days,
'holiday_work_days': holiday_work_days,
'rest_days': rest_days,
'target_hours': target_hours,
'completion_rate': round((workday_total + holiday_total) / target_hours * 100, 1) if target_hours > 0 else 0
}
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@statistics_bp.route('/api/statistics/periods', methods=['GET'])
def get_cutoff_periods():
"""获取Cut-Off周期列表"""
try:
session = get_db_session()
periods = session.query(CutoffPeriod).order_by(CutoffPeriod.start_date.desc()).all()
result = [period.to_dict() for period in periods]
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@statistics_bp.route('/api/statistics/periods', methods=['POST'])
def create_cutoff_period():
"""创建Cut-Off周期"""
try:
data = request.json
session = get_db_session()
# 验证必填字段
if not all(key in data for key in ['period_name', 'start_date', 'end_date']):
return jsonify({'success': False, 'error': '周期名称、开始日期和结束日期为必填项'}), 400
start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
if start_date >= end_date:
return jsonify({'success': False, 'error': '开始日期必须早于结束日期'}), 400
# 计算周数
days_diff = (end_date - start_date).days + 1
weeks = round(days_diff / 7)
period = CutoffPeriod(
period_name=data['period_name'],
start_date=start_date,
end_date=end_date,
target_hours=data.get('target_hours', weeks * 40),
weeks=weeks,
year=start_date.year,
month=start_date.month
)
session.add(period)
session.commit()
result = period.to_dict()
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@statistics_bp.route('/api/statistics/periods/<int:period_id>', methods=['DELETE'])
def delete_cutoff_period(period_id):
"""删除Cut-Off周期"""
try:
session = get_db_session()
period = session.query(CutoffPeriod).get(period_id)
if not period:
session.close()
return jsonify({'success': False, 'error': '周期不存在'}), 404
session.delete(period)
session.commit()
session.close()
return jsonify({'success': True, 'message': '周期已删除'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500

203
backend/api/timerecords.py Normal file
View File

@@ -0,0 +1,203 @@
from flask import Blueprint, request, jsonify
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine, and_
from backend.models.models import TimeRecord, Project, Holiday
from backend.models.utils import *
from datetime import datetime, date
import json
timerecords_bp = Blueprint('timerecords', __name__)
def get_db_session():
"""获取数据库会话"""
engine = create_engine('sqlite:///data/timetrack.db')
Session = sessionmaker(bind=engine)
return Session()
@timerecords_bp.route('/api/timerecords', methods=['GET'])
def get_timerecords():
"""获取工时记录列表"""
try:
session = get_db_session()
# 获取查询参数
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
project_id = request.args.get('project_id')
query = session.query(TimeRecord)
# 应用筛选条件
if start_date:
query = query.filter(TimeRecord.date >= datetime.strptime(start_date, '%Y-%m-%d').date())
if end_date:
query = query.filter(TimeRecord.date <= datetime.strptime(end_date, '%Y-%m-%d').date())
if project_id:
query = query.filter(TimeRecord.project_id == int(project_id))
records = query.order_by(TimeRecord.date.desc()).all()
result = []
for record in records:
record_dict = record.to_dict()
# 添加星期几信息
if record.date:
record_dict['day_of_week'] = get_day_of_week_chinese(record.date)
result.append(record_dict)
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@timerecords_bp.route('/api/timerecords', methods=['POST'])
def create_timerecord():
"""创建工时记录"""
try:
data = request.json
session = get_db_session()
# 验证必填字段
if not data.get('date'):
return jsonify({'success': False, 'error': '日期为必填项'}), 400
record_date = datetime.strptime(data['date'], '%Y-%m-%d').date()
# 检查是否为休息日
holidays = session.query(Holiday).all()
holiday_info = is_holiday(record_date, holidays)
# 计算工时
hours = data.get('hours', '')
if not hours and data.get('start_time') and data.get('end_time'):
hours = calculate_hours(data['start_time'], data['end_time'], holiday_info['is_holiday'])
elif not hours:
hours = '0:00' if holiday_info['is_holiday'] else '-'
# 获取周信息
week_info = get_week_info(record_date)
# 创建记录
record = TimeRecord(
date=record_date,
event_description=data.get('event_description', ''),
project_id=data.get('project_id') if data.get('project_id') else None,
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'],
is_working_on_holiday=holiday_info['is_holiday'] and hours not in ['-', '0:00'],
holiday_type=holiday_info['holiday_type'],
week_info=week_info
)
session.add(record)
session.commit()
result = record.to_dict()
if record.date:
result['day_of_week'] = get_day_of_week_chinese(record.date)
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['PUT'])
def update_timerecord(record_id):
"""更新工时记录"""
try:
data = request.json
session = get_db_session()
record = session.query(TimeRecord).get(record_id)
if not record:
session.close()
return jsonify({'success': False, 'error': '记录不存在'}), 404
# 更新字段
if 'event_description' in data:
record.event_description = data['event_description']
if 'project_id' in data:
record.project_id = data['project_id'] if data['project_id'] else None
if 'activity_num' in data:
record.activity_num = data['activity_num']
# 更新时间相关字段
if 'start_time' in data:
record.start_time = datetime.strptime(data['start_time'], '%H:%M').time() if data['start_time'] and data['start_time'] != '-' else None
if 'end_time' in data:
record.end_time = datetime.strptime(data['end_time'], '%H:%M').time() if data['end_time'] and data['end_time'] != '-' else None
# 重新计算工时
if 'hours' in data:
record.hours = data['hours']
elif record.start_time and record.end_time:
start_str = record.start_time.strftime('%H:%M')
end_str = record.end_time.strftime('%H:%M')
record.hours = calculate_hours(start_str, end_str, record.is_holiday)
# 更新工作日状态
record.is_working_on_holiday = record.is_holiday and record.hours not in ['-', '0:00']
session.commit()
result = record.to_dict()
if record.date:
result['day_of_week'] = get_day_of_week_chinese(record.date)
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['DELETE'])
def delete_timerecord(record_id):
"""删除工时记录"""
try:
session = get_db_session()
record = session.query(TimeRecord).get(record_id)
if not record:
session.close()
return jsonify({'success': False, 'error': '记录不存在'}), 404
session.delete(record)
session.commit()
session.close()
return jsonify({'success': True, 'message': '记录已删除'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@timerecords_bp.route('/api/timerecords/check_holiday/<string:date_str>', methods=['GET'])
def check_holiday(date_str):
"""检查指定日期是否为休息日"""
try:
session = get_db_session()
check_date = datetime.strptime(date_str, '%Y-%m-%d').date()
holidays = session.query(Holiday).all()
holiday_info = is_holiday(check_date, holidays)
result = {
'date': date_str,
'is_holiday': holiday_info['is_holiday'],
'holiday_type': holiday_info['holiday_type'],
'holiday_name': holiday_info['holiday_name'],
'day_of_week': get_day_of_week_chinese(check_date),
'week_info': get_week_info(check_date)
}
session.close()
return jsonify({'success': True, 'data': result})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500