فهرست منبع

Add: Yearly Dashboard Summary

iaun 3 ماه پیش
والد
کامیت
926b92fe4f

+ 210 - 0
.kiro/specs/yearly-dashboard-summary/design.md

@@ -0,0 +1,210 @@
+# Design Document: Yearly Dashboard Summary
+
+## Overview
+
+本设计文档描述在仪表盘中添加年度汇总功能的技术实现方案。该功能将展示与Excel年度报表相同格式的数据,包括每人按月的收入统计和年度合计。
+
+## Architecture
+
+### 系统架构
+
+```mermaid
+graph TB
+    subgraph Frontend
+        Dashboard[Dashboard.jsx]
+        YearlySummary[YearlySummaryTable Component]
+        API[api.js]
+    end
+    
+    subgraph Backend
+        Routes[work_record_routes.py]
+        Service[work_record_service.py]
+        DB[(Database)]
+    end
+    
+    Dashboard --> YearlySummary
+    Dashboard --> API
+    API --> Routes
+    Routes --> Service
+    Service --> DB
+```
+
+### 数据流
+
+1. 用户在仪表盘选择年份
+2. 前端调用 `workRecordApi.getYearlySummary({ year })`
+3. 后端 `WorkRecordService.get_yearly_summary(year)` 查询数据库
+4. 返回按人员分组的月度收入数据
+5. 前端渲染年度汇总表格
+
+## Components and Interfaces
+
+### Backend API
+
+#### 新增端点: GET /api/work-records/yearly-summary
+
+**请求参数:**
+```
+year: integer (required) - 年份,如 2024
+```
+
+**响应格式:**
+```json
+{
+  "year": 2024,
+  "persons": [
+    {
+      "person_id": 1,
+      "person_name": "张三",
+      "monthly_earnings": [100.00, 200.00, 150.00, ...], // 12个月的收入
+      "yearly_total": 1500.00
+    }
+  ],
+  "monthly_totals": [500.00, 600.00, ...], // 每月所有人的合计
+  "grand_total": 6000.00
+}
+```
+
+**错误响应:**
+```json
+{
+  "error": "年份无效,必须在 1900 到 9999 之间"
+}
+```
+
+### Backend Service
+
+#### WorkRecordService.get_yearly_summary(year)
+
+```python
+def get_yearly_summary(year: int) -> dict:
+    """
+    获取年度汇总数据,格式与Excel导出一致。
+    
+    Args:
+        year: 年份
+        
+    Returns:
+        包含年度汇总数据的字典
+    """
+```
+
+### Frontend API
+
+#### workRecordApi.getYearlySummary
+
+```javascript
+getYearlySummary: (params) => api.get('/work-records/yearly-summary', { params })
+```
+
+### Frontend Component
+
+#### Dashboard.jsx 修改
+
+在现有月度报告下方添加年度汇总区域:
+
+```jsx
+// 新增状态
+const [selectedYear, setSelectedYear] = useState(dayjs())
+const [yearlySummary, setYearlySummary] = useState(null)
+const [yearlyLoading, setYearlyLoading] = useState(false)
+
+// 新增年度汇总表格
+<Card title="年度汇总">
+  <DatePicker.YearPicker value={selectedYear} onChange={handleYearChange} />
+  <Table columns={yearlySummaryColumns} dataSource={yearlySummary?.persons} />
+</Card>
+```
+
+## Data Models
+
+### 年度汇总响应模型
+
+| 字段 | 类型 | 描述 |
+|------|------|------|
+| year | integer | 年份 |
+| persons | array | 人员汇总列表 |
+| persons[].person_id | integer | 人员ID |
+| persons[].person_name | string | 人员姓名 |
+| persons[].monthly_earnings | array[12] | 12个月的收入数组 |
+| persons[].yearly_total | float | 年度总收入 |
+| monthly_totals | array[12] | 每月所有人的合计 |
+| grand_total | float | 年度总计 |
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: Grand Total Correctness
+
+*For any* yearly summary response, the grand_total SHALL equal the sum of all persons' yearly_total values, and each monthly_totals[i] SHALL equal the sum of all persons' monthly_earnings[i].
+
+**Validates: Requirements 1.3, 3.3**
+
+### Property 2: API Response Structure
+
+*For any* valid year parameter, the API response SHALL contain a persons array where each entry includes person_id (integer), person_name (string), monthly_earnings (array of 12 numbers), and yearly_total (number).
+
+**Validates: Requirements 3.1, 3.2**
+
+### Property 3: Dashboard-Export Consistency
+
+*For any* year and set of work records, the yearly summary data returned by the API SHALL produce identical person totals and monthly breakdowns as the Excel export yearly summary for the same year.
+
+**Validates: Requirements 4.1**
+
+### Property 4: Monetary Precision
+
+*For any* monetary value in the yearly summary response, the value SHALL be rounded to exactly 2 decimal places.
+
+**Validates: Requirements 4.2**
+
+### Property 5: Alphabetical Sorting
+
+*For any* yearly summary response with multiple persons, the persons array SHALL be sorted alphabetically by person_name.
+
+**Validates: Requirements 4.3**
+
+## Error Handling
+
+| 场景 | 处理方式 |
+|------|----------|
+| 年份参数缺失 | 返回 400,错误信息:"缺少年份参数" |
+| 年份格式无效 | 返回 400,错误信息:"年份无效,必须在 1900 到 9999 之间" |
+| 无数据 | 返回空的 persons 数组,grand_total 为 0 |
+| 数据库错误 | 返回 500,记录错误日志 |
+
+## Testing Strategy
+
+### 单元测试
+
+1. **API端点测试**
+   - 测试有效年份返回正确数据
+   - 测试无效年份返回400错误
+   - 测试空数据年份返回空结果
+
+2. **Service层测试**
+   - 测试数据聚合逻辑
+   - 测试边界条件(年初、年末)
+
+### 属性测试
+
+使用 Hypothesis (Python) 进行属性测试:
+
+1. **Property 1: Grand Total Correctness**
+   - 生成随机工作记录数据
+   - 验证 grand_total = sum(yearly_totals)
+   - 验证 monthly_totals[i] = sum(monthly_earnings[i])
+
+2. **Property 3: Dashboard-Export Consistency**
+   - 生成随机年份和工作记录
+   - 比较 API 返回数据与 ExportService 计算结果
+
+3. **Property 5: Alphabetical Sorting**
+   - 生成随机人员名称
+   - 验证返回结果按字母顺序排列
+
+### 测试配置
+
+- 属性测试最少运行 100 次迭代
+- 每个测试标注对应的设计属性编号

