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