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

48 KiB
Raw Permalink Blame History

个人工时记录网站设计文档

概述

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