+ 57 - 0
.kiro/specs/yearly-dashboard-summary/requirements.md

@@ -0,0 +1,57 @@
+# Requirements Document
+
+## Introduction
+
+在仪表盘中添加年度汇总功能,展示与导出Excel年度报表相似的数据视图。用户可以在仪表盘中直接查看每人按月的收入统计和年度合计,无需导出Excel文件即可获得年度数据概览。
+
+## Glossary
+
+- **Dashboard**: 仪表盘,系统的主页面,展示工作统计数据
+- **Yearly_Summary**: 年度汇总,按年份统计每个人员每月的收入数据
+- **Person**: 人员,系统中的工作人员
+- **Work_Record**: 工作记录,记录人员的工作内容和收入
+- **Monthly_Breakdown**: 月度明细,将年度数据按12个月份分别展示
+
+## Requirements
+
+### Requirement 1: 年度汇总数据展示
+
+**User Story:** As a user, I want to view yearly summary data in the dashboard, so that I can see each person's monthly earnings and yearly totals without exporting to Excel.
+
+#### Acceptance Criteria
+
+1. WHEN a user selects a year in the dashboard, THE Dashboard SHALL display a yearly summary table showing each person's earnings by month
+2. THE Yearly_Summary table SHALL display columns for: person name, earnings for each month (1-12), and yearly total
+3. THE Yearly_Summary table SHALL display a grand total row showing the sum of all persons' earnings for each month and the overall yearly total
+4. WHEN there are no work records for the selected year, THE Dashboard SHALL display an empty state message
+
+### Requirement 2: 年度选择器
+
+**User Story:** As a user, I want to select different years to view their summaries, so that I can compare data across years.
+
+#### Acceptance Criteria
+
+1. THE Dashboard SHALL provide a year picker control for selecting the year to display
+2. WHEN the year picker value changes, THE Dashboard SHALL fetch and display the yearly summary for the selected year
+3. THE year picker SHALL default to the current year when the dashboard loads
+
+### Requirement 3: 年度汇总API
+
+**User Story:** As a developer, I want a backend API endpoint that returns yearly summary data, so that the frontend can display the yearly summary table.
+
+#### Acceptance Criteria
+
+1. WHEN a GET request is made to the yearly summary endpoint with a year parameter, THE API SHALL return yearly summary data grouped by person with monthly breakdown
+2. THE API response SHALL include: person_id, person_name, monthly earnings array (12 values), and yearly_total for each person
+3. THE API response SHALL include grand totals for each month and the overall yearly total
+4. IF the year parameter is invalid, THEN THE API SHALL return an appropriate error response with status code 400
+
+### Requirement 4: 数据格式一致性
+
+**User Story:** As a user, I want the dashboard yearly summary to match the exported Excel format, so that I can trust the data consistency.
+
+#### Acceptance Criteria
+
+1. THE Yearly_Summary data calculation logic SHALL use the same algorithm as the Excel export yearly summary
+2. THE monetary values SHALL be displayed with 2 decimal places precision
+3. THE person names SHALL be sorted alphabetically in the summary table

