commit cda1360ce402c93860f4a123791a707222124549 Author: bf1942 Date: Thu Sep 4 15:19:30 2025 +0800 feat(time-tracking): 添加个人工时记录系统设计文档 - 完成系统架构和数据模型设计,包括项目、工时记录、休息日和周期表模型 - 设计项目管理模块,支持传统项目与PSI项目管理及批量导入功能 - 规划工时记录模块,含日期、事件描述、项目选择及工时计算规则 - 定义休息日分类,支持周末、国定节假日、个人假期及调休工时管理 - 制定统计分析模块设计,支持按Cut-Off周期的周统计与项目工时分布 - 设计周期管理模块,提供周期设置及预设模板功能 - 制定用户界面布局及各页面表单、样式设计方案 - 规划RESTful API端 diff --git a/.qoder/quests/time-tracking-system.md b/.qoder/quests/time-tracking-system.md new file mode 100644 index 0000000..f92e6cd --- /dev/null +++ b/.qoder/quests/time-tracking-system.md @@ -0,0 +1,1467 @@ +# 个人工时记录网站设计文档 + +## 概述 + +个人工时记录网站是一个简单的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项目 +- 数据导入导出测试(两种项目类型) +- 多种工时格式处理测试 +- **项目显示格式测试**:在工时记录中正确显示不同类型项目 \ No newline at end of file