+ 47 - 0
.kiro/specs/yearly-dashboard-summary/tasks.md

@@ -0,0 +1,47 @@
+# Implementation Plan: Yearly Dashboard Summary
+
+## Overview
+
+实现仪表盘年度汇总功能,包括后端API和前端展示组件。
+
+## Tasks
+
+- [x] 1. 实现后端年度汇总API
+  - [x] 1.1 在 WorkRecordService 中添加 get_yearly_summary 方法
+    - 复用 ExportService 的数据聚合逻辑
+    - 返回按人员分组的月度收入数据
+    - 人员按姓名字母顺序排序
+    - _Requirements: 3.1, 3.2, 4.1, 4.3_
+  - [x] 1.2 在 work_record_routes 中添加 yearly-summary 端点
+    - GET /api/work-records/yearly-summary
+    - 参数验证:year 必须为有效整数
+    - _Requirements: 3.1, 3.4_
+  - [ ]* 1.3 编写属性测试:Grand Total Correctness
+    - **Property 1: Grand Total Correctness**
+    - **Validates: Requirements 1.3, 3.3**
+  - [ ]* 1.4 编写属性测试:Dashboard-Export Consistency
+    - **Property 3: Dashboard-Export Consistency**
+    - **Validates: Requirements 4.1**
+
+- [x] 2. Checkpoint - 确保后端测试通过
+  - 确保所有测试通过,如有问题请询问用户
+
+- [x] 3. 实现前端年度汇总展示
+  - [x] 3.1 在 api.js 中添加 getYearlySummary 方法
+    - 调用 /api/work-records/yearly-summary 端点
+    - _Requirements: 2.2_
+  - [x] 3.2 在 Dashboard.jsx 中添加年度汇总区域
+    - 添加年份选择器,默认当前年份
+    - 添加年度汇总表格,显示人员、12个月收入、年度合计
+    - 添加合计行显示每月总计和年度总计
+    - 处理空数据状态
+    - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 4.2_
+
+- [x] 4. Final Checkpoint - 确保功能完整
+  - 确保所有测试通过,如有问题请询问用户
+
+## Notes
+
+- 标记 `*` 的任务为可选任务,可跳过以加快MVP开发
+- 后端数据计算逻辑应与 ExportService 保持一致
+- 前端表格样式应与现有月度报告保持一致

+ 58 - 0
backend/app/routes/work_record.py

@@ -99,6 +99,21 @@ monthly_summary_response = work_record_ns.model('MonthlySummaryResponse', {
     'item_breakdown': fields.List(fields.Nested(monthly_item_breakdown), description='物品收入明细')
 })
 
+# Yearly summary models
+yearly_summary_person = work_record_ns.model('YearlySummaryPerson', {
+    'person_id': fields.Integer(description='人员ID'),
+    'person_name': fields.String(description='人员姓名'),
+    'monthly_earnings': fields.List(fields.Float, description='12个月的收入数组'),
+    'yearly_total': fields.Float(description='年度总收入')
+})
+
+yearly_summary_response = work_record_ns.model('YearlySummaryResponse', {
+    'year': fields.Integer(description='年份'),
+    'persons': fields.List(fields.Nested(yearly_summary_person), description='人员汇总列表'),
+    'monthly_totals': fields.List(fields.Float, description='每月所有人的合计'),
+    'grand_total': fields.Float(description='年度总计')
+})
+
 
 @work_record_ns.route('')
 class WorkRecordList(Resource):
@@ -383,3 +398,46 @@ class WorkRecordMonthlySummary(Resource):
                 'error': str(e),
                 'code': 'VALIDATION_ERROR'
             }, 400
+
+
+
+@work_record_ns.route('/yearly-summary')
+class WorkRecordYearlySummary(Resource):
+    """Resource for getting yearly summary with monthly breakdown."""
+    
+    @work_record_ns.doc('get_yearly_summary')
+    @work_record_ns.param('year', '年份 (如 2024)', required=True, type=int)
+    @work_record_ns.response(200, 'Success')
+    @work_record_ns.response(400, 'Validation error', error_response)
+    @require_auth
+    def get(self):
+        """获取年度工作汇总(按月份分解)"""
+        year = request.args.get('year', type=int)
+        
+        if year is None:
+            return {
+                'success': False,
+                'error': '缺少年份参数',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        if not isinstance(year, int) or year < 1900 or year > 9999:
+            return {
+                'success': False,
+                'error': '年份无效,必须在 1900 到 9999 之间',
+                'code': 'VALIDATION_ERROR'
+            }, 400
+        
+        try:
+            summary = WorkRecordService.get_yearly_summary(year=year)
+            return {
+                'success': True,
+                'data': summary,
+                'message': 'Yearly summary retrieved successfully'
+            }, 200
+        except Exception as e:
+            return {
+                'success': False,
+                'error': str(e),
+                'code': 'INTERNAL_ERROR'
+            }, 500

+ 74 - 0
backend/app/services/work_record_service.py

@@ -240,6 +240,80 @@ class WorkRecordService:
             'grand_total_value': sum(p['total_value'] for p in summary_by_person.values())
         }
 
+    @staticmethod
+    def get_yearly_summary(year):
+        """Get yearly summary of work records with monthly breakdown.
+        
+        Uses the same calculation logic as ExportService for consistency.
+        
+        Args:
+            year: Year to get summary for
+            
+        Returns:
+            Dictionary with yearly summary data including:
+            - year: The year
+            - persons: List of person summaries with monthly_earnings and yearly_total
+            - monthly_totals: Array of 12 monthly totals across all persons
+            - grand_total: Overall yearly total
+        """
+        # Calculate date range for the year
+        start_date = date(year, 1, 1)
+        end_date = date(year, 12, 31)
+        
+        # Get all work records for the year
+        work_records = WorkRecord.query.filter(
+            WorkRecord.work_date >= start_date,
+            WorkRecord.work_date <= end_date
+        ).join(Person).order_by(Person.name).all()
+        
+        # Calculate totals by person and month (same logic as ExportService)
+        person_monthly_totals = {}
+        for record in work_records:
+            person_id = record.person_id
+            person_name = record.person.name
+            month = record.work_date.month
+            
+            if person_id not in person_monthly_totals:
+                person_monthly_totals[person_id] = {
+                    'person_id': person_id,
+                    'person_name': person_name,
+                    'monthly_data': {m: 0.0 for m in range(1, 13)}
+                }
+            
+            person_monthly_totals[person_id]['monthly_data'][month] += record.total_price
+        
+        # Build persons list sorted alphabetically by name
+        persons = []
+        monthly_grand_totals = [0.0] * 12
+        
+        for person_data in sorted(person_monthly_totals.values(), key=lambda x: x['person_name']):
+            monthly_earnings = []
+            yearly_total = 0.0
+            
+            for month in range(1, 13):
+                value = round(person_data['monthly_data'][month], 2)
+                monthly_earnings.append(value)
+                yearly_total += value
+                monthly_grand_totals[month - 1] += value
+            
+            persons.append({
+                'person_id': person_data['person_id'],
+                'person_name': person_data['person_name'],
+                'monthly_earnings': monthly_earnings,
+                'yearly_total': round(yearly_total, 2)
+            })
+        
+        # Round monthly totals
+        monthly_totals = [round(total, 2) for total in monthly_grand_totals]
+        grand_total = round(sum(monthly_totals), 2)
+        
+        return {
+            'year': year,
+            'persons': persons,
+            'monthly_totals': monthly_totals,
+            'grand_total': grand_total
+        }
+
     @staticmethod
     def get_monthly_summary(year, month):
         """Get monthly summary of work records.

+ 5 - 2
backend/tests/test_admin.py

@@ -231,10 +231,13 @@ def test_delete_last_admin(client, test_admin, auth_headers):
 
 
 def test_delete_admin_not_found(client, test_admin, auth_headers):
-    """Test deleting non-existent admin returns 400."""
+    """Test deleting non-existent admin returns 404."""
     response = client.post('/api/admins/delete',
         headers=auth_headers,
         json={'id': 9999}
     )
     
-    assert response.status_code == 400
+    assert response.status_code == 404
+    data = response.get_json()
+    assert data['success'] is False
+    assert data['code'] == 'NOT_FOUND'

+ 109 - 1
frontend/src/components/Dashboard.jsx

@@ -8,7 +8,8 @@ import {
   AppstoreOutlined,
   ReloadOutlined,
   TrophyOutlined,
-  ShoppingOutlined
+  ShoppingOutlined,
+  CalendarOutlined
 } from '@ant-design/icons'
 import dayjs from 'dayjs'
 import { workRecordApi, personApi, itemApi } from '../services/api'
@@ -20,10 +21,13 @@ function Dashboard() {
   const [loading, setLoading] = useState(false)
   const [countsLoading, setCountsLoading] = useState(false)
   const [monthlyLoading, setMonthlyLoading] = useState(false)
+  const [yearlyLoading, setYearlyLoading] = useState(false)
   const [selectedDate, setSelectedDate] = useState(dayjs())
   const [selectedMonth, setSelectedMonth] = useState(dayjs())
+  const [selectedYear, setSelectedYear] = useState(dayjs())
   const [summary, setSummary] = useState(null)
   const [monthlySummary, setMonthlySummary] = useState(null)
+  const [yearlySummary, setYearlySummary] = useState(null)
   const [personCount, setPersonCount] = useState(0)
   const [itemCount, setItemCount] = useState(0)
   const [error, setError] = useState(null)
@@ -71,6 +75,21 @@ function Dashboard() {
     }
   }
 
+  // Fetch yearly summary
+  const fetchYearlySummary = async (date) => {
+    setYearlyLoading(true)
+    try {
+      const year = date.year()
+      const response = await workRecordApi.getYearlySummary({ year })
+      setYearlySummary(response.data)
+    } catch (error) {
+      message.error('获取年度统计失败: ' + error.message)
+      setYearlySummary(null)
+    } finally {
+      setYearlyLoading(false)
+    }
+  }
+
   // Fetch counts for persons and items
   const fetchCounts = async () => {
     setCountsLoading(true)
@@ -92,12 +111,14 @@ function Dashboard() {
   useEffect(() => {
     fetchDailySummary(selectedDate)
     fetchMonthlySummary(selectedMonth)
+    fetchYearlySummary(selectedYear)
     fetchCounts()
   }, [])
 
   const handleRefresh = () => {
     fetchDailySummary(selectedDate)
     fetchMonthlySummary(selectedMonth)
+    fetchYearlySummary(selectedYear)
     fetchCounts()
   }
 
@@ -115,6 +136,13 @@ function Dashboard() {
     }
   }
 
+  const handleYearChange = (date) => {
+    if (date) {
+      setSelectedYear(date)
+      fetchYearlySummary(date)
+    }
+  }
+
   const handleAddWorkRecord = () => {
     navigate('/work-records')
   }
@@ -184,6 +212,35 @@ function Dashboard() {
     }
   ]
 
+  // Table columns for yearly summary
+  const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
+  const yearlySummaryColumns = [
+    {
+      title: '人员',
+      dataIndex: 'person_name',
+      key: 'person_name',
+      fixed: 'left',
+      width: 100
+    },
+    ...monthNames.map((name, index) => ({
+      title: name,
+      dataIndex: ['monthly_earnings', index],
+      key: `month_${index}`,
+      align: 'right',
+      width: 80,
+      render: (value) => value ? `¥${value.toFixed(2)}` : '¥0.00'
+    })),
+    {
+      title: '年度合计',
+      dataIndex: 'yearly_total',
+      key: 'yearly_total',
+      align: 'right',
+      fixed: 'right',
+      width: 100,
+      render: (value) => <strong>¥{(value || 0).toFixed(2)}</strong>
+    }
+  ]
+
   return (
     <div>
       {error && (
@@ -294,6 +351,57 @@ function Dashboard() {
         </Spin>
       </Card>
 
+      {/* Yearly Summary Section */}
+      <Card 
+        title={
+          <Row justify="space-between" align="middle" gutter={[8, 8]}>
+            <Col xs={24} sm="auto">年度汇总</Col>
+            <Col xs={24} sm="auto" style={{ paddingBottom: 8 }}>
+              <DatePicker.YearPicker
+                value={selectedYear}
+                onChange={handleYearChange}
+                allowClear={false}
+                placeholder="选择年份"
+                style={{ width: isMobile ? '100%' : 'auto' }}
+              />
+            </Col>
+          </Row>
+        }
+        style={{ marginBottom: isMobile ? 12 : 24 }}
+      >
+        <Spin spinning={yearlyLoading}>
+          {yearlySummary?.persons?.length > 0 ? (
+            <Table
+              columns={yearlySummaryColumns}
+              dataSource={yearlySummary.persons}
+              rowKey="person_id"
+              pagination={false}
+              size="small"
+              scroll={{ x: 1200 }}
+              summary={() => (
+                <Table.Summary fixed>
+                  <Table.Summary.Row>
+                    <Table.Summary.Cell index={0} fixed="left">
+                      <strong>合计</strong>
+                    </Table.Summary.Cell>
+                    {(yearlySummary.monthly_totals || []).map((total, index) => (
+                      <Table.Summary.Cell key={index} index={index + 1} align="right">
+                        <strong>¥{(total || 0).toFixed(2)}</strong>
+                      </Table.Summary.Cell>
+                    ))}
+                    <Table.Summary.Cell index={13} align="right" fixed="right">
+                      <strong>¥{(yearlySummary.grand_total || 0).toFixed(2)}</strong>
+                    </Table.Summary.Cell>
+                  </Table.Summary.Row>
+                </Table.Summary>
+              )}
+            />
+          ) : (
+            <Empty description="该年度暂无工作记录" />
+          )}
+        </Spin>
+      </Card>
+
       <div style={{ marginBottom: isMobile ? 12 : 24, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
         <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
           <DatePicker

+ 2 - 1
frontend/src/services/api.js

@@ -82,7 +82,8 @@ export const workRecordApi = {
   update: (data) => api.post('/work-records/update', data),
   delete: (id) => api.post('/work-records/delete', { id }),
   getDailySummary: (params) => api.get('/work-records/daily-summary', { params }),
-  getMonthlySummary: (params) => api.get('/work-records/monthly-summary', { params })
+  getMonthlySummary: (params) => api.get('/work-records/monthly-summary', { params }),
+  getYearlySummary: (params) => api.get('/work-records/yearly-summary', { params })
 }
 
 // Export API - uses axios directly to handle blob responses